Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 57 additions & 15 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
name: Publish Docker image

# Publishing used to be a manual step, so a GitHub release could be tagged while
# Docker Hub still served the previous image. Building from the release event
# keeps the two in step.
# The GitHub release is created by this workflow, as its final step, rather than
# being the thing that triggers it. Publishing the release first left a window
# where a release existed but its image did not: `docker pull` then served the
# previous version while the release notes described the new one. Ordering it
# this way means a failed push leaves no release at all, and recovering is
# re-running the workflow rather than deleting and re-cutting a release.

on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build and push, e.g. 0.34.17'
description: 'Version to build and push, e.g. 0.34.18'
required: true
update_latest:
description: 'Also move the latest tag'
Expand All @@ -20,6 +24,9 @@ on:
env:
IMAGE: rbardaji/ndp-ep-api

permissions:
contents: write

jobs:
publish:
runs-on: ubuntu-latest
Expand All @@ -36,30 +43,45 @@ jobs:
id: meta
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "release" ]; then
raw='${{ github.event.release.tag_name }}'
prerelease='${{ github.event.release.prerelease }}'
if [ "$prerelease" = "true" ]; then latest=false; else latest=true; fi
if [ "${{ github.event_name }}" = "push" ]; then
raw='${{ github.ref_name }}'
latest=true
else
raw='${{ inputs.version }}'
latest='${{ inputs.update_latest }}'
fi
version="${raw#v}"
# A SemVer prerelease carries an identifier after a hyphen
# (0.35.0-rc1). There is no release object to ask any more, so the
# version string is what decides: a prerelease must never move
# `latest`, or `docker pull` hands users an unreleased build.
prerelease=false
case "$version" in
*-*) prerelease=true; latest=false ;;
esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT"
{
echo 'tags<<TAGS_EOF'
echo "${IMAGE}:${version}"
# A prerelease must not move `latest`, or `docker pull` would hand
# every user an unreleased build.
if [ "$latest" = "true" ]; then
echo "${IMAGE}:latest"
fi
echo 'TAGS_EOF'
} >> "$GITHUB_OUTPUT"

- name: Fail if the release tag and swagger_version disagree
- name: Fail if the tag and swagger_version disagree
run: python scripts/check_release_version.py "${{ steps.meta.outputs.version }}"

- name: Read the release notes from the CHANGELOG
run: |
set -euo pipefail
python scripts/extract_changelog.py "${{ steps.meta.outputs.version }}" \
> release-notes.md
echo "Release notes for ${{ steps.meta.outputs.version }}:"
cat release-notes.md

- name: Set up Buildx
uses: docker/setup-buildx-action@v3

Expand All @@ -83,10 +105,30 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Summarise what was pushed
# Only now that the image is on Docker Hub. Re-running for a tag whose
# release already exists updates it instead of failing, so a partial run
# can be finished by running this workflow again.
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag='${{ steps.meta.outputs.tag }}'
prerelease='${{ steps.meta.outputs.prerelease }}'
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
--notes-file release-notes.md \
--prerelease="$prerelease" --draft=false
else
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
--title "$tag" --notes-file release-notes.md \
--prerelease="$prerelease"
fi

