From d0e65ee9ea7ad0583ce7fb14c91b119e4489e2f4 Mon Sep 17 00:00:00 2001 From: Raul Bardaji Date: Wed, 26 Aug 2026 21:47:08 +0200 Subject: [PATCH] fix(ci): create the GitHub release only after the image is published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release was published first and the image was built from that event, so anything failing in between left a published release whose image had never been pushed. `docker pull` then served the previous version while the release notes described the new one — the exact gap the automated publish was meant to close. It is not hypothetical: releasing 0.34.18 hit it twice, once on a Docker Hub credentials error and once on a GitHub Actions outage that produced a startup failure before any job existed. Trigger the workflow on a `v*` tag being pushed instead, and create the release as the final step, once the image is on Docker Hub. A failed run leaves no release at all, and re-running for the same version finishes the job without cutting a new tag. Triggering on a draft release was not an option: GitHub does not emit release events for drafts. Take the notes from that version's CHANGELOG section via scripts/extract_changelog.py, so the release page and the changelog cannot drift apart, and refuse to release the Unreleased section. Derive prerelease status from the SemVer version, since there is no release object to read it from any more: a tag like v0.35.0-rc1 is published as a prerelease and does not move `latest`. Closes #255 --- .github/workflows/docker-publish.yml | 72 +++++++++++++---- CHANGELOG.md | 6 ++ README.md | 50 ++++++++---- scripts/extract_changelog.py | 116 +++++++++++++++++++++++++++ tests/test_extract_changelog.py | 115 ++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 31 deletions(-) create mode 100644 scripts/extract_changelog.py create mode 100644 tests/test_extract_changelog.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a005e61..14ef9c1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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' @@ -20,6 +24,9 @@ on: env: IMAGE: rbardaji/ndp-ep-api +permissions: + contents: write + jobs: publish: runs-on: ubuntu-latest @@ -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<> "$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 @@ -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 }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index feb6b62..f32130b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 339ac8e..7c1957e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/extract_changelog.py b/scripts/extract_changelog.py new file mode 100644 index 0000000..25eb17f --- /dev/null +++ b/scripts/extract_changelog.py @@ -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()) diff --git a/tests/test_extract_changelog.py b/tests/test_extract_changelog.py new file mode 100644 index 0000000..8911202 --- /dev/null +++ b/tests/test_extract_changelog.py @@ -0,0 +1,115 @@ +"""Tests for extracting release notes out of CHANGELOG.md.""" + +import pytest + +from scripts.extract_changelog import ( + extract_section, + main, + normalize_version, +) + +CHANGELOG = """# Changelog + +Some preamble. + +## [Unreleased] + +## [0.34.18] - 2026-08-26 + +### Added +- A new thing. + +### Backwards compatibility +- Nothing breaks. + +## [0.34.17] - 2026-08-17 + +### Fixed +- An older thing. + +## [0.34.16] + +### Added +- The oldest entry, with no date. +""" + + +@pytest.fixture +def changelog(tmp_path): + """Write a changelog with a known shape and return its path.""" + path = tmp_path / "CHANGELOG.md" + path.write_text(CHANGELOG, encoding="utf-8") + return path + + +class TestNormalizeVersion: + def test_strips_a_leading_v(self): + assert normalize_version("v0.34.18") == "0.34.18" + + def test_leaves_a_bare_version_untouched(self): + assert normalize_version("0.34.18") == "0.34.18" + + def test_strips_surrounding_whitespace(self): + assert normalize_version(" v1.0.0\n") == "1.0.0" + + +class TestExtractSection: + def test_returns_only_that_version(self, changelog): + body = extract_section("0.34.18", changelog) + assert "A new thing." in body + assert "An older thing." not in body + + def test_accepts_a_v_prefixed_tag(self, changelog): + assert extract_section("v0.34.18", changelog) == extract_section( + "0.34.18", changelog + ) + + def test_keeps_the_subsection_headings(self, changelog): + body = extract_section("0.34.18", changelog) + assert "### Added" in body + assert "### Backwards compatibility" in body + + def test_drops_the_heading_and_its_date(self, changelog): + body = extract_section("0.34.18", changelog) + assert "2026-08-26" not in body + assert not body.startswith("## [") + assert "[0.34.18]" not in body + + def test_reads_the_last_section_to_end_of_file(self, changelog): + body = extract_section("0.34.16", changelog) + assert "The oldest entry" in body + + def test_raises_for_an_unknown_version(self, changelog): + with pytest.raises(ValueError, match="no section for 9.9.9"): + extract_section("9.9.9", changelog) + + def test_raises_for_an_empty_section(self, changelog): + # "Unreleased" is present but carries no body. + with pytest.raises(ValueError, match="is empty"): + extract_section("Unreleased", changelog) + + def test_raises_when_the_file_is_missing(self, tmp_path): + with pytest.raises(FileNotFoundError): + extract_section("1.0.0", tmp_path / "nope.md") + + def test_reads_the_repository_changelog(self): + # The real file must stay parseable, or every release would fail. + assert "###" in extract_section("0.34.18") + + +class TestMain: + def test_prints_the_section(self, changelog, capsys): + assert main(["v0.34.18", "--changelog", str(changelog)]) == 0 + assert "A new thing." in capsys.readouterr().out + + def test_refuses_to_release_unreleased(self, changelog, capsys): + assert main(["Unreleased", "--changelog", str(changelog)]) == 2 + assert "refusing to release" in capsys.readouterr().err + + def test_returns_two_for_an_unknown_version(self, changelog, capsys): + assert main(["9.9.9", "--changelog", str(changelog)]) == 2 + assert "no section for" in capsys.readouterr().err + + def test_returns_two_when_the_file_is_missing(self, tmp_path, capsys): + assert main(["1.0.0", "--changelog", str(tmp_path / "nope.md")]) == 2 + assert "error:" in capsys.readouterr().err