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
26 changes: 26 additions & 0 deletions .github/release/preview-notes.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 27 additions & 5 deletions tools/ci_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'])
Expand Down
24 changes: 22 additions & 2 deletions tools/test_ci_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Loading