- name: Summarise what was published
run: |
{
echo "### Pushed to Docker Hub"
echo "### Published ${{ steps.meta.outputs.tag }}"
echo ''
echo '```'
echo "${{ steps.meta.outputs.tags }}"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **The GitHub release is now created after the Docker image is published, not before.** The previous release flow published the release first and built the image from that event, so anything failing in between left a published release whose image had never been pushed — `docker pull` served the old version while the release notes described the new one. This was not hypothetical: it happened twice while releasing 0.34.18, once on a credentials error and once on a GitHub Actions outage. The workflow now triggers on a `v*` tag being pushed, and creates the release as its final step, once the image is on Docker Hub. A failed run leaves no release at all, and re-running the workflow for the same version finishes the job without cutting a new tag. Release notes are taken from that version's `CHANGELOG.md` section, so the release page and the changelog cannot drift apart, and a tag whose SemVer version carries a prerelease identifier (`v0.35.0-rc1`) is published as a prerelease without moving `latest`.

### Backwards compatibility
- No runtime change; the API and the image contents are untouched. The manual step changes: releases are no longer created with `gh release create`. Push the `vX.Y.Z` tag and the workflow creates the release. Publishing a release by hand no longer builds anything.

## [0.34.18] - 2026-08-26

### Added
Expand Down
50 changes: 34 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,33 +725,51 @@ PELICAN_DIRECT_READS=False

## 🚢 Releasing

The Docker Hub image is built and pushed by the **Publish Docker image**
workflow, which runs when a GitHub release is published. Releasing is therefore:
The GitHub release and the Docker Hub image are both produced by the **Publish
Docker image** workflow, which runs when a `v*` tag is pushed. The release is
created as the workflow's *last* step, once the image is on Docker Hub, so a
failed build never leaves a release pointing at an image that does not exist.

1. Bump `swagger_version` in `api/config/swagger_settings.py` and move the
`## [Unreleased]` notes into a new `## [X.Y.Z]` section of
[CHANGELOG.md](CHANGELOG.md).
2. Merge that to `main` and tag it `vX.Y.Z`.
3. Publish the GitHub release for that tag.
2. Commit that to `main`.
3. Tag and push:
```bash
git tag vX.Y.Z
git push origin main --tags
```

The workflow then builds [Dockerfile.allinone](Dockerfile.allinone) and pushes
`rbardaji/ndp-ep-api:X.Y.Z`, plus `rbardaji/ndp-ep-api:latest` when the release
is not marked as a prerelease.
The workflow then validates the tag, builds
[Dockerfile.allinone](Dockerfile.allinone), pushes
`rbardaji/ndp-ep-api:X.Y.Z` (plus `latest`), and finally creates the GitHub
release with the notes taken from that version's CHANGELOG section.

The release tag must match `swagger_version`; if it does not, the workflow stops
before building. `swagger_version` is what the API reports at `/status/`, in
`/docs` and in its metrics, so a mismatch would misreport every deployment built
from that release. Check it locally with:
Do not create the GitHub release by hand — the workflow does it.

**Prereleases.** A tag with a SemVer prerelease identifier (`v0.35.0-rc1`) is
detected automatically: the image is pushed under its own tag only, `latest` is
left alone, and the GitHub release is marked as a prerelease.

**If a run fails**, no release is created. Fix the cause and run the workflow
again from the Actions tab with the same version — it will finish the release
without needing a new tag.

**Checks that run before anything is published:**

- The tag must match `swagger_version`, which the API reports at `/status/`, in
`/docs` and in its metrics, so a mismatch would misreport every deployment.
- The version must have a non-empty `CHANGELOG.md` section to use as notes.

Both can be run locally:

```bash
python scripts/check_release_version.py v0.34.17
python scripts/check_release_version.py v0.34.18
python scripts/extract_changelog.py v0.34.18
```

To republish an image without cutting a new release, run the workflow manually
from the Actions tab and pass the version explicitly.

**Required repository secrets:** `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN`
(a Docker Hub access token, not the account password).
(a Docker Hub access token with Read & Write permissions).

## 📄 License

Expand Down
116 changes: 116 additions & 0 deletions scripts/extract_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Extract the CHANGELOG section for one version, to use as release notes.

The release is created by the publish workflow rather than by hand, so its notes
have to come from somewhere. CHANGELOG.md already carries them in the house
style, and taking them from there keeps the release page and the changelog from
drifting apart.
"""

import argparse
import re
import sys
from pathlib import Path

DEFAULT_CHANGELOG_PATH = Path("CHANGELOG.md")

# Matches a release heading: "## [0.34.18] - 2026-08-26" (the date is optional,
# and "## [Unreleased]" is matched too so it can be rejected explicitly).
_HEADING_RE = re.compile(r"^##\s+\[([^\]]+)\]", re.MULTILINE)


def normalize_version(version):
"""Strip the leading ``v`` from a tag or version string.

Parameters
----------
version : str
Tag or version, e.g. ``v0.34.18`` or ``0.34.18``.

Returns
-------
str
The bare version string, without surrounding whitespace.
"""
version = version.strip()
return version[1:] if version.startswith("v") else version


def extract_section(version, path=DEFAULT_CHANGELOG_PATH):
"""Return the body of the CHANGELOG section for ``version``.

Parameters
----------
version : str
Version to look for, with or without a leading ``v``.
path : pathlib.Path, optional
The changelog to read.

Returns
-------
str
Everything between that version's heading and the next one, stripped of
surrounding blank lines.

Raises
------
FileNotFoundError
If ``path`` does not exist.
ValueError
If the version has no section, or its section is empty.
"""
wanted = normalize_version(version)
text = Path(path).read_text(encoding="utf-8")

headings = list(_HEADING_RE.finditer(text))
for index, heading in enumerate(headings):
if heading.group(1) != wanted:
continue
start = heading.end()
# The section runs to the next release heading, or to the end of file
# for the oldest entry.
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
# Drop the rest of the heading line (the " - 2026-08-26" date part).
body = text[start:end].split("\n", 1)[-1].strip()
if not body:
raise ValueError(f"the section for {wanted} in {path} is empty")
return body

raise ValueError(f"no section for {wanted} found in {path}")


def main(argv=None):
"""Print the release notes for the requested version.

Returns
-------
int
``0`` on success, ``2`` when the notes cannot be produced.
"""
parser = argparse.ArgumentParser(
description="Print the CHANGELOG section for a version."
)
parser.add_argument("version", help="Version to extract, e.g. v0.34.18")
parser.add_argument(
"--changelog",
type=Path,
default=DEFAULT_CHANGELOG_PATH,
help="Changelog to read (default: %(default)s)",
)
args = parser.parse_args(argv)

if normalize_version(args.version).lower() == "unreleased":
print("error: refusing to release the Unreleased section", file=sys.stderr)
return 2

try:
print(extract_section(args.version, args.changelog))
except (FileNotFoundError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2

return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading