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
23 changes: 6 additions & 17 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]+$ ]] ||
Expand Down Expand Up @@ -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"<!-- ftw-state-schema-v2:{receipt['state_schema']} -->\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 }}
Expand Down Expand Up @@ -264,12 +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=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=false --verify-tag --notes-file "${release_dir}/notes.md"
fi
- name: Report verified dry run
if: ${{ inputs.dry_run }}
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions scripts/test_write_native_release_notes.py
Original file line number Diff line number Diff line change
@@ -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("<!-- ftw-state-schema-v2:7 -->", 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<!-- marker -->\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('--draft=false --latest=false --verify-tag', workflow)
self.assertNotIn('--latest=true', workflow)


if __name__ == "__main__":
unittest.main()
50 changes: 50 additions & 0 deletions scripts/write-native-release-notes.py
Original file line number Diff line number Diff line change
@@ -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"<!-- ftw-state-schema-v2:{receipt['state_schema']} -->\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()
Loading