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