From a06310f442051af1961ba92c7b566587d1049e11 Mon Sep 17 00:00:00 2001 From: Josh Liebow-Feeser Date: Fri, 31 Jul 2026 20:58:28 +0000 Subject: [PATCH] [ci] Separate testing from privileged publication Same-repository PRs previously gave the core image builder package-write authority, and Anneal tests received repository-write authority only to store timing data. Make both producers read-only. Publish the Docker cache from a successful default-branch workflow and validated benchmarks from the trusted docs coordinator. Add a semantic workflow check which rejects any write-capable GITHUB_TOKEN in pull-request or merge-group workflows. This makes the trust boundary a repository invariant instead of a job convention. Split manual Anneal toolchain releases into immutable source resolution, unprivileged patch and archive production, trusted artifact review, release publication, and PR submission. Validate the archive set, metadata, hashes, URLs, sizes, release target, and final patch at each boundary. No job which executes selected-branch code receives a write credential. Make asset publication resumable: reuse a correctly targeted draft, accept matching assets, repair missing draft assets, and reject any mismatch before publishing the prerelease and opening its release PR. gherrit-pr-id: Grlosy5kwxiwxexwdtdxzx7i2ezmymtrq --- .../setup-docker-with-retry/action.yml | 26 +- .../actions/upload-file-artifact/action.yml | 18 +- .github/scripts/check-workflow-permissions.py | 548 ++++++++++++++++++ .../test_check_workflow_permissions.py | 260 +++++++++ .github/scripts/test_workflow_artifacts.py | 111 ++++ .github/workflows/anneal-release.yml | 529 +++++++++++++---- .github/workflows/anneal.yml | 44 +- .github/workflows/ci.yml | 23 +- .github/workflows/docs.yml | 78 ++- .github/workflows/publish-ci-cache.yml | 94 +++ anneal/v1/tools/check-release-flow-dry-run.sh | 77 ++- .../tools/test_validate_release_artifacts.py | 286 +++++++++ anneal/v1/tools/validate-release-artifacts.py | 276 +++++++++ ci/check_actions.sh | 10 + 14 files changed, 2206 insertions(+), 174 deletions(-) create mode 100644 .github/scripts/check-workflow-permissions.py create mode 100644 .github/scripts/test_check_workflow_permissions.py create mode 100644 .github/scripts/test_workflow_artifacts.py create mode 100644 .github/workflows/publish-ci-cache.yml create mode 100644 anneal/v1/tools/test_validate_release_artifacts.py create mode 100755 anneal/v1/tools/validate-release-artifacts.py diff --git a/.github/actions/setup-docker-with-retry/action.yml b/.github/actions/setup-docker-with-retry/action.yml index 21f1be74e9..0ad9a10f40 100644 --- a/.github/actions/setup-docker-with-retry/action.yml +++ b/.github/actions/setup-docker-with-retry/action.yml @@ -7,15 +7,24 @@ inputs: required: false default: ghcr.io username: - description: Container registry username - required: true + description: Optional container registry username + required: false + default: '' password: - description: Container registry password or token - required: true + description: Optional container registry password or token + required: false + default: '' runs: using: composite steps: + - name: Validate registry credentials + if: ${{ (inputs.username == '') != (inputs.password == '') }} + shell: bash + run: | + echo "Registry username and password must be provided together" >&2 + exit 1 + # Retry the actions themselves rather than reproducing their behavior in # shell. This preserves Buildx's builder cleanup and login-action's use of # password-stdin and its end-of-job logout. The first two attempts use @@ -58,6 +67,7 @@ runs: # shell or expose it in a `docker login` command line. - name: Log in to the container registry (attempt 1) id: login_1 + if: ${{ inputs.username != '' && inputs.password != '' }} continue-on-error: true uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: @@ -66,7 +76,7 @@ runs: password: ${{ inputs.password }} - name: Wait to retry container registry login - if: ${{ !cancelled() && steps.login_1.outcome == 'failure' }} + if: ${{ inputs.username != '' && inputs.password != '' && !cancelled() && steps.login_1.outcome == 'failure' }} shell: bash run: | set -eu @@ -76,7 +86,7 @@ runs: - name: Log in to the container registry (attempt 2) id: login_2 - if: ${{ !cancelled() && steps.login_1.outcome == 'failure' }} + if: ${{ inputs.username != '' && inputs.password != '' && !cancelled() && steps.login_1.outcome == 'failure' }} continue-on-error: true uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: @@ -85,7 +95,7 @@ runs: password: ${{ inputs.password }} - name: Wait to retry container registry login again - if: ${{ !cancelled() && steps.login_2.outcome == 'failure' }} + if: ${{ inputs.username != '' && inputs.password != '' && !cancelled() && steps.login_2.outcome == 'failure' }} shell: bash run: | set -eu @@ -94,7 +104,7 @@ runs: sleep "$delay" - name: Log in to the container registry (attempt 3) - if: ${{ !cancelled() && steps.login_2.outcome == 'failure' }} + if: ${{ inputs.username != '' && inputs.password != '' && !cancelled() && steps.login_2.outcome == 'failure' }} uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: registry: ${{ inputs.registry }} diff --git a/.github/actions/upload-file-artifact/action.yml b/.github/actions/upload-file-artifact/action.yml index 8e98d18359..34e8cd3ac1 100644 --- a/.github/actions/upload-file-artifact/action.yml +++ b/.github/actions/upload-file-artifact/action.yml @@ -1,5 +1,5 @@ name: Upload file artifact -description: Publish one already-compressed file for jobs in this workflow run +description: Publish one nonempty file as a direct Actions artifact inputs: name: @@ -8,6 +8,10 @@ inputs: path: description: Exact path of the nonempty file to publish required: true + retention-days: + description: Days to retain the artifact; defaults to the short CI lifetime + required: false + default: "1" outputs: artifact-id: @@ -49,10 +53,12 @@ runs: artifact_size=$(stat --format=%s "$ARTIFACT_PATH") echo "Uploading $ARTIFACT_NAME ($artifact_size bytes)" | tee -a "$GITHUB_STEP_SUMMARY" - # Both current callers produce tar archives whose contents are already - # compressed (Docker's image layers use gzip; Anneal uses zstd). Version 7's - # direct-file mode avoids a redundant ZIP on upload and extraction on every - # consumer. Keep this coordinated with download-artifact v8 in + # Large workflow-local callers produce tar archives whose contents are + # already compressed (Docker's image layers use gzip; Anneal uses zstd), so + # they keep the one-day default. The tiny benchmark JSON consumed by + # docs.yml explicitly opts into longer retention. Version 7's direct-file + # mode avoids a redundant ZIP on upload and extraction. Keep this + # coordinated with download-artifact v8 in # `../download-artifact-with-retry/action.yml`, which understands direct # artifacts and verifies their service-provided digest. - name: Upload artifact @@ -62,7 +68,7 @@ runs: name: ${{ inputs.name }} path: ${{ inputs.path }} if-no-files-found: error - retention-days: 1 + retention-days: ${{ inputs.retention-days }} archive: false # Artifacts are immutable. Delete an artifact with the same validated # name first so "Re-run all jobs" can republish it under a new ID. diff --git a/.github/scripts/check-workflow-permissions.py b/.github/scripts/check-workflow-permissions.py new file mode 100644 index 0000000000..bca3699d72 --- /dev/null +++ b/.github/scripts/check-workflow-permissions.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Reject write-capable tokens in workflows which execute proposed changes. + +This checker deliberately does not try to be a general YAML parser. GitHub's +workflow validator owns syntax validation; this script recognizes only the +small, security-sensitive subset needed to answer two questions: + +* Does a workflow run for ``pull_request`` or ``merge_group``? +* Does an untrusted workflow declare an explicit read-only permission baseline, + and does any workflow-level or job-level ``permissions`` grant write access? + +The scanner tracks YAML mapping indentation so a step input such as +``with: {access: write}`` cannot be mistaken for a token permission. It accepts +the block and compact trigger/permission forms supported below and fails closed +when an untrusted workflow uses aliases, expressions, or another permissions +shape whose authority cannot be established from the text. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import pathlib +import re +import sys +from collections.abc import Iterable, Sequence + + +_UNTRUSTED_EVENTS = frozenset({"merge_group", "pull_request"}) +_WORKFLOW_SUFFIXES = frozenset({".yaml", ".yml"}) +_PLAIN_NAME = re.compile(r"^[A-Za-z0-9_-]+$") +_BLOCK_SCALAR = re.compile(r"^[|>][0-9+-]*$") + + +@dataclasses.dataclass(frozen=True) +class Issue: + line: int + message: str + + +@dataclasses.dataclass +class _PermissionBlock: + line: int + owner: str + saw_entry: bool = False + + +class _Unsupported(ValueError): + pass + + +def _strip_comment(text: str) -> str: + """Removes an unquoted YAML comment from one physical line.""" + + single = False + double = False + escaped = False + index = 0 + while index < len(text): + character = text[index] + if double: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + double = False + elif single: + if character == "'": + # YAML escapes a single quote inside a single-quoted scalar by + # doubling it. + if index + 1 < len(text) and text[index + 1] == "'": + index += 1 + else: + single = False + elif character == '"': + double = True + elif character == "'": + single = True + elif character == "#" and ( + index == 0 or text[index - 1].isspace() + ): + return text[:index].rstrip() + index += 1 + return text.rstrip() + + +def _split_mapping_entry(text: str) -> tuple[str, str] | None: + """Splits ``key: value`` at an unquoted, top-level colon.""" + + single = False + double = False + escaped = False + square_depth = 0 + curly_depth = 0 + for index, character in enumerate(text): + if double: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + double = False + continue + if single: + if character == "'": + if index + 1 < len(text) and text[index + 1] == "'": + continue + single = False + continue + if character == '"': + double = True + elif character == "'": + single = True + elif character == "[": + square_depth += 1 + elif character == "]": + square_depth -= 1 + elif character == "{": + curly_depth += 1 + elif character == "}": + curly_depth -= 1 + elif character == ":" and square_depth == 0 and curly_depth == 0: + return text[:index].strip(), text[index + 1 :].strip() + return None + + +def _split_flow_items(text: str) -> list[str]: + """Splits a flow sequence/map body at top-level commas.""" + + items: list[str] = [] + start = 0 + single = False + double = False + escaped = False + square_depth = 0 + curly_depth = 0 + for index, character in enumerate(text): + if double: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + double = False + continue + if single: + if character == "'": + if index + 1 < len(text) and text[index + 1] == "'": + continue + single = False + continue + if character == '"': + double = True + elif character == "'": + single = True + elif character == "[": + square_depth += 1 + elif character == "]": + square_depth -= 1 + elif character == "{": + curly_depth += 1 + elif character == "}": + curly_depth -= 1 + elif character == "," and square_depth == 0 and curly_depth == 0: + items.append(text[start:index].strip()) + start = index + 1 + items.append(text[start:].strip()) + if any(not item for item in items): + raise _Unsupported("empty item in a compact YAML collection") + return items + + +def _scalar(text: str) -> str: + """Returns a quoted or plain scalar without YAML type coercion.""" + + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] == "'": + return text[1:-1].replace("''", "'") + if len(text) >= 2 and text[0] == text[-1] == '"': + try: + value = json.loads(text) + except json.JSONDecodeError as error: + raise _Unsupported(f"unsupported quoted scalar: {error}") from error + if not isinstance(value, str): + raise _Unsupported("expected a string scalar") + return value + return text + + +def _name(text: str) -> str: + value = _scalar(text) + if not _PLAIN_NAME.fullmatch(value): + raise _Unsupported(f"unsupported name {text!r}") + return value + + +def _flow_sequence(text: str) -> list[str]: + if text == "[]": + return [] + return [_name(item) for item in _split_flow_items(text[1:-1])] + + +def _flow_mapping(text: str) -> list[tuple[str, str]]: + if text == "{}": + return [] + entries: list[tuple[str, str]] = [] + for item in _split_flow_items(text[1:-1]): + entry = _split_mapping_entry(item) + if entry is None: + raise _Unsupported(f"unsupported compact mapping entry {item!r}") + key, value = entry + entries.append((_name(key), value)) + return entries + + +def _compact_events(value: str) -> set[str]: + if value.startswith("[") and value.endswith("]"): + return set(_flow_sequence(value)) + if value.startswith("{") and value.endswith("}"): + return {key for key, _ in _flow_mapping(value)} + return {_name(value)} + + +def _permission_value( + value: str, *, line: int, owner: str, scope: str +) -> Issue | None: + value = _scalar(value) + if value == "write": + return Issue(line, f"{owner} grants `{scope}: write`") + if value not in {"none", "read"}: + return Issue( + line, + f"cannot safely interpret {owner} permission `{scope}: {value}`", + ) + return None + + +def _compact_permissions(value: str, *, line: int, owner: str) -> list[Issue]: + scalar = _scalar(value) + if scalar == "write-all": + return [Issue(line, f"{owner} grants `write-all`")] + if scalar in {"read-all", "{}"}: + return [] + if not (value.startswith("{") and value.endswith("}")): + return [ + Issue(line, f"cannot safely interpret {owner} permissions `{value}`") + ] + + issues: list[Issue] = [] + try: + entries = _flow_mapping(value) + except _Unsupported as error: + return [Issue(line, f"cannot safely interpret {owner} permissions: {error}")] + for scope, permission in entries: + issue = _permission_value( + permission, line=line, owner=owner, scope=scope + ) + if issue is not None: + issues.append(issue) + return issues + + +def analyze_workflow(text: str) -> list[Issue]: + """Returns security or ambiguity findings for one workflow's YAML text.""" + + stack: list[tuple[int, str]] = [] + permission_blocks: dict[tuple[str, ...], _PermissionBlock] = {} + permission_issues: list[Issue] = [] + structure_issues: list[Issue] = [] + trigger_issues: list[Issue] = [] + events: set[str] = set() + on_line: int | None = None + on_is_block = False + workflow_permissions_declared = False + block_scalar_indent: int | None = None + + for line_number, raw_line in enumerate(text.splitlines(), start=1): + if "\t" in raw_line[: len(raw_line) - len(raw_line.lstrip())]: + trigger_issues.append( + Issue(line_number, "tab indentation cannot be interpreted safely") + ) + continue + + indent = len(raw_line) - len(raw_line.lstrip(" ")) + content = _strip_comment(raw_line[indent:]) + if not content: + continue + if block_scalar_indent is not None: + if indent > block_scalar_indent: + continue + block_scalar_indent = None + if content in {"---", "..."}: + continue + + while stack and indent <= stack[-1][0]: + stack.pop() + parent = tuple(key for _, key in stack) + + if content.startswith("- "): + if parent == ("on",): + try: + events.add(_name(content[2:].strip())) + except _Unsupported as error: + trigger_issues.append(Issue(line_number, str(error))) + elif parent in permission_blocks: + block = permission_blocks[parent] + permission_issues.append( + Issue( + line_number, + f"cannot safely interpret {block.owner} permissions as a list", + ) + ) + continue + + entry = _split_mapping_entry(content) + if entry is None: + # Plain scalar lines are valid inside block scalars (handled above) + # but not in a mapping structure relevant to this checker. + if parent == ("on",): + trigger_issues.append( + Issue(line_number, "cannot safely interpret workflow triggers") + ) + elif parent in permission_blocks: + block = permission_blocks[parent] + permission_issues.append( + Issue( + line_number, + f"cannot safely interpret {block.owner} permissions", + ) + ) + continue + + raw_key, value = entry + try: + key = _name(raw_key) + except _Unsupported: + key = _scalar(raw_key) + path = parent + (key,) + + if path == ("on",): + if indent != 0 or on_line is not None: + trigger_issues.append( + Issue(line_number, "workflow must have one top-level `on` key") + ) + on_line = line_number + if not value: + on_is_block = True + else: + try: + events.update(_compact_events(value)) + except _Unsupported as error: + trigger_issues.append( + Issue( + line_number, + f"cannot safely interpret workflow triggers: {error}", + ) + ) + elif len(path) == 2 and path[0] == "on" and on_is_block: + events.add(key) + + if path == ("jobs",) and value and value != "{}": + structure_issues.append( + Issue(line_number, "cannot safely inspect compact or aliased `jobs`") + ) + elif len(path) == 2 and path[0] == "jobs" and value: + structure_issues.append( + Issue( + line_number, + f"cannot safely inspect compact or aliased job `{path[1]}`", + ) + ) + if key == "<<" and ( + not parent or (len(parent) == 2 and parent[0] == "jobs") + ): + structure_issues.append( + Issue(line_number, "YAML merge may conceal token permissions") + ) + + permission_owner: str | None = None + if path == ("permissions",): + permission_owner = "workflow" + workflow_permissions_declared = True + elif len(path) == 3 and path[0] == "jobs" and path[2] == "permissions": + permission_owner = f"job `{path[1]}`" + + if permission_owner is not None: + if path in permission_blocks: + permission_issues.append( + Issue(line_number, f"duplicate {permission_owner} permissions") + ) + if value: + permission_issues.extend( + _compact_permissions( + value, line=line_number, owner=permission_owner + ) + ) + else: + permission_blocks[path] = _PermissionBlock( + line=line_number, owner=permission_owner + ) + else: + for prefix, block in permission_blocks.items(): + if path[: len(prefix)] != prefix: + continue + if len(path) == len(prefix) + 1: + block.saw_entry = True + if not value: + permission_issues.append( + Issue( + line_number, + f"cannot safely interpret {block.owner} permission `{key}`", + ) + ) + else: + issue = _permission_value( + value, + line=line_number, + owner=block.owner, + scope=key, + ) + if issue is not None: + permission_issues.append(issue) + elif len(path) > len(prefix) + 1: + permission_issues.append( + Issue( + line_number, + f"cannot safely interpret nested {block.owner} permissions", + ) + ) + + if not value: + stack.append((indent, key)) + elif _BLOCK_SCALAR.fullmatch(value): + block_scalar_indent = indent + + if on_line is None: + trigger_issues.append(Issue(1, "workflow has no top-level `on` key")) + if on_is_block and not events: + trigger_issues.append( + Issue(on_line or 1, "workflow trigger block does not name an event") + ) + + # Syntax/trigger ambiguity prevents this checker from establishing whether + # proposed code can execute, so it is always fatal. Permission ambiguity is + # relevant only after an untrusted trigger has been positively identified; + # release and deployment workflows are intentionally allowed to write. + if trigger_issues: + return trigger_issues + if not events.intersection(_UNTRUSTED_EVENTS): + return [] + + # Without a workflow-level cap, jobs which omit `permissions` inherit the + # repository or organization default. That default is mutable external + # state and may be read/write, so inspecting only the permissions which are + # present cannot prove that proposed code receives a read-only token. An + # explicit empty map, read-all, or read/none mapping provides the required + # fail-closed baseline. Individual jobs may replace that baseline and are + # checked independently below. + if not workflow_permissions_declared: + permission_issues.append( + Issue( + on_line or 1, + "untrusted workflow must declare explicit top-level `permissions`", + ) + ) + + for block in permission_blocks.values(): + if not block.saw_entry: + permission_issues.append( + Issue( + block.line, + f"cannot safely interpret empty {block.owner} permissions block", + ) + ) + return sorted( + permission_issues + structure_issues, + key=lambda issue: (issue.line, issue.message), + ) + + +def _workflow_paths(arguments: Iterable[str]) -> tuple[list[pathlib.Path], list[str]]: + paths: list[pathlib.Path] = [] + errors: list[str] = [] + seen: set[pathlib.Path] = set() + for argument in arguments: + candidate = pathlib.Path(argument) + if candidate.is_dir(): + discovered = sorted( + path + for path in candidate.rglob("*") + if path.is_file() and path.suffix.lower() in _WORKFLOW_SUFFIXES + ) + if not discovered: + errors.append(f"{candidate}: directory contains no workflow YAML files") + candidates = discovered + elif candidate.is_file(): + candidates = [candidate] + else: + errors.append(f"{candidate}: no such workflow file or directory") + continue + + for path in candidates: + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + paths.append(path) + return paths, errors + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "paths", + nargs="+", + help="workflow YAML files or directories to scan recursively", + ) + arguments = parser.parse_args(argv) + + paths, errors = _workflow_paths(arguments.paths) + for error in errors: + print(error, file=sys.stderr) + + failed = bool(errors) + for path in paths: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + print(f"{path}: {error}", file=sys.stderr) + failed = True + continue + for issue in analyze_workflow(text.lstrip("\ufeff")): + print(f"{path}:{issue.line}: {issue.message}", file=sys.stderr) + failed = True + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_check_workflow_permissions.py b/.github/scripts/test_check_workflow_permissions.py new file mode 100644 index 0000000000..fca20c0b51 --- /dev/null +++ b/.github/scripts/test_check_workflow_permissions.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import pathlib +import sys +import tempfile +import textwrap +import unittest + + +_SCRIPT = pathlib.Path(__file__).with_name("check-workflow-permissions.py") +_SPEC = importlib.util.spec_from_file_location("check_workflow_permissions", _SCRIPT) +assert _SPEC is not None and _SPEC.loader is not None +checker = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = checker +_SPEC.loader.exec_module(checker) + + +def _workflow(source: str) -> str: + return textwrap.dedent(source).lstrip() + + +class WorkflowPermissionTests(unittest.TestCase): + def assert_safe(self, source: str) -> None: + self.assertEqual(checker.analyze_workflow(_workflow(source)), []) + + def assert_finding(self, source: str, fragment: str) -> None: + issues = checker.analyze_workflow(_workflow(source)) + self.assertTrue(issues, "expected a workflow-permission finding") + self.assertTrue( + any(fragment in issue.message for issue in issues), + f"{fragment!r} not found in {[issue.message for issue in issues]!r}", + ) + + def test_safe_read_permissions(self) -> None: + self.assert_safe( + """ + name: Safe + on: + pull_request: + merge_group: + permissions: + contents: read + jobs: + test: + permissions: + actions: read + contents: none + runs-on: ubuntu-latest + steps: [] + """ + ) + self.assert_safe( + """ + name: Empty but harmless + on: pull_request + permissions: read-all + jobs: {} + """ + ) + + def test_compact_trigger_forms(self) -> None: + for trigger in ( + "pull_request", + "[push, pull_request]", + "{push: {}, merge_group: {types: [checks_requested]}}", + ): + with self.subTest(trigger=trigger): + self.assert_safe( + f""" + name: Compact + on: {trigger} + permissions: {{contents: read}} + jobs: + test: + runs-on: ubuntu-latest + steps: [] + """ + ) + + def test_top_level_write_permission(self) -> None: + self.assert_finding( + """ + name: Unsafe + on: [push, pull_request] + permissions: + contents: write + jobs: {} + """, + "workflow grants `contents: write`", + ) + self.assert_finding( + """ + name: Unsafe + on: merge_group + permissions: write-all + jobs: {} + """, + "workflow grants `write-all`", + ) + + def test_untrusted_workflow_requires_explicit_permission_cap(self) -> None: + for event in ("pull_request", "merge_group"): + for jobs in ( + "jobs: {}", + """jobs: + test: + permissions: {contents: read} + runs-on: ubuntu-latest + steps: []""", + ): + with self.subTest(event=event, jobs=jobs): + self.assert_finding( + f""" + name: Inherits mutable repository default + on: {event} + {textwrap.indent(jobs, " " * 24).lstrip()} + """, + "must declare explicit top-level `permissions`", + ) + + self.assert_safe( + """ + name: Explicitly no token permissions + on: merge_group + permissions: {} + jobs: {} + """ + ) + + def test_job_write_permission(self) -> None: + self.assert_finding( + """ + name: Unsafe job + on: + merge_group: + permissions: {} + jobs: + publish: + permissions: {packages: write, contents: read} + runs-on: ubuntu-latest + steps: [] + """, + "job `publish` grants `packages: write`", + ) + + def test_unrelated_workflow_may_write(self) -> None: + self.assert_safe( + """ + name: Trusted workflow may inherit repository policy + on: push + jobs: {} + """ + ) + self.assert_safe( + """ + name: Trusted deployment + on: + push: + branches: [main] + workflow_dispatch: + permissions: write-all + jobs: + publish: + permissions: + contents: write + runs-on: ubuntu-latest + steps: [] + """ + ) + + def test_non_permission_write_values_are_ignored(self) -> None: + self.assert_safe( + """ + name: Words are not authority + on: pull_request + permissions: {contents: read} + env: + ACCESS: write + jobs: + test: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: example/action@0123456789abcdef + with: + permissions: write + contents: write + - name: Mention permissions in a script + run: | + permissions: + contents: write + """ + ) + + def test_ambiguous_permission_shapes_fail_closed(self) -> None: + for permissions in ( + "*shared_permissions", + "${{ fromJSON(inputs.permissions) }}", + "[contents, read]", + ): + with self.subTest(permissions=permissions): + self.assert_finding( + f""" + name: Ambiguous + on: pull_request + permissions: {permissions} + jobs: {{}} + """, + "cannot safely interpret workflow permissions", + ) + + def test_cli_accepts_directories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "safe.yml").write_text( + _workflow( + """ + on: pull_request + permissions: read-all + jobs: {} + """ + ), + encoding="utf-8", + ) + nested = root / "nested" + nested.mkdir() + (nested / "unsafe.yaml").write_text( + _workflow( + """ + on: merge_group + permissions: {contents: write} + jobs: {} + """ + ), + encoding="utf-8", + ) + + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + result = checker.main([str(root)]) + self.assertEqual(result, 1) + self.assertIn("unsafe.yaml", stderr.getvalue()) + self.assertIn("contents: write", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_workflow_artifacts.py b/.github/scripts/test_workflow_artifacts.py new file mode 100644 index 0000000000..ec35ee9ace --- /dev/null +++ b/.github/scripts/test_workflow_artifacts.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Regression tests for cross-workflow Actions artifact contracts.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def named_step(workflow: str, name: str) -> str: + marker = f" - name: {name}\n" + before, found, remainder = workflow.partition(marker) + del before + if not found: + raise ValueError(f"workflow has no step named {name!r}") + next_step = re.search(r"(?m)^ - (?:name|uses):", remainder) + if next_step is not None: + remainder = remainder[: next_step.start()] + return marker + remainder + + +def named_job(workflow: str, name: str) -> str: + jobs = list( + re.finditer(r"(?m)^ (?P[A-Za-z0-9_-]+):\n", workflow) + ) + for index, match in enumerate(jobs): + if match.group("name") != name: + continue + end = jobs[index + 1].start() if index + 1 < len(jobs) else None + return workflow[match.start() : end] + raise ValueError(f"workflow has no job named {name!r}") + + +class WorkflowArtifactTests(unittest.TestCase): + def test_manual_release_artifacts_outlive_environment_approval(self) -> None: + release = read(".github/workflows/anneal-release.yml") + retention_name = "ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS" + self.assertIn(f'{retention_name}: "90"', release) + retention = f"retention-days: ${{{{ env.{retention_name} }}}}" + expected_uploads = { + "prepare-release-source": 1, + "build-toolchains": 2, + "prepare-release-pr": 1, + } + for job_name, expected_count in expected_uploads.items(): + with self.subTest(job=job_name): + block = named_job(release, job_name) + upload_count = block.count("uses: actions/upload-artifact@") + self.assertEqual(upload_count, expected_count) + self.assertEqual(block.count(retention), upload_count) + self.assertEqual( + block.count("overwrite: true"), upload_count + ) + + def test_benchmark_survives_delayed_paired_workflow_rerun(self) -> None: + anneal = read(".github/workflows/anneal.yml") + uploader = read(".github/actions/upload-file-artifact/action.yml") + docs = read(".github/workflows/docs.yml") + + producer_name = ( + "ANNEAL_BENCHMARK_ARTIFACT: " + "anneal-ci-duration-benchmarks-${{ github.run_id }}.json" + ) + self.assertIn(producer_name, anneal) + upload = named_step(anneal, "Upload CI duration benchmarks") + self.assertIn("uses: ./.github/actions/upload-file-artifact", upload) + self.assertIn("name: ${{ env.ANNEAL_BENCHMARK_ARTIFACT }}", upload) + self.assertIn("path: ${{ env.ANNEAL_BENCHMARK_ARTIFACT }}", upload) + self.assertIn("retention-days: 90", upload) + + self.assertRegex( + uploader, + r"(?m)^ retention-days:\n" + r" description: .+\n" + r" required: false\n" + r' default: "1"$', + ) + self.assertIn( + "retention-days: ${{ inputs.retention-days }}", uploader + ) + + consumer_name = ( + "BENCHMARK_ARTIFACT: " + "anneal-ci-duration-benchmarks-" + "${{ needs.coordinate.outputs.anneal_run_id }}.json" + ) + self.assertIn(consumer_name, docs) + self.assertIn("name: ${{ env.BENCHMARK_ARTIFACT }}", docs) + self.assertIn( + "run-id: ${{ needs.coordinate.outputs.anneal_run_id }}", docs + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/anneal-release.yml b/.github/workflows/anneal-release.yml index 73eaf60086..d4dfdbc575 100644 --- a/.github/workflows/anneal-release.yml +++ b/.github/workflows/anneal-release.yml @@ -26,6 +26,12 @@ on: permissions: {} +env: + # The manual toolchain release can wait behind matrix work and environment + # approval. Keep every intermediate artifact available for the same long + # window; `.github/scripts/test_workflow_artifacts.py` owns this contract. + ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS: "90" + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} @@ -122,32 +128,72 @@ jobs: TAG="anneal-v$VERSION" gh release create "$TAG" --generate-notes + resolve-release-source: + name: Resolve release source + # The workflow definition and trusted validators must come from main. The + # independently selected source ref remains an explicit input below. + if: | + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + base_sha: ${{ steps.resolve.outputs.base_sha }} + tag_name: ${{ steps.resolve.outputs.tag_name }} + steps: + # This job resolves the operator-selected ref but executes no code from + # it. Its outputs are therefore safe to pass into privileged jobs; do not + # move selected-branch scripts into this job after the output step. + - name: Checkout selected ref without executing it + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.inputs.branch }} + persist-credentials: false + fetch-depth: 0 + + - name: Resolve immutable identifiers + id: resolve + env: + VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid Anneal version: $VERSION" >&2 + exit 1 + fi + + BASE_SHA="$(git rev-parse HEAD)" + if ! [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Checkout did not resolve to a commit SHA: $BASE_SHA" >&2 + exit 1 + fi + SHORT_BASE_SHA="${BASE_SHA:0:12}" + TAG_NAME="anneal-toolchains-v${VERSION}-${GITHUB_RUN_ID}-${SHORT_BASE_SHA}" + + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + prepare-release-source: name: Prepare release source patch + needs: resolve-release-source if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: read - outputs: - base_sha: ${{ steps.prepare.outputs.base_sha }} - short_base_sha: ${{ steps.prepare.outputs.short_base_sha }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ needs.resolve-release-source.outputs.base_sha }} persist-credentials: false fetch-depth: 0 - name: Prepare version bump patch - id: prepare env: VERSION: ${{ github.event.inputs.version }} run: | set -eo pipefail - BASE_SHA="$(git rev-parse HEAD)" - SHORT_BASE_SHA="${BASE_SHA:0:12}" - ./ci/release_anneal_version.sh "$VERSION" python3 anneal/v1/tools/check-release-pr-files.py \ @@ -164,56 +210,24 @@ jobs: exit 1 fi - echo "base_sha=${BASE_SHA}" >> "$GITHUB_OUTPUT" - echo "short_base_sha=${SHORT_BASE_SHA}" >> "$GITHUB_OUTPUT" - - name: Upload release source patch uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: anneal-release-source-patch path: anneal-release-source.patch if-no-files-found: error - retention-days: 1 - - create-release: - name: Create toolchain artifact release - needs: prepare-release-source - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - contents: write # required to create a GitHub release - outputs: - tag_name: ${{ steps.tag.outputs.tag_name }} - steps: - - name: Create Release - id: tag - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ github.event.inputs.version }} - GH_REPO: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} - BASE_SHA: ${{ needs.prepare-release-source.outputs.base_sha }} - SHORT_BASE_SHA: ${{ needs.prepare-release-source.outputs.short_base_sha }} - run: | - set -eo pipefail - TAG_NAME="anneal-toolchains-v${VERSION}-${GITHUB_RUN_ID}-${SHORT_BASE_SHA}" - gh release create "$TAG_NAME" \ - --repo "$GH_REPO" \ - --target "$BASE_SHA" \ - --title "$TAG_NAME" \ - --notes "Machine-generated Anneal toolchain artifacts for cargo-anneal ${VERSION}, built from ${BASE_SHA} after applying the generated release version-bump patch." \ - --draft \ - --prerelease \ - --latest=false - echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" - - publish-artifacts: - name: Publish toolchain artifact (${{ matrix.target }}) - needs: [prepare-release-source, create-release] + retention-days: ${{ env.ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS }} + # A rerun can observe an artifact written by an earlier attempt whose + # upload response was lost. Replace that exact-name artifact so the + # release remains resumable instead of failing on a name conflict. + overwrite: true + + build-toolchains: + name: Build toolchain artifact (${{ matrix.target }}) + needs: resolve-release-source if: github.event_name == 'workflow_dispatch' permissions: - actions: read # Required to download the release source patch. - contents: write # This is required to upload assets to a release. + contents: read runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -239,21 +253,9 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.prepare-release-source.outputs.base_sha }} + ref: ${{ needs.resolve-release-source.outputs.base_sha }} persist-credentials: false - - name: Download release source patch - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: anneal-release-source-patch - path: . - - - name: Apply release source patch - run: | - set -eo pipefail - git apply --check anneal-release-source.patch - git apply anneal-release-source.patch - - name: Free up disk space if: runner.os == 'Linux' run: | @@ -272,11 +274,13 @@ jobs: if: runner.os == 'Linux' run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build and publish toolchain artifact + # Toolchain contents depend only on the selected commit's flake inputs, + # not on cargo-anneal's requested package version. Build the exact commit + # and leave version-patch processing to a separate unprivileged job. + - name: Build toolchain artifact env: - GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG_NAME: ${{ needs.create-release.outputs.tag_name }} + TAG_NAME: ${{ needs.resolve-release-source.outputs.tag_name }} TARGET: ${{ matrix.target }} CARGO_OS: ${{ matrix.cargo_os }} CARGO_ARCH: ${{ matrix.cargo_arch }} @@ -284,7 +288,7 @@ jobs: set -eo pipefail ARCHIVE_NAME="anneal-toolchain-${TARGET}.tar.zst" MAX_GITHUB_RELEASE_ASSET_SIZE=2147483647 - mkdir -p anneal/target anneal/v1/release-download-check anneal/v1/release-metadata + mkdir -p anneal/target anneal/v1/release-metadata nix build ./anneal#omnibus-archive-ci --out-link "anneal/target/${ARCHIVE_NAME}" cp -L "anneal/target/${ARCHIVE_NAME}" "anneal/v1/${ARCHIVE_NAME}" @@ -295,17 +299,9 @@ jobs: exit 1 fi - gh release upload "$TAG_NAME" "anneal/v1/${ARCHIVE_NAME}" --repo "$GH_REPO" --clobber - gh release download "$TAG_NAME" \ - --repo "$GH_REPO" \ - --pattern "$ARCHIVE_NAME" \ - --dir anneal/v1/release-download-check \ - --clobber - cmp "anneal/v1/${ARCHIVE_NAME}" "anneal/v1/release-download-check/${ARCHIVE_NAME}" - url="https://github.com/${GH_REPO}/releases/download/${TAG_NAME}/${ARCHIVE_NAME}" python3 anneal/v1/tools/collect-release-archive-metadata.py \ - --archive "anneal/v1/release-download-check/${ARCHIVE_NAME}" \ + --archive "anneal/v1/${ARCHIVE_NAME}" \ --target "$TARGET" \ --os "$CARGO_OS" \ --arch "$CARGO_ARCH" \ @@ -316,31 +312,41 @@ jobs: if: always() && runner.os == 'Linux' run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + - name: Upload toolchain archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: anneal-toolchain-${{ matrix.target }}.tar.zst + path: anneal/v1/anneal-toolchain-${{ matrix.target }}.tar.zst + if-no-files-found: error + retention-days: ${{ env.ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS }} + archive: false + overwrite: true + - name: Upload toolchain metadata uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: anneal-toolchain-metadata-${{ matrix.target }} + name: ${{ matrix.target }}.json path: anneal/v1/release-metadata/${{ matrix.target }}.json if-no-files-found: error - retention-days: 1 - - release-version-action: - # This job handles manual release triggers. It updates version numbers and - # exocrate archive metadata, then creates a PR for review. - name: Release new Anneal version - needs: [prepare-release-source, create-release, publish-artifacts] + retention-days: ${{ env.ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS }} + archive: false + overwrite: true + + prepare-release-pr: + # Selected-branch helper scripts create the final reviewable patch here, + # before any repository or release write credential is introduced. + name: Prepare final release PR patch + needs: [resolve-release-source, prepare-release-source, build-toolchains] if: github.event_name == 'workflow_dispatch' permissions: actions: read # Required to download toolchain metadata artifacts. - contents: write # Required by the fork-only create-pull-request fallback. - issues: write # Required to label the generated release PR. - pull-requests: write # Required to create pull requests + contents: read runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.prepare-release-source.outputs.base_sha }} + ref: ${{ needs.resolve-release-source.outputs.base_sha }} persist-credentials: false - name: Download release source patch @@ -358,13 +364,13 @@ jobs: - name: Download toolchain metadata uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: anneal-toolchain-metadata-* + pattern: '*.json' path: anneal/v1/release-metadata merge-multiple: true - name: Update Anneal archive metadata env: - TAG_NAME: ${{ needs.create-release.outputs.tag_name }} + TAG_NAME: ${{ needs.resolve-release-source.outputs.tag_name }} run: | set -eo pipefail python3 anneal/v1/tools/update-exocrate-metadata.py \ @@ -384,6 +390,336 @@ jobs: --allowed anneal/v1/README.md \ --required anneal/v1/Cargo.toml + - name: Create final release PR patch + run: | + set -euo pipefail + git diff --binary > anneal-release-final.patch + if [ ! -s anneal-release-final.patch ]; then + echo "Final release PR patch is empty" >&2 + exit 1 + fi + + - name: Upload final release PR patch + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: anneal-release-final-patch + path: anneal-release-final.patch + if-no-files-found: error + retention-days: ${{ env.ANNEAL_RELEASE_ARTIFACT_RETENTION_DAYS }} + overwrite: true + + review-release: + name: Review release inputs + needs: [resolve-release-source, prepare-release-pr, build-toolchains] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + # `github.workflow_sha` identifies the trusted workflow definition. The + # validators from this checkout inspect selected-branch outputs as data; + # no helper from the operator-selected branch executes in this job. + - name: Checkout trusted validators + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: trusted + persist-credentials: false + + - name: Checkout selected source without executing it + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-release-source.outputs.base_sha }} + path: source + persist-credentials: false + + - name: Download final release PR patch + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: anneal-release-final-patch + path: source + + - name: Download toolchain metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: '*.json' + path: release-metadata + merge-multiple: true + + - name: Validate and summarize release inputs + env: + BASE_SHA: ${{ needs.resolve-release-source.outputs.base_sha }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ needs.resolve-release-source.outputs.tag_name }} + run: | + set -euo pipefail + python3 trusted/anneal/v1/tools/validate-release-artifacts.py \ + --metadata-dir release-metadata \ + --tag "$TAG_NAME" \ + --repository "$GH_REPO" + + cd source + git apply --check anneal-release-final.patch + git apply anneal-release-final.patch + rm anneal-release-final.patch + python3 ../trusted/anneal/v1/tools/check-release-pr-files.py \ + --context "Trusted release review" \ + --include-untracked \ + --allowed anneal/v1/Cargo.lock \ + --allowed anneal/v1/Cargo.toml \ + --allowed anneal/v1/README.md \ + --required anneal/v1/Cargo.toml + + { + echo "## Anneal toolchain release" + echo + echo "- Source: \`$BASE_SHA\`" + echo "- Tag: \`$TAG_NAME\`" + echo + echo '### Release PR changes' + echo '```' + git diff --stat + echo '```' + echo + echo '### Toolchain archives' + jq -r \ + '"- \(.target): \(.sha256) (\(.filename))"' \ + ../release-metadata/*.json + } >> "$GITHUB_STEP_SUMMARY" + + publish-release-assets: + name: Publish toolchain release + needs: [resolve-release-source, review-release] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: release + permissions: + actions: read + contents: write + steps: + - name: Checkout trusted validators + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: trusted + persist-credentials: false + + - name: Download toolchain archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: anneal-toolchain-*.tar.zst + path: release-assets + merge-multiple: true + + - name: Download toolchain metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: '*.json' + path: release-metadata + merge-multiple: true + + - name: Validate untrusted release artifacts + env: + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ needs.resolve-release-source.outputs.tag_name }} + run: | + set -euo pipefail + python3 trusted/anneal/v1/tools/validate-release-artifacts.py \ + --metadata-dir release-metadata \ + --archive-dir release-assets \ + --tag "$TAG_NAME" \ + --repository "$GH_REPO" + + - name: Create or reconcile the toolchain release + env: + BASE_SHA: ${{ needs.resolve-release-source.outputs.base_sha }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ needs.resolve-release-source.outputs.tag_name }} + VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + if RELEASE="$( + gh release view "$TAG_NAME" \ + --repo "$GH_REPO" \ + --json isDraft,isPrerelease,tagName,targetCommitish 2>/dev/null + )"; then + if [ "$(jq -r .tagName <<< "$RELEASE")" != "$TAG_NAME" ] \ + || [ "$(jq -r .isPrerelease <<< "$RELEASE")" != true ] \ + || [ "$(jq -r .targetCommitish <<< "$RELEASE")" != "$BASE_SHA" ]; then + echo "Existing release has unexpected metadata: $RELEASE" >&2 + exit 1 + fi + else + gh release create "$TAG_NAME" \ + --repo "$GH_REPO" \ + --target "$BASE_SHA" \ + --title "$TAG_NAME" \ + --notes "Machine-generated Anneal toolchain artifacts for cargo-anneal ${VERSION}, built from exact source commit ${BASE_SHA}." \ + --draft \ + --prerelease \ + --latest=false + RELEASE="$( + jq -cn --arg BASE_SHA "$BASE_SHA" '{ + isDraft: true, + isPrerelease: true, + targetCommitish: $BASE_SHA + }' + )" + fi + + IS_DRAFT="$(jq -r .isDraft <<< "$RELEASE")" + CHECK_DIR="$(mktemp -d)" + trap 'rm -rf "$CHECK_DIR"' EXIT + for ASSET in release-assets/*; do + NAME="${ASSET##*/}" + rm -f "$CHECK_DIR/$NAME" + if gh release download "$TAG_NAME" \ + --repo "$GH_REPO" \ + --pattern "$NAME" \ + --dir "$CHECK_DIR" \ + --clobber 2>/dev/null \ + && cmp "$ASSET" "$CHECK_DIR/$NAME"; then + echo "Existing release asset matches: $NAME" + continue + fi + + if [ "$IS_DRAFT" != true ]; then + echo "Published release has a missing or mismatched $NAME" >&2 + exit 1 + fi + gh release upload "$TAG_NAME" "$ASSET" \ + --repo "$GH_REPO" \ + --clobber + rm -f "$CHECK_DIR/$NAME" + gh release download "$TAG_NAME" \ + --repo "$GH_REPO" \ + --pattern "$NAME" \ + --dir "$CHECK_DIR" \ + --clobber + cmp "$ASSET" "$CHECK_DIR/$NAME" + done + + EXPECTED_ASSETS="$( + find release-assets -mindepth 1 -maxdepth 1 -type f \ + -printf '%f\n' | sort + )" + ACTUAL_ASSETS="$( + gh release view "$TAG_NAME" \ + --repo "$GH_REPO" \ + --json assets \ + --jq '.assets[].name' \ + | sort + )" + if [ "$ACTUAL_ASSETS" != "$EXPECTED_ASSETS" ]; then + echo "Release has an unexpected asset set" >&2 + printf 'Expected:\n%s\nActual:\n%s\n' \ + "$EXPECTED_ASSETS" "$ACTUAL_ASSETS" >&2 + exit 1 + fi + + if [ "$IS_DRAFT" = true ]; then + gh release edit "$TAG_NAME" \ + --repo "$GH_REPO" \ + --draft=false + fi + if [ "$( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json isDraft \ + --jq .isDraft + )" != false ]; then + echo "Toolchain release is still a draft" >&2 + exit 1 + fi + + # GitHub creates a missing tag only when a draft is published. The + # draft's exact target was checked above; now verify the resulting + # ref actually resolves to that commit before downstream PR CI starts + # consuming its public URLs. + TAG_SHA="$( + gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq .sha + )" + if [ "$TAG_SHA" != "$BASE_SHA" ]; then + echo "Release tag points at $TAG_SHA, expected $BASE_SHA" >&2 + exit 1 + fi + + for METADATA in release-metadata/*.json; do + URL="$(jq -r .url "$METADATA")" + AVAILABLE=false + for ATTEMPT in {1..10}; do + if curl --fail --silent --show-error --location --head "$URL" \ + >/dev/null; then + AVAILABLE=true + break + fi + echo "Asset is not public yet (attempt $ATTEMPT): $URL" + sleep 6 + done + if [ "$AVAILABLE" != true ]; then + echo "Release asset did not become public: $URL" >&2 + exit 1 + fi + done + + submit-release-pr: + name: Submit Anneal release PR + needs: [resolve-release-source, prepare-release-pr, publish-release-assets] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + issues: write + pull-requests: write + steps: + # This checkout is treated only as Git data. Before the bot token is + # passed to the pinned PR action, inline trusted workflow code applies + # and constrains the prepared patch without running repository scripts. + - name: Checkout selected source without executing it + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-release-source.outputs.base_sha }} + persist-credentials: false + + - name: Download final release PR patch + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: anneal-release-final-patch + path: . + + - name: Apply and constrain release PR patch + run: | + set -euo pipefail + git apply --check anneal-release-final.patch + git apply anneal-release-final.patch + rm anneal-release-final.patch + git diff --check + + mapfile -t CHANGED < <(git diff --name-only) + if [ "${#CHANGED[@]}" -eq 0 ]; then + echo "Release patch changed no tracked files" >&2 + exit 1 + fi + FOUND_MANIFEST=false + for PATH in "${CHANGED[@]}"; do + case "$PATH" in + anneal/v1/Cargo.lock|anneal/v1/Cargo.toml|anneal/v1/README.md) + ;; + *) + echo "Release patch changed unexpected path: $PATH" >&2 + exit 1 + ;; + esac + if [ "$PATH" = anneal/v1/Cargo.toml ]; then + FOUND_MANIFEST=true + fi + done + if [ "$FOUND_MANIFEST" != true ]; then + echo "Release patch did not change anneal/v1/Cargo.toml" >&2 + exit 1 + fi + - name: Submit PR id: submit-pr-upstream if: github.repository == 'google/zerocopy' @@ -422,22 +758,13 @@ jobs: - name: Add labels if: steps.submit-pr-upstream.outputs.pull-request-operation == 'created' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.submit-pr-upstream.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "hide-from-release-notes" - name: Add labels (fork test) if: steps.submit-pr-fork.outputs.pull-request-operation == 'created' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.submit-pr-fork.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "hide-from-release-notes" - - - name: Publish toolchain artifact release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG_NAME: ${{ needs.create-release.outputs.tag_name }} - run: | - set -eo pipefail - gh release edit "$TAG_NAME" --repo "$GH_REPO" --draft=false diff --git a/.github/workflows/anneal.yml b/.github/workflows/anneal.yml index 384c1a161c..3ebe4439b2 100644 --- a/.github/workflows/anneal.yml +++ b/.github/workflows/anneal.yml @@ -28,6 +28,10 @@ env: # the same filename after downloading by immutable artifact ID. Keep the # filename centralized here; the ID, not this name, selects the artifact. ANNEAL_TOOLCHAIN_ARCHIVE: anneal-exocrate.tar.zst + # This run-derived filename is the producer/consumer contract with the + # trusted publisher in `.github/workflows/docs.yml`. The test job may create + # the data, but only that post-CI workflow can write benchmark history. + ANNEAL_BENCHMARK_ARTIFACT: anneal-ci-duration-benchmarks-${{ github.run_id }}.json # Keep these bounded, download-only retry counts coordinated with ci.yml and # its Dockerfile. Neither setting reruns a build or test command. CARGO_NET_RETRY: "10" @@ -62,6 +66,7 @@ jobs: anneal/v1/tools/test_exocrate_metadata_helpers.py \ anneal/v1/tools/test_release_pr_files.py \ anneal/v1/tools/collect-release-archive-metadata.py \ + anneal/v1/tools/validate-release-artifacts.py \ anneal/v1/tools/update-exocrate-metadata.py \ anneal/tests/test_prune_lake_cache.py \ anneal/prune-lake-cache.py \ @@ -82,7 +87,7 @@ jobs: needs: v2_nix_cache permissions: actions: read # Required to download the toolchain artifact. - contents: write # Required to push benchmark data to the storage branch + contents: read env: ANNEAL_TOOLCHAIN_DIR: ${{ github.workspace }}/anneal/v1/target/anneal-toolchain __ZEROCOPY_LOCAL_DEV: 1 @@ -155,25 +160,26 @@ jobs: '[ $test[0][0], $total[0][0] - ]' > output.json - - - name: Store CI duration benchmarks - # Only trusted main-branch pushes to the canonical repository can - # update the benchmark-data branch. Pull requests from forks receive a - # read-only GITHUB_TOKEN, and forks running this workflow should not try - # to publish benchmark history for google/zerocopy. - if: github.event_name == 'push' && github.repository == 'google/zerocopy' && github.ref == 'refs/heads/main' - uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + ]' > "$ANNEAL_BENCHMARK_ARTIFACT" + + - name: Upload CI duration benchmarks + # This job runs PR-controlled code and therefore never receives write + # permission. On canonical main, publish its small result as an + # immutable Actions artifact. `docs.yml` selects this exact run only + # after both CI workflows succeed, validates the JSON, and performs the + # privileged benchmark-data update. + if: | + github.event_name == 'push' && + github.repository == 'google/zerocopy' && + github.ref == 'refs/heads/main' + uses: ./.github/actions/upload-file-artifact with: - name: CI Durations - tool: 'customSmallerIsBetter' - output-file-path: output.json - gh-pages-branch: benchmark-data - auto-push: true - save-data-file: true - benchmark-data-dir-path: dashboard - fail-on-alert: false - github-token: ${{ secrets.GITHUB_TOKEN }} + name: ${{ env.ANNEAL_BENCHMARK_ARTIFACT }} + path: ${{ env.ANNEAL_BENCHMARK_ARTIFACT }} + # docs.yml may pair this successful Anneal run with a delayed rerun + # of core CI. This JSON is tiny, so retain it long enough for that + # recovery path instead of inheriting the one-day matrix default. + retention-days: 90 verify_examples: name: Verify V1 example (${{ matrix.example }}) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0422ff3082..84b4bf9e26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1007,7 +1007,9 @@ jobs: image_artifact_id: ${{ steps.upload_image.outputs.artifact-id }} permissions: contents: read - packages: write # required to push docker caches to ghcr.io + # This job runs on PR and merge-group source, so it may consume but never + # mutate the shared cache. `publish-ci-cache.yml` owns the write token. + packages: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1015,18 +1017,6 @@ jobs: - name: Set up Docker uses: ./.github/actions/setup-docker-with-retry - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate sanitized Docker tag - id: docker_tag - env: - REF_NAME: ${{ github.ref_name }} - shell: bash - run: | - echo "tag=${REF_NAME//\//-}" >> "$GITHUB_OUTPUT" - name: Build, cache, and export image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 @@ -1040,12 +1030,7 @@ jobs: # upload-file-artifact's direct (non-ZIP) transfer. This path and tag # are coupled to the workflow-level producer/consumer values above. outputs: type=docker,dest=${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }},compression=gzip - cache-from: | - type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:${{ steps.docker_tag.outputs.tag }} - type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main - # External pull requests have read-only package permissions, so they - # can consume GHCR caches but cannot export updated cache layers. - cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:{0},mode=max', steps.docker_tag.outputs.tag) || '' }} + cache-from: type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main # Uploading is the producer's final gate. Matrix jobs cannot start until # this succeeds and the job exposes its artifact ID, so no runner is spent diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a5c474346c..3d8021aa51 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -144,10 +144,86 @@ jobs: .[] | select(.name == "Anneal Tests") | "anneal_run_id=\(.id)" ' <<< "$RUNS" >> "$GITHUB_OUTPUT" + publish-benchmarks: + name: Publish CI duration benchmarks + runs-on: ubuntu-latest + needs: coordinate + if: needs.coordinate.outputs.deploy == 'true' + permissions: + actions: read + contents: write + env: + # Keep this filename formula coordinated with + # `ANNEAL_BENCHMARK_ARTIFACT` in `anneal.yml`. Including the authorized + # run ID prevents this privileged job from selecting another run's data. + BENCHMARK_ARTIFACT: anneal-ci-duration-benchmarks-${{ needs.coordinate.outputs.anneal_run_id }}.json + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.coordinate.outputs.sha }} + persist-credentials: false + + - name: Download benchmark input from the authorized Anneal run + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ env.BENCHMARK_ARTIFACT }} + path: ${{ runner.temp }}/benchmarks + repository: ${{ github.repository }} + run-id: ${{ needs.coordinate.outputs.anneal_run_id }} + github-token: ${{ github.token }} + + - name: Validate untrusted benchmark input + env: + BENCHMARK_DIR: ${{ runner.temp }}/benchmarks + shell: bash + run: | + set -euo pipefail + EXPECTED="$BENCHMARK_DIR/$BENCHMARK_ARTIFACT" + + mapfile -d '' FILES < <( + find "$BENCHMARK_DIR" -mindepth 1 -maxdepth 1 -print0 + ) + if [ "${#FILES[@]}" -ne 1 ] \ + || [ "${FILES[0]}" != "$EXPECTED" ] \ + || [ ! -f "$EXPECTED" ] \ + || [ -L "$EXPECTED" ]; then + echo "Benchmark artifact did not contain exactly $EXPECTED" >&2 + exit 1 + fi + + jq -e ' + type == "array" + and length == 2 + and .[0].name == "Test Time" + and .[1].name == "Total CI Duration (All Steps)" + and all(.[ ]; + (keys | sort) == ["name", "unit", "value"] + and .unit == "seconds" + and (.value | type) == "number" + and .value >= 0 + and .value < 604800 + ) + ' "$EXPECTED" >/dev/null + + - name: Store CI duration benchmarks + uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1 + with: + name: CI Durations + tool: 'customSmallerIsBetter' + output-file-path: ${{ runner.temp }}/benchmarks/${{ env.BENCHMARK_ARTIFACT }} + gh-pages-branch: benchmark-data + auto-push: true + save-data-file: true + benchmark-data-dir-path: dashboard + fail-on-alert: false + github-token: ${{ github.token }} + build: name: Build runs-on: ubuntu-latest - needs: coordinate + # Benchmark publication updates the branch copied below, so do not race the + # branch checkout against that privileged write. + needs: [coordinate, publish-benchmarks] if: needs.coordinate.outputs.deploy == 'true' defaults: run: diff --git a/.github/workflows/publish-ci-cache.yml b/.github/workflows/publish-ci-cache.yml new file mode 100644 index 0000000000..c1d6c3e58f --- /dev/null +++ b/.github/workflows/publish-ci-cache.yml @@ -0,0 +1,94 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# PR and merge-queue workflows must never receive package write permission. +# After core CI succeeds on canonical main, this default-branch workflow +# rebuilds the same Dockerfile and alone updates the shared BuildKit cache. + +name: Publish CI Docker cache +on: + # This name is coupled to the top-level `name` in `ci.yml`. Unlike a direct + # PR trigger, workflow_run executes this workflow's trusted default-branch + # definition, so checked source cannot grant itself the write token below. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["Build & Tests"] + types: [completed] + branches: [main] + +permissions: {} + +concurrency: + group: publish-ci-docker-cache + cancel-in-progress: true + +jobs: + authorize: + name: Authorize canonical main + runs-on: ubuntu-latest + if: | + github.repository == 'google/zerocopy' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + permissions: + contents: read + outputs: + sha: ${{ steps.authorize.outputs.sha }} + steps: + - name: Require the current main SHA + id: authorize + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + shell: bash + run: | + set -euo pipefail + MAIN_SHA="$( + gh api \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "repos/$REPOSITORY/git/ref/heads/main" \ + --jq .object.sha + )" + if [ "$MAIN_SHA" != "$SOURCE_SHA" ]; then + echo "Refusing to publish a stale cache for $SOURCE_SHA" >&2 + exit 1 + fi + echo "sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + publish: + name: Publish main cache + runs-on: ubuntu-latest + needs: authorize + permissions: + contents: read + packages: write + env: + CARGO_NET_RETRY: "10" + RUSTUP_MAX_RETRIES: "10" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.authorize.outputs.sha }} + persist-credentials: false + + - name: Set up authenticated Docker + uses: ./.github/actions/setup-docker-with-retry + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build and publish main cache + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: .github/workflows/Dockerfile + provenance: false + cache-from: type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main + cache-to: type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main,mode=max diff --git a/anneal/v1/tools/check-release-flow-dry-run.sh b/anneal/v1/tools/check-release-flow-dry-run.sh index 5a26e47b10..e70faf2a15 100644 --- a/anneal/v1/tools/check-release-flow-dry-run.sh +++ b/anneal/v1/tools/check-release-flow-dry-run.sh @@ -94,6 +94,11 @@ for target in linux-x86_64 linux-aarch64 macos-x86_64 macos-aarch64; do EOF done +python3 anneal/v1/tools/validate-release-artifacts.py \ + --metadata-dir anneal/v1/release-metadata \ + --tag "$TAG_NAME" \ + --repository google/zerocopy + python3 anneal/v1/tools/update-exocrate-metadata.py \ --cargo-toml anneal/v1/Cargo.toml \ --metadata-dir anneal/v1/release-metadata \ @@ -144,26 +149,58 @@ import pathlib workflow = pathlib.Path(".github/workflows/anneal-release.yml").read_text(encoding="utf-8") -create_release = 'gh release create "$TAG_NAME"' -if create_release not in workflow: - raise SystemExit("release workflow no longer creates a toolchain release with TAG_NAME") - -create_release_index = workflow.index(create_release) -publish_release_index = workflow.find('gh release edit "$TAG_NAME"') -if publish_release_index == -1: - raise SystemExit("release workflow must publish the draft toolchain release after uploads succeed") -if publish_release_index < create_release_index: - raise SystemExit("release workflow publishes the toolchain release before creating it") - -create_release_block = workflow[create_release_index:publish_release_index] -if "--draft" not in create_release_block: - raise SystemExit( - "toolchain release must be created as a draft so GitHub immutable releases still allow asset uploads" - ) - -publish_release_block = workflow[publish_release_index:publish_release_index + 500] -if "--draft=false" not in publish_release_block: - raise SystemExit("toolchain release publish step must pass --draft=false") +def job(name: str, next_name: str | None) -> str: + start = workflow.index(f" {name}:\n") + end = len(workflow) if next_name is None else workflow.index(f" {next_name}:\n") + return workflow[start:end] + + +resolve = job("resolve-release-source", "prepare-release-source") +prepare = job("prepare-release-source", "build-toolchains") +build = job("build-toolchains", "prepare-release-pr") +prepare_pr = job("prepare-release-pr", "review-release") +review = job("review-release", "publish-release-assets") +publish = job("publish-release-assets", "submit-release-pr") +submit = job("submit-release-pr", None) + +for name, block in { + "resolve-release-source": resolve, + "prepare-release-source": prepare, + "build-toolchains": build, + "prepare-release-pr": prepare_pr, + "review-release": review, +}.items(): + if "contents: write" in block or "GOOGLE_PR_CREATION_BOT_TOKEN" in block: + raise SystemExit(f"unprivileged {name} job gained a repository credential") + +if "nix build" not in build or "gh release" in build or "GH_TOKEN" in build: + raise SystemExit("toolchain builders must build without release credentials") +if "git apply anneal-release-source.patch" in build: + raise SystemExit("toolchain builders must build the exact selected commit") +if "validate-release-artifacts.py" not in review: + raise SystemExit("release review must validate matrix-produced metadata") + +if "environment: release" not in publish or "contents: write" not in publish: + raise SystemExit("asset publisher must use the release environment and write token") +for forbidden in ( + "nix build", + "release_anneal_version.sh", + "update-exocrate-metadata.py", + "GOOGLE_PR_CREATION_BOT_TOKEN", +): + if forbidden in publish: + raise SystemExit(f"asset publisher executes or receives forbidden input: {forbidden}") +if "validate-release-artifacts.py" not in publish: + raise SystemExit("asset publisher must validate archives with trusted code") +if '--draft' not in publish or '--draft=false' not in publish: + raise SystemExit("asset publisher must stage a draft before publishing it") + +if "GOOGLE_PR_CREATION_BOT_TOKEN" not in submit: + raise SystemExit("PR submitter is missing the bot credential") +if "gh release" in submit or "nix build" in submit: + raise SystemExit("PR submitter must not hold release-asset authority") +if workflow.index(" publish-release-assets:\n") > workflow.index(" submit-release-pr:\n"): + raise SystemExit("toolchain assets must be public before the release PR is submitted") PY echo "Anneal release dry-run checks passed." diff --git a/anneal/v1/tools/test_validate_release_artifacts.py b/anneal/v1/tools/test_validate_release_artifacts.py new file mode 100644 index 0000000000..1c1f1db166 --- /dev/null +++ b/anneal/v1/tools/test_validate_release_artifacts.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Unit tests for trusted Anneal release artifact validation.""" + +from __future__ import annotations + +import contextlib +import hashlib +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path + + +TOOLS = Path(__file__).resolve().parent +TAG = "anneal-toolchains-v1.2.3-456-deadbeefcafe" +REPOSITORY = "google/zerocopy" + + +def load_validator(): + path = TOOLS / "validate-release-artifacts.py" + spec = importlib.util.spec_from_file_location("validate_release_artifacts", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = load_validator() + + +class ValidateReleaseArtifactsTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.metadata_dir = self.root / "metadata" + self.archive_dir = self.root / "archives" + self.metadata_dir.mkdir() + self.archive_dir.mkdir() + self.metadata = {} + + for target, (os_name, arch) in validator.EXPECTED_TARGETS.items(): + filename = validator.archive_filename(target) + contents = f"archive contents for {target}\n".encode() + (self.archive_dir / filename).write_bytes(contents) + metadata = { + "target": target, + "os": os_name, + "arch": arch, + "filename": filename, + "sha256": hashlib.sha256(contents).hexdigest(), + "url": validator.release_url(REPOSITORY, TAG, filename), + } + self.metadata[target] = metadata + self._write_metadata(target) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def _metadata_path(self, target: str) -> Path: + return self.metadata_dir / f"{target}.json" + + def _write_metadata(self, target: str) -> None: + self._metadata_path(target).write_text( + json.dumps(self.metadata[target], indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + def _validate(self, *, with_archives: bool = True): + return validator.validate_release_artifacts( + metadata_dir=self.metadata_dir, + archive_dir=self.archive_dir if with_archives else None, + tag=TAG, + repository=REPOSITORY, + ) + + def test_accepts_exact_metadata_and_archives(self) -> None: + self.assertEqual(self._validate(), self.metadata) + + def test_archive_validation_is_optional(self) -> None: + for archive in self.archive_dir.iterdir(): + archive.unlink() + self.assertEqual(self._validate(with_archives=False), self.metadata) + + def test_metadata_directory_must_have_exact_files(self) -> None: + target = "linux-x86_64" + path = self._metadata_path(target) + path.unlink() + with self.assertRaisesRegex(validator.ValidationError, "missing linux-x86_64.json"): + self._validate() + + self._write_metadata(target) + (self.metadata_dir / "extra.json").write_text("{}", encoding="utf-8") + with self.assertRaisesRegex(validator.ValidationError, "unexpected extra.json"): + self._validate() + + def test_archive_directory_must_have_exact_files(self) -> None: + target = "linux-x86_64" + filename = validator.archive_filename(target) + path = self.archive_dir / filename + path.unlink() + with self.assertRaisesRegex(validator.ValidationError, f"missing {filename}"): + self._validate() + + path.write_bytes(b"archive contents for linux-x86_64\n") + (self.archive_dir / "extra.tar.zst").write_bytes(b"extra") + with self.assertRaisesRegex(validator.ValidationError, "unexpected extra.tar.zst"): + self._validate() + + def test_rejects_non_object_and_inexact_json_schema(self) -> None: + target = "linux-x86_64" + path = self._metadata_path(target) + cases = [ + ([], "one JSON object"), + ( + { + key: value + for key, value in self.metadata[target].items() + if key != "url" + }, + "missing url", + ), + ({**self.metadata[target], "size": 123}, "unexpected size"), + ] + for value, message in cases: + with self.subTest(message=message): + path.write_text(json.dumps(value), encoding="utf-8") + with self.assertRaisesRegex(validator.ValidationError, message): + self._validate() + + def test_rejects_duplicate_json_keys(self) -> None: + target = "linux-x86_64" + metadata = self.metadata[target] + self._metadata_path(target).write_text( + "{" + f'"target": {json.dumps(target)}, "target": {json.dumps(target)}, ' + + ", ".join( + f"{json.dumps(key)}: {json.dumps(value)}" + for key, value in metadata.items() + if key != "target" + ) + + "}", + encoding="utf-8", + ) + with self.assertRaisesRegex(validator.ValidationError, "duplicate JSON key 'target'"): + self._validate() + + def test_all_metadata_values_must_be_strings(self) -> None: + target = "linux-x86_64" + for key in validator.EXPECTED_METADATA_KEYS: + with self.subTest(key=key): + original = self.metadata[target][key] + self.metadata[target][key] = 1 + self._write_metadata(target) + with self.assertRaisesRegex( + validator.ValidationError, f"non-string values for: {key}" + ): + self._validate() + self.metadata[target][key] = original + + def test_rejects_unreasonably_large_metadata(self) -> None: + target = "linux-x86_64" + with self._metadata_path(target).open("wb") as metadata: + metadata.truncate(validator.MAX_METADATA_FILE_SIZE + 1) + with self.assertRaisesRegex(validator.ValidationError, "maximum is 65536 bytes"): + self._validate() + + def test_rejects_wrong_target_platform_filename_and_url(self) -> None: + target = "linux-x86_64" + filename = validator.archive_filename(target) + wrong_values = { + "target": "linux-aarch64", + "os": "macos", + "arch": "aarch64", + "filename": "anneal-toolchain-linux-x86_64.zip", + "url": validator.release_url(REPOSITORY, "another-tag", filename), + } + for key, wrong_value in wrong_values.items(): + with self.subTest(key=key): + original = self.metadata[target][key] + self.metadata[target][key] = wrong_value + self._write_metadata(target) + with self.assertRaisesRegex(validator.ValidationError, f"has {key}="): + self._validate() + self.metadata[target][key] = original + + def test_url_must_exactly_match_repository_and_tag(self) -> None: + target = "linux-x86_64" + filename = validator.archive_filename(target) + url = self.metadata[target]["url"] + for wrong_url in ( + url.replace("google/zerocopy", "attacker/zerocopy"), + url.replace(TAG, TAG + "-other"), + url.replace("https://", "http://"), + url + "?download=1", + validator.release_url(REPOSITORY, TAG, filename + ".other"), + ): + with self.subTest(url=wrong_url): + self.metadata[target]["url"] = wrong_url + self._write_metadata(target) + with self.assertRaisesRegex(validator.ValidationError, "has url="): + self._validate() + self.metadata[target]["url"] = url + + def test_sha256_must_be_lowercase_hex(self) -> None: + target = "linux-x86_64" + for sha256 in ("a" * 63, "A" * 64, "g" * 64): + with self.subTest(sha256=sha256): + self.metadata[target]["sha256"] = sha256 + self._write_metadata(target) + with self.assertRaisesRegex(validator.ValidationError, "64 lowercase hexadecimal"): + self._validate() + + def test_archive_hash_must_match_metadata(self) -> None: + target = "linux-x86_64" + (self.archive_dir / validator.archive_filename(target)).write_bytes(b"tampered") + with self.assertRaisesRegex(validator.ValidationError, "has sha256"): + self._validate() + + def test_archive_must_not_exceed_github_asset_limit(self) -> None: + self.assertEqual(validator.GITHUB_RELEASE_ASSET_SIZE_LIMIT, 2_147_483_647) + target = "linux-x86_64" + path = self.archive_dir / validator.archive_filename(target) + with path.open("wb") as archive: + archive.truncate(validator.GITHUB_RELEASE_ASSET_SIZE_LIMIT + 1) + with self.assertRaisesRegex(validator.ValidationError, "must not exceed 2147483647 bytes"): + self._validate() + + def test_metadata_and_archives_must_be_regular_non_symlink_files(self) -> None: + target = "linux-x86_64" + metadata_path = self._metadata_path(target) + real_metadata = self.root / "real-metadata.json" + metadata_path.replace(real_metadata) + metadata_path.symlink_to(real_metadata) + with self.assertRaisesRegex(validator.ValidationError, "regular non-symlink"): + self._validate() + + metadata_path.unlink() + real_metadata.replace(metadata_path) + archive_path = self.archive_dir / validator.archive_filename(target) + real_archive = self.root / "real-archive.tar.zst" + archive_path.replace(real_archive) + archive_path.symlink_to(real_archive) + with self.assertRaisesRegex(validator.ValidationError, "regular non-symlink"): + self._validate() + + def test_directories_must_not_be_symlinks(self) -> None: + real_metadata_dir = self.root / "real-metadata" + self.metadata_dir.replace(real_metadata_dir) + self.metadata_dir.symlink_to(real_metadata_dir, target_is_directory=True) + with self.assertRaisesRegex(validator.ValidationError, "non-symlink directory"): + self._validate() + + def test_cli_accepts_optional_archive_directory_and_reports_errors(self) -> None: + arguments = [ + "--metadata-dir", + str(self.metadata_dir), + "--archive-dir", + str(self.archive_dir), + "--tag", + TAG, + "--repository", + REPOSITORY, + ] + self.assertEqual(validator.main(arguments), 0) + + self.metadata["linux-x86_64"]["url"] = "https://example.com/archive" + self._write_metadata("linux-x86_64") + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + self.assertEqual(validator.main(arguments), 1) + self.assertIn("error:", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/anneal/v1/tools/validate-release-artifacts.py b/anneal/v1/tools/validate-release-artifacts.py new file mode 100755 index 0000000000..e668f6478e --- /dev/null +++ b/anneal/v1/tools/validate-release-artifacts.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +"""Validate untrusted Anneal release metadata and, optionally, archives. + +This helper is intended to run from a trusted workflow checkout. Metadata and +archives produced by release matrix jobs are untrusted inputs: do not move this +validation into the checkout used to build those inputs. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import sys +from pathlib import Path + + +# Keep this in sync with the release matrix in +# .github/workflows/anneal-release.yml and the exocrate sections in +# anneal/v1/Cargo.toml. Exact validation is deliberate: adding or renaming a +# release platform must fail until every consumer of the platform matrix has +# been reviewed and updated together. +EXPECTED_TARGETS = { + "linux-x86_64": ("linux", "x86_64"), + "linux-aarch64": ("linux", "aarch64"), + "macos-x86_64": ("macos", "x86_64"), + "macos-aarch64": ("macos", "aarch64"), +} + +# collect-release-archive-metadata.py is the producer of this schema. Keep the +# two helpers coordinated; accepting unknown keys would let a producer change +# meaning without requiring a corresponding change to this trusted validator. +EXPECTED_METADATA_KEYS = frozenset( + {"target", "os", "arch", "filename", "sha256", "url"} +) + +# GitHub documents a maximum size of 2 GiB minus one byte for a single release +# asset. Check the actual file, rather than trusting matrix-produced metadata. +GITHUB_RELEASE_ASSET_SIZE_LIMIT = 2_147_483_647 +MAX_METADATA_FILE_SIZE = 64 * 1024 +SHA256_RE = re.compile(r"[0-9a-f]{64}") + + +class ValidationError(Exception): + """An untrusted release artifact did not match the trusted contract.""" + + +def archive_filename(target: str) -> str: + return f"anneal-toolchain-{target}.tar.zst" + + +def release_url(repository: str, tag: str, filename: str) -> str: + return f"https://github.com/{repository}/releases/download/{tag}/{filename}" + + +def _require_directory(path: Path, description: str) -> None: + try: + mode = path.lstat().st_mode + except OSError as error: + raise ValidationError(f"cannot inspect {description} {path}: {error}") from error + if not stat.S_ISDIR(mode): + raise ValidationError(f"{description} is not a non-symlink directory: {path}") + + +def _require_regular_file(path: Path, description: str) -> os.stat_result: + try: + file_stat = path.lstat() + except OSError as error: + raise ValidationError(f"cannot inspect {description} {path}: {error}") from error + if not stat.S_ISREG(file_stat.st_mode): + raise ValidationError(f"{description} is not a regular non-symlink file: {path}") + return file_stat + + +def _require_exact_directory_entries( + directory: Path, expected_names: set[str], description: str +) -> dict[str, Path]: + _require_directory(directory, f"{description} directory") + try: + entries = {entry.name: entry for entry in directory.iterdir()} + except OSError as error: + raise ValidationError( + f"cannot list {description} directory {directory}: {error}" + ) from error + + actual_names = set(entries) + missing = sorted(expected_names - actual_names) + unexpected = sorted(actual_names - expected_names) + errors = [] + if missing: + errors.append("missing " + ", ".join(missing)) + if unexpected: + errors.append("unexpected " + ", ".join(unexpected)) + if errors: + raise ValidationError(f"invalid {description} directory {directory}: {'; '.join(errors)}") + return entries + + +def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + value = {} + for key, item in pairs: + if key in value: + raise ValidationError(f"duplicate JSON key {key!r}") + value[key] = item + return value + + +def _reject_json_constant(value: str): + raise ValidationError(f"invalid JSON constant {value!r}") + + +def _load_json(path: Path) -> object: + file_stat = _require_regular_file(path, "metadata file") + if file_stat.st_size > MAX_METADATA_FILE_SIZE: + raise ValidationError( + f"metadata file {path} is {file_stat.st_size} bytes; " + f"maximum is {MAX_METADATA_FILE_SIZE} bytes" + ) + try: + contents = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise ValidationError(f"cannot read metadata file {path}: {error}") from error + try: + return json.loads( + contents, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_json_constant, + ) + except (json.JSONDecodeError, ValidationError) as error: + raise ValidationError(f"invalid metadata file {path}: {error}") from error + + +def _validate_metadata( + path: Path, target: str, platform: tuple[str, str], tag: str, repository: str +) -> dict[str, str]: + value = _load_json(path) + if not isinstance(value, dict): + raise ValidationError(f"metadata file {path} must contain one JSON object") + + actual_keys = set(value) + missing = sorted(EXPECTED_METADATA_KEYS - actual_keys) + unexpected = sorted(actual_keys - EXPECTED_METADATA_KEYS) + if missing or unexpected: + details = [] + if missing: + details.append("missing " + ", ".join(missing)) + if unexpected: + details.append("unexpected " + ", ".join(unexpected)) + raise ValidationError(f"metadata file {path} has invalid keys: {'; '.join(details)}") + + non_strings = sorted(key for key, item in value.items() if not isinstance(item, str)) + if non_strings: + raise ValidationError( + f"metadata file {path} has non-string values for: {', '.join(non_strings)}" + ) + + # The exact key and type checks above establish this narrower runtime type. + metadata: dict[str, str] = value + os_name, arch = platform + filename = archive_filename(target) + expected_fields = { + "target": target, + "os": os_name, + "arch": arch, + "filename": filename, + "url": release_url(repository, tag, filename), + } + for key, expected in expected_fields.items(): + if metadata[key] != expected: + raise ValidationError( + f"metadata file {path} has {key}={metadata[key]!r}; expected {expected!r}" + ) + + if SHA256_RE.fullmatch(metadata["sha256"]) is None: + raise ValidationError( + f"metadata file {path} sha256 must be 64 lowercase hexadecimal characters" + ) + return metadata + + +def _sha256_file(path: Path) -> str: + hasher = hashlib.sha256() + try: + with path.open("rb") as archive: + for chunk in iter(lambda: archive.read(1024 * 1024), b""): + hasher.update(chunk) + except OSError as error: + raise ValidationError(f"cannot read archive file {path}: {error}") from error + return hasher.hexdigest() + + +def _validate_archive(path: Path, expected_sha256: str) -> None: + file_stat = _require_regular_file(path, "archive file") + if file_stat.st_size > GITHUB_RELEASE_ASSET_SIZE_LIMIT: + raise ValidationError( + f"archive file {path} is {file_stat.st_size} bytes; GitHub release assets " + f"must not exceed {GITHUB_RELEASE_ASSET_SIZE_LIMIT} bytes" + ) + + actual_sha256 = _sha256_file(path) + if actual_sha256 != expected_sha256: + raise ValidationError( + f"archive file {path} has sha256 {actual_sha256}; expected {expected_sha256}" + ) + + +def validate_release_artifacts( + metadata_dir: Path, + tag: str, + repository: str, + archive_dir: Path | None = None, +) -> dict[str, dict[str, str]]: + """Validate and return metadata indexed by its exact release target.""" + + expected_metadata_names = {f"{target}.json" for target in EXPECTED_TARGETS} + metadata_entries = _require_exact_directory_entries( + metadata_dir, expected_metadata_names, "metadata" + ) + + metadata = { + target: _validate_metadata( + metadata_entries[f"{target}.json"], target, platform, tag, repository + ) + for target, platform in EXPECTED_TARGETS.items() + } + + if archive_dir is not None: + expected_archive_names = {archive_filename(target) for target in EXPECTED_TARGETS} + archive_entries = _require_exact_directory_entries( + archive_dir, expected_archive_names, "archive" + ) + for target in EXPECTED_TARGETS: + filename = archive_filename(target) + _validate_archive(archive_entries[filename], metadata[target]["sha256"]) + + return metadata + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metadata-dir", required=True, type=Path) + parser.add_argument("--archive-dir", type=Path) + parser.add_argument("--tag", required=True) + parser.add_argument("--repository", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + try: + validate_release_artifacts( + metadata_dir=args.metadata_dir, + archive_dir=args.archive_dir, + tag=args.tag, + repository=args.repository, + ) + except ValidationError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/check_actions.sh b/ci/check_actions.sh index 12e56d4f67..a653d566b1 100755 --- a/ci/check_actions.sh +++ b/ci/check_actions.sh @@ -20,6 +20,16 @@ if [ ! -x "$HOME/.cargo/bin/action-validator" ]; then cargo install -q action-validator --version 0.8.0 --locked fi export PATH="$HOME/.cargo/bin:$PATH" +export PYTHONDONTWRITEBYTECODE=1 + +# Pull request and merge-group jobs execute proposed repository code. Keep +# their GITHUB_TOKEN read-only even for same-repository PRs, whose tokens are +# not automatically downgraded like fork tokens. The checker is deliberately +# separate from individual workflows so a future publishing optimization +# cannot silently reintroduce write authority. +python3 .github/scripts/check-workflow-permissions.py .github/workflows +python3 .github/scripts/test_check_workflow_permissions.py +python3 .github/scripts/test_workflow_artifacts.py # Files to exclude from validation (e.g., because they are not Actions/Workflows) # Use relative paths matching `find .github` output