From a5ddca4470e134426abf7eb726b5fbb7ac5030d1 Mon Sep 17 00:00:00 2001 From: Cheolmin Jo Date: Wed, 11 Nov 2026 13:43:47 +0900 Subject: [PATCH] ci: render release notes from a reviewed template and fix the digest-before-build ordering The notes were required to contain the archive digest, which is unknown until the archive is built, so a committed note could never pass. The reviewed template now carries explicit tokens that publish fills, and refuses a template without the digest token or with an unknown token. A first template states only what the workflow itself verifies. Co-Authored-By: Claude Sonnet 5.5 --- .github/release/preview-notes.md | 26 ++++++++++++++++++++++++++ tools/ci_release.py | 32 +++++++++++++++++++++++++++----- tools/test_ci_release.py | 24 ++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 .github/release/preview-notes.md diff --git a/.github/release/preview-notes.md b/.github/release/preview-notes.md new file mode 100644 index 0000000..abdd88c --- /dev/null +++ b/.github/release/preview-notes.md @@ -0,0 +1,26 @@ +# crt-cli Preview + +**Prerelease.** Built from commit `{{COMMIT}}` (the exact tag target) by `release.yml`, against the public CRT Dev SDK pinned in +`.github/ci/pins.json` for this host. The CLI reports `crt-cli 0.1.0-dev`. + +## Asset + +| File | Bytes | SHA-256 | +| --- | ---: | --- | +| the `.tar.xz` archive attached to this release | {{ARCHIVE_BYTES}} | `{{ARCHIVE_SHA256}}` | + +The archive holds one statically linked executable `crt-cli`, the Apache-2.0 license, component licenses/provenance notices and a +format-1 `manifest.json` pinning every file. It contains no SDK, compiler, interpreter, CA certificate or key. Verify the archive digest +from a source you trust independently of this page. + +## What was checked before publication + +The `release` workflow built this commit with the pinned published Preview and public SDK, packed the archive twice with identical bytes, +and, on a separate runner, extracted it and ran the packaged executable: `--version`, self `doctor` with the manifest pin (a wrong pin is +refused), a real GitHub `sdk acquire`, then C and C++ `new`, `build`, `test`, `run` and a warm rebuild. A reviewer approved the publication. + +## Not claimed + +- **No publisher authentication**: the archive and manifest are not signed. Trust comes from digests you pin yourself (`publisher_authentication: not_verified`). +- This template does not repeat per-host test counts or toolchain versions; the workflow run that produced this release records them. + The CLI is experimental and its JSON/command contract may change. diff --git a/tools/ci_release.py b/tools/ci_release.py index 50913b7..3244203 100644 --- a/tools/ci_release.py +++ b/tools/ci_release.py @@ -77,6 +77,27 @@ def parse_sums(text): return entries +NOTE_TOKENS = ('{{ARCHIVE_SHA256}}', '{{ARCHIVE_BYTES}}', '{{COMMIT}}', '{{TAG}}') + + +def render_notes(template, *, digest, size, commit, tag): + """Fill the reviewed template's explicit tokens; anything else in the text is left exactly as reviewed. + + The digest only exists after the archive is built, so the committed notes carry tokens instead of numbers. A template + without the digest token cannot state what is being published and is refused, as is a stray unknown {{TOKEN}}. + """ + if '{{ARCHIVE_SHA256}}' not in template: + raise ReleaseError('the notes template must contain {{ARCHIVE_SHA256}}') + values = {'{{ARCHIVE_SHA256}}': digest, '{{ARCHIVE_BYTES}}': format(size, ','), '{{COMMIT}}': commit[:7], '{{TAG}}': tag} + text = template + for token, value in values.items(): + text = text.replace(token, value) + leftover = re.findall(r'\{\{[A-Z0-9_]+\}\}', text) + if leftover: + raise ReleaseError('unknown notes token(s): %s' % ', '.join(sorted(set(leftover)))) + return text + + def require_commit(sha): if not SHA40.match(sha or ''): raise ReleaseError('a release is built from a full lowercase 40-character commit SHA, never a branch or tag') @@ -253,11 +274,12 @@ def command_publish(args): repo = args.repo if gh('release', 'view', n['tag'], '--repo', repo, check=False).returncode == 0: raise ReleaseError('a release for %s already exists; releases are never overwritten' % n['tag']) - notes = Path(args.notes_file).resolve() - if not notes.is_file() or not notes.read_text(encoding='utf-8').strip(): - raise ReleaseError('the release notes file is missing or empty') - if digest not in notes.read_text(encoding='utf-8'): - raise ReleaseError('the release notes do not state the archive SHA-256 %s' % digest) + template_path = Path(args.notes_file).resolve() + if not template_path.is_file() or not template_path.read_text(encoding='utf-8').strip(): + raise ReleaseError('the release notes template is missing or empty') + rendered = render_notes(template_path.read_text(encoding='utf-8'), digest=digest, size=archive.stat().st_size, commit=commit, tag=n['tag']) + notes = Path(args.work).resolve().parent / (Path(args.work).name + '-notes.md') + notes.write_text(rendered, encoding='utf-8') gh('release', 'create', n['tag'], '--repo', repo, '--draft', '--prerelease', '--latest=false', '--target', commit, '--title', n['title'], '--notes-file', notes, archive, sums) log('draft %s created' % n['tag']) diff --git a/tools/test_ci_release.py b/tools/test_ci_release.py index 9b038dc..2d37f71 100644 --- a/tools/test_ci_release.py +++ b/tools/test_ci_release.py @@ -4,8 +4,8 @@ import unittest from pathlib import Path -from ci_release import (ReleaseError, locate_executable, locate_sdk, names, parse_sums, require_artifact_match, require_commit, - sums_line) +from ci_release import (ReleaseError, locate_executable, locate_sdk, names, parse_sums, render_notes, require_artifact_match, + require_commit, sums_line) class NamesTest(unittest.TestCase): @@ -105,5 +105,25 @@ def test_sdk_is_the_directory_the_summary_names(self): locate_sdk(self.dir, dict(sdk, content_sha256='f' * 64)) +class NotesTest(unittest.TestCase): + ARGS = dict(digest='a' * 64, size=5036544, commit='6a008856d4a92fafbea1221b0f6c88a4559ccc29', tag='v0.1.0-preview.20261111.linux-arm64.1') + + def test_tokens_are_filled_and_other_text_is_untouched(self): + text = render_notes('Built from `{{COMMIT}}`, tag {{TAG}}.\nSHA {{ARCHIVE_SHA256}} ({{ARCHIVE_BYTES}} bytes). {braces} stay.\n', **self.ARGS) + self.assertEqual(text, 'Built from `6a00885`, tag v0.1.0-preview.20261111.linux-arm64.1.\nSHA ' + 'a' * 64 + ' (5,036,544 bytes). {braces} stay.\n') + + def test_a_template_without_the_digest_token_is_refused(self): + with self.assertRaisesRegex(ReleaseError, 'must contain'): + render_notes('no digest token here', **self.ARGS) + + def test_an_unknown_token_is_refused_not_published(self): + with self.assertRaisesRegex(ReleaseError, 'unknown notes token'): + render_notes('{{ARCHIVE_SHA256}} and {{TYPO_TOKEN}}', **self.ARGS) + + def test_a_digest_typed_by_hand_does_not_substitute_for_the_token(self): + with self.assertRaisesRegex(ReleaseError, 'must contain'): + render_notes('SHA ' + 'a' * 64, **self.ARGS) + + if __name__ == '__main__': unittest.main()