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