diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 291044fd55..73925968a5 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -1302,7 +1302,8 @@ jobs: cache-mode: read timeout-minutes: 45 permissions: - contents: read + contents: write + actions: read env: CHANNEL_BUILD: ${{ needs.validate.outputs.channel-build }} CHANNEL_REQUEST_SHA256: ${{ needs.validate.outputs.channel-request-sha256 }} @@ -1940,12 +1941,14 @@ jobs: runs-on: ubuntu-24.04 environment: release-signing permissions: - contents: read + contents: write + actions: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # Runs from the admitted commit so the renderer matches the built bytes. ref: ${{ needs.validate.outputs.sha }} + fetch-depth: 0 - name: Render the full expected-binary matrix env: HERMES_BUNDLE_ENV_JSON: ${{ inputs.bundle_env }} @@ -1973,6 +1976,23 @@ jobs: --summary-commit "$RELEASE_COMMIT" --repo "$GITHUB_REPOSITORY" \ --summary-out "$GITHUB_STEP_SUMMARY" \ --summary-failed-legs "$failed" --run-url "$RUN_URL" + - name: Return to the trusted receipt controller + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Publish the post-build commit receipt + env: + BUILD_COMMIT: ${{ needs.validate.outputs.sha }} + TAG: '' + RELEASE_PHASE: '' + UPLOAD_RELEASE: 'false' + TERMUX_UPGRADE_FROM_TAG: '' + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + BUNDLE_ENV_JSON: ${{ inputs.bundle_env }} + RELEASE_NEEDS: ${{ toJSON(needs) }} + GH_TOKEN: ${{ github.token }} + run: python -m scripts.releases.commit_build receipt candidate-manifest: name: Stage verified stable candidate artifacts diff --git a/scripts/releases/channel_publish.py b/scripts/releases/channel_publish.py index 676ebced39..58a962aca6 100644 --- a/scripts/releases/channel_publish.py +++ b/scripts/releases/channel_publish.py @@ -193,7 +193,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--out", type=Path) parser.add_argument("--root", type=Path) args = parser.parse_args(argv) - from scripts.releases.r2_scope import channel_public_base, require_run + from scripts.releases.r2_scope import R2Scope, channel_public_base, require_run if args.disposable_run is not None: os.environ["R2_DISPOSABLE_RUN"] = require_run(args.disposable_run) args.public_base = channel_public_base(args.public_base) @@ -233,6 +233,15 @@ def main(argv: list[str] | None = None) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) result = publish(request, root, needs=needs, publisher=publisher) + if args.command == "publish" and not R2Scope.configured().prefix: + commit_build.publish_receipt( + "channel", dict(os.environ), version=request["version"], + commit=request["commit"], details={ + "buildId": request["buildId"], + "channel": request["channel"], + "requestSha256": args.request_sha256, + }, + ) print(json.dumps(result, sort_keys=True)) if args.out: args.out.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/releases/commit_build.py b/scripts/releases/commit_build.py index 9b43156b63..2cdeaedb79 100644 --- a/scripts/releases/commit_build.py +++ b/scripts/releases/commit_build.py @@ -7,6 +7,7 @@ import re import shlex import subprocess import tomllib +from datetime import datetime, timezone from pathlib import Path WORKFLOW = "desktop-bundled-release.yml" @@ -22,34 +23,28 @@ def output(argv: list[str], repo: Path | None = None) -> str: return subprocess.check_output(argv, cwd=repo, text=True, encoding="utf-8", timeout=60).strip() -def require_pushed(commit: str, remote: str, repo: Path | None = None) -> None: +def require_pushed(commit: str, remote: str, repo: Path | None = None, *, run=output) -> None: """Require ancestry from a branch or tag currently advertised by this remote.""" require_commit(commit) - advertised = {line.split()[0] for line in output( + advertised = {line.split()[0] for line in run( ["git", "ls-remote", remote, "refs/heads/*", "refs/tags/*"], repo).splitlines()} - containing = set(output(["git", "for-each-ref", f"--contains={commit}", "--format=%(objectname)", - f"refs/remotes/{remote}/", "refs/tags/"], repo).splitlines()) + containing = set(run(["git", "for-each-ref", f"--contains={commit}", "--format=%(objectname)", + f"refs/remotes/{remote}/", "refs/tags/"], repo).splitlines()) if not advertised.intersection(containing): raise ValueError(f"Commit {commit} is not reachable from a pushed branch or tag on {remote}") -def version_at(repo: Path | None, commit: str) -> str: +def version_at(repo: Path | None, commit: str, *, run=output) -> str: require_commit(commit) - document = tomllib.loads(output(["git", "show", f"{commit}:pyproject.toml"], repo)) + document = tomllib.loads(run(["git", "show", f"{commit}:pyproject.toml"], repo)) version = document["project"]["version"] if not isinstance(version, str) or not re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version): raise ValueError("Commit packaging requires project.version=X.Y.Z") return version -def admit(env: dict[str, str]) -> dict[str, str]: - from scripts.releases.bundle_env import decode - - commit = require_commit(env.get("BUILD_COMMIT", "")) - if env.get("TAG") or env.get("RELEASE_PHASE") or env.get("UPLOAD_RELEASE", "false") != "false": - raise ValueError("Commit builds cannot use tag, release-phase or upload_release") - if env.get("TERMUX_UPGRADE_FROM_TAG"): - raise ValueError("Commit builds do not run release-channel upgrade acceptance") +def _controller(env: dict[str, str], commit: str, *, run=output, + repo: Path | None = None) -> dict: default = env.get("DEFAULT_BRANCH", "") ref = f"refs/heads/{default}" repository = env.get("GITHUB_REPOSITORY", "") @@ -61,12 +56,120 @@ def admit(env: dict[str, str]) -> dict[str, str]: for actor in actors: if not actor: raise ValueError("Commit builds require a repository maintainer") - permission = output(["gh", "api", f"repos/{repository}/collaborators/{actor}/permission", "--jq", ".permission"]) + permission = run(["gh", "api", f"repos/{repository}/collaborators/{actor}/permission", + "--jq", ".permission"], repo) if permission not in {"write", "maintain", "admin"}: raise ValueError("Commit builds require repository write, maintain or admin permission") - require_pushed(commit, "origin") + require_pushed(commit, "origin", repo, run=run) + return {"repository": repository, "default": default} + + +def admit(env: dict[str, str], *, run=output, repo: Path | None = None) -> dict[str, str]: + from scripts.releases.bundle_env import decode + + commit = require_commit(env.get("BUILD_COMMIT", "")) + if env.get("TAG") or env.get("RELEASE_PHASE") or env.get("UPLOAD_RELEASE", "false") != "false": + raise ValueError("Commit builds cannot use tag, release-phase or upload_release") + if env.get("TERMUX_UPGRADE_FROM_TAG"): + raise ValueError("Commit builds do not run release-channel upgrade acceptance") + _controller(env, commit, run=run, repo=repo) decode(env.get("BUNDLE_ENV_JSON", "")) - return {"sha": commit, "channel": "commit", "payload-version": version_at(None, commit)} + return {"sha": commit, "channel": "commit", + "payload-version": version_at(repo, commit, run=run)} + + +def receipt_tag(kind: str, version: str, created_at: str, run_id: str) -> str: + """Return the canonical post-build receipt identity.""" + from hermes_cli.update_channel import STABLE_TAG_RE + + if kind not in {"channel", "commit"} or not STABLE_TAG_RE.fullmatch("v" + version): + raise ValueError("Build receipt kind or version is invalid") + if not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): + raise ValueError("Build receipt run ID is invalid") + try: + instant = datetime.fromisoformat(created_at.replace("Z", "+00:00")).astimezone(timezone.utc) + except (AttributeError, ValueError) as error: + raise ValueError("Build receipt creation time is invalid") from error + if instant.microsecond or created_at != instant.strftime("%Y-%m-%dT%H:%M:%SZ"): + raise ValueError("Build receipt creation time must be UTC whole seconds") + return f"v{version}+{kind}.{instant.strftime('%Y%m%dT%H%M%SZ')}.{run_id}" + + +def _verify_receipt(tag: str, commit: str, record: dict, *, run, repo: Path | None) -> None: + tag_object = run(["git", "rev-parse", f"refs/tags/{tag}"], repo) + if (run(["git", "cat-file", "-t", tag_object], repo) != "tag" + or run(["git", "rev-parse", f"refs/tags/{tag}^{{commit}}"], repo) != commit + or json.loads(run(["git", "tag", "-l", tag, "--format=%(contents)"], repo)) != record): + raise ValueError("Build receipt tag differs from this run") + + +def publish_receipt(kind: str, env: dict[str, str], *, version: str, commit: str, + details: dict, run=output, repo: Path | None = None) -> dict: + """Create one annotated post-build receipt, or verify its exact replay.""" + require_commit(commit) + controller = _controller(env, commit, run=run, repo=repo) + run_id = env.get("GITHUB_RUN_ID", "") + if env.get("GITHUB_ACTIONS") != "true" or not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): + raise ValueError("Build receipts require a GitHub Actions run ID") + info = json.loads(run([ + "gh", "api", f"repos/{controller['repository']}/actions/runs/{run_id}", + ], repo)) + created_at = info.get("created_at") + if (info.get("id") != int(run_id) or info.get("event") != "workflow_dispatch" + or info.get("status") != "in_progress" + or info.get("head_branch") != controller["default"] + or info.get("head_sha") != env.get("GITHUB_SHA") + or not isinstance(created_at, str)): + raise ValueError("Build receipt run differs from the trusted controller") + tag = receipt_tag(kind, version, created_at, run_id) + record = { + "schema": 1, + "kind": kind, + "tag": tag, + "version": version, + "commit": commit, + "runId": run_id, + "runCreatedAt": created_at, + "details": details, + } + ref = f"refs/tags/{tag}" + remote = {} + for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines(): + sha, name = line.split() + remote[name] = sha + if remote: + if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit: + raise ValueError("Remote build receipt tag custody changed") + run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo) + _verify_receipt(tag, commit, record, run=run, repo=repo) + return record + + try: + local = run(["git", "rev-parse", "--verify", ref], repo) + except subprocess.CalledProcessError: + local = "" + if local: + _verify_receipt(tag, commit, record, run=run, repo=repo) + else: + message = json.dumps(record, sort_keys=True, separators=(",", ":")) + run([ + "git", "-c", "user.name=Hermes Build Receipt", + "-c", "user.email=actions@users.noreply.github.com", + "tag", "-a", tag, commit, "-m", message, + ], repo) + try: + run(["git", "push", "origin", ref], repo) + except subprocess.CalledProcessError: + pass + remote = {} + for line in run(["git", "ls-remote", "origin", ref, f"{ref}^{{}}"], repo).splitlines(): + sha, name = line.split() + remote[name] = sha + if set(remote) != {ref, f"{ref}^{{}}"} or remote[f"{ref}^{{}}"] != commit: + raise ValueError("Build receipt tag was not published exactly") + run(["git", "fetch", "--force", "origin", f"+{ref}:{ref}"], repo) + _verify_receipt(tag, commit, record, run=run, repo=repo) + return record def resolve_revision(rev: str, remote: str, repo: Path) -> str: @@ -131,12 +234,26 @@ def cmd_build_commit(args) -> None: def main() -> None: import sys - if sys.argv[1:] != ["admit"]: - raise SystemExit("usage: python -m scripts.releases.commit_build admit") - values = admit(dict(os.environ)) - with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: - stream.write("".join(f"{key}={value}\n" for key, value in values.items())) - print(json.dumps(values, sort_keys=True)) + if sys.argv[1:] not in (["admit"], ["receipt"]): + raise SystemExit("usage: python -m scripts.releases.commit_build {admit|receipt}") + env = dict(os.environ) + values = admit(env) + if sys.argv[1:] == ["admit"]: + with Path(env["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write("".join(f"{key}={value}\n" for key, value in values.items())) + print(json.dumps(values, sort_keys=True)) + return + + from scripts.releases.bundle_env import decode + from scripts.releases.stable import require_success + + needs = json.loads(env.get("RELEASE_NEEDS", "{}")) + require_success(needs, list(needs)) + result = publish_receipt( + "commit", env, version=values["payload-version"], commit=values["sha"], + details={"bundleEnv": decode(env.get("BUNDLE_ENV_JSON", ""))}, + ) + print(json.dumps(result, sort_keys=True)) if __name__ == "__main__": diff --git a/tests/ci/test_channel_build_publication.py b/tests/ci/test_channel_build_publication.py index 82bf3db646..f637411fed 100644 --- a/tests/ci/test_channel_build_publication.py +++ b/tests/ci/test_channel_build_publication.py @@ -183,7 +183,14 @@ def run_shell(tmp_path, r2_server, script, env, *, cwd=None): python.write_text(f'#!/bin/sh\nexec {shlex.quote(sys.executable)} {shlex.quote(str(driver))} "$@"\n', encoding="utf-8") python.chmod(0o755) gh = tools / "gh" - gh.write_text('#!/bin/sh\n[ "$1" = api ] || exit 3\nprintf "write\\n"\n', encoding="utf-8") + gh.write_text( + '#!/bin/sh\n[ "$1" = api ] || exit 3\n' + 'case "$2" in\n' + ' */actions/runs/*) printf \'{"id":98765,"event":"workflow_dispatch",' + '"status":"in_progress","head_branch":"main","head_sha":"%s",' + '"created_at":"2026-09-22T01:23:45Z"}\\n\' "$GITHUB_SHA" ;;\n' + ' *) printf "write\\n" ;;\n' + 'esac\n', encoding="utf-8") gh.chmod(0o755) return subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], cwd=cwd or tmp_path, env={**os.environ, **env, "PATH": str(tools) + os.pathsep + os.environ["PATH"]}, @@ -265,7 +272,8 @@ def test_real_publication_cas_and_manifest_summary(tmp_path, r2_server, staged_c "RELEASE_NEEDS": json.dumps({name: {"result": "success"} for name in channel_publish.REQUIRED_JOBS}), "DEFAULT_BRANCH": "main", "GITHUB_REF": "refs/heads/main", "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_WORKFLOW_REF": "fixture/repo/.github/workflows/desktop-bundled-release.yml@refs/heads/main", - "GITHUB_SHA": request["commit"], "GITHUB_ACTOR": "fixture", "GITHUB_TRIGGERING_ACTOR": "fixture"} + "GITHUB_SHA": request["commit"], "GITHUB_ACTOR": "fixture", "GITHUB_TRIGGERING_ACTOR": "fixture", + "GITHUB_ACTIONS": "true", "GITHUB_RUN_ID": "98765"} prefix = scope.prefix + handoff.channel_prefix(request) if scope.prefix: env["CLOUDFLARE_R2_PUBLIC_URL"] = os.environ["CLOUDFLARE_R2_PUBLIC_URL"] @@ -304,6 +312,16 @@ def test_real_publication_cas_and_manifest_summary(tmp_path, r2_server, staged_c assert result.returncode == 0, result.stdout + result.stderr evidence = json.loads((tmp_path / "scoped-smoke/out/download.json").read_text()) assert evidence["request"] == request + else: + tag = "v0.0.7+channel.20260922T012345Z.98765" + assert _git("tag", "--list", tag, cwd=tmp_path / "clone") == tag + receipt = json.loads(_git("tag", "-l", tag, "--format=%(contents)", cwd=tmp_path / "clone")) + assert receipt == { + "schema": 1, "kind": "channel", "tag": tag, "version": request["version"], + "commit": request["commit"], "runId": "98765", "runCreatedAt": "2026-09-22T01:23:45Z", + "details": {"buildId": request["buildId"], "channel": request["channel"], + "requestSha256": hashlib.sha256(canonical_json(request)).hexdigest()}, + } # A retired publisher can stage immutable diagnostics, but never revive its pointer. retired = {**stored, "state": "retired", "destination": "stable", "minimumVersion": "1.2.3", "lastHead": stored["head"], "destinationHead": stored["head"], "receiverProtocol": 1, diff --git a/tests/ci/test_desktop_release_commit_admission.py b/tests/ci/test_desktop_release_commit_admission.py index fb46f77a53..0fb40a66e6 100644 --- a/tests/ci/test_desktop_release_commit_admission.py +++ b/tests/ci/test_desktop_release_commit_admission.py @@ -7,7 +7,15 @@ import shutil import subprocess import sys -from tests.ci.test_desktop_release_tag_admission import _admission_script, _child_env, _git, _seed_repo, _BASH +from scripts.releases.commit_build import publish_receipt, receipt_tag +from tests.ci.test_desktop_release_tag_admission import _child_env, _git, _seed_repo, _workflow, _BASH + + +def _admission_script(): + return next( + step["run"] for step in _workflow()["jobs"]["validate"]["steps"] + if step.get("name") == "Validate tag shape, pyproject lockstep, and ancestry on origin/main" + ) def environment(clone, commit): @@ -94,3 +102,49 @@ def test_trusted_dispatch_admits_a_pushed_feature_without_switching_checkout(tmp requests = [json.loads(line) for line in permission_log.read_text(encoding='utf-8').splitlines()] assert requests and all(row[1] == 'repos/fixture/repo/collaborators/maintainer/permission' for row in requests) assert not _git('tag', '--list', cwd=clone) + + +def test_post_build_receipts_bind_kind_commit_and_run_without_same_second_collisions(tmp_path): + _origin, clone = _seed_repo(tmp_path) + commit = _git('rev-parse', 'HEAD', cwd=clone) + created_at = '2026-09-22T01:23:45Z' + assert receipt_tag('commit', '0.0.0', created_at, '123') == \ + 'v0.0.0+commit.20260922T012345Z.123' + assert receipt_tag('commit', '0.0.0', created_at, '124') != \ + receipt_tag('commit', '0.0.0', created_at, '123') + + def run(argv, repo=None): + if argv[:2] == ['gh', 'api'] and argv[-1] == '.permission': + return 'write' + if argv[:2] == ['gh', 'api'] and '/actions/runs/' in argv[2]: + run_id = argv[2].rsplit('/', 1)[-1] + return json.dumps({ + 'id': int(run_id), 'event': 'workflow_dispatch', 'status': 'in_progress', + 'head_branch': 'main', 'head_sha': commit, 'created_at': created_at, + }) + return subprocess.check_output(argv, cwd=repo or clone, text=True, encoding='utf-8').strip() + + base = { + **environment(clone, commit), + 'GITHUB_ACTIONS': 'true', + 'GITHUB_SHA': commit, + 'GITHUB_RUN_ID': '123', + } + first = publish_receipt( + 'commit', base, version='0.0.0', commit=commit, + details={'bundleEnv': {}}, run=run, repo=clone, + ) + second = publish_receipt( + 'commit', {**base, 'GITHUB_RUN_ID': '124'}, version='0.0.0', commit=commit, + details={'bundleEnv': {}}, run=run, repo=clone, + ) + assert [first['tag'], second['tag']] == [ + 'v0.0.0+commit.20260922T012345Z.123', + 'v0.0.0+commit.20260922T012345Z.124', + ] + assert json.loads(_git('tag', '-l', first['tag'], '--format=%(contents)', cwd=clone)) == first + assert _git('rev-parse', f"{first['tag']}^{{commit}}", cwd=clone) == commit + assert publish_receipt( + 'commit', base, version='0.0.0', commit=commit, + details={'bundleEnv': {}}, run=run, repo=clone, + ) == first diff --git a/tests/scripts/test_release_version_from_ref.py b/tests/scripts/test_release_version_from_ref.py index a37efaa7b8..cc91f8baf4 100644 --- a/tests/scripts/test_release_version_from_ref.py +++ b/tests/scripts/test_release_version_from_ref.py @@ -20,8 +20,8 @@ def test_final_tag_is_its_version(): "v0.21.4+canary.20260922T001400Z", "v2026.9.21", "canary-0.21.4+canary.20260922T001400Z", - "channel-preview-20260922T001400Z", - "commit-abcdef1-20260922T001400Z", + "v0.0.7+channel.20260922T001400Z.98765", + "v0.0.0+commit.20260922T001400Z.98766", ]) def test_non_final_refs_are_not_versions(ref): assert version_from_tag(ref) is None