From 854c430a931a5f3110fb05d9279b64c558f2c525 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:33:51 +0200 Subject: [PATCH 1/2] fix(release): publish changes with every native candidate --- .github/workflows/native-release.yml | 29 ++++----- Makefile | 1 + scripts/test_write_native_release_notes.py | 71 ++++++++++++++++++++++ scripts/write-native-release-notes.py | 50 +++++++++++++++ 4 files changed, 133 insertions(+), 18 deletions(-) create mode 100644 scripts/test_write_native_release_notes.py create mode 100644 scripts/write-native-release-notes.py diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 8814a0ccb..2262d97fe 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -53,11 +53,6 @@ jobs: test "${GITHUB_REF}" = refs/heads/master test -n "${GH_TOKEN}" [[ "${CHANNEL}" = beta || "${CHANNEL}" = stable ]] - latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) - if [[ ! "${latest}" =~ ^v2\.[0-9]+\.[0-9]+$ ]]; then - echo "public latest must stay on a 2.x stable release (found ${latest})" >&2 - exit 1 - fi gh api --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ > "${RUNNER_TEMP}/native-release-pages.json" @@ -66,6 +61,7 @@ jobs: # Stable checks out an older beta's source below, which may lack # this checker; keep the current one. cp scripts/check-native-schema.py "${RUNNER_TEMP}/check-native-schema.py" + cp scripts/write-native-release-notes.py "${RUNNER_TEMP}/write-native-release-notes.py" if [ "${CHANNEL}" = stable ]; then if [[ ! "${TAG}" =~ ^v0\.[0-9]+\.[0-9]+$ ]] || [[ ! "${SOURCE_BETA}" =~ ^v0\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]] || @@ -186,13 +182,11 @@ jobs: "sha256": assets, } (release / "ftw-native-release.json").write_text(json.dumps(receipt, indent=2) + "\n") - notes = f"Native FTW {receipt['tag']} from {receipt['commit']}.\n\n" - if receipt["source_beta"]: - notes += f"Promoted from site-tested {receipt['source_beta']}.\n\n" - notes += f"\n" - (release / "notes.md").write_text(notes) PY - - name: Anchor immutable tag and publish without moving legacy latest + python3 "${RUNNER_TEMP}/write-native-release-notes.py" \ + "${RUNNER_TEMP}/native-release/ftw-native-release.json" CHANGELOG.md \ + "${RUNNER_TEMP}/native-release/notes.md" --repo "${GITHUB_REPOSITORY}" + - name: Anchor immutable tag and publish the selected channel if: ${{ !inputs.dry_run }} env: CHANNEL: ${{ inputs.channel }} @@ -244,7 +238,11 @@ jobs: release_info=$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" \ --json tagName,isDraft,isPrerelease,body) expected_prerelease=false - if [ "${CHANNEL}" = beta ]; then expected_prerelease=true; fi + make_latest=true + if [ "${CHANNEL}" = beta ]; then + expected_prerelease=true + make_latest=false + fi jq -e --arg tag "${TAG}" --argjson prerelease "${expected_prerelease}" \ --arg marker "" ' .tagName == $tag and .isPrerelease == $prerelease and @@ -264,12 +262,7 @@ jobs: "${release_dir}/ftw-native-release.json" if jq -e '.isDraft == true' <<<"${release_info}" >/dev/null; then gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" \ - --draft=false --latest=false --verify-tag - fi - latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) - if [[ ! "${latest}" =~ ^v2\.[0-9]+\.[0-9]+$ ]]; then - echo "native publication changed the old latest slot to ${latest}" >&2 - exit 1 + --draft=false --latest="${make_latest}" --verify-tag --notes-file "${release_dir}/notes.md" fi - name: Report verified dry run if: ${{ inputs.dry_run }} diff --git a/Makefile b/Makefile index 1667f74e3..ea11f9f73 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,7 @@ release-workflow-test: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts -p 'test_package_legacy_linux.py' PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts -p 'test_check_native_release_order.py' PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts -p 'test_check_native_schema.py' + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts -p 'test_write_native_release_notes.py' bash scripts/test-upload-release-assets.sh bash -n scripts/check-ghcr-write-access.sh scripts/test-ghcr-write-access.sh bash -n scripts/test-exact-image-promotion.sh scripts/build-core.sh diff --git a/scripts/test_write_native_release_notes.py b/scripts/test_write_native_release_notes.py new file mode 100644 index 000000000..16fdb9117 --- /dev/null +++ b/scripts/test_write_native_release_notes.py @@ -0,0 +1,71 @@ +"""Release pages must describe the candidate and retain the schema marker.""" + +import importlib.util +from pathlib import Path +import unittest + + +spec = importlib.util.spec_from_file_location( + "release_notes", Path(__file__).with_name("write-native-release-notes.py")) +release_notes = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_notes) + + +class ReleaseNotesTest(unittest.TestCase): + def receipt(self, tag="v0.148.0-beta.1", source_beta=""): + return dict(tag=tag, source_beta=source_beta, commit="a" * 40, state_schema=7) + + def test_candidate_section_keeps_user_steps_and_leaves_out_other_versions(self): + changelog = """# Changelog + +## 0.149.0 +- Future change. + +## 0.148.0 +### Minor Changes +- 6335dbc: Add hourly statistics. + Older consents stay inactive. + +## Before enabling statistics +Read the new policy. + +## 0.147.2 +- Old charging change. +""" + notes = release_notes.build_notes(self.receipt(), changelog) + self.assertIn("## Changes in this beta", notes) + self.assertIn("- Add hourly statistics.\n Older consents stay inactive.", notes) + self.assertIn("## Before enabling statistics\nRead the new policy.", notes) + self.assertNotIn("Future change", notes) + self.assertNotIn("Old charging change", notes) + self.assertNotIn("6335dbc:", notes) + self.assertIn("/blob/v0.148.0-beta.1/CHANGELOG.md", notes) + self.assertIn("", notes) + + def test_stable_uses_tested_beta_source_changelog(self): + notes = release_notes.build_notes( + self.receipt("v0.148.0", "v0.148.0-beta.1"), "## 0.148.0\n- Add statistics.") + self.assertIn("## Changes in this release", notes) + self.assertIn("Promoted from site-tested [v0.148.0-beta.1]", notes) + + def test_missing_or_empty_candidate_fails_instead_of_publishing_generic_notes(self): + for changelog in ("## 0.147.2\n- Old change.", "## 0.148.0\n", + "## 0.148.0\n### Minor Changes\n\n"): + with self.subTest(changelog=changelog), self.assertRaises(ValueError): + release_notes.build_notes(self.receipt(), changelog) + + def test_workflow_keeps_note_writer_when_promoting_older_beta(self): + workflow = (Path(__file__).parents[1] / ".github/workflows/native-release.yml").read_text() + copy = workflow.index('cp scripts/write-native-release-notes.py "${RUNNER_TEMP}/write-native-release-notes.py"') + checkout = workflow.index('git checkout --detach "${beta_commit}"') + run = workflow.index('python3 "${RUNNER_TEMP}/write-native-release-notes.py"') + self.assertLess(copy, checkout) + self.assertLess(checkout, run) + self.assertIn('--notes-file "${release_dir}/notes.md"', workflow) + self.assertNotIn('/releases/latest', workflow) + self.assertIn('make_latest=false', workflow) + self.assertIn('--latest="${make_latest}"', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/write-native-release-notes.py b/scripts/write-native-release-notes.py new file mode 100644 index 000000000..4ed277e0d --- /dev/null +++ b/scripts/write-native-release-notes.py @@ -0,0 +1,50 @@ +"""Write release notes from the candidate's changeset changelog.""" + +import argparse +import json +from pathlib import Path +import re + + +def build_notes(receipt, changelog, repo="srcfl/ftw"): + tag = receipt["tag"] + match = re.fullmatch(r"v(0\.\d+\.\d+)(-beta\.\d+)?", tag) + if not match: + raise ValueError(f"invalid native release tag: {tag}") + version, beta = match.groups() + lines = changelog.splitlines() + try: + start = lines.index(f"## {version}") + 1 + except ValueError: + raise ValueError(f"CHANGELOG.md has no entry for {version}") from None + end = next((i for i in range(start, len(lines)) + if re.match(r"^## \[?\d", lines[i])), len(lines)) + changes = "\n".join(lines[start:end]).strip() + changes = re.sub(r"", "", changes, flags=re.DOTALL).strip() + if not re.sub(r"(?m)^#+.*$", "", changes).strip(): + raise ValueError(f"CHANGELOG.md has no changes for {version}") + changes = re.sub(r"(?m)^- [0-9a-f]{7,40}: ", "- ", changes) + heading = "Changes in this beta" if beta else "Changes in this release" + notes = f"## {heading}\n\n{changes}\n\n" + source_beta = receipt.get("source_beta") + if source_beta: + notes += f"Promoted from site-tested [{source_beta}](https://github.com/{repo}/releases/tag/{source_beta}).\n\n" + notes += (f"[Full changelog](https://github.com/{repo}/blob/{tag}/CHANGELOG.md)" + f" ยท [Source commit](https://github.com/{repo}/commit/{receipt['commit']})\n\n" + f"\n") + return notes + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("receipt", type=Path) + parser.add_argument("changelog", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--repo", default="srcfl/ftw") + args = parser.parse_args() + notes = build_notes(json.loads(args.receipt.read_text()), args.changelog.read_text(), args.repo) + args.output.write_text(notes) + + +if __name__ == "__main__": + main() From e903c51d62517c686404557e696962ec5a56fecc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:36:46 +0200 Subject: [PATCH 2/2] fix(release): preserve Latest behavior for legacy clients --- .github/workflows/native-release.yml | 8 ++------ scripts/test_write_native_release_notes.py | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 2262d97fe..b7f4f762b 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -238,11 +238,7 @@ jobs: release_info=$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" \ --json tagName,isDraft,isPrerelease,body) expected_prerelease=false - make_latest=true - if [ "${CHANNEL}" = beta ]; then - expected_prerelease=true - make_latest=false - fi + if [ "${CHANNEL}" = beta ]; then expected_prerelease=true; fi jq -e --arg tag "${TAG}" --argjson prerelease "${expected_prerelease}" \ --arg marker "" ' .tagName == $tag and .isPrerelease == $prerelease and @@ -262,7 +258,7 @@ jobs: "${release_dir}/ftw-native-release.json" if jq -e '.isDraft == true' <<<"${release_info}" >/dev/null; then gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" \ - --draft=false --latest="${make_latest}" --verify-tag --notes-file "${release_dir}/notes.md" + --draft=false --latest=false --verify-tag --notes-file "${release_dir}/notes.md" fi - name: Report verified dry run if: ${{ inputs.dry_run }} diff --git a/scripts/test_write_native_release_notes.py b/scripts/test_write_native_release_notes.py index 16fdb9117..bd6e42dc5 100644 --- a/scripts/test_write_native_release_notes.py +++ b/scripts/test_write_native_release_notes.py @@ -63,8 +63,8 @@ def test_workflow_keeps_note_writer_when_promoting_older_beta(self): self.assertLess(checkout, run) self.assertIn('--notes-file "${release_dir}/notes.md"', workflow) self.assertNotIn('/releases/latest', workflow) - self.assertIn('make_latest=false', workflow) - self.assertIn('--latest="${make_latest}"', workflow) + self.assertIn('--draft=false --latest=false --verify-tag', workflow) + self.assertNotIn('--latest=true', workflow) if __name__ == "__main__":