diff --git a/.github/actions/protected-candidate-checkout/action.yml b/.github/actions/protected-candidate-checkout/action.yml new file mode 100644 index 0000000..d4147a6 --- /dev/null +++ b/.github/actions/protected-candidate-checkout/action.yml @@ -0,0 +1,79 @@ +name: Protected candidate checkout +description: Stage protected policy, check out one exact candidate, and verify it. + +inputs: + policy-path: + description: Workspace-relative protected policy checkout. + required: false + default: policy + candidate-path: + description: Workspace-relative candidate checkout destination. + required: false + default: candidate + repository: + description: Exact candidate repository. + required: true + head-sha: + description: Exact authorized candidate commit. + required: true + base-sha: + description: Exact protected-main base commit. + required: true + fetch-depth: + description: Candidate checkout fetch depth. + required: false + default: "1" + +outputs: + python: + description: Absolute protected interpreter staged before candidate checkout. + value: ${{ steps.protected.outputs.python }} + +runs: + using: composite + steps: + # Candidate files do not exist while trusted policy and tool paths are staged. + - name: Stage protected verifier and trusted tools + id: protected + shell: bash + run: >- + python3 "${GITHUB_ACTION_PATH}/../../scripts/protected_checkout.py" stage + --source-root "${GITHUB_WORKSPACE}/${{ inputs.policy-path }}" + --destination "${RUNNER_TEMP}/yaml-sigil-protected-checkout" + --github-output "${GITHUB_OUTPUT}" + + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Preserve Git blob bytes on every host before the protected verifier + # compares sensitive worktree files with their authorized objects. + env: + GIT_CONFIG_COUNT: "1" + GIT_CONFIG_KEY_0: core.autocrlf + GIT_CONFIG_VALUE_0: "false" + with: + fetch-depth: ${{ inputs.fetch-depth }} + path: ${{ inputs.candidate-path }} + persist-credentials: false + ref: ${{ inputs.head-sha }} + repository: ${{ inputs.repository }} + submodules: false + + # This is the first post-checkout step and executes only staged protected code. + - name: Verify exact candidate checkout + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + PATH: ${{ steps.protected.outputs.path }} + TRUSTED_GIT: ${{ steps.protected.outputs.git }} + TRUSTED_PYTHON: ${{ steps.protected.outputs.python }} + run: >- + "${TRUSTED_PYTHON}" "${{ steps.protected.outputs.verifier }}" verify + --candidate-root "${GITHUB_WORKSPACE}/${{ inputs.candidate-path }}" + --git "${TRUSTED_GIT}" + --repository "${GITHUB_REPOSITORY}" + --base-sha "${{ inputs.base-sha }}" + --head-sha "${{ inputs.head-sha }}" + --config "${{ steps.protected.outputs.config }}" + --expected-verifier-sha256 "${{ steps.protected.outputs.verifier_sha256 }}" + --expected-controller-sha256 "${{ steps.protected.outputs.controller_sha256 }}" + --expected-config-sha256 "${{ steps.protected.outputs.config_sha256 }}" diff --git a/.github/legacy-release-inventory.json b/.github/legacy-release-inventory.json new file mode 100644 index 0000000..7ded8ea --- /dev/null +++ b/.github/legacy-release-inventory.json @@ -0,0 +1,209 @@ +{ + "schema_version": 1, + "api_version": "2026-03-10", + "repository": "NVIDIA/yaml-sigil-rs", + "legacy_author": { + "id": 41898282, + "login": "github-actions[bot]", + "type": "Bot" + }, + "prospective_author": { + "id": 318780254, + "login": "nvidia-yamlsigil-release-pr[bot]", + "type": "Bot" + }, + "entries": [ + { + "release_id": 374728976, + "package": "yaml-sigil-verification", + "version": "0.5.0-rc.1", + "tag": "yaml-sigil-verification-v0.5.0-rc.1", + "tag_object_sha": "9fe046b72bd8a0917f32f73a2d5e5b35b69f02f9", + "peeled_commit_sha": "80e06775ac6b5a2f19286bd92492e68374a90eb2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "3b23aa0020c3dc4d1962423c5f51df3634491e0443b1b470285dfe530acc9ad2", + "source_archive_sha256": "37527966228fa878c2451e153b33ffcb17d73e4d48e973e29a14072814d58b6b", + "path_in_vcs": "crates/yaml-sigil-verification" + }, + { + "release_id": 374728608, + "package": "yaml-sigil-transcription", + "version": "0.5.0-rc.1", + "tag": "yaml-sigil-transcription-v0.5.0-rc.1", + "tag_object_sha": "f65966bdfafb602bd0ed63264a7137365765f1b5", + "peeled_commit_sha": "80e06775ac6b5a2f19286bd92492e68374a90eb2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "f4a0ad8182b166379f16bb7bcc6be185cdd77ee9b24162af7d45c28a1d05c645", + "source_archive_sha256": "0470ef628bf5645a869f90d1cc6fdf3989d433b9e2fde9029ec555d1b0544a13", + "path_in_vcs": "crates/yaml-sigil-transcription" + }, + { + "release_id": 374728947, + "package": "yaml-sigil-signing", + "version": "0.5.0-rc.1", + "tag": "yaml-sigil-signing-v0.5.0-rc.1", + "tag_object_sha": "77a810a91c879ca8eeaebfce4a300d98a6389350", + "peeled_commit_sha": "80e06775ac6b5a2f19286bd92492e68374a90eb2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "9e296c79e73ba4ff6c2bca976a7f37740334cb3eef259c009f1a1e31ad4b375c", + "source_archive_sha256": "f1e1d91d6f46127e0e8232b7e6b39fd06e5012f392c5415f5b97aeaf4b2846b4", + "path_in_vcs": "crates/yaml-sigil-signing" + }, + { + "release_id": 374728253, + "package": "yaml-sigil-core", + "version": "0.5.0-rc.1", + "tag": "yaml-sigil-core-v0.5.0-rc.1", + "tag_object_sha": "93ddb571695ff462fba4ae73d2cd51cf92e1530b", + "peeled_commit_sha": "80e06775ac6b5a2f19286bd92492e68374a90eb2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "d6e32bca1de4f89fc2c10094aeab985b885b3022d5ad8f213b3cd642032f22ec", + "source_archive_sha256": "2ca28fea80a47a6b28fd10ae04e78332d71a6e85cdaa6fe8725a0cf584a37caa", + "path_in_vcs": "crates/yaml-sigil-core" + }, + { + "release_id": 373953610, + "package": "yaml-sigil-verification", + "version": "0.4.0-rc.2", + "tag": "yaml-sigil-verification-v0.4.0-rc.2", + "tag_object_sha": "f422b7b1462c4264bd3dfa4ef88f2bdedd661fb8", + "peeled_commit_sha": "ceb24a09323eeaea6ebe580aa4ad1eaf49027750", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "913330bde4dc8d2a6db4884222b6ba161bc4db706561db5fed3551541ee649d0", + "source_archive_sha256": "fd88de0ebdb72326e6fcf97f40afae2620e07c7ed8f248e0c30e069d2cac90ef", + "path_in_vcs": "crates/yaml-sigil-verification" + }, + { + "release_id": 373953498, + "package": "yaml-sigil-transcription", + "version": "0.4.0-rc.2", + "tag": "yaml-sigil-transcription-v0.4.0-rc.2", + "tag_object_sha": "30ce47c74ff3e8ba9528cf7e21d77f9c61a51788", + "peeled_commit_sha": "ceb24a09323eeaea6ebe580aa4ad1eaf49027750", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "913330bde4dc8d2a6db4884222b6ba161bc4db706561db5fed3551541ee649d0", + "source_archive_sha256": "919ac2c68b8c550d9f577edffdd6191748d37c43752e2f6983dabffdaa39998a", + "path_in_vcs": "crates/yaml-sigil-transcription" + }, + { + "release_id": 373953561, + "package": "yaml-sigil-signing", + "version": "0.4.0-rc.2", + "tag": "yaml-sigil-signing-v0.4.0-rc.2", + "tag_object_sha": "4a4a2320a0f4b400cdf7615f87421a57440d3be9", + "peeled_commit_sha": "ceb24a09323eeaea6ebe580aa4ad1eaf49027750", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "913330bde4dc8d2a6db4884222b6ba161bc4db706561db5fed3551541ee649d0", + "source_archive_sha256": "f1cd22c670055476c1f94a46584042b3223f76088a11a51bb6fe1e6ffb78c730", + "path_in_vcs": "crates/yaml-sigil-signing" + }, + { + "release_id": 373953410, + "package": "yaml-sigil-core", + "version": "0.4.0-rc.2", + "tag": "yaml-sigil-core-v0.4.0-rc.2", + "tag_object_sha": "120a161a5e4a290930cfefc54101b166a45dba99", + "peeled_commit_sha": "ceb24a09323eeaea6ebe580aa4ad1eaf49027750", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "913330bde4dc8d2a6db4884222b6ba161bc4db706561db5fed3551541ee649d0", + "source_archive_sha256": "2d52abf8c1c0fcf8543d589f7f48ed1cc5887182f87a8c2d224cb7170bb77bc8", + "path_in_vcs": "crates/yaml-sigil-core" + }, + { + "release_id": 373131899, + "package": "yaml-sigil-verification", + "version": "0.4.0-rc.1", + "tag": "yaml-sigil-verification-v0.4.0-rc.1", + "tag_object_sha": "fea5e58521530e51c99f24e7cdcbec0847885a33", + "peeled_commit_sha": "bfd62fef2e95b4a16f7015b4d2ba3f28a7ba76b2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "ce788ff5a9be321d7b2480f74130c09e7320deea1e7a5754f746b0547b4ef2e1", + "source_archive_sha256": "3b6df28014ef103671c85926d5521e1e68484054879b1d09eebf3b682ce302e6", + "path_in_vcs": "crates/yaml-sigil-verification" + }, + { + "release_id": 372740779, + "package": "yaml-sigil-transcription", + "version": "0.4.0-rc.1", + "tag": "yaml-sigil-transcription-v0.4.0-rc.1", + "tag_object_sha": "bf5379259bb87414648cfc74a5e3fc9812d5105c", + "peeled_commit_sha": "d8f2227bcafbd86f12441ed3c6c355806a0ccf3c", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "a19a5d5b05522d4f613dbd378e8dc08e2b141494806a21cdb824383c96ed07e8", + "source_archive_sha256": "dd29ad1a8dab5c49b4df5ccfca48d1639431e5a82c622472e95299b3dca69731", + "path_in_vcs": "crates/yaml-sigil-transcription" + }, + { + "release_id": 373131837, + "package": "yaml-sigil-signing", + "version": "0.4.0-rc.1", + "tag": "yaml-sigil-signing-v0.4.0-rc.1", + "tag_object_sha": "0fa5144c58129bf9231bc8bf63b9643c2fc4044a", + "peeled_commit_sha": "bfd62fef2e95b4a16f7015b4d2ba3f28a7ba76b2", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "28faef8bd29ebeced423d2085d201368c336e7c18b3a1dd1de1c6fb670ad5ba2", + "source_archive_sha256": "83c9e919abcf2a559365f97e2c823ef084de8eaa8a42b1e53445d8d404c59be4", + "path_in_vcs": "crates/yaml-sigil-signing" + }, + { + "release_id": 372740726, + "package": "yaml-sigil-core", + "version": "0.4.0-rc.1", + "tag": "yaml-sigil-core-v0.4.0-rc.1", + "tag_object_sha": "5910b3145e705de0bb77d96ad206d1dac5cf4c4a", + "peeled_commit_sha": "d8f2227bcafbd86f12441ed3c6c355806a0ccf3c", + "target_commitish": "main", + "draft": false, + "prerelease": true, + "immutable": false, + "asset_count": 0, + "body_sha256": "5e40cccd339ddecd5ecfe2d6e517e79e6349771558a327233f353c0b285cc8cd", + "source_archive_sha256": "2a492caa5e1d72e9f9f2f8172e3d63e4bc7672bc312b6922e2288ca9c3c38445", + "path_in_vcs": "crates/yaml-sigil-core" + } + ] +} diff --git a/.github/protected-pr-ci.json b/.github/protected-pr-ci.json index 9dae7c1..81b9c77 100644 --- a/.github/protected-pr-ci.json +++ b/.github/protected-pr-ci.json @@ -1,5 +1,7 @@ { - "version": 2, + "version": 3, + "repository": "NVIDIA/yaml-sigil-rs", + "repository_kind": "rs", "default_branch": "main", "workflow_file": ".github/workflows/pr-ci-command.yml", "required_check": "Required CI", @@ -26,34 +28,10 @@ "expected_jobs": [ "commit_policy", "workflow_lint", + "platform_verifier", "static_checks", "rust", "candidate_ci" ], - "candidate_ci_paths": [ - ".cargo/**", - "**/.cargo/**", - ".github/workflows/ci.yml", - ".github/workflows/pr-ci.yml", - "rust-toolchain", - "rust-toolchain.toml", - "**/rust-toolchain", - "**/rust-toolchain.toml", - "rustfmt.toml", - ".rustfmt.toml", - "**/rustfmt.toml", - "**/.rustfmt.toml", - "clippy.toml", - ".clippy.toml", - "**/clippy.toml", - "**/.clippy.toml", - ".rumdl.toml", - "rumdl.toml", - "deny.toml", - "deny.exceptions.toml", - "crates/yaml-sigil-core/buf.yaml", - "crates/yaml-sigil-core/buf.lock", - "crates/yaml-sigil-core/buf.gen.yaml", - "xtask/**" - ] + "supplemental_candidate_ci": true } diff --git a/.github/release-notification-policy.json b/.github/release-notification-policy.json new file mode 100644 index 0000000..f2dd39f --- /dev/null +++ b/.github/release-notification-policy.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "api_version": "2026-03-10", + "repository": "NVIDIA/yaml-sigil-rs", + "default_branch": "main", + "sender": { + "id": 318780254, + "login": "nvidia-yamlsigil-release-pr[bot]", + "type": "Bot" + }, + "app": { + "id": 4653064, + "slug": "nvidia-yamlsigil-release-pr", + "email": "318780254+nvidia-yamlsigil-release-pr[bot]@users.noreply.github.com" + }, + "release_branch": "release-plz-next", + "packages": [ + { + "name": "yaml-sigil-core", + "tag_prefix": "yaml-sigil-core-v", + "path_in_vcs": "crates/yaml-sigil-core" + }, + { + "name": "yaml-sigil-transcription", + "tag_prefix": "yaml-sigil-transcription-v", + "path_in_vcs": "crates/yaml-sigil-transcription" + }, + { + "name": "yaml-sigil-signing", + "tag_prefix": "yaml-sigil-signing-v", + "path_in_vcs": "crates/yaml-sigil-signing" + }, + { + "name": "yaml-sigil-verification", + "tag_prefix": "yaml-sigil-verification-v", + "path_in_vcs": "crates/yaml-sigil-verification" + } + ] +} diff --git a/.github/scripts/check-pull-request-commits.sh b/.github/scripts/check-pull-request-commits.sh index 53a0479..6df8025 100755 --- a/.github/scripts/check-pull-request-commits.sh +++ b/.github/scripts/check-pull-request-commits.sh @@ -24,7 +24,13 @@ fi # Target-branch linear-history rules do not reject merge commits hidden inside # a pull request that is later squash-merged. Inspect parent counts directly. -mapfile -t merge_commits < <(git rev-list --merges "${range}") +merge_commit_output="$(git rev-list --merges "${range}")" +merge_commits=() +while IFS= read -r commit; do + if [[ -n "${commit}" ]]; then + merge_commits+=("${commit}") + fi +done <<< "${merge_commit_output}" if ((${#merge_commits[@]} > 0)); then printf 'Merge commits are not allowed in pull requests:\n' git show --no-patch --format=' %H %s' "${merge_commits[@]}" @@ -33,7 +39,13 @@ fi # An empty range indicates that the event and checkout do not describe a # reviewable change; do not silently report that as a successful policy check. -mapfile -t commits < <(git rev-list --reverse "${range}") +commit_output="$(git rev-list --reverse "${range}")" +commits=() +while IFS= read -r commit; do + if [[ -n "${commit}" ]]; then + commits+=("${commit}") + fi +done <<< "${commit_output}" if ((${#commits[@]} == 0)); then echo "::error::The pull request commit range is empty." exit 1 diff --git a/.github/scripts/legacy_release_preflight.py b/.github/scripts/legacy_release_preflight.py new file mode 100644 index 0000000..fc9b162 --- /dev/null +++ b/.github/scripts/legacy_release_preflight.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Fail closed on drift in the exact historical GitHub Release inventory.""" + +from __future__ import annotations + +import argparse +import os +import re +import urllib.parse +from pathlib import Path +from typing import Any + +from release_notification_preflight import ( + API_VERSION, + MAX_CONFIG_BYTES, + Api, + PackagePolicy, + PreflightError, + append_outputs, + inspect_archive, + read_json, + require, + require_digest, + require_keys, + require_positive, + require_sha, + require_string, + sha256, +) + +MAX_RELEASE_BODY_BYTES = 1024 * 1024 +MAX_RELEASES = 64 +VERSION_RE = re.compile( + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-rc\.[1-9][0-9]*)?\Z" +) +ENTRY_KEYS = ( + "release_id", + "package", + "version", + "tag", + "tag_object_sha", + "peeled_commit_sha", + "target_commitish", + "draft", + "prerelease", + "immutable", + "asset_count", + "body_sha256", + "source_archive_sha256", + "path_in_vcs", +) + + +def parse_author(value: Any, label: str) -> dict[str, Any]: + author = require_keys(value, ("id", "login", "type"), label) + require_positive(author["id"], f"{label} ID") + require_string(author["login"], f"{label} login", 128) + require(author["type"] == "Bot", f"{label} must be a Bot") + return author + + +def parse_inventory(raw: dict[str, Any]) -> dict[str, Any]: + raw = require_keys( + raw, + ( + "schema_version", + "api_version", + "repository", + "legacy_author", + "prospective_author", + "entries", + ), + "legacy Release inventory", + ) + require(raw["schema_version"] == 1, "legacy Release inventory version is unsupported") + require(raw["api_version"] == API_VERSION, "legacy Release API version is unsupported") + require_string(raw["repository"], "legacy Release repository", 256) + parse_author(raw["legacy_author"], "legacy Release author") + parse_author(raw["prospective_author"], "prospective Release author") + entries = raw["entries"] + require(type(entries) is list and 0 < len(entries) <= MAX_RELEASES, "legacy Release entries are empty or oversized") + ids: set[int] = set() + tags: set[str] = set() + for index, entry in enumerate(entries): + entry = require_keys(entry, ENTRY_KEYS, f"legacy Release entry {index}") + release_id = require_positive(entry["release_id"], f"legacy Release entry {index} ID") + package = require_string(entry["package"], f"legacy Release entry {index} package", 128) + version = require_string(entry["version"], f"legacy Release entry {index} version", 128) + tag = require_string(entry["tag"], f"legacy Release entry {index} tag", 256) + require(VERSION_RE.fullmatch(version) is not None, "legacy Release version is noncanonical") + require(version in tag, "legacy Release version and tag disagree") + require_sha(entry["tag_object_sha"], "legacy tag object") + require_sha(entry["peeled_commit_sha"], "legacy peeled commit") + require(entry["target_commitish"] == "main", "legacy target_commitish is not exact main") + require(type(entry["draft"]) is bool and type(entry["prerelease"]) is bool, "legacy Release state is invalid") + require(entry["immutable"] is False and entry["asset_count"] == 0, "legacy Release mutability or asset state is wrong") + require_digest(entry["body_sha256"], "legacy Release body digest") + require_digest(entry["source_archive_sha256"], "legacy source archive digest") + path = entry["path_in_vcs"] + require(type(path) is str and len(path.encode("utf-8")) <= 256, "legacy VCS path is invalid") + require(release_id not in ids and tag not in tags, "legacy Release inventory contains duplicates") + ids.add(release_id) + tags.add(tag) + return raw + + +def require_author(actual: Any, expected: dict[str, Any], label: str) -> None: + require(type(actual) is dict, f"{label} is missing") + require( + actual.get("id") == expected["id"] + and actual.get("login") == expected["login"] + and actual.get("type") == expected["type"], + f"{label} identity drifted", + ) + + +def validate_legacy_entry( + api: Api, + repository: str, + author: dict[str, Any], + entry: dict[str, Any], +) -> None: + release = api.github_json(f"repos/{repository}/releases/{entry['release_id']}") + require(type(release) is dict, "legacy GitHub Release response is invalid") + body = release.get("body") + assets = release.get("assets") + require(type(body) is str and len(body.encode("utf-8")) <= MAX_RELEASE_BODY_BYTES, "legacy Release body is invalid") + require(type(assets) is list, "legacy Release assets are invalid") + require_author(release.get("author"), author, "legacy Release author") + require( + release.get("id") == entry["release_id"] + and release.get("tag_name") == entry["tag"] + and release.get("target_commitish") == entry["target_commitish"] + and release.get("draft") is entry["draft"] + and release.get("prerelease") is entry["prerelease"] + and release.get("immutable") is entry["immutable"] + and len(assets) == entry["asset_count"] + and sha256(body.encode("utf-8")) == entry["body_sha256"], + f"legacy Release {entry['release_id']} drifted", + ) + + encoded_tag = urllib.parse.quote(entry["tag"], safe="") + reference = api.github_json(f"repos/{repository}/git/ref/tags/{encoded_tag}") + require(type(reference) is dict and type(reference.get("object")) is dict, "legacy tag ref is invalid") + require( + reference.get("ref") == f"refs/tags/{entry['tag']}" + and reference["object"].get("type") == "tag" + and reference["object"].get("sha") == entry["tag_object_sha"], + "legacy annotated tag ref drifted", + ) + tag = api.github_json(f"repos/{repository}/git/tags/{entry['tag_object_sha']}") + require(type(tag) is dict and type(tag.get("object")) is dict, "legacy tag object is invalid") + require( + tag.get("sha") == entry["tag_object_sha"] + and tag.get("tag") == entry["tag"] + and tag["object"].get("type") == "commit" + and tag["object"].get("sha") == entry["peeled_commit_sha"], + "legacy annotated tag object drifted", + ) + + package = urllib.parse.quote(entry["package"], safe="") + version = urllib.parse.quote(entry["version"], safe="") + registry = api.crates_json(f"crates/{package}/{version}") + require(type(registry) is dict and type(registry.get("version")) is dict, "legacy registry response is invalid") + record = registry["version"] + require( + record.get("num") == entry["version"] + and record.get("yanked") is False + and record.get("checksum") == entry["source_archive_sha256"], + "legacy registry record drifted", + ) + archive = api.crate_archive(entry["package"], entry["version"]) + require(sha256(archive) == entry["source_archive_sha256"], "legacy source archive checksum drifted") + inspect_archive( + archive, + PackagePolicy(entry["package"], "", entry["path_in_vcs"]), + entry["version"], + entry["peeled_commit_sha"], + ) + + +def validate_inventory(raw: dict[str, Any], api: Api) -> None: + inventory = parse_inventory(raw) + repository = inventory["repository"] + entries = {entry["release_id"]: entry for entry in inventory["entries"]} + releases = api.github_json(f"repos/{repository}/releases?per_page=100") + require(type(releases) is list and len(releases) < 100, "GitHub Release inventory is invalid or truncated") + listed_ids: set[int] = set() + for release in releases: + require(type(release) is dict, "listed GitHub Release is invalid") + release_id = require_positive(release.get("id"), "listed GitHub Release ID") + require(release_id not in listed_ids, "GitHub listed a duplicate Release") + listed_ids.add(release_id) + if release_id in entries: + require(release.get("tag_name") == entries[release_id]["tag"], "listed legacy Release tag drifted") + continue + assets = release.get("assets") + require(type(assets) is list and not assets, "a prospective Release retained assets") + require_author(release.get("author"), inventory["prospective_author"], "prospective Release author") + require( + release.get("immutable") is True + and release.get("draft") is False + and release.get("target_commitish") == "main", + "an unpinned mutable or draft Release exists", + ) + require(set(entries) <= listed_ids, "a pinned legacy Release is missing") + for entry in inventory["entries"]: + validate_legacy_entry(api, repository, inventory["legacy_author"], entry) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args() + try: + raw_bytes = args.inventory.read_bytes() + require(0 < len(raw_bytes) <= MAX_CONFIG_BYTES, "legacy inventory file is empty or oversized") + inventory = read_json(args.inventory, MAX_CONFIG_BYTES, "legacy Release inventory") + api = Api(os.environ.get("GITHUB_TOKEN", ""), os.environ.get("GITHUB_API_URL", "https://api.github.com")) + validate_inventory(inventory, api) + append_outputs(args.github_output, {"legacy_inventory_digest": sha256(raw_bytes)}) + except (OSError, PreflightError) as error: + print(f"legacy Release inventory rejected: {error}", file=os.sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/protected_checkout.py b/.github/scripts/protected_checkout.py new file mode 100644 index 0000000..da49bcd --- /dev/null +++ b/.github/scripts/protected_checkout.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Verify a candidate checkout against immutable protected-main policy. + +The caller stages this file and ``protected_pr_ci.py`` from the exact policy +commit before checking out candidate content. This verifier then compares all +sensitive working-tree files with their exact Git blobs before any later step +reads or executes a candidate path. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import pathlib +import shutil +import stat +import subprocess +import sys +import time +from collections.abc import Iterable + +import protected_pr_ci as policy + + +MAX_SECONDS = 30.0 +MAX_POLICY_FILE_BYTES = 4 * 1024 * 1024 + + +def metadata_identity(path: str) -> str: + return policy.normalized_casefold(path) + + +def has_reparse_point(metadata: os.stat_result) -> bool: + attributes = getattr(metadata, "st_file_attributes", 0) + marker = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return bool(attributes & marker) + + +def bounded_regular_bytes(path: pathlib.Path, label: str) -> bytes: + try: + metadata = path.lstat() + policy.require( + stat.S_ISREG(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and not has_reparse_point(metadata), + f"{label} is not a regular file", + ) + policy.require( + metadata.st_size <= MAX_POLICY_FILE_BYTES, + f"{label} exceeds the 4 MiB staging limit", + ) + with path.open("rb") as handle: + value = handle.read(MAX_POLICY_FILE_BYTES + 1) + except OSError as error: + raise policy.PolicyError(f"cannot read {label}: {error}") from error + policy.require( + len(value) <= MAX_POLICY_FILE_BYTES, + f"{label} exceeds the 4 MiB staging limit", + ) + return value + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def portable_tool_path(path: str) -> str: + """Use slash-separated absolute paths accepted by Bash on every runner.""" + + return os.path.realpath(path).replace("\\", "/") + + +def stage_policy( + source_root: pathlib.Path, + destination: pathlib.Path, + github_output: str, +) -> None: + """Stage immutable policy and trusted tool identities before checkout.""" + + sources = { + "verifier": source_root / policy.CHECKOUT_VERIFIER, + "controller": source_root / policy.POLICY_CONTROLLER, + "config": source_root / policy.POLICY_CONFIG, + } + names = { + "verifier": "protected_checkout.py", + "controller": "protected_pr_ci.py", + "config": "protected-pr-ci.json", + } + try: + destination.mkdir(mode=0o700, parents=True, exist_ok=False) + except OSError as error: + raise policy.PolicyError(f"cannot create protected staging directory: {error}") from error + + outputs: dict[str, str] = {} + for label, source in sources.items(): + value = bounded_regular_bytes(source, f"protected {label}") + target = destination / names[label] + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(target, flags, 0o600) + with os.fdopen(descriptor, "wb") as handle: + handle.write(value) + handle.flush() + os.fsync(handle.fileno()) + except OSError as error: + raise policy.PolicyError(f"cannot stage protected {label}: {error}") from error + outputs[label] = portable_tool_path(os.fspath(target)) + outputs[f"{label}_sha256"] = sha256_bytes(value) + + python = os.path.realpath(sys.executable) + git = shutil.which("git") + policy.require( + os.path.isabs(python) and os.path.isfile(python) and os.access(python, os.X_OK), + "trusted Python path is invalid", + ) + policy.require(git is not None, "trusted Git executable is unavailable") + assert git is not None + git = os.path.realpath(git) + policy.require( + os.path.isabs(git) and os.path.isfile(git) and os.access(git, os.X_OK), + "trusted Git path is invalid", + ) + outputs["python"] = portable_tool_path(python) + outputs["git"] = portable_tool_path(git) + tool_directories = list(dict.fromkeys((os.path.dirname(python), os.path.dirname(git)))) + outputs["path"] = os.pathsep.join(portable_tool_path(item) for item in tool_directories) + policy.write_github_outputs(github_output, outputs) + + +def enumerate_checkout(root: pathlib.Path) -> dict[str, os.stat_result]: + started = time.monotonic() + observed: dict[str, os.stat_result] = {} + identities: dict[str, str] = {} + pending = [root] + metadata_bytes = 0 + while pending: + policy.require( + time.monotonic() - started <= MAX_SECONDS, + "candidate checkout enumeration exceeded 30 seconds", + ) + directory = pending.pop() + try: + entries = list(os.scandir(directory)) + except OSError as error: + raise policy.PolicyError(f"cannot enumerate candidate checkout: {error}") from error + for entry in entries: + if directory == root and entry.name == ".git": + continue + relative = entry.path[len(os.fspath(root)) :].lstrip("/\\").replace("\\", "/") + normalized = policy.validate_path(relative, "candidate checkout path") + components = policy.normalized_components(normalized) + policy.require( + all("~" not in component for component in components), + "candidate checkout contains a Windows short-name-shaped path", + ) + identity = metadata_identity(normalized) + previous = identities.setdefault(identity, normalized) + policy.require( + previous == normalized, + "candidate checkout contains Unicode-normalized casefold aliases", + ) + try: + metadata = entry.stat(follow_symlinks=False) + except OSError as error: + raise policy.PolicyError(f"cannot inspect candidate path {normalized}: {error}") from error + metadata_bytes += len(normalized.encode("utf-8")) + 96 + policy.require( + metadata_bytes <= policy.MAX_PATH_METADATA_BYTES, + "candidate path metadata exceeds the 4 MiB limit", + ) + observed[normalized] = metadata + policy.require( + len(observed) <= policy.MAX_TREE_ENTRIES, + f"candidate checkout exceeds {policy.MAX_TREE_ENTRIES} entries", + ) + if stat.S_ISDIR(metadata.st_mode) and not has_reparse_point(metadata): + pending.append(pathlib.Path(entry.path)) + return observed + + +def trusted_git( + git: str, root: pathlib.Path, arguments: Iterable[str], *, text: bool = True +) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: + environment = { + "GIT_CONFIG_COUNT": "2", + "GIT_CONFIG_KEY_0": "core.hooksPath", + "GIT_CONFIG_VALUE_0": os.devnull, + "GIT_CONFIG_KEY_1": "core.fsmonitor", + "GIT_CONFIG_VALUE_1": "false", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_OPTIONAL_LOCKS": "0", + "PATH": os.path.dirname(git), + } + if "SYSTEMROOT" in os.environ: + environment["SYSTEMROOT"] = os.environ["SYSTEMROOT"] + return subprocess.run( + [git, "--no-pager", "-C", os.fspath(root), *arguments], + check=False, + capture_output=True, + env=environment, + text=text, + timeout=10, + ) + + +def verify( + root: pathlib.Path, + git: str, + repository: str, + base_sha: str, + head_sha: str, + config_path: str, + api: policy.GitHubApi, +) -> None: + config = policy.load_config(config_path) + policy.require(config.get("repository") == repository, "checkout repository does not match policy") + base = policy.git_tree_for_commit(api, repository, base_sha, "base") + head = policy.git_tree_for_commit(api, repository, head_sha, "head") + policy.require_no_path_collisions(head.paths) + paths, statuses = policy.changed_tree_paths(base, head) + policy.sensitive_inventory( + base, + head, + paths, + statuses, + policy.require_string(config.get("repository_kind"), "repository_kind"), + ) + + resolved_root = root.resolve(strict=True) + policy.require(resolved_root.is_dir(), "candidate root is not a directory") + observed = enumerate_checkout(resolved_root) + kind = policy.require_string(config.get("repository_kind"), "repository_kind") + expected = { + path: leaf + for path, leaf in head.leaves.items() + if policy.is_sensitive_path(path, kind) + } + policy.require( + len(expected) <= policy.MAX_SENSITIVE_FILES, + f"candidate tree exceeds {policy.MAX_SENSITIVE_FILES} sensitive files", + ) + + actual_sensitive = { + path + for path, metadata in observed.items() + if policy.is_sensitive_path(path, kind) + and (not stat.S_ISDIR(metadata.st_mode) or has_reparse_point(metadata)) + } + policy.require( + actual_sensitive == set(expected), + "candidate checkout has missing or untracked sensitive paths", + ) + + head_result = trusted_git(git, resolved_root, ["rev-parse", "--verify", "HEAD^{commit}"]) + policy.require(head_result.returncode == 0, "trusted Git could not resolve candidate HEAD") + policy.require( + head_result.stdout.strip() == head_sha, + "candidate checkout HEAD is not the authorized commit", + ) + + for path, leaf in sorted(expected.items()): + entry_type, mode, blob = leaf + policy.require( + entry_type == "blob" and mode in {"100644", "100755"}, + f"sensitive path {path} is not a regular Git file", + ) + metadata = observed[path] + policy.require( + stat.S_ISREG(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and not has_reparse_point(metadata), + f"sensitive path {path} is a link, reparse point, or non-regular file", + ) + result = trusted_git( + git, + resolved_root, + ["hash-object", "--no-filters", "--", path], + ) + policy.require(result.returncode == 0, f"trusted Git could not hash sensitive path {path}") + policy.require( + result.stdout.strip() == blob, + f"sensitive path {path} differs from its authorized Git blob", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + stage = subparsers.add_parser("stage") + stage.add_argument("--source-root", required=True) + stage.add_argument("--destination", required=True) + stage.add_argument("--github-output", required=True) + + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("--candidate-root", required=True) + verify_parser.add_argument("--git", required=True) + verify_parser.add_argument("--repository", required=True) + verify_parser.add_argument("--base-sha", required=True) + verify_parser.add_argument("--head-sha", required=True) + verify_parser.add_argument("--config", required=True) + verify_parser.add_argument("--expected-verifier-sha256", required=True) + verify_parser.add_argument("--expected-controller-sha256", required=True) + verify_parser.add_argument("--expected-config-sha256", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.command == "stage": + stage_policy( + pathlib.Path(args.source_root).resolve(strict=True), + pathlib.Path(args.destination), + args.github_output, + ) + print("Staged protected checkout policy and trusted tools.") + return 0 + + staged = { + "verifier": pathlib.Path(__file__), + "controller": pathlib.Path(policy.__file__), + "config": pathlib.Path(args.config), + } + expected = { + "verifier": policy.validate_digest( + args.expected_verifier_sha256, "expected verifier digest" + ), + "controller": policy.validate_digest( + args.expected_controller_sha256, "expected controller digest" + ), + "config": policy.validate_digest( + args.expected_config_sha256, "expected config digest" + ), + } + for label, path in staged.items(): + observed = sha256_bytes(bounded_regular_bytes(path, f"staged {label}")) + policy.require( + observed == expected[label], + f"staged {label} digest changed after candidate checkout", + ) + git = os.path.realpath(args.git) + policy.require(os.path.isabs(git) and os.path.isfile(git), "trusted Git path is invalid") + verify( + pathlib.Path(args.candidate_root), + git, + policy.validate_repository(args.repository), + policy.validate_sha(args.base_sha, "base SHA"), + policy.validate_sha(args.head_sha, "head SHA"), + args.config, + policy.GitHubApi(os.environ.get("GITHUB_TOKEN", ""), os.environ.get("GITHUB_API_URL", "https://api.github.com")), + ) + print("Protected checkout verification succeeded.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except policy.PolicyError as error: + print(f"policy error: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/.github/scripts/protected_pr_ci.py b/.github/scripts/protected_pr_ci.py index 126f839..34ce068 100755 --- a/.github/scripts/protected_pr_ci.py +++ b/.github/scripts/protected_pr_ci.py @@ -20,7 +20,7 @@ import argparse import datetime as dt -import fnmatch +import hashlib import json import os import re @@ -34,8 +34,8 @@ from typing import Any, Iterable, Mapping, Sequence -API_VERSION = "2022-11-28" -COMMAND_RE = re.compile(r"/ok to test ([0-9a-f]{40})") +API_VERSION = "2026-03-10" +COMMAND_RE = re.compile(r"/ok to (test|test-and-adopt) ([0-9a-f]{40})") SHA_RE = re.compile(r"[0-9a-f]{40}") JOB_BINDING_MARKER = "protected-ci|" CALLER_JOB_NAME = "Run authorized protected CI" @@ -53,10 +53,25 @@ MAX_API_ERROR_DETAIL_BYTES = 500 MAX_CHANGED_PATHS = 3_000 MAX_PULL_COMMITS = 250 -MAX_TREE_ENTRIES = 100_000 +MAX_SIGNATURE_BATCH = 50 +MAX_SIGNATURE_REQUESTS = 5 +MAX_SIGNATURE_JSON_NODES = 25_000 +MAX_SIGNATURE_CURSOR_BYTES = 1_024 +MAX_TREE_ENTRIES = 10_000 +MAX_SENSITIVE_FILES = 512 +MAX_PATH_METADATA_BYTES = 4 * 1024 * 1024 MAX_WORKFLOW_JOBS = 1_000 MAX_PAGES = 100 +POLICY_CONTROLLER = ".github/scripts/protected_pr_ci.py" +POLICY_TESTS = ".github/scripts/test_protected_pr_ci.py" +CHECKOUT_VERIFIER = ".github/scripts/protected_checkout.py" +CANDIDATE_CHECKOUT_ACTION = ".github/actions/protected-candidate-checkout/action.yml" +POLICY_CONFIG = ".github/protected-pr-ci.json" +RECONCILE_WORKFLOW = ".github/workflows/pr-ci-reconcile.yml" +REUSABLE_WORKFLOW = ".github/workflows/pr-ci.yml" +COMMIT_POLICY = ".github/scripts/check-pull-request-commits.sh" + class PolicyError(RuntimeError): """A closed-policy decision or ambiguous API response.""" @@ -93,6 +108,15 @@ def validate_sha(value: Any, label: str) -> str: return sha +def validate_digest(value: Any, label: str) -> str: + digest = require_string(value, label) + require( + re.fullmatch(r"[0-9a-f]{64}", digest) is not None, + f"{label} must be a lowercase SHA-256 digest", + ) + return digest + + def validate_login(value: Any, label: str) -> str: login = require_string(value, label) require( @@ -137,17 +161,33 @@ def load_config(path: str) -> Mapping[str, Any]: config = require_mapping(load_json(path), "policy configuration") required = { "version", + "repository", + "repository_kind", "default_branch", "workflow_file", "required_check", "release_app", "expected_jobs", - "candidate_ci_paths", + "supplemental_candidate_ci", } require(set(config) == required, "policy configuration keys are incomplete or ambiguous") - require(config["version"] == 2, "unsupported policy configuration version") + require(config["version"] == 3, "unsupported policy configuration version") + validate_repository(config["repository"], "repository") + require( + config["repository_kind"] in {"spec", "traits", "rs"}, + "repository_kind is unsupported", + ) require(config["default_branch"] == "main", "the protected branch must be exact main") - validate_path(config["workflow_file"], "workflow_file") + workflow_file = validate_path(config["workflow_file"], "workflow_file") + require( + isinstance(config["supplemental_candidate_ci"], bool), + "supplemental_candidate_ci must be boolean", + ) + require( + config["supplemental_candidate_ci"] + == (config["repository_kind"] != "spec"), + "supplemental candidate CI must be disabled only for spec", + ) require(config["required_check"] == CHECK_NAME, f"required_check must be {CHECK_NAME!r}") release_app = require_mapping(config["release_app"], "release_app") @@ -212,23 +252,27 @@ def load_config(path: str) -> Mapping[str, Any]: f"expected_jobs[{index}] is not a job identifier", ) - patterns = require_sequence(config["candidate_ci_paths"], "candidate_ci_paths") + protected_paths = { + POLICY_CONTROLLER, + POLICY_TESTS, + CHECKOUT_VERIFIER, + CANDIDATE_CHECKOUT_ACTION, + POLICY_CONFIG, + workflow_file, + RECONCILE_WORKFLOW, + REUSABLE_WORKFLOW, + COMMIT_POLICY, + } require( - patterns and len(set(patterns)) == len(patterns), - "candidate_ci_paths must be nonempty and unique", + all(is_sensitive_path(item, config["repository_kind"]) for item in protected_paths), + "protected CI policy files are not all sensitive", ) - for index, pattern in enumerate(patterns): - require_string(pattern, f"candidate_ci_paths[{index}]") - require( - "\\" not in pattern and not pattern.startswith("/"), - f"candidate_ci_paths[{index}] is invalid", - ) return config class GitHubApi: - """Small fail-closed GitHub REST client.""" + """Small fail-closed GitHub REST and GraphQL client.""" def __init__(self, token: str, api_url: str = "https://api.github.com") -> None: require(token != "", "GitHub API token is empty") @@ -287,6 +331,174 @@ def post(self, path: str, payload: Mapping[str, Any]) -> Any: def patch(self, path: str, payload: Mapping[str, Any]) -> Any: return self.request("PATCH", path, payload) + def commit_signatures( + self, repository: str, pull_number: int, oids: Sequence[str] + ) -> Mapping[str, Mapping[str, Any]]: + """Read exact PR commit-signature identities within one aggregate budget.""" + + require( + 1 <= len(oids) <= MAX_PULL_COMMITS, + "signature inventory is outside the supported commit limit", + ) + require( + len(set(oids)) == len(oids), + "signature inventory contains duplicate commit OIDs", + ) + require( + isinstance(pull_number, int) and not isinstance(pull_number, bool) + and pull_number > 0, + "signature pull request number must be positive", + ) + owner, name = validate_repository(repository).split("/", 1) + observed: dict[str, Mapping[str, Any]] = {} + aggregate_bytes = 0 + aggregate_nodes = 0 + request_count = 0 + cursor: str | None = None + + for offset in range(0, len(oids), MAX_SIGNATURE_BATCH): + batch = list(oids[offset : offset + MAX_SIGNATURE_BATCH]) + request_count += 1 + require( + request_count <= MAX_SIGNATURE_REQUESTS, + "commit signatures require too many GraphQL requests", + ) + for index, oid in enumerate(batch): + validate_sha(oid, f"signature OID {offset + index}") + variables: dict[str, Any] = { + "owner": owner, + "name": name, + "number": pull_number, + "first": len(batch), + "after": cursor, + } + query = ( + "query($owner:String!,$name:String!,$number:Int!," + "$first:Int!,$after:String){repository(owner:$owner,name:$name){" + "pullRequest(number:$number){commits(first:$first,after:$after){" + "totalCount nodes{commit{oid signature{__typename email isValid " + "state wasSignedByGitHub signer{databaseId login __typename}}}}" + "pageInfo{hasNextPage endCursor}}}}}" + ) + payload = json.dumps( + {"query": query, "variables": variables}, + separators=(",", ":"), + ).encode("utf-8") + request = urllib.request.Request( + f"{self.api_url}/graphql", + data=payload, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "User-Agent": "yaml-sigil-protected-pr-ci/2", + "X-GitHub-Api-Version": API_VERSION, + }, + method="POST", + ) + remaining = MAX_API_RESPONSE_BYTES - aggregate_bytes + require(remaining >= 0, "commit signature response budget is exhausted") + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read(remaining + 1) + require( + len(raw) <= remaining, + "aggregate commit signature responses exceed the 32 MiB limit", + ) + require( + 200 <= response.status < 300, + f"GitHub GraphQL returned HTTP {response.status}", + ) + except urllib.error.HTTPError as error: + raw_detail = error.read(MAX_API_ERROR_DETAIL_BYTES + 1) + detail = raw_detail[:MAX_API_ERROR_DETAIL_BYTES].decode("utf-8", "replace") + if len(raw_detail) > MAX_API_ERROR_DETAIL_BYTES: + detail = f"{detail}..." + raise PolicyError( + f"GitHub GraphQL failed with HTTP {error.code}: {detail}" + ) from error + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise PolicyError(f"GitHub GraphQL failed: {error}") from error + aggregate_bytes += len(raw) + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise PolicyError("GitHub GraphQL returned invalid JSON") from error + aggregate_nodes += json_node_count(value) + require( + aggregate_nodes <= MAX_SIGNATURE_JSON_NODES, + "commit signature responses contain too many JSON nodes", + ) + envelope = require_mapping(value, "GraphQL response") + require("errors" not in envelope, "GraphQL signature response contains errors") + data = require_mapping(envelope.get("data"), "GraphQL data") + repo = require_mapping(data.get("repository"), "GraphQL repository") + pull = require_mapping(repo.get("pullRequest"), "GraphQL pull request") + commits = require_mapping( + pull.get("commits"), "GraphQL pull request commits" + ) + require( + set(commits) == {"totalCount", "nodes", "pageInfo"}, + "GraphQL signature response fields are incomplete or ambiguous", + ) + total_count = require_integer( + commits.get("totalCount"), "GraphQL signature total count" + ) + require( + total_count == len(oids), + "GraphQL signature total count changed", + ) + nodes = require_sequence(commits.get("nodes"), "GraphQL signature results") + require( + len(nodes) == len(batch), + "GraphQL signature response has missing or unrequested results", + ) + for index, requested_oid in enumerate(batch): + node = require_mapping(nodes[index], f"signature node {offset + index}") + require( + set(node) == {"commit"}, + f"signature node {offset + index} fields are ambiguous", + ) + commit = require_mapping( + node.get("commit"), f"signature result {offset + index}" + ) + oid = validate_sha(commit.get("oid"), f"signature result {index} OID") + require(oid == requested_oid, "GraphQL signature result OID is out of order") + require(oid not in observed, "GraphQL signature result is duplicated") + observed[oid] = commit + + page_info = require_mapping( + commits.get("pageInfo"), "GraphQL signature page info" + ) + require( + set(page_info) == {"hasNextPage", "endCursor"}, + "GraphQL signature page info fields are incomplete or ambiguous", + ) + has_next = page_info.get("hasNextPage") + require( + isinstance(has_next, bool), + "GraphQL signature pagination state is missing", + ) + more_expected = offset + len(batch) < len(oids) + require( + has_next is more_expected, + "GraphQL signature pagination does not match the commit inventory", + ) + if more_expected: + cursor = require_string( + page_info.get("endCursor"), "GraphQL signature cursor" + ) + require( + len(cursor.encode("utf-8")) <= MAX_SIGNATURE_CURSOR_BYTES, + "GraphQL signature cursor exceeds the supported size limit", + ) + + require( + list(observed) == list(oids), + "GraphQL signature results do not exactly match the requested OIDs", + ) + return observed + def paginate(self, path: str, *, max_items: int, label: str) -> list[Any]: items: list[Any] = [] for page in range(1, MAX_PAGES + 1): @@ -325,6 +537,21 @@ def paginate_key( raise PolicyError(f"{label} pagination did not terminate") +def json_node_count(value: Any) -> int: + """Count aggregate JSON values without recursive call-stack growth.""" + + count = 0 + pending = [value] + while pending: + current = pending.pop() + count += 1 + if isinstance(current, dict): + pending.extend(current.values()) + elif isinstance(current, list): + pending.extend(current) + return count + + def repo_api_path(repository: str, suffix: str) -> str: owner, name = validate_repository(repository).split("/", 1) return f"/repos/{urllib.parse.quote(owner, safe='')}/{urllib.parse.quote(name, safe='')}{suffix}" @@ -341,26 +568,69 @@ def permission_for(api: GitHubApi, repository: str, login: str) -> str: return permission +def require_app_token_repository_scope(api: GitHubApi, repository: str) -> None: + """Require an installation token scoped to exactly one named repository.""" + + repositories = api.paginate_key( + "/installation/repositories", + "repositories", + max_items=2, + label="App token repository inventory", + ) + require( + len(repositories) == 1, + "App token repository inventory is not exactly one repository", + ) + selected = require_mapping(repositories[0], "App token repository") + require( + validate_repository(selected.get("full_name"), "App token repository name") + == repository, + "App token is scoped to an unexpected repository", + ) + require( + require_integer(selected.get("id"), "App token repository ID") > 0, + "App token repository ID must be positive", + ) + + def require_writer(api: GitHubApi, repository: str, login: str, label: str) -> None: permission = permission_for(api, repository, login) require(permission in WRITER_PERMISSIONS, f"{label} does not currently have write authority") -def command_sha(body: Any) -> str | None: +@dataclass(frozen=True) +class CommandRequest: + mode: str + head_sha: str + + @property + def adoption(self) -> bool: + return self.mode == "test-and-adopt" + + +def command_request(body: Any) -> CommandRequest | None: if not isinstance(body, str): return None match = COMMAND_RE.fullmatch(body) - return match.group(1) if match is not None else None + if match is None: + return None + return CommandRequest(mode=match.group(1), head_sha=match.group(2)) + + +def command_sha(body: Any) -> str | None: + request = command_request(body) + return request.head_sha if request is not None else None -def exact_command(body: Any) -> str: - requested_sha = command_sha(body) +def exact_command(body: Any) -> CommandRequest: + requested = command_request(body) require( - requested_sha is not None, - "comment must be exactly /ok to test followed by a lowercase full head SHA", + requested is not None, + "comment must be exactly /ok to test or /ok to test-and-adopt " + "followed by a lowercase full head SHA", ) - assert requested_sha is not None - return requested_sha + assert requested is not None + return requested def normalized_casefold(value: str) -> str: @@ -370,15 +640,60 @@ def normalized_casefold(value: str) -> str: return unicodedata.normalize("NFKC", normalized.casefold()) -def matches_path_inventory(path: str, patterns: Sequence[str]) -> bool: - """Match declarations, treating a trailing ``/**`` as including its root.""" - identity = normalized_casefold(path) - for pattern in patterns: - declaration = normalized_casefold(pattern) - if fnmatch.fnmatchcase(identity, declaration): - return True - if declaration.endswith("/**") and fnmatch.fnmatchcase( - identity, declaration[:-3] +def normalized_components(path: str) -> tuple[str, ...]: + return tuple(normalized_casefold(part) for part in validate_path(path, "path").split("/")) + + +def is_sensitive_path(path: str, repository_kind: str) -> bool: + """Classify one normalized repository path under the shared policy.""" + + parts = normalized_components(path) + name = parts[-1] + if "~" in path or any("~" in part for part in parts): + return True + if parts[0] == ".github": + return True + if name == ".gitattributes" or parts == (".gitmodules",): + return True + if parts in { + ("codeowners",), + (".github", "codeowners"), + ("docs", "codeowners"), + }: + return True + if name in { + "cargo.toml", + "cargo.lock", + "build.rs", + "rust-toolchain", + "rust-toolchain.toml", + "rustfmt.toml", + ".rustfmt.toml", + "clippy.toml", + ".clippy.toml", + "deny.toml", + ".deny.toml", + "cargo-deny.toml", + ".cargo-deny.toml", + "cargo-machete.toml", + ".cargo-machete.toml", + "audit.toml", + ".release-plz.toml", + "release-plz.toml", + }: + return True + if ".cargo" in parts or parts[0] == "xtask" or name == "releasing.md": + return True + if repository_kind == "rs" and name in {"buf.yaml", "buf.lock", "buf.gen.yaml"}: + return True + if repository_kind == "spec": + acvp_root = ("conformance", "rebuild-rs") + if parts[: len(acvp_root)] == acvp_root and ( + parts[:4] == (*acvp_root, "vendor", "acvp") + or parts[:3] == (*acvp_root, "pinned-dir") + or parts[:3] == (*acvp_root, "xtask") + or parts == (*acvp_root, "src", "acvp.rs") + or parts == (*acvp_root, "src", "alg_ecdsa.rs") ): return True return False @@ -400,11 +715,85 @@ def signoffs(message: Any) -> set[str]: return found -def require_verified(commit: Mapping[str, Any], label: str) -> None: - details = require_mapping(commit.get("commit"), f"{label} details") - verification = require_mapping(details.get("verification"), f"{label} verification") - require(verification.get("verified") is True, f"{label} is not GitHub Verified") - require(verification.get("reason") == "valid", f"{label} verification is not valid") +@dataclass(frozen=True) +class SignatureIdentity: + oid: str + kind: str + email: str + signer_id: int + signer_login: str + signer_type: str + was_signed_by_github: bool + + +def signature_identity(value: Mapping[str, Any], label: str) -> SignatureIdentity: + require( + set(value) == {"oid", "signature"}, + f"{label} signature result fields are incomplete or ambiguous", + ) + oid = validate_sha(value.get("oid"), f"{label} OID") + signature = require_mapping(value.get("signature"), f"{label} signature") + require( + set(signature) + == { + "__typename", + "email", + "isValid", + "state", + "wasSignedByGitHub", + "signer", + }, + f"{label} signature fields are incomplete or ambiguous", + ) + kind = require_string(signature.get("__typename"), f"{label} signature type") + require( + kind in {"GpgSignature", "SshSignature", "SmimeSignature"}, + f"{label} signature type is unsupported", + ) + require(signature.get("isValid") is True, f"{label} is not GitHub Verified") + require(signature.get("state") == "VALID", f"{label} signature state is not valid") + require( + isinstance(signature.get("wasSignedByGitHub"), bool), + f"{label} GitHub-signing state is missing", + ) + signer = require_mapping(signature.get("signer"), f"{label} signer") + require( + set(signer) == {"databaseId", "login", "__typename"}, + f"{label} signer fields are incomplete or ambiguous", + ) + signer_id = require_integer(signer.get("databaseId"), f"{label} signer ID") + require(signer_id > 0, f"{label} signer ID must be positive") + signer_type = require_string(signer.get("__typename"), f"{label} signer type") + require(signer_type == "User", f"{label} signer is not a GitHub User") + return SignatureIdentity( + oid=oid, + kind=kind, + email=require_string(signature.get("email"), f"{label} signature email"), + signer_id=signer_id, + signer_login=validate_login(signer.get("login"), f"{label} signer login"), + signer_type=signer_type, + was_signed_by_github=signature.get("wasSignedByGitHub") is True, + ) + + +def rest_account(value: Any, label: str) -> tuple[int, str, str]: + account = require_mapping(value, label) + account_id = require_integer(account.get("id"), f"{label} ID") + require(account_id > 0, f"{label} ID must be positive") + account_type = require_string(account.get("type"), f"{label} type") + require(account_type in {"User", "Bot"}, f"{label} type is unsupported") + return account_id, validate_login(account.get("login"), f"{label} login"), account_type + + +def require_rest_signature_account( + account: Any, signature: SignatureIdentity, label: str +) -> tuple[int, str, str]: + identity = rest_account(account, label) + require( + identity == (signature.signer_id, signature.signer_login, signature.signer_type), + f"{label} does not match the verified signer", + ) + return identity def require_author_dco(commit: Mapping[str, Any], *, label: str) -> None: @@ -414,6 +803,61 @@ def require_author_dco(commit: Mapping[str, Any], *, label: str) -> None: require(author in found, f"{label} lacks the author's DCO sign-off") +def raw_identity(commit: Mapping[str, Any], role: str, label: str) -> tuple[str, str, str]: + details = require_mapping(commit.get("commit"), f"{label} details") + actor = require_mapping(details.get(role), f"{label} raw {role}") + name = require_string(actor.get("name"), f"{label} raw {role} name") + email = require_string(actor.get("email"), f"{label} raw {role} email") + return name, email, f"{name} <{email}>" + + +def require_direct_contributor_commit( + commit: Mapping[str, Any], signature: SignatureIdentity, label: str +) -> None: + require(not signature.was_signed_by_github, f"{label} uses an unsupported GitHub web-flow signature") + author_account = require_rest_signature_account(commit.get("author"), signature, f"{label} author") + committer_account = require_rest_signature_account( + commit.get("committer"), signature, f"{label} committer" + ) + require(author_account == committer_account, f"{label} author and committer identities differ") + _, author_email, author_dco = raw_identity(commit, "author", label) + _, committer_email, _ = raw_identity(commit, "committer", label) + require( + author_email == committer_email == signature.email, + f"{label} signature email does not match the raw author and committer", + ) + require( + author_dco in signoffs(require_mapping(commit.get("commit"), f"{label} details").get("message")), + f"{label} lacks the author's DCO sign-off", + ) + + +def require_adopted_commit( + api: GitHubApi, + repository: str, + commit: Mapping[str, Any], + signature: SignatureIdentity, + label: str, +) -> None: + require(not signature.was_signed_by_github, f"{label} uses an unsupported GitHub web-flow signature") + author_account = rest_account(commit.get("author"), f"{label} author") + committer_account = require_rest_signature_account( + commit.get("committer"), signature, f"{label} committer" + ) + require(author_account[2] == "User", f"{label} original author is not a GitHub User") + require(committer_account[2] == "User", f"{label} adopting committer is not a GitHub User") + require_writer(api, repository, signature.signer_login, f"{label} adopting signer") + _, _, author_dco = raw_identity(commit, "author", label) + _, committer_email, committer_dco = raw_identity(commit, "committer", label) + require( + committer_email == signature.email, + f"{label} signature email does not match the adopting committer", + ) + found = signoffs(require_mapping(commit.get("commit"), f"{label} details").get("message")) + require(author_dco in found, f"{label} lacks the original author's DCO sign-off") + require(committer_dco in found, f"{label} lacks the adopting committer's DCO sign-off") + + def current_main(api: GitHubApi, repository: str, branch: str) -> str: ref = require_mapping(api.get(repo_api_path(repository, f"/git/ref/heads/{branch}")), "main ref") obj = require_mapping(ref.get("object"), "main ref object") @@ -511,6 +955,10 @@ def require_no_path_collisions(paths: Iterable[str]) -> None: identities: dict[str, str] = {} for path in sorted(paths): + require( + all("~" not in part for part in normalized_components(path)), + "candidate tree contains a Windows short-name-shaped path component", + ) identity = normalized_casefold(path) previous = identities.setdefault(identity, path) require( @@ -534,11 +982,58 @@ def changed_tree_paths(base: GitTree, head: GitTree) -> tuple[list[str], list[st statuses.append("added") elif path not in head.leaves: statuses.append("removed") + elif base.leaves[path][:2] != head.leaves[path][:2]: + statuses.append("mode-or-type-changed") else: statuses.append("modified") return paths, statuses +@dataclass(frozen=True) +class SensitiveInventory: + entries: tuple[Mapping[str, Any], ...] + digest: str + + @property + def present(self) -> bool: + return bool(self.entries) + + +def sensitive_inventory( + base: GitTree, + head: GitTree, + paths: Sequence[str], + statuses: Sequence[str], + repository_kind: str, +) -> SensitiveInventory: + require(len(paths) == len(statuses), "tree diff paths and statuses are misaligned") + entries: list[Mapping[str, Any]] = [] + for path, status in zip(paths, statuses, strict=True): + if not is_sensitive_path(path, repository_kind): + continue + entries.append( + { + "path": path, + "status": status, + "base": list(base.leaves[path]) if path in base.leaves else None, + "head": list(head.leaves[path]) if path in head.leaves else None, + } + ) + require( + len(entries) <= MAX_SENSITIVE_FILES, + f"sensitive diff exceeds the supported limit of {MAX_SENSITIVE_FILES} files", + ) + encoded = json.dumps(entries, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + require( + len(encoded) <= MAX_PATH_METADATA_BYTES, + "sensitive diff metadata exceeds the 4 MiB limit", + ) + return SensitiveInventory( + entries=tuple(entries), + digest=hashlib.sha256(encoded).hexdigest(), + ) + + def pull_commits(api: GitHubApi, repository: str, number: int, expected: int) -> list[Mapping[str, Any]]: require(1 <= expected <= MAX_PULL_COMMITS, "pull request commit count is outside the supported range") values = api.paginate( @@ -641,12 +1136,21 @@ def require_release_app_change( statuses: Sequence[str], main_sha: str, release_app: Mapping[str, Any], + signatures: Mapping[str, Mapping[str, Any]], ) -> None: require(release_app.get("enabled") is True, "release App exception is disabled") - user = require_mapping(pull.get("user"), "pull request author") - require(user.get("login") == release_app.get("login"), "pull request is not owned by the release App") + user = rest_account(pull.get("user"), "pull request author") + require( + user + == ( + release_app.get("bot_user_id"), + release_app.get("login"), + "Bot", + ), + "pull request is not owned by the exact release App identity", + ) require( - user.get("id") == release_app.get("bot_user_id"), + user[0] == release_app.get("bot_user_id"), "pull request author ID does not match the release App", ) head = require_mapping(pull.get("head"), "pull request head") @@ -663,25 +1167,51 @@ def require_release_app_change( sha = validate_sha(commits[0].get("sha"), "release App commit SHA") commit = full_commit(api, repository, sha) + signature = signature_identity( + require_mapping(signatures.get(sha), "release App signature result"), + "release App commit", + ) + require(signature.oid == sha, "release App signature OID is unexpected") parents = require_sequence(commit.get("parents"), "release App commit parents") require(len(parents) == 1, "release App commit must have exactly one parent") parent = require_mapping(parents[0], "release App commit parent") require(parent.get("sha") == main_sha, "release App commit parent is not current main") - author = require_mapping(commit.get("author"), "release App commit author") - require(author.get("login") == release_app.get("login"), "release App commit author is unexpected") + author = rest_account(commit.get("author"), "release App commit author") + require( + author + == ( + release_app.get("bot_user_id"), + release_app.get("login"), + "Bot", + ), + "release App commit author is unexpected", + ) require( - author.get("id") == release_app.get("bot_user_id"), + author[0] == release_app.get("bot_user_id"), "release App commit author ID is unexpected", ) - committer = require_mapping(commit.get("committer"), "release App commit committer") + committer = rest_account(commit.get("committer"), "release App commit committer") require( - committer.get("login") == release_app.get("commit_committer_login"), + committer + == ( + release_app.get("commit_committer_user_id"), + release_app.get("commit_committer_login"), + "User", + ), "release App commit committer is unexpected", ) require( - committer.get("id") == release_app.get("commit_committer_user_id"), + committer[0] == release_app.get("commit_committer_user_id"), "release App commit committer ID is unexpected", ) + require( + signature.kind == "GpgSignature" + and signature.was_signed_by_github + and signature.signer_id == release_app.get("commit_committer_user_id") + and signature.signer_login == release_app.get("commit_committer_login") + and signature.email == release_app.get("commit_committer_email"), + "release App commit does not have the exact GitHub web-flow signature", + ) details = require_mapping(commit.get("commit"), "release App commit details") raw_author = require_mapping(details.get("author"), "release App raw commit author") require( @@ -703,7 +1233,6 @@ def require_release_app_change( raw_committer.get("email") == release_app.get("commit_committer_email"), "release App raw commit committer email is unexpected", ) - require_verified(commit, "release App commit") require_author_dco(commit, label="release App commit") @@ -711,6 +1240,9 @@ def require_contributor_change( api: GitHubApi, repository: str, commits: Sequence[Mapping[str, Any]], + signatures: Mapping[str, Mapping[str, Any]], + *, + adopted: bool, ) -> None: for index, summary in enumerate(commits): sha = validate_sha(summary.get("sha"), f"contributor commit {index} SHA") @@ -719,8 +1251,16 @@ def require_contributor_change( commit.get("parents"), f"contributor commit {index} parents" ) require(len(parents) == 1, f"contributor commit {index} must be linear") - require_verified(commit, f"contributor commit {index}") - require_author_dco(commit, label=f"contributor commit {index}") + label = f"contributor commit {index}" + signature = signature_identity( + require_mapping(signatures.get(sha), f"{label} signature result"), + label, + ) + require(signature.oid == sha, f"{label} signature OID is unexpected") + if adopted: + require_adopted_commit(api, repository, commit, signature, label) + else: + require_direct_contributor_commit(commit, signature, label) @dataclass(frozen=True) @@ -728,14 +1268,54 @@ class Authorization: repository: str pull_number: int commenter: str + commenter_id: int + commenter_type: str head_sha: str base_sha: str head_repository: str head_ref: str policy_sha: str comment_id: int + comment_body: str + comment_created_at: str + comment_updated_at: str + command_mode: str + sensitive_inventory_digest: str + sensitive: bool candidate_ci_required: bool + def canonical_binding(self) -> bytes: + value = { + "version": 2, + "repository": self.repository, + "pull_number": self.pull_number, + "head_sha": self.head_sha, + "base_sha": self.base_sha, + "head_repository": self.head_repository, + "head_ref": self.head_ref, + "policy_sha": self.policy_sha, + "comment": { + "id": self.comment_id, + "body": self.comment_body, + "created_at": self.comment_created_at, + "updated_at": self.comment_updated_at, + "user": { + "id": self.commenter_id, + "login": self.commenter, + "type": self.commenter_type, + }, + }, + "command_mode": self.command_mode, + "sensitive_inventory_digest": self.sensitive_inventory_digest, + "sensitive": self.sensitive, + "candidate_ci_required": self.candidate_ci_required, + } + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + @property + def binding_digest(self) -> str: + return hashlib.sha256(self.canonical_binding()).hexdigest() + def github_outputs(self) -> Mapping[str, str]: return { "repository": self.repository, @@ -745,6 +1325,9 @@ def github_outputs(self) -> Mapping[str, str]: "head_repository": self.head_repository, "policy_sha": self.policy_sha, "comment_id": str(self.comment_id), + "binding_digest": self.binding_digest, + "command_mode": self.command_mode, + "sensitive": str(self.sensitive).lower(), "candidate_ci_required": str(self.candidate_ci_required).lower(), } @@ -783,6 +1366,69 @@ def decode_job_name(value: Any) -> "CallBinding | None": ) +@dataclass(frozen=True) +class CommentBinding: + comment_id: int + body: str + created_at: str + updated_at: str + user_id: int + user_login: str + user_type: str + + +def timestamp(value: Any, label: str) -> str: + encoded = require_string(value, label) + try: + parsed = dt.datetime.fromisoformat(encoded.replace("Z", "+00:00")) + except ValueError as error: + raise PolicyError(f"{label} is not an ISO-8601 timestamp") from error + require(parsed.tzinfo is not None, f"{label} must include a timezone") + return encoded + + +def comment_binding(value: Mapping[str, Any], label: str) -> CommentBinding: + user = require_mapping(value.get("user"), f"{label} user") + user_id = require_integer(user.get("id"), f"{label} user ID") + require(user_id > 0, f"{label} user ID must be positive") + user_type = require_string(user.get("type"), f"{label} user type") + require(user_type == "User", f"{label} must be authored by a GitHub User") + comment_id = require_integer(value.get("id"), f"{label} ID") + require(comment_id > 0, f"{label} ID must be positive") + return CommentBinding( + comment_id=comment_id, + body=require_string(value.get("body"), f"{label} body"), + created_at=timestamp(value.get("created_at"), f"{label} created_at"), + updated_at=timestamp(value.get("updated_at"), f"{label} updated_at"), + user_id=user_id, + user_login=validate_login(user.get("login"), f"{label} user login"), + user_type=user_type, + ) + + +def require_comment_unchanged( + api: GitHubApi, + repository: str, + pull_number: int, + expected: CommentBinding, + phase: str, +) -> None: + current = require_mapping( + api.get(repo_api_path(repository, f"/issues/comments/{expected.comment_id}")), + "rechecked authorization comment", + ) + expected_issue_url = f"{api.api_url}{repo_api_path(repository, f'/issues/{pull_number}')}" + require( + current.get("issue_url") == expected_issue_url, + f"authorization comment moved during {phase}", + ) + require( + comment_binding(current, "rechecked authorization comment") == expected, + f"authorization comment or identity changed during {phase}", + ) + require_writer(api, repository, expected.user_login, "comment author") + + def authorize( event: Mapping[str, Any], config: Mapping[str, Any], @@ -791,6 +1437,7 @@ def authorize( ) -> Authorization: require(event.get("action") == "created", "only newly created comments are accepted") repository = validate_repository(environment.get("GITHUB_REPOSITORY"), "GITHUB_REPOSITORY") + require(repository == config.get("repository"), "policy repository does not match the workflow repository") event_repo = require_mapping(event.get("repository"), "event repository") require(event_repo.get("full_name") == repository, "event repository does not match the workflow repository") @@ -799,15 +1446,28 @@ def authorize( pull_number = require_integer(issue.get("number"), "pull request number") require(pull_number > 0, "pull request number must be positive") comment = require_mapping(event.get("comment"), "event comment") - requested_sha = exact_command(comment.get("body")) - comment_id = require_integer(comment.get("id"), "comment id") - require(comment_id > 0, "comment id must be positive") - commenter = validate_login(require_mapping(comment.get("user"), "comment user").get("login"), "commenter") + event_comment = comment_binding(comment, "event comment") + requested = exact_command(event_comment.body) + comment_id = event_comment.comment_id + commenter = event_comment.user_login + sender = require_mapping(event.get("sender"), "event sender") + require( + rest_account(sender, "event sender") + == (event_comment.user_id, event_comment.user_login, event_comment.user_type), + "event sender does not match the comment author", + ) actor = validate_login(environment.get("GITHUB_ACTOR"), "GITHUB_ACTOR") triggering_actor = validate_login(environment.get("GITHUB_TRIGGERING_ACTOR"), "GITHUB_TRIGGERING_ACTOR") require(commenter == actor, "workflow actor does not match the comment author") require_writer(api, repository, commenter, "comment author") require_writer(api, repository, triggering_actor, "triggering actor") + require_comment_unchanged( + api, + repository, + pull_number, + event_comment, + "initial authorization", + ) policy_sha = validate_sha(environment.get("POLICY_SHA"), "POLICY_SHA") branch = require_string(config.get("default_branch"), "default_branch") @@ -825,7 +1485,7 @@ def authorize( require(base_sha == main_sha, "pull request base is not current main") head = require_mapping(pull.get("head"), "pull request head") head_sha = validate_sha(head.get("sha"), "pull request head SHA") - require(head_sha == requested_sha, "comment SHA is not the exact current pull request head") + require(head_sha == requested.head_sha, "comment SHA is not the exact current pull request head") head_repo = require_mapping(head.get("repo"), "pull request head repository") head_repository = validate_repository(head_repo.get("full_name"), "pull request head repository") head_ref = require_string(head.get("ref"), "pull request head ref") @@ -840,6 +1500,14 @@ def authorize( commits = pull_commits(api, repository, pull_number, commit_count) require(validate_sha(commits[-1].get("sha"), "last pull request commit SHA") == head_sha, "commit list does not end at pull request head") require_commit_chain(commits, base_sha, head_sha) + commit_oids = [ + validate_sha(commit.get("sha"), f"pull request commit {index} SHA") + for index, commit in enumerate(commits) + ] + signatures = api.commit_signatures(repository, pull_number, commit_oids) + + repository_kind = require_string(config.get("repository_kind"), "repository_kind") + inventory = sensitive_inventory(base_tree, head_tree, paths, statuses, repository_kind) release_app = require_mapping(config.get("release_app"), "release_app") user = require_mapping(pull.get("user"), "pull request author") @@ -848,21 +1516,37 @@ def authorize( or user.get("id") == release_app.get("bot_user_id") ) if is_release_app: + require(not requested.adoption, "release App proposals use the ordinary authorization command") require_release_app_change( - api, repository, pull, commits, paths, statuses, main_sha, release_app + api, + repository, + pull, + commits, + paths, + statuses, + main_sha, + release_app, + signatures, ) else: - require_contributor_change(api, repository, commits) - - patterns = [ - require_string(value, "candidate CI path pattern") - for value in require_sequence( - config.get("candidate_ci_paths"), "candidate_ci_paths" + require( + requested.adoption == inventory.present, + "sensitive changes require /ok to test-and-adopt; ordinary changes require /ok to test", + ) + if inventory.present and head_repository != repository: + require( + pull.get("maintainer_can_modify") is True, + "sensitive fork changes require maintainer edits on the original pull request", + ) + require_contributor_change( + api, + repository, + commits, + signatures, + adopted=inventory.present, ) - ] - candidate_ci_required = any( - matches_path_inventory(path, patterns) for path in paths - ) + + candidate_ci_required = bool(config.get("supplemental_candidate_ci")) and inventory.present require_live_authorization_state( api, @@ -874,17 +1558,32 @@ def authorize( head_repository, head_ref, ) + require_comment_unchanged( + api, + repository, + pull_number, + event_comment, + "final authorization", + ) return Authorization( repository=repository, pull_number=pull_number, commenter=commenter, + commenter_id=event_comment.user_id, + commenter_type=event_comment.user_type, head_sha=head_sha, base_sha=base_sha, head_repository=head_repository, head_ref=head_ref, policy_sha=policy_sha, comment_id=comment_id, + comment_body=event_comment.body, + comment_created_at=event_comment.created_at, + comment_updated_at=event_comment.updated_at, + command_mode=requested.mode, + sensitive_inventory_digest=inventory.digest, + sensitive=inventory.present, candidate_ci_required=candidate_ci_required, ) @@ -907,6 +1606,7 @@ def original_comment_event( "repository": {"full_name": repository}, "issue": {"number": pull_number, "pull_request": {}}, "comment": comment, + "sender": comment.get("user"), } @@ -919,6 +1619,7 @@ def require_authorization_values( base_sha: str, policy_sha: str, comment_id: int, + binding_digest: str | None = None, ) -> None: require(authorization.repository == repository, "authorized repository changed") require(authorization.pull_number == pull_number, "authorized pull request changed") @@ -926,6 +1627,11 @@ def require_authorization_values( require(authorization.base_sha == base_sha, "authorized base SHA changed") require(authorization.policy_sha == policy_sha, "authorized policy SHA changed") require(authorization.comment_id == comment_id, "authorized comment changed") + if binding_digest is not None: + require( + authorization.binding_digest == binding_digest, + "authorization binding digest changed", + ) def authorize_live_comment( @@ -1061,6 +1767,7 @@ def authorize_call( base_sha=base_sha, policy_sha=policy_sha, comment_id=comment_id, + binding_digest=event_authorization.binding_digest, ) return live_authorization @@ -1083,17 +1790,16 @@ class ExternalId: head_sha: str base_sha: str policy_sha: str + binding_digest: str run_id: int run_attempt: int def encode(self) -> str: fields = ( - "v1", - self.repository, + "v2", str(self.pull_number), self.head_sha, - self.base_sha, - self.policy_sha, + validate_digest(self.binding_digest, "authorization binding digest"), str(self.run_id), str(self.run_attempt), ) @@ -1103,23 +1809,30 @@ def encode(self) -> str: return encoded @staticmethod - def decode(value: Any) -> "ExternalId": + def decode( + value: Any, + *, + repository: str = "unknown/unknown", + base_sha: str = "0000000000000000000000000000000000000000", + policy_sha: str = "0000000000000000000000000000000000000000", + ) -> "ExternalId": encoded = require_string(value, "check external ID") fields = encoded.split("|") - require(len(fields) == 8 and fields[0] == "v1", "check external ID is invalid") + require(len(fields) == 6 and fields[0] == "v2", "check external ID is invalid") try: - pull_number = int(fields[2]) - run_id = int(fields[6]) - run_attempt = int(fields[7]) + pull_number = int(fields[1]) + run_id = int(fields[4]) + run_attempt = int(fields[5]) except ValueError as error: raise PolicyError("check external ID contains a non-integer field") from error require(pull_number > 0 and run_id > 0 and run_attempt > 0, "check external ID integers must be positive") return ExternalId( - repository=validate_repository(fields[1], "external ID repository"), + repository=validate_repository(repository, "external ID repository context"), pull_number=pull_number, - head_sha=validate_sha(fields[3], "external ID head SHA"), - base_sha=validate_sha(fields[4], "external ID base SHA"), - policy_sha=validate_sha(fields[5], "external ID policy SHA"), + head_sha=validate_sha(fields[2], "external ID head SHA"), + base_sha=validate_sha(base_sha, "external ID base SHA context"), + policy_sha=validate_sha(policy_sha, "external ID policy SHA context"), + binding_digest=validate_digest(fields[3], "external ID binding digest"), run_id=run_id, run_attempt=run_attempt, ) @@ -1192,8 +1905,13 @@ def start_check( for check in checks: if not check_is_from_app(check, app_slug) or check.get("status") in TERMINAL_CHECK_STATUSES: continue - prior = ExternalId.decode(check.get("external_id")) - if prior.repository == external.repository and prior.pull_number == external.pull_number and prior.head_sha == external.head_sha: + prior = ExternalId.decode( + check.get("external_id"), + repository=external.repository, + base_sha=external.base_sha, + policy_sha=external.policy_sha, + ) + if prior.pull_number == external.pull_number and prior.head_sha == external.head_sha: check_id = require_integer(check.get("id"), "prior check id") complete_check( api, @@ -1241,6 +1959,28 @@ def parse_results(values: Iterable[str], expected_jobs: Sequence[str]) -> Mappin return results +def parse_results_json(value: str, expected_jobs: Sequence[str]) -> Mapping[str, str]: + require(len(value.encode("utf-8")) <= 16 * 1024, "job result JSON exceeds its limit") + try: + decoded = json.loads(value) + except json.JSONDecodeError as error: + raise PolicyError("job result JSON is invalid") from error + mapping = require_mapping(decoded, "job results") + require( + set(mapping) == set(expected_jobs), + "reported jobs do not exactly match the protected inventory", + ) + results: dict[str, str] = {} + for job in expected_jobs: + result = require_string(mapping.get(job), f"job {job} result") + require( + result in {"success", "failure", "cancelled", "skipped"}, + f"job {job!r} has unknown result {result!r}", + ) + results[job] = result + return results + + def validate_check_value( check: Mapping[str, Any], config: Mapping[str, Any], @@ -1284,7 +2024,7 @@ def finish_check( external: ExternalId, comment_id: int, check_id: int, - result_values: Iterable[str], + results_json: str, observed_app_slug: str, ) -> None: check = validate_check(app_api, config, external, check_id, observed_app_slug) @@ -1305,8 +2045,12 @@ def finish_check( run_id=external.run_id, run_attempt=external.run_attempt, ) + require( + current.binding_digest == external.binding_digest, + "authorization binding changed before finalization", + ) expected = [require_string(value, "expected job") for value in require_sequence(config.get("expected_jobs"), "expected_jobs")] - results = parse_results(result_values, expected) + results = parse_results_json(results_json, expected) failed = [ (job, result) for job, result in results.items() @@ -1353,6 +2097,21 @@ def finish_check( current.head_ref, phase="final check reconciliation", ) + require_comment_unchanged( + auth_api, + external.repository, + external.pull_number, + CommentBinding( + comment_id=current.comment_id, + body=current.comment_body, + created_at=current.comment_created_at, + updated_at=current.comment_updated_at, + user_id=current.commenter_id, + user_login=current.commenter, + user_type=current.commenter_type, + ), + "final check reconciliation", + ) except PolicyError as reconciliation_error: failed = complete_check( app_api, @@ -1620,14 +2379,16 @@ def pending_checks_for_run( for check in checks: if not check_is_from_app(check, app_slug) or check.get("status") in TERMINAL_CHECK_STATUSES: continue - external = ExternalId.decode(check.get("external_id")) + external = ExternalId.decode( + check.get("external_id"), + repository=repository, + policy_sha=policy_sha, + ) if ( - external.repository == repository - and external.pull_number == pull_number + external.pull_number == pull_number and external.head_sha == head_sha and external.run_id == run_id and external.run_attempt == run_attempt - and external.policy_sha == policy_sha ): matches.append((check, external)) return matches @@ -1676,9 +2437,10 @@ def close_pending_check_for_run( repository=external.repository, pull_number=external.pull_number, head_sha=external.head_sha, - base_sha=external.base_sha, + base_sha=authorization.base_sha, policy_sha=external.policy_sha, comment_id=run.binding.comment_id, + binding_digest=external.binding_digest, ) except PolicyError as error: summary = f"{summary} Final state validation failed: {error}" @@ -1821,6 +2583,7 @@ def external_from_args(args: argparse.Namespace) -> ExternalId: head_sha=validate_sha(args.head_sha, "head SHA"), base_sha=validate_sha(args.base_sha, "base SHA"), policy_sha=validate_sha(args.policy_sha, "policy SHA"), + binding_digest=validate_digest(args.binding_digest, "binding digest"), run_id=args.run_id, run_attempt=args.run_attempt, ) @@ -1832,6 +2595,7 @@ def add_external_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--head-sha", required=True) parser.add_argument("--base-sha", required=True) parser.add_argument("--policy-sha", required=True) + parser.add_argument("--binding-digest", required=True) parser.add_argument("--run-id", required=True, type=int) parser.add_argument("--run-attempt", required=True, type=int) @@ -1862,7 +2626,7 @@ def build_parser() -> argparse.ArgumentParser: finish_parser.add_argument("--config", required=True) finish_parser.add_argument("--check-id", required=True, type=int) finish_parser.add_argument("--comment-id", required=True, type=int) - finish_parser.add_argument("--result", action="append", default=[]) + finish_parser.add_argument("--results-json", required=True) add_external_arguments(finish_parser) inspect_parser = subparsers.add_parser("inspect-run") @@ -1879,6 +2643,10 @@ def build_parser() -> argparse.ArgumentParser: sweep_parser = subparsers.add_parser("sweep") sweep_parser.add_argument("--config", required=True) sweep_parser.add_argument("--repository", required=True) + + verify_token_parser = subparsers.add_parser("verify-app-token") + verify_token_parser.add_argument("--config", required=True) + verify_token_parser.add_argument("--repository", required=True) return parser @@ -1948,13 +2716,20 @@ def main(argv: Sequence[str] | None = None) -> int: external_from_args(args), args.comment_id, args.check_id, - args.result, + args.results_json, required_env("APP_SLUG"), ) print(f"Finished App check {args.check_id}.") return 0 repository = validate_repository(args.repository) + if args.command == "verify-app-token": + require_app_token_repository_scope( + GitHubApi(required_env("APP_TOKEN"), api_url), repository + ) + print(f"Verified App token repository scope for {repository}.") + return 0 + if args.command == "inspect-run": protected = completed_run_from_event( GitHubApi(required_env("GITHUB_TOKEN"), api_url), diff --git a/.github/scripts/release_notification_preflight.py b/.github/scripts/release_notification_preflight.py new file mode 100644 index 0000000..706c9f8 --- /dev/null +++ b/.github/scripts/release_notification_preflight.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Validate one protected source-only release notification without checkout.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import re +import tarfile +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +API_VERSION = "2026-03-10" +EVENT_TYPE = "official-release-published" +INTENT_NAME = "Release finalization intent" +INTENT_TITLE = "Attested source-only release train" +MAX_EVENT_BYTES = 128 * 1024 +MAX_CONFIG_BYTES = 64 * 1024 +MAX_NOTIFICATION_BYTES = 8 * 1024 +MAX_INTENT_BYTES = 64 * 1024 +MAX_PLAN_BYTES = 48 * 1024 +MAX_RELEASE_BODY_BYTES = 16 * 1024 +MAX_API_CALLS = 96 +MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024 +MAX_TOTAL_API_BYTES = 32 * 1024 * 1024 +MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 +MAX_ARCHIVE_FILES = 10_000 +MAX_ARCHIVE_CONTENT_BYTES = 128 * 1024 * 1024 +MAX_VCS_BYTES = 1024 * 1024 +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z") +VERSION_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-rc\.[1-9][0-9]*)?\Z") +REPLAY_TRAILER = "YamlSigil-Release-Replay: " +REPLAY_COMMENT = "" + + +class PreflightError(RuntimeError): + """A closed validation boundary rejected the notification.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise PreflightError(message) + + +def require_keys(value: Any, keys: tuple[str, ...], label: str) -> dict[str, Any]: + require(type(value) is dict, f"{label} must be an object") + require(set(value) == set(keys), f"{label} has missing or unknown fields") + return value + + +def require_string(value: Any, label: str, limit: int = 512) -> str: + require(type(value) is str, f"{label} must be a string") + require(0 < len(value.encode("utf-8")) <= limit, f"{label} is empty or oversized") + require(not any(character in value for character in "\x00\r\n"), f"{label} must be one line") + return value + + +def require_positive(value: Any, label: str) -> int: + require(type(value) is int and 0 < value < 2**63, f"{label} must be a positive integer") + return value + + +def require_sha(value: Any, label: str) -> str: + value = require_string(value, label, 40) + require(SHA_RE.fullmatch(value) is not None, f"{label} must be a lowercase SHA-1") + return value + + +def require_digest(value: Any, label: str) -> str: + value = require_string(value, label, 64) + require(DIGEST_RE.fullmatch(value) is not None, f"{label} must be a lowercase SHA-256") + return value + + +def canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def sha256(body: bytes) -> str: + return hashlib.sha256(body).hexdigest() + + +def read_json(path: Path, limit: int, label: str) -> dict[str, Any]: + with path.open("rb") as handle: + body = handle.read(limit + 1) + require(0 < len(body) <= limit, f"{label} is empty or oversized") + try: + value = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PreflightError(f"{label} is not valid UTF-8 JSON: {error}") from error + require(type(value) is dict, f"{label} must contain one object") + return value + + +@dataclass(frozen=True) +class PackagePolicy: + name: str + tag_prefix: str + path_in_vcs: str + + +@dataclass(frozen=True) +class Policy: + repository: str + default_branch: str + sender_id: int + sender_login: str + app_id: int + app_slug: str + app_email: str + release_branch: str + packages: tuple[PackagePolicy, ...] + + +def parse_policy(raw: dict[str, Any]) -> Policy: + raw = require_keys( + raw, + ( + "schema_version", + "api_version", + "repository", + "default_branch", + "sender", + "app", + "release_branch", + "packages", + ), + "release notification policy", + ) + require(raw["schema_version"] == 1, "release notification policy version is unsupported") + require(raw["api_version"] == API_VERSION, "release notification API version is unsupported") + sender = require_keys(raw["sender"], ("id", "login", "type"), "sender policy") + app = require_keys(raw["app"], ("id", "slug", "email"), "App policy") + require(sender["type"] == "Bot", "sender policy must require a Bot") + packages_raw = raw["packages"] + require(type(packages_raw) is list and 0 < len(packages_raw) <= 8, "package policy is empty or oversized") + packages: list[PackagePolicy] = [] + names: set[str] = set() + prefixes: set[str] = set() + for index, item in enumerate(packages_raw): + item = require_keys(item, ("name", "tag_prefix", "path_in_vcs"), f"package policy {index}") + name = require_string(item["name"], f"package policy {index} name", 128) + prefix = require_string(item["tag_prefix"], f"package policy {index} tag prefix", 160) + path = item["path_in_vcs"] + require(type(path) is str and len(path.encode("utf-8")) <= 256, "path_in_vcs is invalid") + require(name not in names and prefix not in prefixes, "package policy contains duplicates") + names.add(name) + prefixes.add(prefix) + packages.append(PackagePolicy(name, prefix, path)) + return Policy( + repository=require_string(raw["repository"], "policy repository", 256), + default_branch=require_string(raw["default_branch"], "policy default branch", 64), + sender_id=require_positive(sender["id"], "policy sender ID"), + sender_login=require_string(sender["login"], "policy sender login", 128), + app_id=require_positive(app["id"], "policy App ID"), + app_slug=require_string(app["slug"], "policy App slug", 128), + app_email=require_string(app["email"], "policy App email", 256), + release_branch=require_string(raw["release_branch"], "release branch", 128), + packages=tuple(packages), + ) + + +class Api: + """Bounded GitHub and crates.io reads with host-isolated credentials.""" + + def __init__(self, token: str, github_api_url: str = "https://api.github.com") -> None: + self.token = require_string(token, "GITHUB_TOKEN", 4096) + self.github_api_url = github_api_url.rstrip("/") + self.calls = 0 + self.total = 0 + + def _read(self, url: str, github: bool, optional: bool, limit: int) -> bytes | None: + self.calls += 1 + require(self.calls <= MAX_API_CALLS, "API request count exceeded its bound") + headers = {"User-Agent": "yaml-sigil-release-preflight/1.0"} + if github: + headers.update( + { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": API_VERSION, + } + ) + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(limit + 1) + except urllib.error.HTTPError as error: + detail = error.read(501)[:500].decode("utf-8", "replace") + if optional and error.code == 404: + return None + raise PreflightError(f"bounded API read returned HTTP {error.code}: {detail}") from error + except urllib.error.URLError as error: + raise PreflightError(f"bounded API read failed: {error.reason}") from error + require(len(body) <= limit, "API response exceeded its per-response bound") + self.total += len(body) + require(self.total <= MAX_TOTAL_API_BYTES, "aggregate API response bytes exceeded their bound") + return body + + def github_json(self, path: str, optional: bool = False) -> Any: + body = self._read(f"{self.github_api_url}/{path.lstrip('/')}", True, optional, MAX_API_RESPONSE_BYTES) + if body is None: + return None + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PreflightError(f"GitHub returned invalid UTF-8 JSON: {error}") from error + + def crates_json(self, path: str) -> Any: + body = self._read(f"https://crates.io/api/v1/{path.lstrip('/')}", False, False, MAX_API_RESPONSE_BYTES) + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PreflightError(f"crates.io returned invalid UTF-8 JSON: {error}") from error + + def crate_archive(self, package: str, version: str) -> bytes: + body = self._read( + f"https://crates.io/api/v1/crates/{urllib.parse.quote(package, safe='')}/{urllib.parse.quote(version, safe='')}/download", + False, + False, + MAX_ARCHIVE_BYTES, + ) + assert body is not None + return body + + +def validate_plan(raw: Any, policy: Policy, payload: dict[str, Any]) -> dict[str, Any]: + plan = require_keys( + raw, + ( + "schema_version", + "repository", + "release_sha", + "authorization", + "release_plz_version", + "release_config_sha256", + "publish_workflow_sha256", + "proposal_workflow_sha256", + "tagger_epoch", + "tagger_date", + "packages", + ), + "embedded release plan", + ) + require(plan["schema_version"] == 1, "release plan version is unsupported") + require(plan["repository"] == policy.repository, "release plan repository is wrong") + require(plan["release_sha"] == payload["captured_sha"], "release plan SHA is wrong") + require(plan["release_plz_version"] == "0.3.160", "release-plz version is wrong") + for field in ("release_config_sha256", "publish_workflow_sha256", "proposal_workflow_sha256"): + require_digest(plan[field], f"release plan {field}") + require_positive(plan["tagger_epoch"], "release plan tagger epoch") + require_string(plan["tagger_date"], "release plan tagger date", 64) + authorization = require_keys( + plan["authorization"], + ("pull_request", "proposal_commit", "base_commit", "owner_id", "merger_id"), + "release authorization", + ) + require_positive(authorization["pull_request"], "release pull request") + require_sha(authorization["proposal_commit"], "proposal commit") + require_sha(authorization["base_commit"], "base commit") + require_positive(authorization["owner_id"], "proposal owner ID") + require_positive(authorization["merger_id"], "proposal merger ID") + packages = plan["packages"] + require(type(packages) is list and len(packages) == len(policy.packages), "release plan package set is incomplete") + for index, (item, expected) in enumerate(zip(packages, policy.packages, strict=True)): + item = require_keys( + item, + ( + "package", + "version", + "tag", + "prerelease", + "source_archive_sha256", + "package_inventory_sha256", + "release_body", + "release_body_sha256", + "registry", + ), + f"release plan package {index}", + ) + version = require_string(item["version"], f"release plan package {index} version", 128) + require(VERSION_RE.fullmatch(version) is not None, "release plan contains a noncanonical version") + require(item["package"] == expected.name, "release plan package order is wrong") + require(item["tag"] == f"{expected.tag_prefix}{version}", "release plan tag is wrong") + require(type(item["prerelease"]) is bool and item["prerelease"] == ("-" in version), "release prerelease state is wrong") + require_digest(item["source_archive_sha256"], "source archive digest") + require_digest(item["package_inventory_sha256"], "package inventory digest") + body = item["release_body"] + require(type(body) is str and 0 < len(body.encode("utf-8")) <= MAX_RELEASE_BODY_BYTES, "release body is empty or oversized") + require(sha256(body.encode("utf-8")) == require_digest(item["release_body_sha256"], "release body digest"), "release body digest is wrong") + registry = require_keys(item["registry"], ("state", "checksum"), "registry baseline") + require(registry["state"] in ("absent", "present"), "registry baseline state is invalid") + if registry["state"] == "absent": + require(registry["checksum"] is None, "absent registry baseline has a checksum") + else: + require_digest(registry["checksum"], "registry baseline checksum") + return plan + + +def validate_intent(check: dict[str, Any], policy: Policy, payload: dict[str, Any]) -> dict[str, Any]: + require(type(check) is dict, "intent Check must be an object") + require(check["id"] == payload["intent_check_id"], "intent Check ID is wrong") + require(check["name"] == INTENT_NAME, "intent Check name is wrong") + require(check["head_sha"] == payload["captured_sha"], "intent Check SHA is wrong") + require(check["external_id"] == payload["intent_external_id"], "intent Check external ID is wrong") + require(check["status"] == "completed" and check["conclusion"] == "neutral", "intent Check state is wrong") + app = check.get("app") + require(type(app) is dict, "intent Check App is missing") + require(app["id"] == policy.app_id and app["slug"] == policy.app_slug, "intent Check App is wrong") + output = check.get("output") + require(type(output) is dict, "intent Check output is missing") + require(output["title"] == INTENT_TITLE, "intent Check title is wrong") + summary = output["summary"] + require(type(summary) is str and 0 < len(summary.encode("utf-8")) <= MAX_INTENT_BYTES, "intent Check summary is empty or oversized") + try: + intent = json.loads(summary) + except json.JSONDecodeError as error: + raise PreflightError(f"intent Check summary is invalid JSON: {error}") from error + intent = require_keys( + intent, + ( + "schema_version", + "repository", + "release_sha", + "plan_digest", + "external_id", + "origin_run_id", + "origin_run_attempt", + "ruleset_evidence_sha256", + "plan", + "tags", + ), + "release intent", + ) + require(canonical(intent) == summary, "release intent is not canonical") + require(intent["schema_version"] == 1, "release intent version is unsupported") + require(intent["repository"] == policy.repository, "release intent repository is wrong") + require(intent["release_sha"] == payload["captured_sha"], "release intent SHA is wrong") + require(intent["plan_digest"] == payload["release_plan_digest"], "release intent plan digest is wrong") + require(intent["external_id"] == payload["intent_external_id"], "release intent external ID is wrong") + require_positive(intent["origin_run_id"], "release intent origin run") + require_positive(intent["origin_run_attempt"], "release intent origin attempt") + require_digest(intent["ruleset_evidence_sha256"], "ruleset evidence digest") + plan = validate_plan(intent["plan"], policy, payload) + plan_body = canonical(plan) + require(len(plan_body.encode("utf-8")) <= MAX_PLAN_BYTES, "embedded release plan is oversized") + require(sha256(plan_body.encode("utf-8")) == payload["release_plan_digest"], "embedded release plan digest is wrong") + tags = intent["tags"] + require(type(tags) is list and len(tags) == len(policy.packages), "release intent tag set is incomplete") + for index, (tag, package, release) in enumerate(zip(tags, plan["packages"], payload["releases"], strict=True)): + tag = require_keys(tag, ("package", "tag", "tag_object_id", "tag_message", "release_body_sha256"), f"intent tag {index}") + require(tag["package"] == package["package"] == release["package"], "intent package order is wrong") + require(tag["tag"] == package["tag"] == release["tag"], "intent tag is wrong") + require(require_sha(tag["tag_object_id"], "intent tag object") == release["tag_object_id"], "intent tag object is wrong") + require(tag["tag_message"] == f"chore: Release package {package['package']} version {package['version']}", "intent tag message is wrong") + require(tag["release_body_sha256"] == package["release_body_sha256"] == release["release_body_sha256"], "intent Release body digest is wrong") + return intent + + +def inspect_archive(archive: bytes, package: PackagePolicy, version: str, commit: str) -> None: + require(0 < len(archive) <= MAX_ARCHIVE_BYTES, "crate archive is empty or oversized") + prefix = f"{package.name}-{version}" + total = 0 + count = 0 + vcs_body: bytes | None = None + try: + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as cargo: + for member in cargo: + count += 1 + require(count <= MAX_ARCHIVE_FILES, "crate archive file count exceeded its bound") + require(member.isfile(), "crate archive contains a non-file entry") + parts = member.name.split("/") + require(parts[0] == prefix and all(part not in ("", ".", "..") for part in parts), "crate archive contains an unsafe path") + require(member.size >= 0, "crate archive contains an invalid size") + total += member.size + require(total <= MAX_ARCHIVE_CONTENT_BYTES, "crate archive content exceeded its bound") + if member.name == f"{prefix}/.cargo_vcs_info.json": + require(vcs_body is None and member.size <= MAX_VCS_BYTES, "crate VCS metadata is duplicate or oversized") + handle = cargo.extractfile(member) + require(handle is not None, "crate VCS metadata is unreadable") + vcs_body = handle.read(MAX_VCS_BYTES + 1) + require(len(vcs_body) == member.size, "crate VCS metadata is truncated") + except (tarfile.TarError, EOFError, OSError) as error: + raise PreflightError(f"crate archive is invalid: {error}") from error + require(vcs_body is not None, "crate archive lacks VCS metadata") + try: + vcs = json.loads(vcs_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PreflightError(f"crate VCS metadata is invalid: {error}") from error + vcs = require_keys(vcs, ("git", "path_in_vcs"), "crate VCS metadata") + require(type(vcs["git"]) is dict, "crate Git metadata must be an object") + git_keys = set(vcs["git"]) + require( + git_keys in ({"sha1"}, {"sha1", "dirty"}), + "crate Git metadata has missing or unknown fields", + ) + git = vcs["git"] + require(git["sha1"] == commit and git.get("dirty", False) is False, "crate VCS commit is wrong or dirty") + require(vcs["path_in_vcs"] == package.path_in_vcs, "crate VCS path is wrong") + + +def validate_registry(api: Api, policy: Policy, plan: dict[str, Any]) -> None: + for expected, package in zip(policy.packages, plan["packages"], strict=True): + response = api.crates_json(f"crates/{urllib.parse.quote(expected.name, safe='')}/{urllib.parse.quote(package['version'], safe='')}") + require(type(response) is dict, "crates.io exact-version response is invalid") + version = response.get("version") + require(type(version) is dict, "crates.io version is invalid") + require(version.get("num") == package["version"] and version.get("yanked") is False, "crates.io version state is wrong") + checksum = require_digest(version.get("checksum"), "crates.io checksum") + require(checksum == package["source_archive_sha256"], "crates.io checksum differs from the release plan") + archive = api.crate_archive(expected.name, package["version"]) + require(sha256(archive) == checksum, "downloaded crate checksum is wrong") + inspect_archive(archive, expected, package["version"], plan["release_sha"]) + + +def validate_release_set(api: Api, policy: Policy, payload: dict[str, Any], intent: dict[str, Any]) -> None: + plan_packages = intent["plan"]["packages"] + intent_tags = intent["tags"] + for release_entry, package, tag_intent in zip(payload["releases"], plan_packages, intent_tags, strict=True): + release = api.github_json(f"repos/{policy.repository}/releases/{release_entry['release_id']}") + require(type(release) is dict, "GitHub Release response is invalid") + author = release.get("author") + require(type(author) is dict, "GitHub Release author is missing") + require( + release.get("id") == release_entry["release_id"] + and release.get("tag_name") == package["tag"] + and release.get("target_commitish") == policy.default_branch + and release.get("name") == package["tag"] + and release.get("body") == package["release_body"] + and release.get("draft") is False + and release.get("prerelease") is package["prerelease"] + and release.get("immutable") is True + and author.get("id") == policy.sender_id + and author.get("login") == policy.sender_login + and author.get("type") == "Bot" + and release.get("assets") == [], + "GitHub Release is not exact, immutable, App-authored, and asset-free", + ) + by_tag = api.github_json(f"repos/{policy.repository}/releases/tags/{urllib.parse.quote(package['tag'], safe='')}") + require(type(by_tag) is dict and by_tag.get("id") == release_entry["release_id"], "GitHub Release tag lookup disagrees") + reference = api.github_json(f"repos/{policy.repository}/git/ref/tags/{urllib.parse.quote(package['tag'], safe='')}") + require(type(reference) is dict and reference.get("ref") == f"refs/tags/{package['tag']}", "annotated tag ref is wrong") + target = reference.get("object") + require(type(target) is dict and target.get("type") == "tag" and target.get("sha") == tag_intent["tag_object_id"], "annotated tag ref target is wrong") + tag = api.github_json(f"repos/{policy.repository}/git/tags/{tag_intent['tag_object_id']}") + require(type(tag) is dict, "annotated tag object is invalid") + tagger = tag.get("tagger") + target = tag.get("object") + require( + tag.get("sha") == tag_intent["tag_object_id"] + and tag.get("tag") == package["tag"] + and tag.get("message") == tag_intent["tag_message"] + and type(tagger) is dict + and tagger.get("name") == policy.sender_login + and tagger.get("email") == policy.app_email + and type(tagger.get("date")) is str + and type(target) is dict + and target.get("type") == "commit" + and target.get("sha") == payload["captured_sha"], + "annotated tag object is not the attested App object", + ) + + +def replay_marker(value: str, prefix: str) -> str | None: + matches = [line[len(prefix):] for line in value.splitlines() if line.startswith(prefix)] + require(len(matches) <= 1, "release proposal contains duplicate replay markers") + if not matches: + return None + return require_digest(matches[0], "release replay marker") + + +def replay_comment(value: str) -> str | None: + matches = re.findall(r"^$", value, re.MULTILINE) + require(len(matches) <= 1, "release proposal contains duplicate replay comments") + return matches[0] if matches else None + + +def replay_state(api: Api, policy: Policy, key: str) -> str: + branch = urllib.parse.quote(policy.release_branch, safe="") + reference = api.github_json(f"repos/{policy.repository}/git/ref/heads/{branch}", optional=True) + owner = policy.repository.split("/", 1)[0] + pulls = api.github_json( + f"repos/{policy.repository}/pulls?state=open&head={urllib.parse.quote(f'{owner}:{policy.release_branch}', safe='')}&per_page=100" + ) + require(type(pulls) is list and len(pulls) <= 1, "release proposal pull-request state is ambiguous") + if reference is None: + require(not pulls, "release proposal exists without its durable branch") + return "new" + require(type(reference) is dict, "release proposal ref is invalid") + target = reference.get("object") + require(type(target) is dict and target.get("type") == "commit", "release proposal ref is not a commit") + commit_sha = require_sha(target.get("sha"), "release proposal commit") + commit = api.github_json(f"repos/{policy.repository}/commits/{commit_sha}") + require(type(commit) is dict and type(commit.get("commit")) is dict, "release proposal commit is invalid") + marker = replay_marker(commit["commit"].get("message", ""), REPLAY_TRAILER) + if pulls: + pull = pulls[0] + body_marker = replay_comment(pull.get("body") or "") + require(marker == key and body_marker == key, "an active release proposal has a different replay identity") + raise PreflightError("release notification replay is already durably consumed") + comparison = api.github_json( + f"repos/{policy.repository}/compare/{urllib.parse.quote(policy.default_branch, safe='')}...{branch}" + ) + require(type(comparison) is dict and type(comparison.get("ahead_by")) is int, "release branch comparison is invalid") + if comparison["ahead_by"] == 0: + return "new" + require(comparison["ahead_by"] == 1 and marker == key, "release proposal branch is not an exact abandoned replay") + return "recover" + + +def validate_event(event: dict[str, Any], policy: Policy, api: Api, repository: str, policy_sha: str) -> dict[str, str]: + require(repository == policy.repository, "workflow repository differs from policy") + require_sha(policy_sha, "policy SHA") + require(type(event) is dict, "repository dispatch event must be an object") + require(event.get("action") == EVENT_TYPE, "repository dispatch event type is wrong") + sender = event.get("sender") + require(type(sender) is dict, "repository dispatch sender is missing") + require(sender.get("id") == policy.sender_id and sender.get("login") == policy.sender_login and sender.get("type") == "Bot", "repository dispatch sender is wrong") + event_repository = event.get("repository") + require(type(event_repository) is dict, "repository dispatch repository is missing") + require(event_repository.get("full_name") == policy.repository and event_repository.get("default_branch") == policy.default_branch, "repository dispatch repository identity is wrong") + live_repository = api.github_json(f"repos/{policy.repository}") + require(type(live_repository) is dict and live_repository.get("full_name") == policy.repository and live_repository.get("default_branch") == policy.default_branch, "live repository identity is wrong") + main = api.github_json(f"repos/{policy.repository}/git/ref/heads/{urllib.parse.quote(policy.default_branch, safe='')}") + require(type(main) is dict and type(main.get("object")) is dict and main["object"].get("sha") == policy_sha, "protected policy SHA is not exact current main") + user = api.github_json(f"users/{urllib.parse.quote(policy.sender_login, safe='')}") + require(type(user) is dict and user.get("id") == policy.sender_id and user.get("login") == policy.sender_login and user.get("type") == "Bot", "live sender identity is wrong") + payload = require_keys( + event.get("client_payload"), + ("schema_version", "repository", "captured_sha", "release_plan_digest", "intent_check_id", "intent_external_id", "releases"), + "release notification", + ) + require(len(canonical(payload).encode("utf-8")) <= MAX_NOTIFICATION_BYTES, "release notification is oversized") + require(payload["schema_version"] == 1, "release notification version is unsupported") + require(payload["repository"] == policy.repository, "release notification repository is wrong") + require_sha(payload["captured_sha"], "captured release SHA") + require_digest(payload["release_plan_digest"], "release plan digest") + require_positive(payload["intent_check_id"], "intent Check ID") + require_digest(payload["intent_external_id"], "intent external ID") + releases = payload["releases"] + require(type(releases) is list and len(releases) == len(policy.packages), "release notification package set is incomplete") + seen_ids: set[int] = set() + seen_tags: set[str] = set() + for index, (release, package) in enumerate(zip(releases, policy.packages, strict=True)): + release = require_keys(release, ("package", "version", "release_id", "tag", "tag_object_id", "release_body_sha256"), f"release entry {index}") + version = require_string(release["version"], f"release entry {index} version", 128) + require(VERSION_RE.fullmatch(version) is not None, "release notification version is noncanonical") + require(release["package"] == package.name and release["tag"] == f"{package.tag_prefix}{version}", "release notification order or tag is wrong") + release_id = require_positive(release["release_id"], "GitHub Release ID") + require_sha(release["tag_object_id"], "tag object ID") + require_digest(release["release_body_sha256"], "Release body digest") + require(release_id not in seen_ids and release["tag"] not in seen_tags, "release notification contains duplicates") + seen_ids.add(release_id) + seen_tags.add(release["tag"]) + check = api.github_json(f"repos/{policy.repository}/check-runs/{payload['intent_check_id']}") + intent = validate_intent(check, policy, payload) + validate_registry(api, policy, intent["plan"]) + validate_release_set(api, policy, payload, intent) + replay_document = { + "schema_version": 1, + "repository": policy.repository, + "release_ids": [entry["release_id"] for entry in releases], + "tags": [entry["tag"] for entry in releases], + "captured_sha": payload["captured_sha"], + "release_plan_digest": payload["release_plan_digest"], + "intent_check_id": payload["intent_check_id"], + } + key = sha256(canonical(replay_document).encode("utf-8")) + state = replay_state(api, policy, key) + return { + "authorized": "true", + "replay_key": key, + "replay_state": state, + "captured_release_sha": payload["captured_sha"], + "release_plan_digest": payload["release_plan_digest"], + "intent_check_id": str(payload["intent_check_id"]), + } + + +def append_outputs(path: Path, values: dict[str, str]) -> None: + for name, value in values.items(): + require(re.fullmatch(r"[a-z_]+", name) is not None, "workflow output name is invalid") + require("\n" not in value and "\r" not in value and "\x00" not in value, "workflow output value is invalid") + with path.open("a", encoding="utf-8", newline="\n") as handle: + for name, value in values.items(): + handle.write(f"{name}={value}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--event", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--github-output", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--policy-sha", required=True) + args = parser.parse_args() + try: + event = read_json(args.event, MAX_EVENT_BYTES, "repository dispatch event") + policy = parse_policy(read_json(args.config, MAX_CONFIG_BYTES, "release notification policy")) + token = os.environ.get("GITHUB_TOKEN", "") + api = Api(token, os.environ.get("GITHUB_API_URL", "https://api.github.com")) + outputs = validate_event(event, policy, api, args.repository, args.policy_sha) + append_outputs(args.github_output, outputs) + except PreflightError as error: + print(f"release notification rejected: {error}", file=os.sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_settings_preflight.py b/.github/scripts/release_settings_preflight.py new file mode 100644 index 0000000..569c0d7 --- /dev/null +++ b/.github/scripts/release_settings_preflight.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Read-only repository-admin preflight for one source-only release run.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import os +from dataclasses import dataclass +from typing import Any + +from release_notification_preflight import ( + API_VERSION, + INTENT_NAME, + Api, + PreflightError, + canonical, + require, + require_digest, + require_positive, + require_sha, + require_string, + sha256, +) + +APP_ID = 4_653_064 +MAIN_RULESET = "Protect main and require CI" +V1ALPHA1_RULESET = "Protect v1alpha1 tag" +CREATION_RULESET = "Protect release tag creation" +UPDATE_DELETE_RULESET = "Protect release tag updates and deletion" +WORKFLOW_PATH = ".github/workflows/publish.yml" +ACTIVE_RUN_STATES = frozenset({"queued", "in_progress", "waiting", "requested", "pending"}) + + +@dataclass(frozen=True) +class SettingsPolicy: + repository: str + tag_patterns: tuple[str, ...] + + +POLICIES = { + "NVIDIA/yaml-sigil-traits": SettingsPolicy( + "NVIDIA/yaml-sigil-traits", + ("refs/tags/v*",), + ), + "NVIDIA/yaml-sigil-rs": SettingsPolicy( + "NVIDIA/yaml-sigil-rs", + ( + "refs/tags/yaml-sigil-core-v*", + "refs/tags/yaml-sigil-transcription-v*", + "refs/tags/yaml-sigil-signing-v*", + "refs/tags/yaml-sigil-verification-v*", + ), + ), +} + + +def require_list(value: Any, label: str, limit: int) -> list[Any]: + require(type(value) is list and len(value) <= limit, f"{label} is invalid or oversized") + return value + + +def binding_values( + policy: SettingsPolicy, + release_sha: str, + run_id: int, + run_attempt: int, +) -> tuple[str, ...]: + values = [ + "yaml-sigil-release-setting-evidence-v1", + policy.repository, + str(run_id), + str(run_attempt), + release_sha, + "immutable-releases=true", + ] + values.extend( + f"creation={pattern}:Integration:{APP_ID}:always" + for pattern in policy.tag_patterns + ) + values.extend( + f"update-delete={pattern}:no-bypass" + for pattern in policy.tag_patterns + ) + values.append(f"forbidden-required-check={INTENT_NAME}") + return tuple(values) + + +def binding_digest( + policy: SettingsPolicy, + release_sha: str, + run_id: int, + run_attempt: int, +) -> str: + body = b"".join( + value.encode("utf-8") + b"\0" + for value in binding_values(policy, release_sha, run_id, run_attempt) + ) + return sha256(body) + + +def ruleset_projection(ruleset: dict[str, Any]) -> dict[str, Any]: + return { + "id": ruleset.get("id"), + "name": ruleset.get("name"), + "target": ruleset.get("target"), + "enforcement": ruleset.get("enforcement"), + "bypass_actors": ruleset.get("bypass_actors"), + "conditions": ruleset.get("conditions"), + "rules": ruleset.get("rules"), + } + + +def fetch_rulesets(api: Api, repository: str) -> list[dict[str, Any]]: + listed = require_list( + api.github_json(f"repos/{repository}/rulesets?includes_parents=true&per_page=100"), + "repository ruleset list", + 99, + ) + ids: set[int] = set() + details: list[dict[str, Any]] = [] + for index, summary in enumerate(listed): + require(type(summary) is dict, f"ruleset summary {index} is invalid") + if summary.get("target") not in {"branch", "tag"}: + continue + ruleset_id = require_positive(summary.get("id"), f"ruleset summary {index} ID") + require(ruleset_id not in ids, "repository ruleset list contains duplicate IDs") + ids.add(ruleset_id) + detail = api.github_json(f"repos/{repository}/rulesets/{ruleset_id}") + require(type(detail) is dict, f"ruleset {ruleset_id} response is invalid") + require(detail.get("id") == ruleset_id, f"ruleset {ruleset_id} ID drifted") + require_list(detail.get("bypass_actors"), f"ruleset {ruleset_id} bypass actors", 32) + require(type(detail.get("conditions")) is dict, f"ruleset {ruleset_id} conditions are invalid") + require_list(detail.get("rules"), f"ruleset {ruleset_id} rules", 32) + details.append(detail) + require(details, "repository has no branch or tag rulesets") + return details + + +def named_rule(rulesets: list[dict[str, Any]], name: str) -> dict[str, Any]: + matches = [ruleset for ruleset in rulesets if ruleset.get("name") == name] + require(len(matches) == 1, f"ruleset {name!r} is missing or duplicated") + return matches[0] + + +def require_ref_conditions(ruleset: dict[str, Any], includes: tuple[str, ...]) -> None: + require( + ruleset.get("conditions") + == {"ref_name": {"exclude": [], "include": list(includes)}}, + f"ruleset {ruleset.get('name')!r} ref conditions drifted", + ) + + +def require_rule_types(ruleset: dict[str, Any], types: tuple[str, ...]) -> None: + require( + ruleset.get("rules") == [{"type": rule_type} for rule_type in types], + f"ruleset {ruleset.get('name')!r} rule types drifted", + ) + + +def reject_intent_collision(rulesets: list[dict[str, Any]]) -> None: + for ruleset in rulesets: + for rule in ruleset["rules"]: + if type(rule) is not dict or rule.get("type") != "required_status_checks": + continue + parameters = rule.get("parameters") + require(type(parameters) is dict, "required-status-check parameters are invalid") + checks = require_list( + parameters.get("required_status_checks"), + "required status checks", + 64, + ) + for check in checks: + require(type(check) is dict, "required status check is invalid") + require( + check.get("context") != INTENT_NAME, + f"{INTENT_NAME!r} collides with an applicable required check", + ) + + +def validate_main_ruleset(rulesets: list[dict[str, Any]]) -> None: + main = named_rule(rulesets, MAIN_RULESET) + require( + main.get("target") == "branch" + and main.get("enforcement") == "active" + and main.get("bypass_actors") == [], + "protected-main ruleset target, enforcement, or bypass drifted", + ) + require_ref_conditions(main, ("refs/heads/main",)) + required = [ + rule + for rule in main["rules"] + if type(rule) is dict and rule.get("type") == "required_status_checks" + ] + require(len(required) == 1, "protected-main required-check rule is missing or duplicated") + parameters = required[0].get("parameters") + require(type(parameters) is dict, "protected-main required-check parameters are invalid") + require( + parameters.get("strict_required_status_checks_policy") is True + and parameters.get("do_not_enforce_on_create") is False + and parameters.get("required_status_checks") + == [{"context": "Required CI", "integration_id": APP_ID}], + "protected-main Required CI binding drifted", + ) + + +def validate_tag_rulesets(policy: SettingsPolicy, rulesets: list[dict[str, Any]]) -> None: + legacy = named_rule(rulesets, V1ALPHA1_RULESET) + require( + legacy.get("target") == "tag" + and legacy.get("enforcement") == "active" + and legacy.get("bypass_actors") == [], + "existing v1alpha1 protection drifted", + ) + require_ref_conditions(legacy, ("refs/tags/v1alpha1",)) + require_rule_types(legacy, ("update", "deletion")) + + creation = named_rule(rulesets, CREATION_RULESET) + require( + creation.get("target") == "tag" and creation.get("enforcement") == "active", + "release-tag creation ruleset is not active", + ) + require( + creation.get("bypass_actors") + == [{"actor_id": APP_ID, "actor_type": "Integration", "bypass_mode": "always"}], + "release-tag creation bypass is not the sole approved App", + ) + require_ref_conditions(creation, policy.tag_patterns) + require_rule_types(creation, ("creation",)) + + updates = named_rule(rulesets, UPDATE_DELETE_RULESET) + require( + updates.get("target") == "tag" + and updates.get("enforcement") == "active" + and updates.get("bypass_actors") == [], + "release-tag update/deletion protection drifted", + ) + require_ref_conditions(updates, policy.tag_patterns) + require_rule_types(updates, ("update", "deletion")) + + +def validate_run( + api: Api, + policy: SettingsPolicy, + release_sha: str, + run_id: int, + run_attempt: int, +) -> dict[str, Any]: + repository = api.github_json(f"repos/{policy.repository}") + require(type(repository) is dict, "repository response is invalid") + require( + repository.get("full_name") == policy.repository + and repository.get("default_branch") == "main", + "repository identity or default branch drifted", + ) + reference = api.github_json(f"repos/{policy.repository}/git/ref/heads/main") + require(type(reference) is dict and type(reference.get("object")) is dict, "main ref is invalid") + require( + reference.get("ref") == "refs/heads/main" + and reference["object"].get("type") == "commit" + and reference["object"].get("sha") == release_sha, + "release SHA is no longer exact current main", + ) + + run = api.github_json(f"repos/{policy.repository}/actions/runs/{run_id}") + require(type(run) is dict, "workflow run response is invalid") + run_repository = run.get("repository") + require(type(run_repository) is dict, "workflow run repository is missing") + require( + run.get("id") == run_id + and run.get("run_attempt") == run_attempt + and run.get("head_sha") == release_sha + and run.get("head_branch") == "main" + and run.get("event") == "workflow_dispatch" + and run.get("path") == WORKFLOW_PATH + and run.get("status") in ACTIVE_RUN_STATES + and run.get("conclusion") is None + and run_repository.get("full_name") == policy.repository, + "workflow run identity, source, attempt, or active state drifted", + ) + return run + + +def validate_preflight( + policy: SettingsPolicy, + api: Api, + release_sha: str, + run_id: int, + run_attempt: int, + observed_at: dt.datetime, +) -> dict[str, str]: + run = validate_run(api, policy, release_sha, run_id, run_attempt) + immutable = api.github_json(f"repos/{policy.repository}/immutable-releases") + require(type(immutable) is dict, "immutable-Release response is invalid") + require( + immutable.get("enabled") is True, + "repository immutable Releases are not enabled", + ) + + rulesets = fetch_rulesets(api, policy.repository) + reject_intent_collision(rulesets) + validate_main_ruleset(rulesets) + validate_tag_rulesets(policy, rulesets) + + snapshot = { + "schema_version": 1, + "api_version": API_VERSION, + "repository": policy.repository, + "release_sha": release_sha, + "run_id": run_id, + "run_attempt": run_attempt, + "run_status": run["status"], + "observed_at": observed_at.isoformat().replace("+00:00", "Z"), + "immutable_releases": { + "enabled": immutable.get("enabled"), + "enforced_by_owner": immutable.get("enforced_by_owner"), + }, + "rulesets": sorted( + (ruleset_projection(ruleset) for ruleset in rulesets), + key=lambda value: (str(value["target"]), int(value["id"])), + ), + } + deadline = observed_at + dt.timedelta(minutes=5) + return { + "workflow_evidence_sha256": binding_digest( + policy, + release_sha, + run_id, + run_attempt, + ), + "readback_sha256": sha256(canonical(snapshot).encode("utf-8")), + "readback_utc": snapshot["observed_at"], + "approve_before_utc": deadline.isoformat().replace("+00:00", "Z"), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--release-sha", required=True) + parser.add_argument("--run-id", type=int, required=True) + parser.add_argument("--run-attempt", type=int, required=True) + parser.add_argument("--expected-evidence-sha256", required=True) + args = parser.parse_args() + + try: + repository = require_string(args.repository, "repository", 256) + require(repository in POLICIES, "repository is outside the release-settings policy") + policy = POLICIES[repository] + release_sha = require_sha(args.release_sha, "release SHA") + run_id = require_positive(args.run_id, "workflow run ID") + run_attempt = require_positive(args.run_attempt, "workflow run attempt") + expected = require_digest( + args.expected_evidence_sha256, + "expected workflow evidence digest", + ) + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN", "") + api = Api(token, os.environ.get("GITHUB_API_URL", "https://api.github.com")) + observed_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + evidence = validate_preflight( + policy, + api, + release_sha, + run_id, + run_attempt, + observed_at, + ) + require( + evidence["workflow_evidence_sha256"] == expected, + "workflow evidence digest does not bind these exact settings inputs", + ) + print("repository_admin_settings=valid") + for key, value in evidence.items(): + print(f"{key}={value}") + except (OSError, PreflightError) as error: + print(f"release settings rejected: {error}", file=os.sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test_legacy_release_preflight.py b/.github/scripts/test_legacy_release_preflight.py new file mode 100644 index 0000000..3d8ad96 --- /dev/null +++ b/.github/scripts/test_legacy_release_preflight.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the closed historical Release inventory.""" + +from __future__ import annotations + +import copy +import io +import tarfile +import unittest +from pathlib import Path +from typing import Any + +import legacy_release_preflight as legacy +import release_notification_preflight as preflight + + +class FakeApi: + def __init__( + self, + github: dict[str, Any], + crates: dict[str, Any], + archives: dict[tuple[str, str], bytes], + ) -> None: + self.github = github + self.crates = crates + self.archives = archives + + def github_json(self, path: str, optional: bool = False) -> Any: + if path not in self.github: + if optional: + return None + raise AssertionError(f"unexpected GitHub read: {path}") + return copy.deepcopy(self.github[path]) + + def crates_json(self, path: str) -> Any: + return copy.deepcopy(self.crates[path]) + + def crate_archive(self, package: str, version: str) -> bytes: + return self.archives[(package, version)] + + +def source_archive(package: str, version: str, commit: str, path_in_vcs: str) -> bytes: + vcs = preflight.canonical( + {"git": {"sha1": commit}, "path_in_vcs": path_in_vcs} + ).encode() + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + info = tarfile.TarInfo(f"{package}-{version}/.cargo_vcs_info.json") + info.size = len(vcs) + archive.addfile(info, io.BytesIO(vcs)) + source = b"pub fn historical_source_fixture() {}\n" + info = tarfile.TarInfo(f"{package}-{version}/src/lib.rs") + info.size = len(source) + archive.addfile(info, io.BytesIO(source)) + return output.getvalue() + + +class Fixture: + def __init__(self) -> None: + inventory_path = Path(__file__).parents[1] / "legacy-release-inventory.json" + self.inventory = copy.deepcopy( + preflight.read_json( + inventory_path, + preflight.MAX_CONFIG_BYTES, + "test legacy inventory", + ) + ) + self.github: dict[str, Any] = {} + self.crates: dict[str, Any] = {} + self.archives: dict[tuple[str, str], bytes] = {} + releases: list[dict[str, Any]] = [] + for entry in self.inventory["entries"]: + body = f"historical notes for {entry['tag']}" + entry["body_sha256"] = preflight.sha256(body.encode()) + archive = source_archive( + entry["package"], + entry["version"], + entry["peeled_commit_sha"], + entry["path_in_vcs"], + ) + digest = preflight.sha256(archive) + entry["source_archive_sha256"] = digest + release = { + "id": entry["release_id"], + "tag_name": entry["tag"], + "target_commitish": "main", + "author": copy.deepcopy(self.inventory["legacy_author"]), + "draft": entry["draft"], + "prerelease": entry["prerelease"], + "immutable": False, + "assets": [], + "body": body, + } + releases.append(copy.deepcopy(release)) + repository = self.inventory["repository"] + self.github[f"repos/{repository}/releases/{entry['release_id']}"] = release + encoded = preflight.urllib.parse.quote(entry["tag"], safe="") + self.github[f"repos/{repository}/git/ref/tags/{encoded}"] = { + "ref": f"refs/tags/{entry['tag']}", + "object": {"type": "tag", "sha": entry["tag_object_sha"]}, + } + self.github[f"repos/{repository}/git/tags/{entry['tag_object_sha']}"] = { + "sha": entry["tag_object_sha"], + "tag": entry["tag"], + "object": {"type": "commit", "sha": entry["peeled_commit_sha"]}, + } + package = preflight.urllib.parse.quote(entry["package"], safe="") + version = preflight.urllib.parse.quote(entry["version"], safe="") + self.crates[f"crates/{package}/{version}"] = { + "version": { + "num": entry["version"], + "yanked": False, + "checksum": digest, + } + } + self.archives[(entry["package"], entry["version"])] = archive + self.github[ + f"repos/{self.inventory['repository']}/releases?per_page=100" + ] = releases + + def api(self) -> FakeApi: + return FakeApi(self.github, self.crates, self.archives) + + +class LegacyReleaseTests(unittest.TestCase): + def test_exact_legacy_inventory_is_accepted(self) -> None: + fixture = Fixture() + legacy.validate_inventory(fixture.inventory, fixture.api()) + + def test_release_body_tag_and_archive_drift_fail(self) -> None: + fixture = Fixture() + entry = fixture.inventory["entries"][0] + fixture.github[ + f"repos/{fixture.inventory['repository']}/releases/{entry['release_id']}" + ]["body"] += " drift" + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + + fixture = Fixture() + entry = fixture.inventory["entries"][0] + encoded = preflight.urllib.parse.quote(entry["tag"], safe="") + fixture.github[ + f"repos/{fixture.inventory['repository']}/git/ref/tags/{encoded}" + ]["object"]["sha"] = "f" * 40 + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + + fixture = Fixture() + entry = fixture.inventory["entries"][0] + fixture.archives[(entry["package"], entry["version"])] += b"drift" + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + + def test_unpinned_release_must_be_immutable_app_authored_and_asset_free(self) -> None: + fixture = Fixture() + releases_path = f"repos/{fixture.inventory['repository']}/releases?per_page=100" + prospective = { + "id": 999, + "tag_name": "future-v1.0.0", + "target_commitish": "main", + "author": copy.deepcopy(fixture.inventory["prospective_author"]), + "draft": False, + "immutable": False, + "assets": [], + } + fixture.github[releases_path].append(prospective) + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + fixture.github[releases_path][-1]["immutable"] = True + legacy.validate_inventory(fixture.inventory, fixture.api()) + fixture.github[releases_path][-1]["assets"] = [{"id": 1}] + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + + def test_inventory_schema_rejects_unknown_fields(self) -> None: + fixture = Fixture() + fixture.inventory["unknown"] = True + with self.assertRaises(preflight.PreflightError): + legacy.validate_inventory(fixture.inventory, fixture.api()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_protected_pr_ci.py b/.github/scripts/test_protected_pr_ci.py index 53f85b3..4bc1409 100755 --- a/.github/scripts/test_protected_pr_ci.py +++ b/.github/scripts/test_protected_pr_ci.py @@ -9,8 +9,11 @@ import copy import importlib.util +import json import os import pathlib +import shutil +import stat import subprocess import sys import tempfile @@ -19,13 +22,30 @@ MODULE_PATH = pathlib.Path(__file__).with_name("protected_pr_ci.py") +VERIFIER_PATH = MODULE_PATH.with_name("protected_checkout.py") COMMIT_POLICY_PATH = MODULE_PATH.with_name("check-pull-request-commits.sh") POLICY_PATH = MODULE_PATH.parent.parent / "protected-pr-ci.json" +COMMAND_WORKFLOW_PATH = MODULE_PATH.parent.parent / "workflows" / "pr-ci-command.yml" +REUSABLE_WORKFLOW_PATH = MODULE_PATH.parent.parent / "workflows" / "pr-ci.yml" +RECONCILE_WORKFLOW_PATH = ( + MODULE_PATH.parent.parent / "workflows" / "pr-ci-reconcile.yml" +) +CHECKOUT_ACTION_PATH = ( + MODULE_PATH.parent.parent + / "actions" + / "protected-candidate-checkout" + / "action.yml" +) SPEC = importlib.util.spec_from_file_location("protected_pr_ci", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None controller = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = controller SPEC.loader.exec_module(controller) +VERIFIER_SPEC = importlib.util.spec_from_file_location("protected_checkout", VERIFIER_PATH) +assert VERIFIER_SPEC is not None and VERIFIER_SPEC.loader is not None +verifier = importlib.util.module_from_spec(VERIFIER_SPEC) +sys.modules[VERIFIER_SPEC.name] = verifier +VERIFIER_SPEC.loader.exec_module(verifier) REPOSITORY = "NVIDIA/yaml-sigil-example" @@ -55,7 +75,9 @@ def policy() -> dict: return { - "version": 2, + "version": 3, + "repository": REPOSITORY, + "repository_kind": "traits", "default_branch": "main", "workflow_file": ".github/workflows/pr-ci-command.yml", "required_check": "Required CI", @@ -74,18 +96,32 @@ def policy() -> dict: "allowed_paths": ["Cargo.toml", "CHANGELOG.md"], }, "expected_jobs": ["commit_policy", "workflow_lint", "candidate_ci"], - "candidate_ci_paths": [ - ".cargo/**", - "**/.cargo/**", - ".github/workflows/ci.yml", - ".github/workflows/pr-ci.yml", - "deny.toml", - "deny.exceptions.toml", - "xtask/**", - ], + "supplemental_candidate_ci": True, } +def policy_bash() -> str: + """Return Git Bash on Windows instead of the WSL launcher.""" + if os.name != "nt": + return "bash" + + git = shutil.which("git") + if git is None: + raise RuntimeError("cannot locate Git while resolving Git Bash") + git_path = pathlib.Path(git) + candidates = ( + git_path.parent / "bash.exe", + git_path.parent.parent / "bin" / "bash.exe", + git_path.parent.parent / "usr" / "bin" / "bash.exe", + git_path.parent.parent.parent / "bin" / "bash.exe", + git_path.parent.parent.parent / "usr" / "bin" / "bash.exe", + ) + for candidate in candidates: + if candidate.is_file(): + return os.fspath(candidate) + raise RuntimeError(f"cannot locate Git Bash beside {git_path}") + + def event(body: str | None = None) -> dict: return { "action": "created", @@ -94,11 +130,18 @@ def event(body: str | None = None) -> dict: "comment": { "id": 19, "body": body if body is not None else f"/ok to test {HEAD_SHA}", - "user": {"login": MAINTAINER}, + "created_at": "2026-08-28T12:00:00Z", + "updated_at": "2026-08-28T12:00:00Z", + "user": {"id": 1, "login": MAINTAINER, "type": "User"}, }, + "sender": {"id": 1, "login": MAINTAINER, "type": "User"}, } +def adoption_event() -> dict: + return event(f"/ok to test-and-adopt {HEAD_SHA}") + + def environment() -> dict[str, str]: return { "GITHUB_REPOSITORY": REPOSITORY, @@ -188,6 +231,8 @@ def git_commit( committer_login: str = MAINTAINER, author_id: int = 1, committer_id: int = 1, + author_type: str = "User", + committer_type: str = "User", author_name: str = "Maintainer", author_email: str = "maintainer@example.invalid", committer_name: str = "Maintainer", @@ -207,8 +252,12 @@ def git_commit( return { "sha": sha, "parents": [{"sha": parent}], - "author": {"login": author_login, "id": author_id}, - "committer": {"login": committer_login, "id": committer_id}, + "author": {"login": author_login, "id": author_id, "type": author_type}, + "committer": { + "login": committer_login, + "id": committer_id, + "type": committer_type, + }, "commit": { "author": {"name": author_name, "email": author_email}, "committer": {"name": committer_name, "email": committer_email}, @@ -221,6 +270,33 @@ def git_commit( } +def git_signature( + *, + sha: str = HEAD_SHA, + signer_login: str = MAINTAINER, + signer_id: int = 1, + email: str = "maintainer@example.invalid", + valid: bool = True, + github_signed: bool = False, + kind: str = "GpgSignature", +) -> dict: + return { + "oid": sha, + "signature": { + "__typename": kind, + "email": email, + "isValid": valid, + "state": "VALID" if valid else "INVALID", + "wasSignedByGitHub": github_signed, + "signer": { + "databaseId": signer_id, + "login": signer_login, + "__typename": "User", + }, + }, + } + + def recursive_tree(tree_sha: str, leaves: dict[str, tuple[str, str, str]]) -> dict: directories = { "/".join(path.split("/")[:length]) @@ -258,6 +334,7 @@ def __init__(self) -> None: self.permissions = {MAINTAINER: "write"} self.commits = [{"sha": HEAD_SHA, "parents": [{"sha": MAIN_SHA}]}] self.details = {HEAD_SHA: git_commit()} + self.signatures = {HEAD_SHA: git_signature()} self.git_commits = { MAIN_SHA: {"sha": MAIN_SHA, "tree": {"sha": BASE_TREE_SHA}}, HEAD_SHA: {"sha": HEAD_SHA, "tree": {"sha": HEAD_TREE_SHA}}, @@ -280,7 +357,7 @@ def __init__(self) -> None: self.pull = { "number": 7, "state": "open", - "user": {"login": "contributor", "id": 42}, + "user": {"login": "contributor", "id": 42, "type": "User"}, "base": { "ref": "main", "sha": MAIN_SHA, @@ -293,6 +370,7 @@ def __init__(self) -> None: }, "changed_files": 1, "commits": 1, + "maintainer_can_modify": True, } def set_tree_files( @@ -392,52 +470,74 @@ def paginate_key(self, path, key, *, max_items, label): return copy.deepcopy(self.runs) raise AssertionError(f"unexpected keyed pagination label {label}") + def commit_signatures(self, repository, pull_number, oids): + self.signature_repository = repository + self.signature_pull_number = pull_number + return {oid: copy.deepcopy(self.signatures[oid]) for oid in oids} + def post(self, path: str, payload: dict): self.posts.append((path, payload)) return None +def authorize_fixture( + api: FakeAuthorizationApi, + approval: dict | None = None, +) -> controller.Authorization: + selected = approval or event() + api.comment = copy.deepcopy(selected["comment"]) + return controller.authorize(selected, policy(), api, environment()) + + class AuthorizationTests(unittest.TestCase): def test_repository_policy_configuration_is_valid(self) -> None: controller.load_config(str(POLICY_PATH)) - def test_repository_policy_covers_candidate_validation_surfaces(self) -> None: + def test_shared_classifier_covers_candidate_validation_surfaces(self) -> None: repository_policy = controller.load_config(str(POLICY_PATH)) - required = { - ".cargo/**", - "**/.cargo/**", + kind = repository_policy["repository_kind"] + for path in ( ".github/workflows/ci.yml", + ".github/workflows/pr-ci-command.yml", + ".github/workflows/pr-ci-reconcile.yml", ".github/workflows/pr-ci.yml", + ".github/scripts/protected_pr_ci.py", + ".github/scripts/protected_checkout.py", + ".github/scripts/check-pull-request-commits.sh", + ".github/protected-pr-ci.json", + ".cargo/config.toml", + "nested/.cargo/config.toml", + "Cargo.toml", + "nested/Cargo.lock", + "build.rs", "deny.toml", - "deny.exceptions.toml", - "xtask/**", - } - self.assertLessEqual(required, set(repository_policy["candidate_ci_paths"])) - - def test_repository_directory_patterns_match_roots_and_descendants(self) -> None: - repository_policy = controller.load_config(str(POLICY_PATH)) - declarations = [ - pattern - for pattern in repository_policy["candidate_ci_paths"] - if pattern.endswith("/**") - ] - self.assertTrue(declarations) - - for declaration in declarations: - root = declaration[:-3] - if root.startswith("**/"): - root = f"nested/{root[3:]}" - with self.subTest(declaration=declaration, root=root): - self.assertTrue(controller.matches_path_inventory(root, [declaration])) - self.assertTrue( - controller.matches_path_inventory( - f"{root}/representative-file", [declaration] - ) - ) + "xtask/src/main.rs", + ".release-plz.toml", + "RELEASING.md", + ): + with self.subTest(path=path): + self.assertTrue(controller.is_sensitive_path(path, kind)) - def test_repository_policy_has_no_path_based_adoption_rule(self) -> None: - repository_policy = controller.load_config(str(POLICY_PATH)) - self.assertNotIn("sensitive_paths", repository_policy) + def test_spec_and_rs_classifier_extensions_are_explicit(self) -> None: + self.assertTrue( + controller.is_sensitive_path( + "conformance/rebuild-rs/vendor/acvp/vectors.json", "spec" + ) + ) + self.assertTrue( + controller.is_sensitive_path( + "conformance/rebuild-rs/pinned-dir/src/lib.rs", "spec" + ) + ) + self.assertTrue( + controller.is_sensitive_path( + "conformance/rebuild-rs/xtask/src/main.rs", "spec" + ) + ) + self.assertTrue( + controller.is_sensitive_path("crates/core/buf.yaml", "rs") + ) + self.assertFalse(controller.is_sensitive_path("README.md", "traits")) def test_writer_permissions_are_accepted(self) -> None: for permission in ("write", "push", "maintain", "admin"): @@ -462,18 +562,23 @@ def test_non_writer_rerun_actor_is_rejected(self) -> None: controller.authorize(event(), policy(), api, env) def test_command_is_exact_and_sha_bound(self) -> None: - api = FakeAuthorizationApi() for body in ( f" /ok to test {HEAD_SHA}", f"/ok to test {HEAD_SHA}\n", f"/ok to test {HEAD_SHA.upper()}", "/ok to test main", ): + candidate_event = event(body) + api = FakeAuthorizationApi() + api.comment = copy.deepcopy(candidate_event["comment"]) with self.subTest(body=body), self.assertRaises(controller.PolicyError): - controller.authorize(event(body), policy(), api, environment()) + controller.authorize(candidate_event, policy(), api, environment()) + candidate_event = event(f"/ok to test {OLD_SHA}") + api = FakeAuthorizationApi() + api.comment = copy.deepcopy(candidate_event["comment"]) with self.assertRaisesRegex(controller.PolicyError, "exact current pull request head"): - controller.authorize(event(f"/ok to test {OLD_SHA}"), policy(), api, environment()) + controller.authorize(candidate_event, policy(), api, environment()) def test_stale_policy_and_base_are_rejected(self) -> None: api = FakeAuthorizationApi() @@ -522,7 +627,9 @@ def test_renamed_candidate_ci_source_requires_candidate_validation(self) -> None "renamed", previous_filename=".github/workflows/ci.yml", ) - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize(approval, policy(), api, environment()) self.assertTrue(result.candidate_ci_required) def test_mutable_pull_file_view_is_never_authoritative(self) -> None: @@ -535,7 +642,9 @@ def test_mutable_pull_file_view_is_never_authoritative(self) -> None: def test_workflow_change_from_fork_is_authorized(self) -> None: api = FakeAuthorizationApi() api.set_change(".github/workflows/ci.yml") - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize(approval, policy(), api, environment()) self.assertEqual( result.head_repository, "contributor/yaml-sigil-example" ) @@ -549,27 +658,23 @@ def test_normalized_workflow_names_do_not_change_commit_policy(self) -> None: with self.subTest(path=path): api = FakeAuthorizationApi() api.set_change(path) - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize( + approval, policy(), api, environment() + ) self.assertTrue(result.candidate_ci_required) - def test_directory_patterns_cover_roots_descendants_and_near_misses(self) -> None: - patterns = [ - ".cargo/**", - "**/.cargo/**", - "source-spec/**", - ] + def test_classifier_directory_roots_descendants_and_near_misses(self) -> None: for path in ( ".cargo", ".CARGO/config.toml", ".CARGO", "nested/.cargo", "nested/.CARGO/config.toml", - "source-spec", - "SOURCE-SPEC/proto/schema.proto", - "SOURCE-SPEC/README.md", ): with self.subTest(path=path): - self.assertTrue(controller.matches_path_inventory(path, patterns)) + self.assertTrue(controller.is_sensitive_path(path, "traits")) for path in ( ".carg", @@ -580,7 +685,7 @@ def test_directory_patterns_cover_roots_descendants_and_near_misses(self) -> Non "source-specification/README.md", ): with self.subTest(near_miss=path): - self.assertFalse(controller.matches_path_inventory(path, patterns)) + self.assertFalse(controller.is_sensitive_path(path, "traits")) def test_unusual_directory_entries_use_the_same_commit_policy(self) -> None: for path, leaf in ( @@ -597,7 +702,13 @@ def test_unusual_directory_entries_use_the_same_commit_policy(self) -> None: with self.subTest(path=path, entry_type=leaf[0]): api = FakeAuthorizationApi() api.set_tree_files({}, {path: leaf}) - result = controller.authorize(event(), policy(), api, environment()) + approval = ( + adoption_event() + if controller.is_sensitive_path(path, "traits") + else event() + ) + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize(approval, policy(), api, environment()) self.assertEqual(result.head_sha, HEAD_SHA) def test_candidate_ci_directory_entries_match_any_leaf_type(self) -> None: @@ -610,7 +721,11 @@ def test_candidate_ci_directory_entries_match_any_leaf_type(self) -> None: with self.subTest(path=path, entry_type=leaf[0]): api = FakeAuthorizationApi() api.set_tree_files({}, {path: leaf}) - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize( + approval, policy(), api, environment() + ) self.assertTrue(result.candidate_ci_required) def test_executable_targets_use_the_same_commit_policy(self) -> None: @@ -632,25 +747,26 @@ def test_build_scripts_use_the_same_commit_policy(self) -> None: with self.subTest(path=path): api = FakeAuthorizationApi() api.set_change(path) - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize(approval, policy(), api, environment()) self.assertEqual(result.head_sha, HEAD_SHA) - self.assertFalse(result.candidate_ci_required) + self.assertTrue(result.candidate_ci_required) - def test_verified_human_commit_requires_only_exact_author_dco(self) -> None: + def test_sensitive_adoption_preserves_author_and_adopter_dco(self) -> None: api = FakeAuthorizationApi() api.set_change("Cargo.toml") api.details[HEAD_SHA] = git_commit( author_login="contributor", + author_id=42, author_name="Contributor", author_email="contributor@example.invalid", committer_name="Maintainer", committer_email="maintainer@example.invalid", - message=( - "ci: update policy\n\n" - "Signed-off-by: Contributor \n" - ), ) - result = controller.authorize(event(), policy(), api, environment()) + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) + result = controller.authorize(approval, policy(), api, environment()) self.assertEqual( result.head_repository, "contributor/yaml-sigil-example" ) @@ -659,49 +775,40 @@ def test_verified_human_commit_requires_only_exact_author_dco(self) -> None: "ci: update policy\n\n" "Signed-off-by: Maintainer \n" ) - with self.assertRaisesRegex(controller.PolicyError, "author's DCO sign-off"): - controller.authorize(event(), policy(), api, environment()) + with self.assertRaisesRegex(controller.PolicyError, "original author's DCO"): + controller.authorize(approval, policy(), api, environment()) + + api.details[HEAD_SHA]["commit"]["message"] = ( + "ci: update policy\n\n" + "Signed-off-by: Contributor \n" + ) + with self.assertRaisesRegex(controller.PolicyError, "adopting committer's DCO"): + controller.authorize(approval, policy(), api, environment()) def test_every_human_commit_requires_valid_github_verification(self) -> None: api = FakeAuthorizationApi() api.set_change("AGENTS.md") - api.details[HEAD_SHA] = git_commit(verified=False) + api.signatures[HEAD_SHA] = git_signature(valid=False) with self.assertRaisesRegex(controller.PolicyError, "not GitHub Verified"): controller.authorize(event(), policy(), api, environment()) + api = FakeAuthorizationApi() + api.set_change("AGENTS.md") api.details[HEAD_SHA] = git_commit(committer_login="outsider") - api.permissions["outsider"] = "read" - result = controller.authorize(event(), policy(), api, environment()) - self.assertEqual(result.head_sha, HEAD_SHA) + with self.assertRaisesRegex(controller.PolicyError, "verified signer"): + controller.authorize(event(), policy(), api, environment()) def test_full_commit_response_must_match_requested_sha(self) -> None: api = FakeAuthorizationApi() api.set_change("Cargo.toml") api.details[HEAD_SHA]["sha"] = OLD_SHA + approval = adoption_event() + api.comment = copy.deepcopy(approval["comment"]) with self.assertRaisesRegex(controller.PolicyError, "requested SHA"): - controller.authorize(event(), policy(), api, environment()) + controller.authorize(approval, policy(), api, environment()) def test_exact_release_app_author_and_committer_are_accepted(self) -> None: - api = FakeAuthorizationApi() - api.set_change("Cargo.toml") - api.pull["user"] = {"login": BOT, "id": BOT_ID} - api.pull["head"]["repo"]["full_name"] = REPOSITORY - api.pull["head"]["ref"] = "release-plz-next" - api.details[HEAD_SHA] = git_commit( - author_login=BOT, - author_id=BOT_ID, - committer_login=WEB_FLOW, - committer_id=WEB_FLOW_ID, - author_name=BOT, - author_email=BOT_EMAIL, - committer_name=GITHUB_COMMITTER_NAME, - committer_email=GITHUB_COMMITTER_EMAIL, - message=( - "chore(release): prepare candidate\n\n" - "Signed-off-by: nvidia-yamlsigil-release-pr[bot] " - f"<{BOT_EMAIL}>\n" - ), - ) + api = self.release_app_api() result = controller.authorize(event(), policy(), api, environment()) self.assertEqual(result.head_sha, HEAD_SHA) @@ -712,12 +819,13 @@ def test_exact_release_app_author_and_committer_are_accepted(self) -> None: def release_app_api(self) -> FakeAuthorizationApi: api = FakeAuthorizationApi() api.set_change("Cargo.toml") - api.pull["user"] = {"login": BOT, "id": BOT_ID} + api.pull["user"] = {"login": BOT, "id": BOT_ID, "type": "Bot"} api.pull["head"]["repo"]["full_name"] = REPOSITORY api.pull["head"]["ref"] = "release-plz-next" api.details[HEAD_SHA] = git_commit( author_login=BOT, author_id=BOT_ID, + author_type="Bot", committer_login=WEB_FLOW, committer_id=WEB_FLOW_ID, author_name=BOT, @@ -730,22 +838,28 @@ def release_app_api(self) -> FakeAuthorizationApi: f"<{BOT_EMAIL}>\n" ), ) + api.signatures[HEAD_SHA] = git_signature( + signer_login=WEB_FLOW, + signer_id=WEB_FLOW_ID, + email=GITHUB_COMMITTER_EMAIL, + github_signed=True, + ) return api def test_release_app_rejects_wrong_bot_id_and_raw_author(self) -> None: api = self.release_app_api() api.pull["user"]["id"] += 1 - with self.assertRaisesRegex(controller.PolicyError, "pull request author ID"): + with self.assertRaisesRegex(controller.PolicyError, "exact release App identity"): controller.authorize(event(), policy(), api, environment()) api = self.release_app_api() api.pull["user"]["login"] = "release-app-lookalike" - with self.assertRaisesRegex(controller.PolicyError, "not owned by the release App"): + with self.assertRaisesRegex(controller.PolicyError, "exact release App identity"): controller.authorize(event(), policy(), api, environment()) api = self.release_app_api() api.details[HEAD_SHA]["author"]["id"] += 1 - with self.assertRaisesRegex(controller.PolicyError, "author ID"): + with self.assertRaisesRegex(controller.PolicyError, "author is unexpected"): controller.authorize(event(), policy(), api, environment()) for field in ("name", "email"): @@ -781,7 +895,7 @@ def test_release_app_rejects_human_rest_committer(self) -> None: def test_release_app_rejects_wrong_web_flow_user_id(self) -> None: api = self.release_app_api() api.details[HEAD_SHA]["committer"]["id"] += 1 - with self.assertRaisesRegex(controller.PolicyError, "committer ID"): + with self.assertRaisesRegex(controller.PolicyError, "committer is unexpected"): controller.authorize(event(), policy(), api, environment()) def test_release_app_rejects_wrong_web_flow_and_raw_github_identity(self) -> None: @@ -799,10 +913,13 @@ def test_release_app_rejects_wrong_web_flow_and_raw_github_identity(self) -> Non def test_release_app_rejects_invalid_signature(self) -> None: api = self.release_app_api() - api.details[HEAD_SHA]["commit"]["verification"] = { - "verified": False, - "reason": "invalid", - } + api.signatures[HEAD_SHA] = git_signature( + signer_login=WEB_FLOW, + signer_id=WEB_FLOW_ID, + email=GITHUB_COMMITTER_EMAIL, + valid=False, + github_signed=True, + ) with self.assertRaisesRegex(controller.PolicyError, "not GitHub Verified"): controller.authorize(event(), policy(), api, environment()) @@ -822,13 +939,14 @@ def test_release_app_requires_one_parent_and_author_dco(self) -> None: def test_release_app_identity_parent_and_allowlist_are_exact(self) -> None: base_api = FakeAuthorizationApi() base_api.set_change("Cargo.toml") - base_api.pull["user"] = {"login": BOT, "id": BOT_ID} + base_api.pull["user"] = {"login": BOT, "id": BOT_ID, "type": "Bot"} base_api.pull["head"]["repo"]["full_name"] = REPOSITORY base_api.pull["head"]["ref"] = "release-plz-next" base_api.details[HEAD_SHA] = git_commit( parent=OLD_SHA, author_login=BOT, author_id=BOT_ID, + author_type="Bot", committer_login=WEB_FLOW, committer_id=WEB_FLOW_ID, author_name=BOT, @@ -841,6 +959,12 @@ def test_release_app_identity_parent_and_allowlist_are_exact(self) -> None: f"<{BOT_EMAIL}>\n" ), ) + base_api.signatures[HEAD_SHA] = git_signature( + signer_login=WEB_FLOW, + signer_id=WEB_FLOW_ID, + email=GITHUB_COMMITTER_EMAIL, + github_signed=True, + ) with self.assertRaisesRegex(controller.PolicyError, "current main"): controller.authorize(event(), policy(), base_api, environment()) @@ -874,6 +998,9 @@ def test_comment_receiver_ignores_near_misses_and_returns_sanitized_values(self) "head_repository": "contributor/yaml-sigil-example", "policy_sha": MAIN_SHA, "comment_id": str(COMMENT_ID), + "binding_digest": authorization.binding_digest, + "command_mode": "test", + "sensitive": "false", "candidate_ci_required": "false", }, ) @@ -930,7 +1057,7 @@ def test_reusable_call_reloads_the_exact_comment_and_job_binding(self) -> None: def test_reusable_call_rejects_changed_comment_issue_or_ref(self) -> None: api = FakeAuthorizationApi() api.comment["body"] = f"/ok to test {OLD_SHA}" - with self.assertRaisesRegex(controller.PolicyError, "exact current pull request head"): + with self.assertRaisesRegex(controller.PolicyError, "comment or identity changed"): controller.authorize_call( event(), policy(), @@ -948,7 +1075,7 @@ def test_reusable_call_rejects_changed_comment_issue_or_ref(self) -> None: api = FakeAuthorizationApi() api.comment_issue_number = 8 - with self.assertRaisesRegex(controller.PolicyError, "another issue"): + with self.assertRaisesRegex(controller.PolicyError, "comment moved"): controller.authorize_call( event(), policy(), @@ -1078,6 +1205,129 @@ def test_reusable_call_binding_is_unique_complete_and_attempt_aware(self) -> Non ) + def test_sensitive_change_rejects_the_ordinary_command(self) -> None: + api = FakeAuthorizationApi() + api.set_change("Cargo.toml") + with self.assertRaisesRegex(controller.PolicyError, "test-and-adopt"): + controller.authorize(event(), policy(), api, environment()) + + def test_sensitive_fork_requires_maintainer_edits(self) -> None: + api = FakeAuthorizationApi() + api.set_change(".github/workflows/ci.yml") + api.pull["maintainer_can_modify"] = False + with self.assertRaisesRegex(controller.PolicyError, "maintainer edits"): + authorize_fixture(api, adoption_event()) + + def test_edit_away_and_restore_invalidates_the_comment_timestamp(self) -> None: + api = FakeAuthorizationApi() + api.comment["updated_at"] = "2026-08-28T12:00:01Z" + with self.assertRaisesRegex(controller.PolicyError, "comment or identity changed"): + controller.authorize(event(), policy(), api, environment()) + + def test_direct_external_contributor_identity_is_valid_without_membership(self) -> None: + api = FakeAuthorizationApi() + api.set_change("src/lib.rs") + api.details[HEAD_SHA] = git_commit( + author_login="external-contributor", + committer_login="external-contributor", + author_id=42, + committer_id=42, + author_name="External Contributor", + committer_name="External Contributor", + author_email="external@example.invalid", + committer_email="external@example.invalid", + ) + api.signatures[HEAD_SHA] = git_signature( + signer_login="external-contributor", + signer_id=42, + email="external@example.invalid", + kind="SshSignature", + ) + result = authorize_fixture(api) + self.assertEqual(result.head_sha, HEAD_SHA) + self.assertNotIn("external-contributor", api.permissions) + + def test_direct_identity_id_login_and_type_disagreements_fail_closed(self) -> None: + mutations = ( + ("id", 2), + ("login", "lookalike"), + ("type", "Bot"), + ) + for field, value in mutations: + with self.subTest(field=field): + api = FakeAuthorizationApi() + api.set_change("src/lib.rs") + api.details[HEAD_SHA]["author"][field] = value + with self.assertRaisesRegex(controller.PolicyError, "verified signer"): + authorize_fixture(api) + + def test_null_signature_signer_and_forged_dco_fail_closed(self) -> None: + api = FakeAuthorizationApi() + api.set_change("src/lib.rs") + api.signatures[HEAD_SHA]["signature"]["signer"] = None + with self.assertRaisesRegex(controller.PolicyError, "signer must be an object"): + authorize_fixture(api) + + api = FakeAuthorizationApi() + api.set_change("src/lib.rs") + api.details[HEAD_SHA]["commit"]["message"] = ( + "ci: forged trailer\n\n" + "Signed-off-by: Lookalike \n" + ) + with self.assertRaisesRegex(controller.PolicyError, "author's DCO"): + authorize_fixture(api) + + def test_web_flow_does_not_authorize_an_arbitrary_human_commit(self) -> None: + api = FakeAuthorizationApi() + api.set_change("src/lib.rs") + api.details[HEAD_SHA] = git_commit( + author_login=WEB_FLOW, + committer_login=WEB_FLOW, + author_id=WEB_FLOW_ID, + committer_id=WEB_FLOW_ID, + author_name=GITHUB_COMMITTER_NAME, + committer_name=GITHUB_COMMITTER_NAME, + author_email=GITHUB_COMMITTER_EMAIL, + committer_email=GITHUB_COMMITTER_EMAIL, + ) + api.signatures[HEAD_SHA] = git_signature( + signer_login=WEB_FLOW, + signer_id=WEB_FLOW_ID, + email=GITHUB_COMMITTER_EMAIL, + github_signed=True, + ) + with self.assertRaisesRegex(controller.PolicyError, "unsupported GitHub web-flow"): + authorize_fixture(api) + + def test_release_app_requires_the_exact_gpg_web_flow_shape(self) -> None: + api = self.release_app_api() + api.signatures[HEAD_SHA]["signature"]["__typename"] = "SshSignature" + with self.assertRaisesRegex(controller.PolicyError, "exact GitHub web-flow"): + controller.authorize(event(), policy(), api, environment()) + + def test_adopting_signer_must_remain_a_writer(self) -> None: + api = FakeAuthorizationApi() + api.set_change("Cargo.toml") + api.details[HEAD_SHA] = git_commit( + author_login="external-contributor", + author_id=42, + author_name="External Contributor", + author_email="external@example.invalid", + committer_login="adopter", + committer_id=2, + committer_name="Adopter", + committer_email="adopter@example.invalid", + ) + api.signatures[HEAD_SHA] = git_signature( + signer_login="adopter", + signer_id=2, + email="adopter@example.invalid", + ) + api.permissions["adopter"] = "read" + with self.assertRaisesRegex(controller.PolicyError, "adopting signer"): + authorize_fixture(api, adoption_event()) + + class ImmutableTreeTests(unittest.TestCase): @staticmethod def snapshot(leaves: dict[str, tuple[str, str, str]]) -> controller.GitTree: @@ -1109,7 +1359,7 @@ def test_additions_removals_modifications_and_renames_are_derived(self) -> None: list(zip(paths, statuses, strict=True)), [ ("added.txt", "added"), - ("modified.txt", "modified"), + ("modified.txt", "mode-or-type-changed"), ("new-name.txt", "added"), ("old-name.txt", "removed"), ("removed.txt", "removed"), @@ -1253,6 +1503,280 @@ def test_candidate_unicode_and_directory_collisions_are_rejected(self) -> None: controller.authorize(event(), policy(), api, environment()) +class ProtectedCheckoutTests(unittest.TestCase): + def setUp(self) -> None: + temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self.temporary_directory = pathlib.Path(temporary_directory.name) + self.repository = self.temporary_directory / "repository" + self.repository.mkdir() + discovered_git = shutil.which("git") + assert discovered_git is not None + self.git = os.path.realpath(discovered_git) + self.run_git("init", "--quiet", "--initial-branch=main") + self.run_git("config", "user.name", "Verifier Test") + self.run_git("config", "user.email", "verifier@example.invalid") + workflow = self.repository / ".github" / "workflows" / "ci.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text("name: candidate\n", encoding="utf-8") + self.run_git("add", ".github/workflows/ci.yml") + self.run_git("commit", "--quiet", "-m", "test: candidate") + self.head_sha = self.run_git("rev-parse", "HEAD").stdout.strip() + self.blob_sha = self.run_git( + "hash-object", "--no-filters", "--", ".github/workflows/ci.yml" + ).stdout.strip() + self.base_tree = controller.GitTree(paths=frozenset(), leaves={}) + self.head_tree = controller.GitTree( + paths=frozenset( + { + ".github", + ".github/workflows", + ".github/workflows/ci.yml", + } + ), + leaves={ + ".github/workflows/ci.yml": ( + "blob", + "100644", + self.blob_sha, + ) + }, + ) + self.config = self.temporary_directory / "protected-pr-ci.json" + self.config.write_text( + json.dumps(policy(), sort_keys=True), encoding="utf-8" + ) + + def run_git(self, *arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [self.git, *arguments], + cwd=self.repository, + text=True, + capture_output=True, + check=True, + ) + + def verify(self) -> None: + with mock.patch.object( + verifier.policy, + "git_tree_for_commit", + side_effect=[self.base_tree, self.head_tree], + ): + verifier.verify( + self.repository, + self.git, + REPOSITORY, + MAIN_SHA, + self.head_sha, + os.fspath(self.config), + mock.Mock(), + ) + + def test_exact_sensitive_blob_and_head_are_accepted(self) -> None: + self.verify() + + def test_policy_staging_records_exact_files_digests_and_tools(self) -> None: + destination = self.temporary_directory / "staged-policy" + github_output = self.temporary_directory / "github-output" + verifier.stage_policy( + MODULE_PATH.parents[2], destination, os.fspath(github_output) + ) + outputs = dict( + line.split("=", 1) + for line in github_output.read_text(encoding="utf-8").splitlines() + ) + self.assertEqual( + set(outputs), + { + "verifier", + "verifier_sha256", + "controller", + "controller_sha256", + "config", + "config_sha256", + "python", + "git", + "path", + }, + ) + for label in ("verifier", "controller", "config"): + value = pathlib.Path(outputs[label]).read_bytes() + self.assertEqual(outputs[f"{label}_sha256"], verifier.sha256_bytes(value)) + self.assertTrue(os.path.isabs(outputs["python"])) + self.assertTrue(os.path.isabs(outputs["git"])) + + def test_modified_or_untracked_sensitive_file_fails_closed(self) -> None: + workflow = self.repository / ".github" / "workflows" / "ci.yml" + workflow.write_text("name: tampered\n", encoding="utf-8") + with self.assertRaisesRegex(controller.PolicyError, "authorized Git blob"): + self.verify() + + self.run_git("checkout", "--quiet", "--", ".github/workflows/ci.yml") + (self.repository / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + with self.assertRaisesRegex(controller.PolicyError, "untracked sensitive"): + self.verify() + + def test_sensitive_symlink_and_reparse_point_fail_closed(self) -> None: + workflow = self.repository / ".github" / "workflows" / "ci.yml" + if os.name != "nt": + workflow.unlink() + workflow.symlink_to(self.repository / ".git" / "HEAD") + with self.assertRaisesRegex( + controller.PolicyError, "link, reparse point" + ): + self.verify() + + workflow.unlink() + workflow.write_text("name: candidate\n", encoding="utf-8") + with ( + mock.patch.object( + verifier, + "has_reparse_point", + side_effect=lambda metadata: stat.S_ISREG(metadata.st_mode), + ), + self.assertRaisesRegex(controller.PolicyError, "link, reparse point"), + ): + self.verify() + + @unittest.skipUnless(os.name == "nt", "Windows reparse-point regression") + def test_windows_intermediate_junction_fails_closed(self) -> None: + workflows = self.repository / ".github" / "workflows" + target = self.repository / "workflows-target" + workflows.replace(target) + completed = subprocess.run( + [ + os.environ.get("COMSPEC", "cmd.exe"), + "/d", + "/c", + "mklink", + "/J", + os.fspath(workflows), + os.fspath(target), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual( + completed.returncode, + 0, + f"cannot create test junction: {completed.stdout}{completed.stderr}", + ) + self.addCleanup(os.rmdir, workflows) + + with self.assertRaisesRegex( + controller.PolicyError, "missing or untracked sensitive paths" + ): + self.verify() + + def test_aliases_and_short_name_shapes_fail_closed(self) -> None: + if os.name != "nt": + alias_root = self.repository / ".GITHUB" + if not alias_root.exists(): + alias = alias_root / "workflows" + alias.mkdir(parents=True) + (alias / "extra.yml").write_text( + "name: alias\n", encoding="utf-8" + ) + with self.assertRaisesRegex( + controller.PolicyError, "casefold aliases" + ): + self.verify() + + shutil.rmtree(alias_root) + else: + self.assertTrue( + os.path.samefile(alias_root, self.repository / ".github") + ) + (self.repository / "CARGO~1.TOM").write_text("alias\n", encoding="utf-8") + with self.assertRaisesRegex(controller.PolicyError, "short-name-shaped"): + self.verify() + + def test_enumeration_entry_metadata_and_time_limits_fail_closed(self) -> None: + with ( + mock.patch.object(controller, "MAX_TREE_ENTRIES", 0), + self.assertRaisesRegex(controller.PolicyError, "exceeds 0 entries"), + ): + self.verify() + + with ( + mock.patch.object(controller, "MAX_PATH_METADATA_BYTES", 1), + self.assertRaisesRegex(controller.PolicyError, "metadata exceeds"), + ): + self.verify() + + with ( + mock.patch.object(verifier.time, "monotonic", side_effect=[0.0, 31.0]), + self.assertRaisesRegex(controller.PolicyError, "exceeded 30 seconds"), + ): + self.verify() + + def test_trusted_git_uses_only_the_resolved_tool_directory(self) -> None: + completed = subprocess.CompletedProcess([], 0, stdout="", stderr="") + with mock.patch.object( + verifier.subprocess, "run", return_value=completed + ) as run: + verifier.trusted_git(self.git, self.repository, ["status"]) + environment = run.call_args.kwargs["env"] + self.assertEqual(environment["PATH"], os.path.dirname(self.git)) + self.assertNotIn("HOME", environment) + self.assertEqual(run.call_args.args[0][0], self.git) + + +class WorkflowStructureTests(unittest.TestCase): + def test_composite_verifier_is_the_first_post_checkout_step(self) -> None: + action = CHECKOUT_ACTION_PATH.read_text(encoding="utf-8") + stage = action.index("- name: Stage protected verifier and trusted tools") + checkout = action.index("- name: Check out exact candidate") + verify_step = action.index("- name: Verify exact candidate checkout") + self.assertLess(stage, checkout) + self.assertLess(checkout, verify_step) + between = action[checkout:verify_step] + self.assertNotIn("\n - name:", between) + self.assertIn("persist-credentials: false", between) + self.assertIn("submodules: false", between) + self.assertIn("--expected-verifier-sha256", action[verify_step:]) + self.assertIn('PATH: ${{ steps.protected.outputs.path }}', action[verify_step:]) + + def test_every_primary_candidate_checkout_uses_the_protected_action(self) -> None: + workflow = REUSABLE_WORKFLOW_PATH.read_text(encoding="utf-8") + protected_uses = ( + "uses: ./policy/.github/actions/protected-candidate-checkout" + ) + self.assertGreaterEqual(workflow.count(protected_uses), 1) + self.assertNotIn("- name: Check out exact candidate\n", workflow) + self.assertIn("binding_digest:", workflow) + self.assertIn("--binding-digest", workflow) + self.assertIn("results_json:", workflow) + + def test_controller_token_jobs_compile_before_checks_only_tokens(self) -> None: + command = COMMAND_WORKFLOW_PATH.read_text(encoding="utf-8") + self.assertEqual(command.count("Create checks-only GitHub App token"), 2) + self.assertEqual(command.count("permission-checks: write"), 2) + self.assertNotIn("permission-contents:", command) + start = command.index(" start_check:") + protected = command.index(" protected_ci:") + finish = command.index(" finish_check:") + start_job = command[start:protected] + finish_job = command[finish:] + for job in (start_job, finish_job): + self.assertLess( + job.index("Load immutable check policy"), + job.index("Create checks-only GitHub App token"), + ) + self.assertLess( + job.index("Verify exact App token repository scope"), + job.index("App-owned in-progress check") + if "App-owned in-progress check" in job + else job.index("Revalidate state and finalize App check"), + ) + self.assertNotIn("actions/checkout@", job) + + def test_reconciliation_token_jobs_remain_checkout_free(self) -> None: + workflow = RECONCILE_WORKFLOW_PATH.read_text(encoding="utf-8") + self.assertNotIn("actions/checkout@", workflow) + + class CommitPolicyTests(unittest.TestCase): def setUp(self) -> None: temporary_directory = tempfile.TemporaryDirectory() @@ -1312,7 +1836,7 @@ def run_policy( process_environment = os.environ.copy() process_environment.update({"BASE_SHA": base_sha, "HEAD_SHA": head_sha}) return subprocess.run( - ["bash", str(COMMIT_POLICY_PATH)], + [policy_bash(), str(COMMIT_POLICY_PATH)], cwd=self.repository, env=process_environment, text=True, @@ -1466,6 +1990,300 @@ def test_pagination_limits_and_completion_fail_closed(self) -> None: api.paginate("/items", max_items=200, label="items") +class GraphQlResponse: + status = 200 + + def __init__(self, value: dict) -> None: + self.raw = json.dumps(value, separators=(",", ":")).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit: int) -> bytes: + return self.raw[:limit] + + +def requested_graphql_page(request) -> tuple[int, str | None, int, str]: + body = json.loads(request.data) + variables = body["variables"] + return variables["first"], variables["after"], variables["number"], body["query"] + + +def graphql_signature_value( + signatures: list[dict], + *, + total_count: int, + has_next: bool, + end_cursor: str | None, +) -> dict: + return { + "data": { + "repository": { + "pullRequest": { + "commits": { + "totalCount": total_count, + "nodes": [{"commit": signature} for signature in signatures], + "pageInfo": { + "hasNextPage": has_next, + "endCursor": end_cursor, + }, + } + } + } + } + } + + +def graphql_signature_response( + oids: list[str], + *, + total_count: int | None = None, + has_next: bool = False, + end_cursor: str | None = None, +) -> GraphQlResponse: + return GraphQlResponse( + graphql_signature_value( + [git_signature(sha=oid) for oid in oids], + total_count=len(oids) if total_count is None else total_count, + has_next=has_next, + end_cursor=end_cursor, + ) + ) + + +def graphql_signature_pages(oids: list[str]): + offset = 0 + expected_cursor = None + + def respond(request, *_args, **_kwargs) -> GraphQlResponse: + nonlocal offset, expected_cursor + first, after, number, query = requested_graphql_page(request) + if number != PULL_NUMBER or after != expected_cursor: + raise AssertionError("unexpected pull request signature page") + if "pullRequest(number:$number)" not in query or "object(oid:" in query: + raise AssertionError("signature query escaped the pull request connection") + batch = oids[offset : offset + first] + offset += len(batch) + has_next = offset < len(oids) + end_cursor = f"cursor-{offset}" + expected_cursor = end_cursor if has_next else None + return graphql_signature_response( + batch, + total_count=len(oids), + has_next=has_next, + end_cursor=end_cursor, + ) + + return respond + + +class GraphQlSignatureTests(unittest.TestCase): + def test_signature_query_text_matches_the_intended_selection_tree(self) -> None: + api = controller.GitHubApi("token", "https://example.invalid") + with mock.patch.object( + controller.urllib.request, + "urlopen", + return_value=graphql_signature_response([HEAD_SHA]), + ) as urlopen: + api.commit_signatures(REPOSITORY, PULL_NUMBER, [HEAD_SHA]) + + _, _, _, query = requested_graphql_page(urlopen.call_args.args[0]) + expected = "".join( + """ + query($owner:String!,$name:String!,$number:Int!,$first:Int!,$after:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$number){ + commits(first:$first,after:$after){ + totalCount + nodes{ + commit{ + oid + signature{ + __typename + email + isValid + state + wasSignedByGitHub + signer{databaseId login __typename} + } + } + } + pageInfo{hasNextPage endCursor} + } + } + } + } + """.split() + ) + self.assertEqual("".join(query.split()), expected) + + def test_signature_inventory_is_batched_five_by_fifty(self) -> None: + oids = [f"{index:040x}" for index in range(1, 251)] + api = controller.GitHubApi("token", "https://example.invalid") + with mock.patch.object( + controller.urllib.request, + "urlopen", + side_effect=graphql_signature_pages(oids), + ) as urlopen: + observed = api.commit_signatures(REPOSITORY, PULL_NUMBER, oids) + self.assertEqual(list(observed), oids) + self.assertEqual(urlopen.call_count, 5) + self.assertTrue( + all( + requested_graphql_page(call.args[0])[0] == 50 + for call in urlopen.call_args_list + ) + ) + self.assertTrue( + all( + requested_graphql_page(call.args[0])[2] == PULL_NUMBER + and "pullRequest(number:$number)" + in requested_graphql_page(call.args[0])[3] + and "object(oid:" not in requested_graphql_page(call.args[0])[3] + for call in urlopen.call_args_list + ) + ) + + def test_251_commits_and_excess_requests_fail_before_ambiguity(self) -> None: + api = controller.GitHubApi("token", "https://example.invalid") + oids = [f"{index:040x}" for index in range(1, 252)] + with self.assertRaisesRegex(controller.PolicyError, "commit limit"): + api.commit_signatures(REPOSITORY, PULL_NUMBER, oids) + + with ( + mock.patch.object(controller, "MAX_SIGNATURE_BATCH", 1), + mock.patch.object(controller, "MAX_SIGNATURE_REQUESTS", 1), + mock.patch.object( + controller.urllib.request, + "urlopen", + side_effect=graphql_signature_pages([HEAD_SHA, OLD_SHA]), + ) as urlopen, + self.assertRaisesRegex(controller.PolicyError, "too many GraphQL requests"), + ): + api.commit_signatures(REPOSITORY, PULL_NUMBER, [HEAD_SHA, OLD_SHA]) + self.assertEqual(urlopen.call_count, 1) + + def test_aggregate_success_body_budget_has_a_sentinel(self) -> None: + first = graphql_signature_response( + [HEAD_SHA], + total_count=2, + has_next=True, + end_cursor="cursor-1", + ) + second = graphql_signature_response( + [OLD_SHA], + total_count=2, + end_cursor="cursor-2", + ) + budget = len(first.raw) + len(second.raw) - 1 + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object(controller, "MAX_SIGNATURE_BATCH", 1), + mock.patch.object(controller, "MAX_API_RESPONSE_BYTES", budget), + mock.patch.object( + controller.urllib.request, + "urlopen", + side_effect=[first, second], + ), + self.assertRaisesRegex(controller.PolicyError, "aggregate commit signature"), + ): + api.commit_signatures(REPOSITORY, PULL_NUMBER, [HEAD_SHA, OLD_SHA]) + + def test_partial_graphql_errors_are_rejected_even_with_data(self) -> None: + response = graphql_signature_response([HEAD_SHA], end_cursor="cursor-1") + value = json.loads(response.raw) + value["errors"] = [{"message": "partial"}] + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object( + controller.urllib.request, + "urlopen", + return_value=GraphQlResponse(value), + ), + self.assertRaisesRegex(controller.PolicyError, "contains errors"), + ): + api.commit_signatures(REPOSITORY, PULL_NUMBER, [HEAD_SHA]) + + def test_missing_extra_duplicate_and_reordered_results_fail_closed(self) -> None: + cases = { + "missing": [git_signature(sha=HEAD_SHA)], + "extra": [ + git_signature(sha=HEAD_SHA), + git_signature(sha=OLD_SHA), + git_signature(sha=MAIN_SHA), + ], + "duplicate": [ + git_signature(sha=HEAD_SHA), + git_signature(sha=HEAD_SHA), + ], + "reordered": [ + git_signature(sha=OLD_SHA), + git_signature(sha=HEAD_SHA), + ], + } + for label, signatures in cases.items(): + with self.subTest(case=label): + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object( + controller.urllib.request, + "urlopen", + return_value=GraphQlResponse( + graphql_signature_value( + signatures, + total_count=2, + has_next=False, + end_cursor="cursor-2", + ) + ), + ), + self.assertRaises(controller.PolicyError), + ): + api.commit_signatures( + REPOSITORY, PULL_NUMBER, [HEAD_SHA, OLD_SHA] + ) + + def test_duplicate_requested_oids_fail_before_network_access(self) -> None: + api = controller.GitHubApi("token", "https://example.invalid") + with ( + mock.patch.object(controller.urllib.request, "urlopen") as urlopen, + self.assertRaisesRegex(controller.PolicyError, "duplicate commit OIDs"), + ): + api.commit_signatures(REPOSITORY, PULL_NUMBER, [HEAD_SHA, HEAD_SHA]) + urlopen.assert_not_called() + + +class AppTokenScopeTests(unittest.TestCase): + def test_exact_single_repository_inventory_is_required(self) -> None: + api = mock.Mock() + api.paginate_key.return_value = [{"id": 11, "full_name": REPOSITORY}] + controller.require_app_token_repository_scope(api, REPOSITORY) + api.paginate_key.assert_called_once_with( + "/installation/repositories", + "repositories", + max_items=2, + label="App token repository inventory", + ) + + for inventory in ( + [], + [ + {"id": 11, "full_name": REPOSITORY}, + {"id": 12, "full_name": "NVIDIA/another"}, + ], + [{"id": 11, "full_name": "NVIDIA/another"}], + [{"id": 0, "full_name": REPOSITORY}], + ): + with self.subTest(inventory=inventory): + api = mock.Mock() + api.paginate_key.return_value = inventory + with self.assertRaises(controller.PolicyError): + controller.require_app_token_repository_scope(api, REPOSITORY) + + class FakeCheckApi: def __init__(self, checks=None) -> None: self.checks = list(checks or []) @@ -1506,18 +2324,49 @@ def post(self, path, payload): return check -def external(run_id: int = 101, attempt: int = 1) -> object: +def authorization_digest( + api: FakeAuthorizationApi | None = None, + approval: dict | None = None, +) -> str: + selected_api = api or FakeAuthorizationApi() + selected_event = approval or event() + selected_api.comment = copy.deepcopy(selected_event["comment"]) + return controller.authorize( + selected_event, policy(), selected_api, environment() + ).binding_digest + + +def external( + run_id: int = 101, + attempt: int = 1, + binding_digest: str | None = None, +) -> object: return controller.ExternalId( repository=REPOSITORY, pull_number=7, head_sha=HEAD_SHA, base_sha=MAIN_SHA, policy_sha=MAIN_SHA, + binding_digest=binding_digest or authorization_digest(), run_id=run_id, run_attempt=attempt, ) +def job_results( + *, workflow_lint: str = "success", candidate_ci: str = "skipped" +) -> str: + return json.dumps( + { + "commit_policy": "success", + "workflow_lint": workflow_lint, + "candidate_ci": candidate_ci, + }, + sort_keys=True, + separators=(",", ":"), + ) + + def pending_check(check_id: int, binding, slug: str = APP_SLUG) -> dict: return { "id": check_id, @@ -1532,7 +2381,15 @@ def pending_check(check_id: int, binding, slug: str = APP_SLUG) -> dict: class CheckRunTests(unittest.TestCase): def test_external_id_round_trip_binds_all_fields(self) -> None: binding = external() - self.assertEqual(controller.ExternalId.decode(binding.encode()), binding) + self.assertEqual( + controller.ExternalId.decode( + binding.encode(), + repository=REPOSITORY, + base_sha=MAIN_SHA, + policy_sha=MAIN_SHA, + ), + binding, + ) self.assertLessEqual(len(binding.encode()), 255) def test_retry_closes_prior_app_check_but_not_actions_check(self) -> None: @@ -1604,11 +2461,7 @@ def test_final_report_reauthorizes_and_requires_every_job(self) -> None: binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=success", - "candidate_ci=skipped", - ], + job_results(), APP_SLUG, ) self.assertEqual(app_api.patches[-1][1]["conclusion"], "success") @@ -1624,36 +2477,34 @@ def test_final_report_reauthorizes_and_requires_every_job(self) -> None: binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=skipped", - "candidate_ci=skipped", - ], + job_results(workflow_lint="skipped"), APP_SLUG, ) self.assertEqual(app_api.patches[-1][1]["conclusion"], "failure") def test_required_candidate_ci_may_not_be_skipped(self) -> None: - binding = external() + approval = adoption_event() + binding_api = FakeAuthorizationApi() + binding_api.set_change(".github/workflows/ci.yml") + binding = external( + binding_digest=authorization_digest(binding_api, approval) + ) app_api = FakeCheckApi([pending_check(1, binding)]) auth_api = FakeAuthorizationApi() auth_api.set_change(".github/workflows/ci.yml") + auth_api.comment = copy.deepcopy(approval["comment"]) with self.assertRaisesRegex(controller.PolicyError, "did not all succeed"): controller.finish_check( app_api, auth_api, policy(), - event(), + approval, environment(), binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=success", - "candidate_ci=skipped", - ], + job_results(), APP_SLUG, ) @@ -1685,11 +2536,7 @@ def test_success_is_overwritten_if_main_advances_during_reconciliation(self) -> binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=success", - "candidate_ci=skipped", - ], + job_results(), APP_SLUG, ) @@ -1716,11 +2563,7 @@ def test_success_is_overwritten_if_reconciliation_read_fails(self) -> None: binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=success", - "candidate_ci=skipped", - ], + job_results(), APP_SLUG, ) @@ -1750,11 +2593,7 @@ def test_reconciliation_validates_failure_patch_response_binding(self) -> None: binding, COMMENT_ID, 1, - [ - "commit_policy=success", - "workflow_lint=success", - "candidate_ci=skipped", - ], + job_results(), APP_SLUG, ) diff --git a/.github/scripts/test_release_notification_preflight.py b/.github/scripts/test_release_notification_preflight.py new file mode 100644 index 0000000..68d69ae --- /dev/null +++ b/.github/scripts/test_release_notification_preflight.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the checkout-free release notification preflight.""" + +from __future__ import annotations + +import copy +import io +import json +import tarfile +import unittest +from pathlib import Path +from typing import Any + +import release_notification_preflight as preflight + + +class FakeApi: + def __init__(self, github: dict[str, Any], crates: dict[str, Any], archives: dict[tuple[str, str], bytes]) -> None: + self.github = github + self.crates = crates + self.archives = archives + + def github_json(self, path: str, optional: bool = False) -> Any: + if path not in self.github: + if optional: + return None + raise AssertionError(f"unexpected GitHub read: {path}") + return copy.deepcopy(self.github[path]) + + def crates_json(self, path: str) -> Any: + if path not in self.crates: + raise AssertionError(f"unexpected crates.io read: {path}") + return copy.deepcopy(self.crates[path]) + + def crate_archive(self, package: str, version: str) -> bytes: + return self.archives[(package, version)] + + +def crate_archive(package: preflight.PackagePolicy, version: str, commit: str) -> bytes: + vcs = preflight.canonical( + {"git": {"sha1": commit}, "path_in_vcs": package.path_in_vcs} + ).encode() + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + info = tarfile.TarInfo(f"{package.name}-{version}/.cargo_vcs_info.json") + info.size = len(vcs) + info.mode = 0o644 + archive.addfile(info, io.BytesIO(vcs)) + source = b"pub fn source_only_fixture() {}\n" + info = tarfile.TarInfo(f"{package.name}-{version}/src/lib.rs") + info.size = len(source) + info.mode = 0o644 + archive.addfile(info, io.BytesIO(source)) + return output.getvalue() + + +class Fixture: + def __init__(self) -> None: + config = Path(__file__).parents[1] / "release-notification-policy.json" + self.policy = preflight.parse_policy( + preflight.read_json(config, preflight.MAX_CONFIG_BYTES, "test policy") + ) + self.policy_sha = "d" * 40 + self.captured_sha = "a" * 40 + self.version = "0.9.0-rc.1" + self.archives: dict[tuple[str, str], bytes] = {} + plan_packages: list[dict[str, Any]] = [] + intent_tags: list[dict[str, Any]] = [] + releases: list[dict[str, Any]] = [] + self.github: dict[str, Any] = { + f"repos/{self.policy.repository}": { + "full_name": self.policy.repository, + "default_branch": self.policy.default_branch, + "extra": True, + }, + f"repos/{self.policy.repository}/git/ref/heads/{self.policy.default_branch}": { + "ref": f"refs/heads/{self.policy.default_branch}", + "object": {"type": "commit", "sha": self.policy_sha}, + }, + f"users/{preflight.urllib.parse.quote(self.policy.sender_login, safe='')}": { + "id": self.policy.sender_id, + "login": self.policy.sender_login, + "type": "Bot", + }, + f"repos/{self.policy.repository}/pulls?state=open&head={preflight.urllib.parse.quote(f'{self.policy.repository.split('/', 1)[0]}:{self.policy.release_branch}', safe='')}&per_page=100": [], + } + self.crates: dict[str, Any] = {} + for index, package in enumerate(self.policy.packages): + archive = crate_archive(package, self.version, self.captured_sha) + digest = preflight.sha256(archive) + self.archives[(package.name, self.version)] = archive + body = f"### Changes\n\n- Source-only notes for {package.name}." + body_digest = preflight.sha256(body.encode()) + tag = f"{package.tag_prefix}{self.version}" + tag_object = f"{index + 1:040x}" + plan_packages.append( + { + "package": package.name, + "version": self.version, + "tag": tag, + "prerelease": True, + "source_archive_sha256": digest, + "package_inventory_sha256": "2" * 64, + "release_body": body, + "release_body_sha256": body_digest, + "registry": {"state": "absent", "checksum": None}, + } + ) + message = f"chore: Release package {package.name} version {self.version}" + intent_tags.append( + { + "package": package.name, + "tag": tag, + "tag_object_id": tag_object, + "tag_message": message, + "release_body_sha256": body_digest, + } + ) + release_id = 1000 + index + releases.append( + { + "package": package.name, + "version": self.version, + "release_id": release_id, + "tag": tag, + "tag_object_id": tag_object, + "release_body_sha256": body_digest, + } + ) + release = { + "id": release_id, + "tag_name": tag, + "target_commitish": self.policy.default_branch, + "name": tag, + "body": body, + "draft": False, + "prerelease": True, + "immutable": True, + "author": { + "id": self.policy.sender_id, + "login": self.policy.sender_login, + "type": "Bot", + }, + "assets": [], + } + self.github[f"repos/{self.policy.repository}/releases/{release_id}"] = release + self.github[ + f"repos/{self.policy.repository}/releases/tags/{preflight.urllib.parse.quote(tag, safe='')}" + ] = release + self.github[ + f"repos/{self.policy.repository}/git/ref/tags/{preflight.urllib.parse.quote(tag, safe='')}" + ] = { + "ref": f"refs/tags/{tag}", + "object": {"type": "tag", "sha": tag_object}, + } + self.github[f"repos/{self.policy.repository}/git/tags/{tag_object}"] = { + "sha": tag_object, + "tag": tag, + "message": message, + "tagger": { + "name": self.policy.sender_login, + "email": self.policy.app_email, + "date": "2026-08-28T12:00:00Z", + }, + "object": {"type": "commit", "sha": self.captured_sha}, + } + self.crates[ + f"crates/{preflight.urllib.parse.quote(package.name, safe='')}/{preflight.urllib.parse.quote(self.version, safe='')}" + ] = { + "version": { + "num": self.version, + "yanked": False, + "checksum": digest, + "extra": "ignored", + }, + "meta": {}, + } + self.plan = { + "schema_version": 1, + "repository": self.policy.repository, + "release_sha": self.captured_sha, + "authorization": { + "pull_request": 50, + "proposal_commit": "b" * 40, + "base_commit": "c" * 40, + "owner_id": 11, + "merger_id": 12, + }, + "release_plz_version": "0.3.160", + "release_config_sha256": "3" * 64, + "publish_workflow_sha256": "4" * 64, + "proposal_workflow_sha256": "5" * 64, + "tagger_epoch": 1_777_777_777, + "tagger_date": "2026-05-02T00:29:37+00:00", + "packages": plan_packages, + } + self.plan_digest = preflight.sha256(preflight.canonical(self.plan).encode()) + self.intent = { + "schema_version": 1, + "repository": self.policy.repository, + "release_sha": self.captured_sha, + "plan_digest": self.plan_digest, + "external_id": "6" * 64, + "origin_run_id": 100, + "origin_run_attempt": 1, + "ruleset_evidence_sha256": "7" * 64, + "plan": self.plan, + "tags": intent_tags, + } + self.payload = { + "schema_version": 1, + "repository": self.policy.repository, + "captured_sha": self.captured_sha, + "release_plan_digest": self.plan_digest, + "intent_check_id": 900, + "intent_external_id": "6" * 64, + "releases": releases, + } + self.event = { + "action": preflight.EVENT_TYPE, + "sender": { + "id": self.policy.sender_id, + "login": self.policy.sender_login, + "type": "Bot", + }, + "repository": { + "full_name": self.policy.repository, + "default_branch": self.policy.default_branch, + }, + "client_payload": self.payload, + } + self.github[f"repos/{self.policy.repository}/check-runs/900"] = { + "id": 900, + "name": preflight.INTENT_NAME, + "head_sha": self.captured_sha, + "external_id": "6" * 64, + "status": "completed", + "conclusion": "neutral", + "app": {"id": self.policy.app_id, "slug": self.policy.app_slug, "extra": True}, + "output": {"title": preflight.INTENT_TITLE, "summary": preflight.canonical(self.intent), "text": None}, + "extra": True, + } + + def api(self) -> FakeApi: + return FakeApi(self.github, self.crates, self.archives) + + def validate(self) -> dict[str, str]: + return preflight.validate_event( + self.event, + self.policy, + self.api(), + self.policy.repository, + self.policy_sha, + ) + + +class PreflightTests(unittest.TestCase): + def test_complete_release_train_is_accepted(self) -> None: + outputs = Fixture().validate() + self.assertEqual(outputs["authorized"], "true") + self.assertEqual(outputs["replay_state"], "new") + self.assertRegex(outputs["replay_key"], r"^[0-9a-f]{64}$") + + def test_legacy_unknown_missing_wrong_version_and_oversized_payloads_fail(self) -> None: + mutations = [ + lambda fixture: fixture.event.__setitem__("client_payload", {"version": "0.9.0"}), + lambda fixture: fixture.payload.__setitem__("unknown", True), + lambda fixture: fixture.payload.pop("intent_check_id"), + lambda fixture: fixture.payload.__setitem__("schema_version", 2), + lambda fixture: fixture.payload.__setitem__("repository", "x" * preflight.MAX_NOTIFICATION_BYTES), + ] + for mutate in mutations: + with self.subTest(mutate=mutate): + fixture = Fixture() + mutate(fixture) + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_wrong_sender_or_intent_app_fails(self) -> None: + fixture = Fixture() + fixture.event["sender"]["id"] += 1 + with self.assertRaises(preflight.PreflightError): + fixture.validate() + fixture = Fixture() + fixture.github[f"repos/{fixture.policy.repository}/check-runs/900"]["app"]["id"] += 1 + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_partial_duplicate_and_reordered_release_sets_fail(self) -> None: + fixture = Fixture() + fixture.payload["releases"] = fixture.payload["releases"][:-1] + with self.assertRaises(preflight.PreflightError): + fixture.validate() + fixture = Fixture() + fixture.payload["releases"] = fixture.payload["releases"] + [copy.deepcopy(fixture.payload["releases"][0])] + with self.assertRaises(preflight.PreflightError): + fixture.validate() + fixture = Fixture() + if len(fixture.payload["releases"]) > 1: + fixture.payload["releases"].reverse() + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_mutable_or_asset_bearing_release_fails(self) -> None: + fixture = Fixture() + release_id = fixture.payload["releases"][0]["release_id"] + fixture.github[f"repos/{fixture.policy.repository}/releases/{release_id}"]["immutable"] = False + with self.assertRaises(preflight.PreflightError): + fixture.validate() + fixture = Fixture() + release_id = fixture.payload["releases"][0]["release_id"] + fixture.github[f"repos/{fixture.policy.repository}/releases/{release_id}"]["assets"] = [{"id": 1}] + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_plan_intent_registry_and_archive_drift_fail(self) -> None: + fixture = Fixture() + check = fixture.github[f"repos/{fixture.policy.repository}/check-runs/900"] + altered = copy.deepcopy(fixture.intent) + altered["unknown"] = True + check["output"]["summary"] = preflight.canonical(altered) + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_archive_vcs_metadata_is_closed_and_commit_bound(self) -> None: + fixture = Fixture() + package = fixture.policy.packages[0] + archive = crate_archive(package, fixture.version, "e" * 40) + with self.assertRaisesRegex(preflight.PreflightError, "VCS commit"): + preflight.inspect_archive( + archive, + package, + fixture.version, + fixture.captured_sha, + ) + + malformed = io.BytesIO() + vcs = preflight.canonical({"git": "not-an-object", "path_in_vcs": package.path_in_vcs}).encode() + with tarfile.open(fileobj=malformed, mode="w:gz") as cargo: + info = tarfile.TarInfo( + f"{package.name}-{fixture.version}/.cargo_vcs_info.json" + ) + info.size = len(vcs) + cargo.addfile(info, io.BytesIO(vcs)) + with self.assertRaisesRegex(preflight.PreflightError, "must be an object"): + preflight.inspect_archive( + malformed.getvalue(), + package, + fixture.version, + fixture.captured_sha, + ) + fixture = Fixture() + crate_path = next(iter(fixture.crates)) + fixture.crates[crate_path]["version"]["checksum"] = "8" * 64 + with self.assertRaises(preflight.PreflightError): + fixture.validate() + fixture = Fixture() + package_version = next(iter(fixture.archives)) + fixture.archives[package_version] = crate_archive( + fixture.policy.packages[0], fixture.version, "e" * 40 + ) + with self.assertRaises(preflight.PreflightError): + fixture.validate() + + def test_consumed_replay_fails_and_abandoned_branch_recovers(self) -> None: + fixture = Fixture() + key = fixture.validate()["replay_key"] + branch_path = f"repos/{fixture.policy.repository}/git/ref/heads/{fixture.policy.release_branch}" + commit_sha = "e" * 40 + pulls_path = next(path for path in fixture.github if "/pulls?state=open&head=" in path) + fixture.github[branch_path] = { + "ref": f"refs/heads/{fixture.policy.release_branch}", + "object": {"type": "commit", "sha": commit_sha}, + } + fixture.github[f"repos/{fixture.policy.repository}/commits/{commit_sha}"] = { + "commit": {"message": f"proposal\n\n{preflight.REPLAY_TRAILER}{key}"} + } + fixture.github[pulls_path] = [ + {"body": f"proposal\n\n{preflight.REPLAY_COMMENT.format(key)}"} + ] + with self.assertRaisesRegex(preflight.PreflightError, "durably consumed"): + fixture.validate() + fixture.github[pulls_path] = [] + fixture.github[ + f"repos/{fixture.policy.repository}/compare/{fixture.policy.default_branch}...{fixture.policy.release_branch}" + ] = {"ahead_by": 1} + self.assertEqual(fixture.validate()["replay_state"], "recover") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_release_settings_preflight.py b/.github/scripts/test_release_settings_preflight.py new file mode 100644 index 0000000..e3c91d8 --- /dev/null +++ b/.github/scripts/test_release_settings_preflight.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for the repository-admin release-settings preflight.""" + +from __future__ import annotations + +import copy +import datetime as dt +import unittest +from typing import Any + +import release_settings_preflight as preflight +from release_notification_preflight import PreflightError + + +class FakeApi: + def __init__(self, github: dict[str, Any]) -> None: + self.github = github + + def github_json(self, path: str, optional: bool = False) -> Any: + if path not in self.github: + if optional: + return None + raise AssertionError(f"unexpected GitHub read: {path}") + return copy.deepcopy(self.github[path]) + + +class Fixture: + def __init__(self, repository: str) -> None: + self.policy = preflight.POLICIES[repository] + self.release_sha = "a" * 40 + self.run_id = 123_456 + self.run_attempt = 2 + self.observed = dt.datetime(2026, 8, 28, 18, 0, tzinfo=dt.timezone.utc) + rulesets = [ + self.main_ruleset(101), + self.v1alpha1_ruleset(102), + self.creation_ruleset(103), + self.update_ruleset(104), + ] + summaries = [ + { + "id": rule["id"], + "name": rule["name"], + "target": rule["target"], + "enforcement": rule["enforcement"], + } + for rule in rulesets + ] + self.github: dict[str, Any] = { + f"repos/{repository}": { + "full_name": repository, + "default_branch": "main", + }, + f"repos/{repository}/git/ref/heads/main": { + "ref": "refs/heads/main", + "object": {"type": "commit", "sha": self.release_sha}, + }, + f"repos/{repository}/actions/runs/{self.run_id}": { + "id": self.run_id, + "run_attempt": self.run_attempt, + "head_sha": self.release_sha, + "head_branch": "main", + "event": "workflow_dispatch", + "path": preflight.WORKFLOW_PATH, + "status": "waiting", + "conclusion": None, + "repository": {"full_name": repository}, + }, + f"repos/{repository}/immutable-releases": { + "enabled": True, + "enforced_by_owner": False, + }, + f"repos/{repository}/rulesets?includes_parents=true&per_page=100": summaries, + } + for rule in rulesets: + self.github[f"repos/{repository}/rulesets/{rule['id']}"] = rule + + @staticmethod + def main_ruleset(identifier: int) -> dict[str, Any]: + return { + "id": identifier, + "name": preflight.MAIN_RULESET, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"exclude": [], "include": ["refs/heads/main"]} + }, + "rules": [ + {"type": "required_linear_history"}, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": False, + "strict_required_status_checks_policy": True, + "required_status_checks": [ + { + "context": "Required CI", + "integration_id": preflight.APP_ID, + } + ], + }, + }, + ], + } + + @staticmethod + def v1alpha1_ruleset(identifier: int) -> dict[str, Any]: + return { + "id": identifier, + "name": preflight.V1ALPHA1_RULESET, + "target": "tag", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"exclude": [], "include": ["refs/tags/v1alpha1"]} + }, + "rules": [{"type": "update"}, {"type": "deletion"}], + } + + def creation_ruleset(self, identifier: int) -> dict[str, Any]: + return { + "id": identifier, + "name": preflight.CREATION_RULESET, + "target": "tag", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": preflight.APP_ID, + "actor_type": "Integration", + "bypass_mode": "always", + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": list(self.policy.tag_patterns), + } + }, + "rules": [{"type": "creation"}], + } + + def update_ruleset(self, identifier: int) -> dict[str, Any]: + return { + "id": identifier, + "name": preflight.UPDATE_DELETE_RULESET, + "target": "tag", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "exclude": [], + "include": list(self.policy.tag_patterns), + } + }, + "rules": [{"type": "update"}, {"type": "deletion"}], + } + + def validate(self) -> dict[str, str]: + return preflight.validate_preflight( + self.policy, + FakeApi(self.github), + self.release_sha, + self.run_id, + self.run_attempt, + self.observed, + ) + + +class ReleaseSettingsPreflightTests(unittest.TestCase): + def test_traits_and_rs_exact_settings_pass(self) -> None: + for repository in preflight.POLICIES: + with self.subTest(repository=repository): + fixture = Fixture(repository) + evidence = fixture.validate() + self.assertEqual( + evidence["workflow_evidence_sha256"], + preflight.binding_digest( + fixture.policy, + fixture.release_sha, + fixture.run_id, + fixture.run_attempt, + ), + ) + self.assertEqual(evidence["readback_utc"], "2026-08-28T18:00:00Z") + self.assertEqual(evidence["approve_before_utc"], "2026-08-28T18:05:00Z") + + def test_immutable_releases_must_be_enabled(self) -> None: + fixture = Fixture("NVIDIA/yaml-sigil-traits") + fixture.github[ + f"repos/{fixture.policy.repository}/immutable-releases" + ]["enabled"] = False + with self.assertRaisesRegex(PreflightError, "not enabled"): + fixture.validate() + + def test_only_the_release_app_may_bypass_creation(self) -> None: + fixture = Fixture("NVIDIA/yaml-sigil-rs") + creation = fixture.github[ + f"repos/{fixture.policy.repository}/rulesets/103" + ] + creation["bypass_actors"].append( + {"actor_id": 7, "actor_type": "RepositoryRole", "bypass_mode": "always"} + ) + with self.assertRaisesRegex(PreflightError, "sole approved App"): + fixture.validate() + + def test_intent_check_may_not_collide_with_required_checks(self) -> None: + fixture = Fixture("NVIDIA/yaml-sigil-traits") + main = fixture.github[f"repos/{fixture.policy.repository}/rulesets/101"] + required = next( + rule for rule in main["rules"] if rule["type"] == "required_status_checks" + ) + required["parameters"]["required_status_checks"].append( + {"context": preflight.INTENT_NAME, "integration_id": preflight.APP_ID} + ) + with self.assertRaisesRegex(PreflightError, "collides"): + fixture.validate() + + def test_run_and_current_main_are_exactly_bound(self) -> None: + fixture = Fixture("NVIDIA/yaml-sigil-rs") + fixture.github[ + f"repos/{fixture.policy.repository}/actions/runs/{fixture.run_id}" + ]["head_sha"] = "b" * 40 + with self.assertRaisesRegex(PreflightError, "workflow run identity"): + fixture.validate() + + fixture = Fixture("NVIDIA/yaml-sigil-rs") + fixture.github[ + f"repos/{fixture.policy.repository}/git/ref/heads/main" + ]["object"]["sha"] = "b" * 40 + with self.assertRaisesRegex(PreflightError, "exact current main"): + fixture.validate() + + def test_release_rules_must_be_active_and_exact(self) -> None: + fixture = Fixture("NVIDIA/yaml-sigil-traits") + fixture.github[ + f"repos/{fixture.policy.repository}/rulesets/104" + ]["enforcement"] = "evaluate" + with self.assertRaisesRegex(PreflightError, "protection drifted"): + fixture.validate() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test_release_workflows.py b/.github/scripts/test_release_workflows.py new file mode 100644 index 0000000..1eda719 --- /dev/null +++ b/.github/scripts/test_release_workflows.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +# SPDX-License-Identifier: Apache-2.0 + +"""Structural security regressions for the mutually exclusive release paths.""" + +from __future__ import annotations + +import json +import re +import unittest +from pathlib import Path + +import release_settings_preflight as settings + + +ROOT = Path(__file__).parents[2] +WORKFLOWS = ROOT / ".github" / "workflows" + + +def read(name: str) -> str: + return (WORKFLOWS / name).read_text(encoding="utf-8") + + +def job_block(workflow: str, name: str) -> str: + jobs = workflow.split("\njobs:\n", 1)[1] + starts = list(re.finditer(r"(?m)^ ([a-z][a-z0-9-]*):\n", jobs)) + for index, match in enumerate(starts): + if match.group(1) == name: + end = starts[index + 1].start() if index + 1 < len(starts) else len(jobs) + return jobs[match.start() : end] + raise AssertionError(f"job {name} is missing") + + +class ReleaseWorkflowTests(unittest.TestCase): + def setUp(self) -> None: + self.publish = read("publish.yml") + self.entrypoint = read("release-pr.yml") + self.reusable = read("release-proposal.yml") + + def test_entrypoints_and_call_only_boundary_are_exact(self) -> None: + self.assertIn(" repository_dispatch:\n types:\n - official-release-published", self.publish) + self.assertNotIn("repository_dispatch:", self.entrypoint) + self.assertIn(" workflow_call:\n", self.reusable) + self.assertNotIn(" push:\n", self.reusable) + self.assertNotIn(" workflow_dispatch:\n", self.reusable) + caller = job_block(self.entrypoint, "proposal") + self.assertIn("uses: ./.github/workflows/release-proposal.yml", caller) + self.assertNotIn("steps:", caller) + receiver = job_block(self.publish, "release-proposal") + self.assertIn("github.event_name == 'repository_dispatch'", receiver) + self.assertIn("github.event.action == 'official-release-published'", receiver) + self.assertIn("source-event: repository_dispatch", receiver) + + def test_dispatch_cannot_enter_publication_or_oidc(self) -> None: + readiness = job_block(self.publish, "release-readiness") + publication = job_block(self.publish, "publication") + self.assertIn("github.event_name == 'workflow_dispatch'", readiness) + self.assertIn("github.event_name == 'workflow_dispatch'", publication) + self.assertNotIn("repository_dispatch", readiness) + self.assertNotIn("repository_dispatch", publication) + self.assertNotIn("environment:", readiness) + self.assertNotIn("id-token:", readiness) + self.assertNotIn("secrets.", readiness) + self.assertRegex( + publication, + r"permissions:\n contents: read\n pull-requests: read\n id-token: write", + ) + self.assertNotIn("permission-contents: write", publication) + + def test_dispatch_preflight_is_checkout_free_and_secretless(self) -> None: + preflight = job_block(self.reusable, "release-notification-preflight") + for forbidden in ( + "environment:", + "secrets.", + "id-token:", + "actions/checkout@", + "create-github-app-token@", + "release-plz", + "cargo ", + "actions/cache@", + ): + self.assertNotIn(forbidden, preflight) + self.assertIn("checks: read", preflight) + self.assertIn("contents: read", preflight) + self.assertIn("pull-requests: read", preflight) + self.assertIn("release_notification_preflight.py", preflight) + self.assertIn("test \"${policy_sha}\" = \"${GITHUB_SHA}\"", preflight) + + def test_every_app_token_job_uses_protected_automation(self) -> None: + protected = [ + (self.reusable, "proposal"), + (self.publish, "release-intent"), + (self.publish, "release-finalizer"), + (self.publish, "release-notification"), + ] + for workflow, name in protected: + with self.subTest(job=name): + block = job_block(workflow, name) + self.assertIn("environment: protected-automation", block) + self.assertIn("create-github-app-token@", block) + notification = job_block(self.publish, "release-notification") + self.assertNotIn("actions/checkout@", notification) + self.assertIn("permission-contents: write", notification) + + def test_tokens_are_minted_after_their_read_only_preflights(self) -> None: + proposal = job_block(self.reusable, "proposal") + self.assertLess( + proposal.index("Recheck official tags and current main before token minting"), + proposal.index("Create repository-scoped proposal token"), + ) + intent = job_block(self.publish, "release-intent") + self.assertLess(intent.index("Recompute release plan"), intent.index("Create checks-only repository token")) + finalizer = job_block(self.publish, "release-finalizer") + self.assertLess( + finalizer.index("Recompute plan and verify protected intent"), + finalizer.index("Create contents-only repository token"), + ) + notification = job_block(self.publish, "release-notification") + self.assertLess( + notification.index("Compile checkout-free typed notifier"), + notification.index("Create notification-only repository token"), + ) + + def test_concurrency_and_replay_inputs_are_static(self) -> None: + concurrency = self.reusable.split("\njobs:\n", 1)[0] + self.assertIn("group: release-proposal-${{ github.repository }}", concurrency) + self.assertIn("cancel-in-progress: false", concurrency) + self.assertNotIn("inputs.", concurrency) + proposal = job_block(self.reusable, "proposal") + self.assertIn("RELEASE_REPLAY_KEY:", proposal) + self.assertIn("steps.release-pr.outputs.release_replay_key", proposal) + + def test_no_release_path_uploads_or_retains_executables(self) -> None: + combined = self.publish + self.entrypoint + self.reusable + for forbidden in ( + "upload-artifact", + "download-artifact", + "docker://", + "actions/cache@", + "cargo install --path", + ): + self.assertNotIn(forbidden, combined) + + def test_admin_setting_digest_matches_read_only_preflight_policy(self) -> None: + repository = settings.require_string( + json.loads( + (ROOT / ".github" / "release-notification-policy.json").read_text( + encoding="utf-8" + ) + )["repository"], + "test repository", + ) + policy = settings.POLICIES[repository] + readiness = job_block(self.publish, "release-readiness") + evidence = readiness.split( + "- name: Bind repository-admin setting evidence to this run", 1 + )[1] + self.assertIn("'yaml-sigil-release-setting-evidence-v1'", evidence) + self.assertIn("'immutable-releases=true'", evidence) + for pattern in policy.tag_patterns: + self.assertIn( + f"'creation={pattern}:Integration:{settings.APP_ID}:always'", + evidence, + ) + self.assertIn(f"'update-delete={pattern}:no-bypass'", evidence) + self.assertIn( + f"'forbidden-required-check={settings.INTENT_NAME}'", + evidence, + ) + self.assertNotIn("PLAN_DIGEST", evidence) + self.assertNotIn("LEGACY_INVENTORY_SHA256", evidence) + self.assertIn("release_sha=${CAPTURED_SHA}", evidence) + self.assertIn("digest=${evidence_digest}", evidence) + + def test_remote_actions_are_full_sha_pinned_with_version_comments(self) -> None: + for workflow_name in ("publish.yml", "release-pr.yml", "release-proposal.yml"): + for line in read(workflow_name).splitlines(): + if "uses:" not in line or "uses: ./" in line: + continue + with self.subTest(workflow=workflow_name, line=line): + self.assertRegex(line, r"uses: [^@\s]+@[0-9a-f]{40} # \S+") + + def test_release_shell_authority_comments_are_adjacent(self) -> None: + for workflow_name in ("publish.yml", "release-proposal.yml"): + lines = read(workflow_name).splitlines() + for index, line in enumerate(lines): + stripped = line.strip() + is_control = (stripped.startswith("if ") or stripped.startswith("case ")) + is_release_plz = stripped.startswith("release-plz ") + if not (is_control or is_release_plz): + continue + previous = next( + (candidate.strip() for candidate in reversed(lines[:index]) if candidate.strip()), + "", + ) + with self.subTest(workflow=workflow_name, line=index + 1): + self.assertTrue(previous.startswith("#"), previous) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7763cd7..5efd78b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,40 @@ jobs: python3 -m unittest discover \ -s .github/scripts -p 'test_*.py' + platform_verifier: + name: Protected checkout verifier (${{ matrix.name }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: Linux + runner: ubuntu-latest + - name: macOS + runner: macos-latest + - name: Windows + runner: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Exercise the checkout verifier on each supported host, including a + # real Windows directory junction rather than a mocked reparse point. + - name: Compile and test protected checkout verifier + shell: bash + run: | + python3 -m py_compile \ + .github/scripts/protected_checkout.py \ + .github/scripts/protected_pr_ci.py \ + .github/scripts/test_protected_pr_ci.py + python3 -m unittest discover \ + -s .github/scripts -p 'test_protected_pr_ci.py' + static_checks: name: Documentation and Protobuf runs-on: linux-amd64-cpu4 @@ -169,6 +203,7 @@ jobs: if: ${{ always() }} needs: - workflow_lint + - platform_verifier - static_checks - rust permissions: {} @@ -178,6 +213,7 @@ jobs: - name: Require successful CI jobs env: GITHUB_ACTIONS_RESULT: ${{ needs.workflow_lint.result }} + PLATFORM_VERIFIER_RESULT: ${{ needs.platform_verifier.result }} STATIC_RESULT: ${{ needs.static_checks.result }} RUST_RESULT: ${{ needs.rust.result }} run: | @@ -193,5 +229,6 @@ jobs: } require_success "GitHub Actions" "${GITHUB_ACTIONS_RESULT}" + require_success "Protected checkout verifier matrix" "${PLATFORM_VERIFIER_RESULT}" require_success "Documentation and Protobuf" "${STATIC_RESULT}" require_success "Rust matrix" "${RUST_RESULT}" diff --git a/.github/workflows/pr-ci-command.yml b/.github/workflows/pr-ci-command.yml index 6473601..8e27522 100644 --- a/.github/workflows/pr-ci-command.yml +++ b/.github/workflows/pr-ci-command.yml @@ -25,6 +25,7 @@ jobs: base_sha: ${{ steps.authorize.outputs.base_sha }} policy_sha: ${{ steps.authorize.outputs.policy_sha }} comment_id: ${{ steps.authorize.outputs.comment_id }} + binding_digest: ${{ steps.authorize.outputs.binding_digest }} steps: # The receiver remains checkout-free. It downloads only the immutable # protected-main controller and configuration selected by this event. @@ -68,22 +69,10 @@ jobs: check_id: ${{ steps.start.outputs.check_id }} external_id: ${{ steps.start.outputs.external_id }} steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - - # The caller owns App credentials and loads only protected-main policy. - # No secret crosses into the reusable candidate workflow. + # Load and compile immutable policy before the App private key is exposed. - name: Load immutable check policy env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} run: | install -d "${RUNNER_TEMP}/protected-pr-ci" @@ -97,6 +86,25 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + - name: Create checks-only GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + + - name: Verify exact App token repository scope + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" verify-app-token + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --repository "${GITHUB_REPOSITORY}" + + # The token exists only in this checkout-free reporting job. - name: Create App-owned in-progress check id: start env: @@ -111,6 +119,7 @@ jobs: --head-sha "${{ needs.authorize.outputs.head_sha }}" --base-sha "${{ needs.authorize.outputs.base_sha }}" --policy-sha "${{ needs.authorize.outputs.policy_sha }}" + --binding-digest "${{ needs.authorize.outputs.binding_digest }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" @@ -132,6 +141,7 @@ jobs: base_sha: ${{ needs.authorize.outputs.base_sha }} policy_sha: ${{ needs.authorize.outputs.policy_sha }} comment_id: ${{ needs.authorize.outputs.comment_id }} + binding_digest: ${{ needs.authorize.outputs.binding_digest }} finish_check: name: Finalize protected Required CI check @@ -149,22 +159,10 @@ jobs: issues: read pull-requests: read steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - - # Finalization reloads immutable policy in the caller, revalidates the - # reusable-call binding, and never checks out candidate content. + # Finalization compiles protected policy before the App key is exposed. - name: Load immutable check policy env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} run: | install -d "${RUNNER_TEMP}/protected-pr-ci" @@ -178,12 +176,32 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + - name: Create checks-only GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + + - name: Verify exact App token repository scope + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" verify-app-token + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --repository "${GITHUB_REPOSITORY}" + + # Results are fixed reusable-workflow output, not candidate-controlled input. - name: Revalidate state and finalize App check env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} APP_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ github.token }} POLICY_SHA: ${{ needs.authorize.outputs.policy_sha }} + RESULTS_JSON: ${{ needs.protected_ci.outputs.results_json }} run: >- python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" finish-check --event "${GITHUB_EVENT_PATH}" @@ -194,11 +212,8 @@ jobs: --head-sha "${{ needs.authorize.outputs.head_sha }}" --base-sha "${{ needs.authorize.outputs.base_sha }}" --policy-sha "${{ needs.authorize.outputs.policy_sha }}" + --binding-digest "${{ needs.authorize.outputs.binding_digest }}" --comment-id "${{ needs.authorize.outputs.comment_id }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" - --result "commit_policy=${{ needs.protected_ci.outputs.commit_policy }}" - --result "workflow_lint=${{ needs.protected_ci.outputs.workflow_lint }}" - --result "static_checks=${{ needs.protected_ci.outputs.static_checks }}" - --result "rust=${{ needs.protected_ci.outputs.rust }}" - --result "candidate_ci=${{ needs.protected_ci.outputs.candidate_ci }}" + --results-json "${RESULTS_JSON}" diff --git a/.github/workflows/pr-ci-reconcile.yml b/.github/workflows/pr-ci-reconcile.yml index 7184a74..8d993f2 100644 --- a/.github/workflows/pr-ci-reconcile.yml +++ b/.github/workflows/pr-ci-reconcile.yml @@ -71,22 +71,10 @@ jobs: issues: read pull-requests: read steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - - # Reconciliation reloads and reruns the current protected-main policy - # before it updates an exact App-owned pending check. + # Reconciliation compiles protected policy before the App key is exposed. - name: Load protected check policy env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} POLICY_SHA: ${{ github.sha }} run: | install -d "${RUNNER_TEMP}/protected-pr-ci" @@ -100,6 +88,24 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + - name: Create checks-only GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + + - name: Verify exact App token repository scope + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" verify-app-token + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --repository "${GITHUB_REPOSITORY}" + - name: Reconcile exact App check env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} @@ -124,20 +130,10 @@ jobs: issues: read pull-requests: read steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-checks: write - permission-contents: read - + # Sweeping compiles protected policy before the App key is exposed. - name: Load protected check policy env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} POLICY_SHA: ${{ github.sha }} run: | install -d "${RUNNER_TEMP}/protected-pr-ci" @@ -151,6 +147,24 @@ jobs: test -s "${RUNNER_TEMP}/protected-pr-ci/policy.json" python3 -m py_compile "${RUNNER_TEMP}/protected-pr-ci/controller.py" + - name: Create checks-only GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + + - name: Verify exact App token repository scope + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: >- + python3 "${RUNNER_TEMP}/protected-pr-ci/controller.py" verify-app-token + --config "${RUNNER_TEMP}/protected-pr-ci/policy.json" + --repository "${GITHUB_REPOSITORY}" + - name: Sweep exact completed runs env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index f378957..c554845 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -23,22 +23,14 @@ on: description: Exact authorization comment. required: true type: string + binding_digest: + description: Exact full authorization binding digest. + required: true + type: string outputs: - commit_policy: - description: Commit-policy job result. - value: ${{ jobs.results.outputs.commit_policy }} - workflow_lint: - description: GitHub Actions validation job result. - value: ${{ jobs.results.outputs.workflow_lint }} - static_checks: - description: Documentation validation job result. - value: ${{ jobs.results.outputs.static_checks }} - rust: - description: Protected Rust validation job result. - value: ${{ jobs.results.outputs.rust }} - candidate_ci: - description: Candidate validation job result. - value: ${{ jobs.results.outputs.candidate_ci }} + results_json: + description: Canonical complete protected-job result inventory. + value: ${{ jobs.results.outputs.results_json }} permissions: {} @@ -99,6 +91,7 @@ jobs: --head-sha "${{ inputs.head_sha }}" --base-sha "${{ inputs.base_sha }}" --policy-sha "${{ inputs.policy_sha }}" + --binding-digest "${{ inputs.binding_digest }}" --comment-id "${{ inputs.comment_id }}" --run-id "${GITHUB_RUN_ID}" --run-attempt "${GITHUB_RUN_ATTEMPT}" @@ -119,13 +112,14 @@ jobs: persist-credentials: false ref: ${{ needs.authorize.outputs.policy_sha }} - - name: Check out exact candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out and verify exact candidate + uses: ./policy/.github/actions/protected-candidate-checkout with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate fetch-depth: 0 - path: candidate - persist-credentials: false - ref: ${{ needs.authorize.outputs.head_sha }} + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy repository: ${{ needs.authorize.outputs.head_repository }} # The fork checkout may not contain the base object. Fetch only the exact @@ -160,13 +154,14 @@ jobs: persist-credentials: false ref: ${{ needs.authorize.outputs.policy_sha }} - - name: Check out exact candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out and verify exact candidate + uses: ./policy/.github/actions/protected-candidate-checkout with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate fetch-depth: 1 - path: candidate - persist-credentials: false - ref: ${{ needs.authorize.outputs.head_sha }} + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy repository: ${{ needs.authorize.outputs.head_repository }} # actionlint discovers configuration and workflows at the workspace root. @@ -220,6 +215,64 @@ jobs: python3 -m unittest discover \ -s candidate/.github/scripts -p 'test_protected_pr_ci.py' + platform_verifier: + name: Protected checkout verifier (${{ matrix.name }}) + needs: authorize + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: Linux + runner: ubuntu-latest + - name: macOS + runner: macos-latest + - name: Windows + runner: windows-latest + steps: + - name: Check out protected policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + path: policy + persist-credentials: false + ref: ${{ needs.authorize.outputs.policy_sha }} + + - name: Check out and verify exact candidate + id: candidate_checkout + uses: ./policy/.github/actions/protected-candidate-checkout + with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate + fetch-depth: 1 + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy + repository: ${{ needs.authorize.outputs.head_repository }} + + # Candidate tests are unprivileged; the interpreter was resolved and + # staged by protected policy before candidate files existed. + - name: Test candidate checkout verifier on host platform + env: + ACTIONS_CACHE_URL: "" + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "" + ACTIONS_ID_TOKEN_REQUEST_URL: "" + ACTIONS_RESULTS_URL: "" + ACTIONS_RUNTIME_TOKEN: "" + GH_TOKEN: "" + GITHUB_TOKEN: "" + TRUSTED_PYTHON: ${{ steps.candidate_checkout.outputs.python }} + shell: bash + run: | + "${TRUSTED_PYTHON}" -m py_compile \ + candidate/.github/scripts/protected_checkout.py \ + candidate/.github/scripts/protected_pr_ci.py \ + candidate/.github/scripts/test_protected_pr_ci.py + "${TRUSTED_PYTHON}" -m unittest discover \ + -s candidate/.github/scripts -p 'test_protected_pr_ci.py' + static_checks: name: Documentation and Protobuf needs: authorize @@ -236,13 +289,14 @@ jobs: persist-credentials: false ref: ${{ needs.authorize.outputs.policy_sha }} - - name: Check out exact candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out and verify exact candidate + uses: ./policy/.github/actions/protected-candidate-checkout with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate fetch-depth: 1 - path: candidate - persist-credentials: false - ref: ${{ needs.authorize.outputs.head_sha }} + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy repository: ${{ needs.authorize.outputs.head_repository }} - name: Lint Markdown with protected configuration @@ -302,13 +356,14 @@ jobs: shell: bash run: git config --global core.autocrlf false - - name: Check out exact candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out and verify exact candidate + uses: ./policy/.github/actions/protected-candidate-checkout with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate fetch-depth: 1 - path: candidate - persist-credentials: false - ref: ${{ needs.authorize.outputs.head_sha }} + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy repository: ${{ needs.authorize.outputs.head_repository }} - name: Remove unused preinstalled Homebrew source @@ -321,6 +376,7 @@ jobs: with: cache: false components: clippy, rustfmt + toolchain: 1.95.0 - name: Install protected Markdown validator # Refresh this immutable pin frequently to keep pace with rumdl releases. @@ -398,13 +454,14 @@ jobs: shell: bash run: git config --global core.autocrlf false - - name: Check out exact candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out and verify exact candidate + uses: ./policy/.github/actions/protected-candidate-checkout with: + base-sha: ${{ needs.authorize.outputs.base_sha }} + candidate-path: candidate fetch-depth: 1 - path: candidate - persist-credentials: false - ref: ${{ needs.authorize.outputs.head_sha }} + head-sha: ${{ needs.authorize.outputs.head_sha }} + policy-path: policy repository: ${{ needs.authorize.outputs.head_repository }} - name: Remove unused preinstalled Homebrew source @@ -442,6 +499,16 @@ jobs: # Only this supplemental, GitHub-hosted job executes the exact candidate # validator. It has no secrets, OIDC, write permission, cache, or retained # artifacts; the protected-main validator remains the required authority. + - name: Verify exact Cargo 1.95 archive metadata + env: + YAML_SIGIL_REQUIRE_CARGO_1_95_ARCHIVE: "1" + shell: bash + run: >- + cargo test --locked --manifest-path xtask/Cargo.toml + crate_archive::tests::cargo_1_95_archive_matches_observed_cross_platform_contract + -- --exact + working-directory: candidate + - name: Run exact candidate validation env: ACTIONS_CACHE_URL: "" @@ -462,6 +529,7 @@ jobs: - authorize - commit_policy - workflow_lint + - platform_verifier - static_checks - rust - candidate_ci @@ -469,11 +537,21 @@ jobs: timeout-minutes: 5 permissions: {} outputs: - commit_policy: ${{ needs.commit_policy.result }} - workflow_lint: ${{ needs.workflow_lint.result }} - static_checks: ${{ needs.static_checks.result }} - rust: ${{ needs.rust.result }} - candidate_ci: ${{ needs.candidate_ci.result }} + results_json: ${{ steps.results.outputs.results_json }} steps: + # Only fixed job-result enums enter this closed, complete JSON inventory. - name: Expose exact candidate results to the protected caller - run: true + id: results + env: + CANDIDATE_CI_RESULT: ${{ needs.candidate_ci.result }} + COMMIT_POLICY_RESULT: ${{ needs.commit_policy.result }} + PLATFORM_VERIFIER_RESULT: ${{ needs.platform_verifier.result }} + RUST_RESULT: ${{ needs.rust.result }} + STATIC_CHECKS_RESULT: ${{ needs.static_checks.result }} + WORKFLOW_LINT_RESULT: ${{ needs.workflow_lint.result }} + run: >- + printf 'results_json={"commit_policy":"%s","workflow_lint":"%s","platform_verifier":"%s","static_checks":"%s","rust":"%s","candidate_ci":"%s"}\n' + "${COMMIT_POLICY_RESULT}" "${WORKFLOW_LINT_RESULT}" + "${PLATFORM_VERIFIER_RESULT}" "${STATIC_CHECKS_RESULT}" + "${RUST_RESULT}" "${CANDIDATE_CI_RESULT}" + >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f9d69b..a191e6a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,7 @@ name: Publish -# This workflow distributes four crates.io source packages and creates -# annotated package tags plus source-only GitHub Releases. It builds, uploads, -# and retains no executable artifact. +# This workflow publishes source crates and creates only annotated tags and +# immutable, zero-asset GitHub Releases. It retains no executable artifact. on: workflow_dispatch: inputs: @@ -14,38 +13,46 @@ on: options: - validate - publish + repository_dispatch: + types: + - official-release-published permissions: {} env: - # Resolve the manifests' explicit `crates-io` publish allowlists by name. + # Resolve the manifest's explicit `crates-io` publish allowlist by name. CARGO_REGISTRIES_CRATES_IO_INDEX: https://github.com/rust-lang/crates.io-index -# Publication is resumable, so never cancel a run that may already have -# uploaded a source package while a replacement waits for approval. +# Publication is resumable and its authenticated receiver is serialized behind +# the originating run. Neither path is payload-controlled. concurrency: group: publish-${{ github.repository }} cancel-in-progress: false jobs: - validation: - name: Validate release - if: ${{ inputs.operation == 'validate' || inputs.operation == 'publish' }} + release-readiness: + name: Validate source-only release readiness + if: >- + ${{ github.event_name == 'workflow_dispatch' && + (inputs.operation == 'validate' || inputs.operation == 'publish') }} runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 75 permissions: contents: read pull-requests: read + outputs: + ruleset_evidence_sha256: ${{ steps.evidence.outputs.ruleset_evidence_sha256 }} steps: - - name: Check out release source + - name: Check out dispatch source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + ref: ${{ github.sha }} - name: Validate exact main invocation run: | - # Official release authority belongs only to exact protected main. + # Release readiness belongs only to the dispatched protected main. if [[ "${GITHUB_REF}" != "refs/heads/main" \ || "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]]; then echo "Official release operations must use exact main." >&2 @@ -66,13 +73,46 @@ jobs: - name: Install and verify release analyzers run: cargo xtask release install-tools - - name: Configure read-only release forge identity + - name: Configure read-only forge identity env: GITHUB_TOKEN: ${{ github.token }} run: cargo xtask github git-identity configure - - name: Require a committed synchronized release version + - name: Discover fresh or recoverable release source + id: discover + env: + GH_TOKEN: ${{ github.token }} + run: >- + cargo xtask github release-train discover + --repository "${GITHUB_REPOSITORY}" + --commit "${GITHUB_SHA}" + + - name: Check out captured release source + if: ${{ steps.discover.outputs.captured_release_sha != github.sha }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.discover.outputs.captured_release_sha }} + + # This is the first command after a recovery checkout and binds it to the + # archive-discovered protected-main ancestor before project execution. + - name: Verify captured source identity + env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} + REGISTRY_STATE: ${{ steps.discover.outputs.registry_state }} run: | + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + # A fresh release must still be exact current main before publication. + if [[ "${REGISTRY_STATE}" = "absent" ]]; then + cargo xtask release require-current-main \ + --head "${CAPTURED_SHA}" \ + --fetch-url "https://github.com/${GITHUB_REPOSITORY}" + fi + + - name: Require a committed release version + run: | + cargo xtask sync-workspace-versions --check cargo xtask release-version check git diff --exit-code @@ -82,14 +122,15 @@ jobs: - name: Prepare the official tagged baseline id: baseline env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: remote.origin.pushurl - GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal + GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-readiness run: | current_version="$(cargo xtask release-version show)" result="${RUNNER_TEMP}/baseline-result.json" cargo xtask release baseline prepare \ - --head "${GITHUB_SHA}" \ + --head "${CAPTURED_SHA}" \ --exclude-version "${current_version}" \ --output "${RUNNER_TEMP}/official-release-baseline" \ --result "${result}" \ @@ -98,8 +139,7 @@ jobs: '"commit=\(.commit)", "manifest=\(.manifest)", "version=\(.version)", - "inventory=\(.inventory)", - "tags=\(.tags | @json)"' \ + "inventory=\(.inventory)"' \ "${result}" >>"${GITHUB_OUTPUT}" - name: Require the tagged baseline on crates.io @@ -123,8 +163,6 @@ jobs: --expected-current-version "${current_version}" \ --intent "${intent}" - # Require the exact dependency-safe package order and reject executable - # targets before any release-plz invocation. - name: Require the exact source-package inventory run: >- cargo xtask release check-packages @@ -133,8 +171,6 @@ jobs: yaml-sigil-signing yaml-sigil-verification - # Patch only unpublished workspace dependencies in a dedicated Cargo - # home so Cargo and release-plz can validate the current source train. - name: Prepare validation-only Cargo configuration run: | validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-release-cargo-home" @@ -142,8 +178,6 @@ jobs: --output "${validation_cargo_home}" printf 'CARGO_HOME=%s\n' "${validation_cargo_home}" >>"${GITHUB_ENV}" - # Keep signing ahead of verification because Cargo resolves the latter's - # signing development dependency during publication preparation. - name: Package with Cargo run: | cargo package --package yaml-sigil-core --all-features @@ -153,55 +187,113 @@ jobs: - name: Require exact merged release authorization env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} GH_TOKEN: ${{ github.token }} run: | cargo xtask github release-source authorize \ --repository "${GITHUB_REPOSITORY}" \ - --commit "${GITHUB_SHA}" \ + --commit "${CAPTURED_SHA}" \ --baseline-version "${{ steps.baseline.outputs.version }}" \ --baseline-commit "${{ steps.baseline.outputs.commit }}" - # Change only release-plz's publication switch and impossible branch - # prefix so it cannot replace the already authorized current checkout. - - name: Prepare checkout-bound release configuration - run: | - cargo xtask release prepare-publication-config \ - --output "${RUNNER_TEMP}/release-plz-publication.toml" + - name: Prepare source-only publication configuration + run: >- + cargo xtask release prepare-publication-config + --output "${RUNNER_TEMP}/release-plz-publication.toml" - name: Validate release-plz publication plan env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} GIT_TOKEN: ${{ github.token }} run: | - cargo xtask release require-current-main \ - --head "${GITHUB_SHA}" \ - --fetch-url "https://github.com/${GITHUB_REPOSITORY}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - # Exercise package selection and forge discovery without OIDC or writes. + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + # Dry-run exercises package selection without OIDC or writes. release-plz release \ --dry-run \ --forge github \ --config "${RUNNER_TEMP}/release-plz-publication.toml" \ --manifest-path Cargo.toml - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + + - name: Capture canonical release plan + id: capture + env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} + GH_TOKEN: ${{ github.token }} + run: >- + cargo xtask github release-train capture + --repository "${GITHUB_REPOSITORY}" + --commit "${CAPTURED_SHA}" + --baseline-version "${{ steps.baseline.outputs.version }}" + --baseline-commit "${{ steps.baseline.outputs.commit }}" + + - name: Verify pinned historical source-only Releases + id: legacy + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 .github/scripts/legacy_release_preflight.py + --inventory .github/legacy-release-inventory.json + --github-output "${GITHUB_OUTPUT}" + + # The protected-environment reviewer performs the fresh repository-admin + # setting readback for this exact run and confirms this displayed digest. + - name: Bind repository-admin setting evidence to this run + id: evidence + env: + CAPTURED_SHA: ${{ steps.capture.outputs.captured_release_sha }} + run: | + evidence_digest="$(printf '%s\0' \ + 'yaml-sigil-release-setting-evidence-v1' \ + "${GITHUB_REPOSITORY}" \ + "${GITHUB_RUN_ID}" \ + "${GITHUB_RUN_ATTEMPT}" \ + "${CAPTURED_SHA}" \ + 'immutable-releases=true' \ + 'creation=refs/tags/yaml-sigil-core-v*:Integration:4653064:always' \ + 'creation=refs/tags/yaml-sigil-transcription-v*:Integration:4653064:always' \ + 'creation=refs/tags/yaml-sigil-signing-v*:Integration:4653064:always' \ + 'creation=refs/tags/yaml-sigil-verification-v*:Integration:4653064:always' \ + 'update-delete=refs/tags/yaml-sigil-core-v*:no-bypass' \ + 'update-delete=refs/tags/yaml-sigil-transcription-v*:no-bypass' \ + 'update-delete=refs/tags/yaml-sigil-signing-v*:no-bypass' \ + 'update-delete=refs/tags/yaml-sigil-verification-v*:no-bypass' \ + 'forbidden-required-check=Release finalization intent' \ + | sha256sum | cut -d' ' -f1)" + printf 'ruleset_evidence_sha256=%s\n' "${evidence_digest}" \ + >>"${GITHUB_OUTPUT}" + echo "::notice title=Repository-admin release evidence::release_sha=${CAPTURED_SHA};run_id=${GITHUB_RUN_ID};run_attempt=${GITHUB_RUN_ATTEMPT};digest=${evidence_digest}" publication: - name: Publish release - if: ${{ inputs.operation == 'publish' }} - needs: validation + name: Publish source crates + if: >- + ${{ github.event_name == 'workflow_dispatch' && + inputs.operation == 'publish' }} + needs: release-readiness runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 75 environment: crates-io permissions: - # Create annotated package tags and source-only Releases after publication. - contents: write + contents: read pull-requests: read id-token: write + outputs: + baseline_commit: ${{ steps.baseline.outputs.commit }} + baseline_version: ${{ steps.baseline.outputs.version }} + captured_release_sha: ${{ steps.capture.outputs.captured_release_sha }} + existing_intent: ${{ steps.capture.outputs.existing_intent }} + existing_intent_check_id: ${{ steps.capture.outputs.existing_intent_check_id }} + existing_intent_digest: ${{ steps.capture.outputs.existing_intent_digest }} + plan: ${{ steps.capture.outputs.plan }} + plan_digest: ${{ steps.capture.outputs.plan_digest }} + registry_state: ${{ steps.capture.outputs.registry_state }} steps: - - name: Check out release source + - name: Check out dispatch source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + ref: ${{ github.sha }} - name: Set up Rust uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 @@ -222,8 +314,29 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: cargo xtask github git-identity configure - - name: Recheck the committed synchronized release version + - name: Rediscover fresh or partial release source + id: discover + env: + GH_TOKEN: ${{ github.token }} + run: >- + cargo xtask github release-train discover + --repository "${GITHUB_REPOSITORY}" + --commit "${GITHUB_SHA}" + + - name: Check out captured release source + if: ${{ steps.discover.outputs.captured_release_sha != github.sha }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.discover.outputs.captured_release_sha }} + + - name: Recheck captured source and committed version + env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} run: | + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + cargo xtask sync-workspace-versions --check cargo xtask release-version check git diff --exit-code @@ -233,14 +346,15 @@ jobs: - name: Prepare the official tagged baseline id: baseline env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} GIT_CONFIG_COUNT: 1 GIT_CONFIG_KEY_0: remote.origin.pushurl - GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal + GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-publication run: | current_version="$(cargo xtask release-version show)" result="${RUNNER_TEMP}/baseline-result.json" cargo xtask release baseline prepare \ - --head "${GITHUB_SHA}" \ + --head "${CAPTURED_SHA}" \ --exclude-version "${current_version}" \ --output "${RUNNER_TEMP}/official-release-baseline" \ --result "${result}" \ @@ -249,21 +363,20 @@ jobs: '"commit=\(.commit)", "manifest=\(.manifest)", "version=\(.version)", - "inventory=\(.inventory)", - "tags=\(.tags | @json)"' \ + "inventory=\(.inventory)"' \ "${result}" >>"${GITHUB_OUTPUT}" - - name: Require the tagged baseline on crates.io - run: >- - cargo xtask release verify-registry - --check-version "${{ steps.baseline.outputs.version }}" - yaml-sigil-core - yaml-sigil-transcription - yaml-sigil-signing - yaml-sigil-verification - - - name: Recheck release API compatibility + - name: Repeat publication authority and package checks + env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} + GH_TOKEN: ${{ github.token }} run: | + cargo xtask release verify-registry \ + --check-version "${{ steps.baseline.outputs.version }}" \ + yaml-sigil-core \ + yaml-sigil-transcription \ + yaml-sigil-signing \ + yaml-sigil-verification current_version="$(cargo xtask release-version show)" intent="$(cargo xtask release-version intent \ --published "${{ steps.baseline.outputs.version }}")" @@ -273,127 +386,438 @@ jobs: --expected-baseline-version "${{ steps.baseline.outputs.version }}" \ --expected-current-version "${current_version}" \ --intent "${intent}" + cargo xtask release check-packages \ + yaml-sigil-core \ + yaml-sigil-transcription \ + yaml-sigil-signing \ + yaml-sigil-verification + validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-publication-check-cargo-home" + cargo xtask release prepare-validation-cargo-home \ + --output "${validation_cargo_home}" + export CARGO_HOME="${validation_cargo_home}" + cargo package --package yaml-sigil-core --all-features + cargo package --package yaml-sigil-transcription + cargo package --package yaml-sigil-signing --all-features + cargo package --package yaml-sigil-verification --all-features + cargo xtask github release-source authorize \ + --repository "${GITHUB_REPOSITORY}" \ + --commit "${CAPTURED_SHA}" \ + --baseline-version "${{ steps.baseline.outputs.version }}" \ + --baseline-commit "${{ steps.baseline.outputs.commit }}" - - name: Recheck the exact source-package inventory - run: >- - cargo xtask release check-packages - yaml-sigil-core - yaml-sigil-transcription - yaml-sigil-signing - yaml-sigil-verification - - # Use a new unpatched Cargo home so the OIDC-authorized job exercises the - # same registry dependency ordering required by a real crates.io upload. - - name: Prepare publication Cargo configuration + - name: Prepare source-only publication configuration run: | publication_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-publication-cargo-home" cargo xtask release prepare-publication-cargo-home \ --output "${publication_cargo_home}" printf 'CARGO_HOME=%s\n' "${publication_cargo_home}" >>"${GITHUB_ENV}" - - # Change only release-plz's publication switch and impossible branch - # prefix so it cannot replace the already authorized current checkout. - - name: Prepare checkout-bound release configuration - run: | cargo xtask release prepare-publication-config \ --output "${RUNNER_TEMP}/release-plz-publication.toml" - - name: Publish with Trusted Publishing + - name: Capture canonical publication plan + id: capture env: + CAPTURED_SHA: ${{ steps.discover.outputs.captured_release_sha }} GH_TOKEN: ${{ github.token }} - GIT_TOKEN: ${{ github.token }} + REGISTRY_STATE: ${{ steps.discover.outputs.registry_state }} run: | - version="$(cargo xtask release-version show)" - set +e - cargo xtask release verify-registry \ - --check-version "${version}" \ - yaml-sigil-core \ - yaml-sigil-transcription \ - yaml-sigil-signing \ - yaml-sigil-verification - registry_status="$?" - set -e - cargo xtask github release-source authorize \ + # A fresh plan is captured only from exact current protected main. + if [[ "${REGISTRY_STATE}" = "absent" ]]; then + cargo xtask release require-current-main \ + --head "${CAPTURED_SHA}" \ + --fetch-url "https://github.com/${GITHUB_REPOSITORY}" + fi + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + cargo xtask github release-train capture \ --repository "${GITHUB_REPOSITORY}" \ - --commit "${GITHUB_SHA}" \ + --commit "${CAPTURED_SHA}" \ --baseline-version "${{ steps.baseline.outputs.version }}" \ --baseline-commit "${{ steps.baseline.outputs.commit }}" - GIT_CONFIG_COUNT=1 \ - GIT_CONFIG_KEY_0=remote.origin.pushurl \ - GIT_CONFIG_VALUE_0=disabled://yaml-sigil-release-proposal \ - cargo xtask release baseline verify \ - --head "${GITHUB_SHA}" \ - --inventory "${{ steps.baseline.outputs.inventory }}" \ - --expected-fetch-url "https://github.com/${GITHUB_REPOSITORY}" - cargo xtask release require-current-main \ - --head "${GITHUB_SHA}" \ - --fetch-url "https://github.com/${GITHUB_REPOSITORY}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - # Complete publication selects metadata recovery. Any absent package - # permits a fresh or fail-closed resumed Trusted Publishing attempt. - case "${registry_status}" in - 0) - cargo xtask github release-objects reconcile \ - --mode recover \ - --repository "${GITHUB_REPOSITORY}" \ - --version "${version}" \ - --commit "${GITHUB_SHA}" + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + + - name: Recheck pinned historical source-only Releases + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 .github/scripts/legacy_release_preflight.py + --inventory .github/legacy-release-inventory.json + --github-output "${GITHUB_OUTPUT}" + + - name: Publish missing source package and await exact registry state + id: publish + env: + CAPTURED_SHA: ${{ steps.capture.outputs.captured_release_sha }} + GH_TOKEN: ${{ github.token }} + GIT_TOKEN: ${{ github.token }} + PLAN: ${{ steps.capture.outputs.plan }} + PLAN_DIGEST: ${{ steps.capture.outputs.plan_digest }} + REGISTRY_STATE: ${{ steps.capture.outputs.registry_state }} + run: | + publisher_status=0 + # Complete recovery performs no registry mutation; other prefixes may. + case "${REGISTRY_STATE}" in + complete) echo "All exact crates are published; skipping registry mutation." ;; - 3) - cargo xtask github release-objects reconcile \ - --mode prepublish \ - --repository "${GITHUB_REPOSITORY}" \ - --version "${version}" \ - --commit "${GITHUB_SHA}" - cargo xtask release require-current-main \ - --head "${GITHUB_SHA}" \ - --fetch-url "https://github.com/${GITHUB_REPOSITORY}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - # Exchange only this protected job's OIDC identity, publish the - # source packages, and create annotated tags and Releases. + absent|partial) + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + set +e + # Pinned release-plz has source-crate publication authority only. release-plz release \ --forge github \ --config "${RUNNER_TEMP}/release-plz-publication.toml" \ --manifest-path Cargo.toml - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + publisher_status="$?" + set -e + test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" ;; *) - echo "The exact crates.io publication state is invalid." >&2 + echo "The captured registry state is invalid." >&2 exit 1 ;; esac + cargo xtask github release-train wait \ + --repository "${GITHUB_REPOSITORY}" \ + --plan "${PLAN}" \ + --plan-digest "${PLAN_DIGEST}" + # Exact complete registry state, not the publisher exit alone, wins. + if [[ "${publisher_status}" -ne 0 ]]; then + echo "release-plz exited ${publisher_status}; exact registry confirmation recovered the run." + fi + printf 'publisher_status=%s\n' "${publisher_status}" >>"${GITHUB_OUTPUT}" + + release-intent: + name: Attest release finalization intent + if: >- + ${{ always() && needs.publication.result == 'success' && + needs.publication.outputs.plan != '' }} + needs: + - release-readiness + - publication + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: protected-automation + permissions: + checks: read + contents: read + pull-requests: read + outputs: + binary_sha256: ${{ steps.compile.outputs.binary_sha256 }} + intent: ${{ steps.create.outputs.intent }} + intent_check_id: ${{ steps.create.outputs.intent_check_id }} + intent_digest: ${{ steps.create.outputs.intent_digest }} + intent_external_id: ${{ steps.create.outputs.intent_external_id }} + steps: + - name: Check out captured trusted source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.publication.outputs.captured_release_sha }} + + - name: Verify exact captured checkout + env: + CAPTURED_SHA: ${{ needs.publication.outputs.captured_release_sha }} + run: test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + cache: false + toolchain: 1.95.0 - - name: Verify crates.io publication + - name: Recompute release plan + env: + GH_TOKEN: ${{ github.token }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} run: >- - cargo xtask release verify-registry - yaml-sigil-core - yaml-sigil-transcription - yaml-sigil-signing - yaml-sigil-verification + cargo xtask github release-train verify + --repository "${GITHUB_REPOSITORY}" + --plan "${PLAN}" + --plan-digest "${PLAN_DIGEST}" + --baseline-version "${{ needs.publication.outputs.baseline_version }}" + --baseline-commit "${{ needs.publication.outputs.baseline_commit }}" + + - name: Compile and bind typed intent attestor + id: compile + env: + EXISTING_INTENT: ${{ needs.publication.outputs.existing_intent }} + EXISTING_INTENT_CHECK_ID: ${{ needs.publication.outputs.existing_intent_check_id }} + EXISTING_INTENT_DIGEST: ${{ needs.publication.outputs.existing_intent_digest }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} + RULESET_EVIDENCE_SHA256: ${{ needs.release-readiness.outputs.ruleset_evidence_sha256 }} + run: | + cargo build --locked --release --manifest-path xtask/Cargo.toml + install -m 700 xtask/target/release/xtask \ + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + binary_sha256="$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" + printf 'binary_sha256=%s\n' "${binary_sha256}" >>"${GITHUB_OUTPUT}" + # Preserve a durable original intent; otherwise create this run's intent. + case "${EXISTING_INTENT}" in + '') + "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + github release-train prepare-intent \ + --plan "${PLAN}" \ + --plan-digest "${PLAN_DIGEST}" \ + --origin-run-id "${GITHUB_RUN_ID}" \ + --origin-run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --ruleset-evidence-sha256 "${RULESET_EVIDENCE_SHA256}" + ;; + *) + test "$(printf '%s' "${EXISTING_INTENT}" | sha256sum | cut -d' ' -f1)" \ + = "${EXISTING_INTENT_DIGEST}" + printf 'intent=%s\n' "${EXISTING_INTENT}" >>"${GITHUB_OUTPUT}" + printf 'intent_digest=%s\n' "${EXISTING_INTENT_DIGEST}" >>"${GITHUB_OUTPUT}" + printf 'intent_check_id=%s\n' "${EXISTING_INTENT_CHECK_ID}" >>"${GITHUB_OUTPUT}" + ;; + esac + rm -rf "${GITHUB_WORKSPACE}" + mkdir -m 700 "${GITHUB_WORKSPACE}" + test "$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" = "${binary_sha256}" + + - name: Create checks-only repository token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-checks: write + + - name: Create or verify durable intent Check + id: create + working-directory: ${{ runner.temp }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INTENT: ${{ steps.compile.outputs.intent }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} + run: >- + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + github release-train create-intent + --repository "${GITHUB_REPOSITORY}" + --plan "${PLAN}" + --plan-digest "${PLAN_DIGEST}" + --intent "${INTENT}" + --expected-app-slug "${{ steps.app-token.outputs.app-slug }}" + --expected-installation-id "${{ steps.app-token.outputs.installation-id }}" + + release-finalizer: + name: Create immutable source-only release objects + if: ${{ needs.release-intent.result == 'success' }} + needs: + - publication + - release-intent + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: protected-automation + permissions: + checks: read + contents: read + pull-requests: read + outputs: + binary_sha256: ${{ steps.compile.outputs.binary_sha256 }} + complete: ${{ steps.finalize.outputs.complete }} + finalized_entries: ${{ steps.finalize.outputs.finalized_entries }} + steps: + - name: Check out captured trusted source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.publication.outputs.captured_release_sha }} - - name: Verify or recover source-only release objects + - name: Verify exact captured checkout + env: + CAPTURED_SHA: ${{ needs.publication.outputs.captured_release_sha }} + run: test "$(git rev-parse HEAD)" = "${CAPTURED_SHA}" + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + cache: false + toolchain: 1.95.0 + + - name: Recompute plan and verify protected intent env: GH_TOKEN: ${{ github.token }} + INTENT: ${{ needs.release-intent.outputs.intent }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} run: | - version="$(cargo xtask release-version show)" - cargo xtask release require-current-main \ - --head "${GITHUB_SHA}" \ - --fetch-url "https://github.com/${GITHUB_REPOSITORY}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - # Recovery can create only missing annotated tags and Releases after - # it independently rechecks every exact non-yanked registry package. - cargo xtask github release-objects reconcile \ - --mode recover \ + cargo xtask github release-train verify \ + --repository "${GITHUB_REPOSITORY}" \ + --plan "${PLAN}" \ + --plan-digest "${PLAN_DIGEST}" \ + --baseline-version "${{ needs.publication.outputs.baseline_version }}" \ + --baseline-commit "${{ needs.publication.outputs.baseline_commit }}" + cargo xtask github release-train verify-intent \ --repository "${GITHUB_REPOSITORY}" \ - --version "${version}" \ - --commit "${GITHUB_SHA}" + --plan "${PLAN}" \ + --plan-digest "${PLAN_DIGEST}" \ + --intent "${INTENT}" \ + --check-id "${{ needs.release-intent.outputs.intent_check_id }}" + + - name: Recheck pinned historical source-only Releases + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + python3 .github/scripts/legacy_release_preflight.py + --inventory .github/legacy-release-inventory.json + --github-output "${GITHUB_OUTPUT}" - - name: Request the next release proposal + - name: Compile and bind checkout-independent finalizer + id: compile + run: | + cargo build --locked --release --manifest-path xtask/Cargo.toml + install -m 700 xtask/target/release/xtask \ + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + binary_sha256="$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" + printf 'binary_sha256=%s\n' "${binary_sha256}" >>"${GITHUB_OUTPUT}" + rm -rf "${GITHUB_WORKSPACE}" + mkdir -m 700 "${GITHUB_WORKSPACE}" + test "$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" = "${binary_sha256}" + + - name: Create contents-only repository token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Finalize exact published registry prefix + id: finalize + working-directory: ${{ runner.temp }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INTENT: ${{ needs.release-intent.outputs.intent }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} + run: >- + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + github release-train finalize + --repository "${GITHUB_REPOSITORY}" + --plan "${PLAN}" + --plan-digest "${PLAN_DIGEST}" + --intent "${INTENT}" + --expected-app-slug "${{ steps.app-token.outputs.app-slug }}" + --expected-installation-id "${{ steps.app-token.outputs.installation-id }}" + + release-notification: + name: Emit complete release train notification + if: >- + ${{ needs.release-finalizer.result == 'success' && + needs.release-finalizer.outputs.complete == 'true' }} + needs: + - publication + - release-intent + - release-finalizer + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: protected-automation + permissions: + contents: read + steps: + # Bootstrap exact attested protected source without a repository checkout, + # compile before token minting, and require finalizer binary parity. + - name: Compile checkout-free typed notifier env: GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ needs.publication.outputs.captured_release_sha }} run: | - version="$(cargo xtask release-version show)" - jq --null-input --arg version "${version}" \ - '{event_type: "official-release-published", client_payload: {version: $version}}' \ - | gh api --method POST "repos/${GITHUB_REPOSITORY}/dispatches" --input - + set -euo pipefail + source_archive="${RUNNER_TEMP}/yaml-sigil-release-source.tar.gz" + gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/tarball/${RELEASE_SHA}" \ + >"${source_archive}" + test "$(wc -c <"${source_archive}")" -gt 0 + test "$(wc -c <"${source_archive}")" -le 33554432 + inventory="${RUNNER_TEMP}/yaml-sigil-release-source.inventory" + tar -tzf "${source_archive}" >"${inventory}" + test "$(wc -l <"${inventory}")" -le 10000 + source_prefix="$(head -n 1 "${inventory}" | cut -d/ -f1)" + test "${source_prefix}" = \ + "NVIDIA-yaml-sigil-rs-${RELEASE_SHA:0:7}" + while IFS= read -r path; do + # Every archive member must remain under the one GitHub source root. + case "${path}" in + "${source_prefix}"|"${source_prefix}/"*) ;; + *) + echo "Source archive contains an unexpected path." >&2 + exit 1 + ;; + esac + [[ "${path}" != *"/../"* ]] + done <"${inventory}" + bootstrap="${RUNNER_TEMP}/yaml-sigil-release-bootstrap" + mkdir -m 700 "${bootstrap}" + tar -xzf "${source_archive}" --no-same-owner --no-same-permissions \ + -C "${bootstrap}" + source_root="${bootstrap}/${source_prefix}" + cargo +1.95.0 build --locked --release \ + --manifest-path "${source_root}/xtask/Cargo.toml" + install -m 700 "${source_root}/xtask/target/release/xtask" \ + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + binary_sha256="$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" + rm -rf "${bootstrap}" "${source_archive}" "${inventory}" + test "$(sha256sum "${RUNNER_TEMP}/yaml-sigil-release-xtask" \ + | cut -d' ' -f1)" = "${binary_sha256}" + + - name: Create notification-only repository token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + + - name: Dispatch exact complete release train + working-directory: ${{ runner.temp }} + env: + FINALIZED_ENTRIES: ${{ needs.release-finalizer.outputs.finalized_entries }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INTENT: ${{ needs.release-intent.outputs.intent }} + PLAN: ${{ needs.publication.outputs.plan }} + PLAN_DIGEST: ${{ needs.publication.outputs.plan_digest }} + run: >- + "${RUNNER_TEMP}/yaml-sigil-release-xtask" + github release-train notify + --repository "${GITHUB_REPOSITORY}" + --plan "${PLAN}" + --plan-digest "${PLAN_DIGEST}" + --intent "${INTENT}" + --intent-check-id "${{ needs.release-intent.outputs.intent_check_id }}" + --finalized-entries "${FINALIZED_ENTRIES}" + --expected-app-slug "${{ steps.app-token.outputs.app-slug }}" + --expected-installation-id "${{ steps.app-token.outputs.installation-id }}" + + release-proposal: + name: Receive authenticated release notification + if: >- + ${{ github.event_name == 'repository_dispatch' && + github.event.action == 'official-release-published' }} + permissions: + checks: read + contents: read + pull-requests: read + uses: ./.github/workflows/release-proposal.yml + with: + source-event: repository_dispatch + mode: next-candidate + bump: patch + secrets: inherit diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index a5743b9..441639c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -4,9 +4,6 @@ on: push: branches: - main - repository_dispatch: - types: - - official-release-published workflow_dispatch: inputs: mode: @@ -27,338 +24,18 @@ on: - minor - major -permissions: - contents: read - -env: - # Resolve the exact external traits package from the named crates.io index. - CARGO_REGISTRIES_CRATES_IO_INDEX: https://github.com/rust-lang/crates.io-index - -concurrency: - group: release-proposal-${{ github.repository }} - cancel-in-progress: true +permissions: {} jobs: - app_pending: - name: GitHub App pending - if: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID == '' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Explain deferred activation - run: >- - echo "Release proposal automation remains inactive until the - organization-owned GitHub App is installed and configured." - proposal: - name: Prepare release proposal - if: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID != '' }} - runs-on: ubuntu-latest - timeout-minutes: 60 - environment: protected-automation + name: Run trusted proposal workflow permissions: + checks: read contents: read pull-requests: read - steps: - - name: Create least-privilege GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} - private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - permission-pull-requests: write - - - name: Check out main - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.sha }} - - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 - with: - cache: false - toolchain: 1.95.0 - - - name: Bind repository and workflow identity - env: - GITHUB_TOKEN: ${{ github.token }} - run: cargo xtask github git-identity configure - - - name: Install exact cargo-binstall - uses: cargo-bins/cargo-binstall@732870f031d2fb36309d0deaf36abcc704a7be65 # v1.20.1 - with: - version: 1.20.1 - - - name: Install and verify release analyzers - run: cargo xtask release install-tools - - - name: Require current main - run: | - # Manual proposals must use the protected default branch as source. - if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then - echo "Release proposals must be dispatched from main." >&2 - exit 1 - fi - # Bind the generated proposal to the event's exact main commit. - if [[ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]]; then - echo "The checked-out main commit does not match the triggering commit." >&2 - exit 1 - fi - # release-plz requires an attached branch with an upstream to resolve - # repository history. Keep the event-bound commit while attaching it. - git switch --force-create main "${GITHUB_SHA}" - git branch --set-upstream-to=origin/main main - # Branch attachment must preserve the exact event source commit. - if [[ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]]; then - echo "Attaching main changed the triggering commit." >&2 - exit 1 - fi - - - name: Require exact traits availability and source - run: cargo xtask release verify-traits - - # Do not advance a release proposal while any crate version integrated on - # main is awaiting publication. This orders merge before publication. - - name: Require the current source version on crates.io - id: published - run: | - version="$(cargo xtask release-version show)" - echo "version=${version}" >>"${GITHUB_OUTPUT}" - # Exit 3 means at least one crate is awaiting publication. That is an - # ordered wait state, while any other error must fail the run. - if cargo xtask release verify-registry \ - --check-version "${version}" \ - yaml-sigil-core \ - yaml-sigil-transcription \ - yaml-sigil-signing \ - yaml-sigil-verification; then - echo "available=true" >>"${GITHUB_OUTPUT}" - else - status="$?" - # Registry failures are errors; only exact absence is a wait state. - if [[ "${status}" -ne 3 ]]; then - exit "${status}" - fi - echo "available=false" >>"${GITHUB_OUTPUT}" - echo "${version} is not published for every crate; no later proposal is created." - fi - - # Manual dispatches may revise the App proposal with explicit intent. - # Background events seed one patch proposal but never revise it. - - name: Resolve release mode and bump intent - id: intent - if: ${{ steps.published.outputs.available == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - MANUAL_DISPATCH: ${{ github.event_name == 'workflow_dispatch' }} - REQUESTED_BUMP: ${{ inputs.bump || 'patch' }} - REQUESTED_MODE: ${{ inputs.mode || 'next-candidate' }} - run: cargo xtask github release-pr resolve-intent - - - name: Prepare the official tagged baseline - id: baseline - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - GIT_CONFIG_COUNT: 1 - GIT_CONFIG_KEY_0: remote.origin.pushurl - GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal - run: | - result="${RUNNER_TEMP}/baseline-result.json" - cargo xtask release baseline prepare \ - --version "${{ steps.published.outputs.version }}" \ - --head "${GITHUB_SHA}" \ - --output "${RUNNER_TEMP}/official-release-baseline" \ - --result "${result}" \ - --expected-fetch-url "https://github.com/${GITHUB_REPOSITORY}" - jq --raw-output \ - '"commit=\(.commit)", - "manifest=\(.manifest)", - "version=\(.version)", - "inventory=\(.inventory)", - "tags=\(.tags | @json)"' \ - "${result}" >>"${GITHUB_OUTPUT}" - - - name: Generate and validate release transaction - id: generate - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - GIT_TOKEN: ${{ github.token }} - GIT_CONFIG_COUNT: 1 - GIT_CONFIG_KEY_0: remote.origin.pushurl - GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal - run: | - result="${RUNNER_TEMP}/proposal-result.json" - cargo xtask release proposal generate \ - --mode "${{ steps.intent.outputs.mode }}" \ - --bump "${{ steps.intent.outputs.bump }}" \ - --published-version "${{ steps.published.outputs.version }}" \ - --registry-manifest "${{ steps.baseline.outputs.manifest }}" \ - --date "$(date -u +%F)" \ - --result "${result}" - jq --raw-output \ - '"target=\(.target)", "substantive=\(.substantive)"' \ - "${result}" >>"${GITHUB_OUTPUT}" - - # Exercise every provider-neutral generated-source gate before the App - # may change a Git object, branch, or pull request. - - name: Validate generated release source before mutation - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - run: | - cargo xtask sync-workspace-versions --check - cargo xtask release-version check - cargo xtask release verify-traits - cargo xtask release check-packages \ - yaml-sigil-core \ - yaml-sigil-transcription \ - yaml-sigil-signing \ - yaml-sigil-verification - current_version="$(cargo xtask release-version show)" - cargo xtask release-version check-compatibility \ - --baseline-manifest "${{ steps.baseline.outputs.manifest }}" \ - --current-manifest Cargo.toml \ - --expected-baseline-version "${{ steps.baseline.outputs.version }}" \ - --expected-current-version "${current_version}" \ - --intent "${{ steps.intent.outputs.bump }}" - cargo metadata --no-deps --format-version 1 >/dev/null - validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-precommit-cargo-home" - cargo xtask release prepare-validation-cargo-home \ - --output "${validation_cargo_home}" - export CARGO_HOME="${validation_cargo_home}" - # Keep signing ahead of verification while validating the transaction. - cargo package --package yaml-sigil-core --all-features - cargo package --package yaml-sigil-transcription - cargo package --package yaml-sigil-signing --all-features - cargo package --package yaml-sigil-verification --all-features - git diff --check - - # Proposal analysis cannot authorize a later tag or main state. Recheck - # the complete persisted authority immediately before the App writes. - - name: Recheck official tags and current main before mutation - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - GIT_CONFIG_COUNT: 1 - GIT_CONFIG_KEY_0: remote.origin.pushurl - GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal - run: | - cargo xtask release baseline verify \ - --head "${GITHUB_SHA}" \ - --inventory "${{ steps.baseline.outputs.inventory }}" \ - --expected-fetch-url "https://github.com/${GITHUB_REPOSITORY}" - - # GitHub signs Git Database commits created with the App installation - # token. The helper derives the bot's DCO identity, rejects foreign - # branch commits, and keeps the generated file set narrow. - - name: Create or update release pull request - id: release-pr - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - RELEASE_BRANCH: release-plz-next - RELEASE_HOLD_DRAFT: "true" - RELEASE_SUBSTANTIVE: ${{ steps.generate.outputs.substantive }} - RELEASE_TARGET: ${{ steps.generate.outputs.target }} - run: cargo xtask github release-pr apply --phase update - - - name: Check out generated Verified commit - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ steps.release-pr.outputs.commit_sha }} - - - name: Attach generated release branch - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - RELEASE_BRANCH: release-plz-next - RELEASE_COMMIT: ${{ steps.release-pr.outputs.commit_sha }} - run: | - # release-plz needs an attached branch and upstream. Bind both local - # refs to the exact App commit already verified by the helper. - git update-ref "refs/remotes/origin/${RELEASE_BRANCH}" "${RELEASE_COMMIT}" - git switch --force-create "${RELEASE_BRANCH}" "${RELEASE_COMMIT}" - git branch --set-upstream-to="origin/${RELEASE_BRANCH}" "${RELEASE_BRANCH}" - # Branch attachment must preserve the exact generated commit twice. - if [[ "$(git rev-parse HEAD)" != "${RELEASE_COMMIT}" \ - || "$(git rev-parse '@{upstream}')" != "${RELEASE_COMMIT}" ]]; then - echo "Attaching the release branch changed the generated commit." >&2 - exit 1 - fi - - - name: Recheck clean generated release source - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - GIT_TOKEN: ${{ github.token }} - run: | - cargo xtask sync-workspace-versions --check - cargo xtask release-version check - test -z "$(git status --porcelain --untracked-files=no)" - cargo xtask release verify-traits - cargo xtask release check-packages \ - yaml-sigil-core \ - yaml-sigil-transcription \ - yaml-sigil-signing \ - yaml-sigil-verification - current_version="$(cargo xtask release-version show)" - cargo xtask release-version check-compatibility \ - --baseline-manifest "${{ steps.baseline.outputs.manifest }}" \ - --current-manifest Cargo.toml \ - --expected-baseline-version "${{ steps.baseline.outputs.version }}" \ - --expected-current-version "${current_version}" \ - --intent "${{ steps.intent.outputs.bump }}" - cargo metadata --no-deps --format-version 1 >/dev/null - validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-proposal-cargo-home" - cargo xtask release prepare-validation-cargo-home \ - --output "${validation_cargo_home}" - export CARGO_HOME="${validation_cargo_home}" - # Keep signing ahead of verification while validating the transaction. - cargo package --package yaml-sigil-core --all-features - cargo package --package yaml-sigil-transcription - cargo package --package yaml-sigil-signing --all-features - cargo package --package yaml-sigil-verification --all-features - # The read-only job token permits forge checks; dry-run cannot publish. - release-plz release \ - --dry-run \ - --forge github \ - --config .release-plz.toml \ - --manifest-path Cargo.toml - test -z "$(git status --porcelain --untracked-files=no)" - - # Change review state only after the exact App commit passes the - # association-dependent release-plz dry run and every repeated source gate. - - name: Finalize validated release pull request - if: >- - ${{ steps.published.outputs.available == 'true' && - steps.intent.outputs.proceed == 'true' }} - env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - RELEASE_BRANCH: release-plz-next - RELEASE_COMMIT: ${{ steps.release-pr.outputs.commit_sha }} - RELEASE_HOLD_DRAFT: "true" - RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.pr_number }} - RELEASE_SUBSTANTIVE: ${{ steps.generate.outputs.substantive }} - RELEASE_TARGET: ${{ steps.generate.outputs.target }} - run: cargo xtask github release-pr apply --phase finalize + uses: ./.github/workflows/release-proposal.yml + with: + source-event: ${{ github.event_name }} + mode: ${{ inputs.mode || 'next-candidate' }} + bump: ${{ inputs.bump || 'patch' }} + secrets: inherit diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml new file mode 100644 index 0000000..d2ff7d6 --- /dev/null +++ b/.github/workflows/release-proposal.yml @@ -0,0 +1,492 @@ +name: Release proposal (call only) + +on: + workflow_call: + inputs: + source-event: + description: Exact trusted caller event. + required: true + type: string + mode: + description: Create the next RC or promote the published RC. + required: true + type: string + bump: + description: Requested next-candidate version line. + required: true + type: string + +permissions: {} + +env: + # Resolve the exact external traits package from the named crates.io index. + CARGO_REGISTRIES_CRATES_IO_INDEX: https://github.com/rust-lang/crates.io-index + +# Serialize all proposal entrypoints on one repository-owned durable branch. +concurrency: + group: release-proposal-${{ github.repository }} + cancel-in-progress: false + +jobs: + release-notification-preflight: + name: Authenticate release notification + if: ${{ inputs.source-event == 'repository_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + checks: read + contents: read + pull-requests: read + outputs: + replay_key: ${{ steps.verify.outputs.replay_key }} + replay_state: ${{ steps.verify.outputs.replay_state }} + policy_sha: ${{ steps.verify.outputs.policy_sha }} + steps: + # The receiver executes no checkout or candidate code. Fetch the bounded + # verifier and policy from exact current main and verify each Git blob. + - name: Stage and run protected notification verifier + id: verify + env: + GITHUB_TOKEN: ${{ github.token }} + SOURCE_EVENT: ${{ inputs.source-event }} + run: | + set -euo pipefail + test "${SOURCE_EVENT}" = "repository_dispatch" + test "${GITHUB_EVENT_NAME}" = "repository_dispatch" + + trusted_gh="$(command -v gh)" + trusted_jq="$(command -v jq)" + trusted_base64="$(command -v base64)" + trusted_git="$(command -v git)" + trusted_python="$(command -v python3)" + test -x "${trusted_gh}" + test -x "${trusted_jq}" + test -x "${trusted_base64}" + test -x "${trusted_git}" + test -x "${trusted_python}" + + repository_json="$(${trusted_gh} api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}")" + test "$(printf '%s' "${repository_json}" | wc -c)" -le 2097152 + test "$(printf '%s' "${repository_json}" | ${trusted_jq} -r '.full_name')" \ + = "${GITHUB_REPOSITORY}" + test "$(printf '%s' "${repository_json}" | ${trusted_jq} -r '.default_branch')" \ + = "main" + + main_ref="$(${trusted_gh} api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/git/ref/heads/main")" + test "$(printf '%s' "${main_ref}" | wc -c)" -le 2097152 + policy_sha="$(printf '%s' "${main_ref}" | ${trusted_jq} -r \ + 'select(.ref == "refs/heads/main" and .object.type == "commit") | .object.sha')" + [[ "${policy_sha}" =~ ^[0-9a-f]{40}$ ]] + # The call-only workflow must itself be the exact current-main policy. + test "${policy_sha}" = "${GITHUB_SHA}" + + protected_root="${RUNNER_TEMP}/yaml-sigil-release-notification" + mkdir -m 700 "${protected_root}" + + fetch_protected_file() { + source_path="$1" + destination="$2" + maximum="$3" + response="$(${trusted_gh} api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/contents/${source_path}?ref=${policy_sha}")" + test "$(printf '%s' "${response}" | wc -c)" -le 2097152 + declared_size="$(printf '%s' "${response}" | ${trusted_jq} -r \ + 'select(.type == "file" and .encoding == "base64") | .size')" + blob_sha="$(printf '%s' "${response}" | ${trusted_jq} -r \ + 'select(.type == "file" and .encoding == "base64") | .sha')" + [[ "${declared_size}" =~ ^[0-9]+$ ]] + test "${declared_size}" -gt 0 + test "${declared_size}" -le "${maximum}" + [[ "${blob_sha}" =~ ^[0-9a-f]{40}$ ]] + printf '%s' "${response}" | ${trusted_jq} -r '.content' \ + | ${trusted_base64} --decode >"${destination}" + test "$(wc -c <"${destination}")" = "${declared_size}" + test "$(${trusted_git} hash-object "${destination}")" = "${blob_sha}" + chmod 600 "${destination}" + } + + fetch_protected_file \ + ".github/scripts/release_notification_preflight.py" \ + "${protected_root}/preflight.py" 131072 + fetch_protected_file \ + ".github/release-notification-policy.json" \ + "${protected_root}/policy.json" 65536 + + env PATH=/usr/local/bin:/usr/bin:/bin PYTHONDONTWRITEBYTECODE=1 \ + "${trusted_python}" -m py_compile "${protected_root}/preflight.py" + env PATH=/usr/local/bin:/usr/bin:/bin PYTHONDONTWRITEBYTECODE=1 \ + GITHUB_TOKEN="${GITHUB_TOKEN}" \ + "${trusted_python}" "${protected_root}/preflight.py" \ + --event "${GITHUB_EVENT_PATH}" \ + --config "${protected_root}/policy.json" \ + --github-output "${GITHUB_OUTPUT}" \ + --repository "${GITHUB_REPOSITORY}" \ + --policy-sha "${policy_sha}" + printf 'policy_sha=%s\n' "${policy_sha}" >>"${GITHUB_OUTPUT}" + + trusted-event-preflight: + name: Authorize trusted proposal event + if: ${{ inputs.source-event != 'repository_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Bind push or manual dispatch to exact current main + env: + GH_TOKEN: ${{ github.token }} + SOURCE_EVENT: ${{ inputs.source-event }} + run: | + set -euo pipefail + # Keep trusted push and manual authorization separate from dispatch. + case "${SOURCE_EVENT}" in + push|workflow_dispatch) + test "${GITHUB_EVENT_NAME}" = "${SOURCE_EVENT}" + ;; + *) + echo "Unsupported proposal caller event." >&2 + exit 1 + ;; + esac + test "${GITHUB_REF}" = "refs/heads/main" + main_sha="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ + --jq 'select(.ref == "refs/heads/main" and .object.type == "commit") | .object.sha')" + test "${main_sha}" = "${GITHUB_SHA}" + + app-pending: + name: GitHub App pending + needs: + - release-notification-preflight + - trusted-event-preflight + if: >- + ${{ always() && vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID == '' && + ((inputs.source-event == 'repository_dispatch' && + needs.release-notification-preflight.result == 'success') || + (inputs.source-event != 'repository_dispatch' && + needs.trusted-event-preflight.result == 'success')) }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Explain deferred activation + run: >- + echo "Release proposal automation remains inactive until the + repository-scoped GitHub App is configured." + + proposal: + name: Prepare release proposal + needs: + - release-notification-preflight + - trusted-event-preflight + if: >- + ${{ always() && vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID != '' && + ((inputs.source-event == 'repository_dispatch' && + needs.release-notification-preflight.result == 'success') || + (inputs.source-event != 'repository_dispatch' && + needs.trusted-event-preflight.result == 'success')) }} + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: protected-automation + permissions: + contents: read + pull-requests: read + steps: + - name: Check out exact authorized main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + cache: false + toolchain: 1.95.0 + + - name: Bind repository and workflow identity + env: + GITHUB_TOKEN: ${{ github.token }} + run: cargo xtask github git-identity configure + + - name: Install exact cargo-binstall + uses: cargo-bins/cargo-binstall@732870f031d2fb36309d0deaf36abcc704a7be65 # v1.20.1 + with: + version: 1.20.1 + + - name: Install and verify release analyzers + run: cargo xtask release install-tools + + - name: Require current main + run: | + # Every proposal source is the exact protected default branch. + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Release proposals must originate from main." >&2 + exit 1 + fi + # Bind the generated proposal to the event's exact main commit. + if [[ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]]; then + echo "The checked-out main commit differs from the authorized event." >&2 + exit 1 + fi + git switch --force-create main "${GITHUB_SHA}" + git branch --set-upstream-to=origin/main main + # Branch attachment must preserve the exact event source commit. + if [[ "$(git rev-parse HEAD)" != "${GITHUB_SHA}" ]]; then + echo "Attaching main changed the authorized commit." >&2 + exit 1 + fi + + - name: Require exact traits availability and source + run: cargo xtask release verify-traits + + - name: Require the current source version on crates.io + id: published + run: | + version="$(cargo xtask release-version show)" + echo "version=${version}" >>"${GITHUB_OUTPUT}" + # Exact absence is an ordered wait state; other failures stop. + if cargo xtask release verify-registry \ + --check-version "${version}" \ + yaml-sigil-core \ + yaml-sigil-transcription \ + yaml-sigil-signing \ + yaml-sigil-verification; then + echo "available=true" >>"${GITHUB_OUTPUT}" + else + status="$?" + # Only the command's exact absence status may suppress a proposal. + if [[ "${status}" -ne 3 ]]; then + exit "${status}" + fi + echo "available=false" >>"${GITHUB_OUTPUT}" + echo "${version} is not yet published; no later proposal is created." + fi + + - name: Resolve release mode and bump intent + id: intent + if: ${{ steps.published.outputs.available == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + MANUAL_DISPATCH: ${{ inputs.source-event == 'workflow_dispatch' }} + REQUESTED_BUMP: ${{ inputs.bump }} + REQUESTED_MODE: ${{ inputs.mode }} + run: cargo xtask github release-pr resolve-intent + + - name: Prepare the official tagged baseline + id: baseline + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: remote.origin.pushurl + GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal + run: | + result="${RUNNER_TEMP}/baseline-result.json" + cargo xtask release baseline prepare \ + --version "${{ steps.published.outputs.version }}" \ + --head "${GITHUB_SHA}" \ + --output "${RUNNER_TEMP}/official-release-baseline" \ + --result "${result}" \ + --expected-fetch-url "https://github.com/${GITHUB_REPOSITORY}" + jq --raw-output \ + '"commit=\(.commit)", + "manifest=\(.manifest)", + "version=\(.version)", + "inventory=\(.inventory)", + "tags=\(.tags | @json)"' \ + "${result}" >>"${GITHUB_OUTPUT}" + + - name: Generate and validate release transaction + id: generate + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + GIT_TOKEN: ${{ github.token }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: remote.origin.pushurl + GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal + run: | + result="${RUNNER_TEMP}/proposal-result.json" + cargo xtask release proposal generate \ + --mode "${{ steps.intent.outputs.mode }}" \ + --bump "${{ steps.intent.outputs.bump }}" \ + --published-version "${{ steps.published.outputs.version }}" \ + --registry-manifest "${{ steps.baseline.outputs.manifest }}" \ + --date "$(date -u +%F)" \ + --result "${result}" + jq --raw-output \ + '"target=\(.target)", "substantive=\(.substantive)"' \ + "${result}" >>"${GITHUB_OUTPUT}" + + - name: Validate generated release source before token minting + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + run: | + cargo xtask sync-workspace-versions --check + cargo xtask release-version check + cargo xtask release verify-traits + cargo xtask release check-packages \ + yaml-sigil-core \ + yaml-sigil-transcription \ + yaml-sigil-signing \ + yaml-sigil-verification + current_version="$(cargo xtask release-version show)" + cargo xtask release-version check-compatibility \ + --baseline-manifest "${{ steps.baseline.outputs.manifest }}" \ + --current-manifest Cargo.toml \ + --expected-baseline-version "${{ steps.baseline.outputs.version }}" \ + --expected-current-version "${current_version}" \ + --intent "${{ steps.intent.outputs.bump }}" + cargo metadata --no-deps --format-version 1 >/dev/null + validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-precommit-cargo-home" + cargo xtask release prepare-validation-cargo-home \ + --output "${validation_cargo_home}" + export CARGO_HOME="${validation_cargo_home}" + cargo package --package yaml-sigil-core --all-features + cargo package --package yaml-sigil-transcription + cargo package --package yaml-sigil-signing --all-features + cargo package --package yaml-sigil-verification --all-features + git diff --check + + - name: Recheck official tags and current main before token minting + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: remote.origin.pushurl + GIT_CONFIG_VALUE_0: disabled://yaml-sigil-release-proposal + run: | + cargo xtask release baseline verify \ + --head "${GITHUB_SHA}" \ + --inventory "${{ steps.baseline.outputs.inventory }}" \ + --expected-fetch-url "https://github.com/${GITHUB_REPOSITORY}" + + - name: Create repository-scoped proposal token + id: app-token + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.YAML_SIGIL_RELEASE_PR_APP_CLIENT_ID }} + private-key: ${{ secrets.YAML_SIGIL_RELEASE_PR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Create or update release pull request + id: release-pr + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + APP_INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_BRANCH: release-plz-next + RELEASE_HOLD_DRAFT: "true" + RELEASE_REPLAY_KEY: ${{ needs.release-notification-preflight.outputs.replay_key }} + RELEASE_SUBSTANTIVE: ${{ steps.generate.outputs.substantive }} + RELEASE_TARGET: ${{ steps.generate.outputs.target }} + run: cargo xtask github release-pr apply --phase update + + - name: Check out generated Verified commit + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.release-pr.outputs.commit_sha }} + + - name: Attach generated release branch + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + RELEASE_BRANCH: release-plz-next + RELEASE_COMMIT: ${{ steps.release-pr.outputs.commit_sha }} + run: | + git update-ref "refs/remotes/origin/${RELEASE_BRANCH}" "${RELEASE_COMMIT}" + git switch --force-create "${RELEASE_BRANCH}" "${RELEASE_COMMIT}" + git branch --set-upstream-to="origin/${RELEASE_BRANCH}" "${RELEASE_BRANCH}" + # Branch attachment must preserve the exact generated commit twice. + if [[ "$(git rev-parse HEAD)" != "${RELEASE_COMMIT}" \ + || "$(git rev-parse '@{upstream}')" != "${RELEASE_COMMIT}" ]]; then + echo "Attaching the release branch changed the generated commit." >&2 + exit 1 + fi + + - name: Recheck clean generated release source + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + GIT_TOKEN: ${{ github.token }} + run: | + cargo xtask sync-workspace-versions --check + cargo xtask release-version check + test -z "$(git status --porcelain --untracked-files=no)" + cargo xtask release verify-traits + cargo xtask release check-packages \ + yaml-sigil-core \ + yaml-sigil-transcription \ + yaml-sigil-signing \ + yaml-sigil-verification + current_version="$(cargo xtask release-version show)" + cargo xtask release-version check-compatibility \ + --baseline-manifest "${{ steps.baseline.outputs.manifest }}" \ + --current-manifest Cargo.toml \ + --expected-baseline-version "${{ steps.baseline.outputs.version }}" \ + --expected-current-version "${current_version}" \ + --intent "${{ steps.intent.outputs.bump }}" + cargo metadata --no-deps --format-version 1 >/dev/null + validation_cargo_home="${RUNNER_TEMP}/yaml-sigil-rs-proposal-cargo-home" + cargo xtask release prepare-validation-cargo-home \ + --output "${validation_cargo_home}" + export CARGO_HOME="${validation_cargo_home}" + cargo package --package yaml-sigil-core --all-features + cargo package --package yaml-sigil-transcription + cargo package --package yaml-sigil-signing --all-features + cargo package --package yaml-sigil-verification --all-features + # Read-only dry-run validates package selection without publication. + release-plz release \ + --dry-run \ + --forge github \ + --config .release-plz.toml \ + --manifest-path Cargo.toml + test -z "$(git status --porcelain --untracked-files=no)" + + - name: Finalize validated release pull request + if: >- + ${{ steps.published.outputs.available == 'true' && + steps.intent.outputs.proceed == 'true' }} + env: + APP_INSTALLATION_ID: ${{ steps.app-token.outputs.installation-id }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_BRANCH: release-plz-next + RELEASE_COMMIT: ${{ steps.release-pr.outputs.commit_sha }} + RELEASE_HOLD_DRAFT: "true" + RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.pr_number }} + RELEASE_REPLAY_KEY: ${{ steps.release-pr.outputs.release_replay_key }} + RELEASE_SUBSTANTIVE: ${{ steps.generate.outputs.substantive }} + RELEASE_TARGET: ${{ steps.generate.outputs.target }} + run: cargo xtask github release-pr apply --phase finalize diff --git a/.release-plz.toml b/.release-plz.toml index 6dd2601..fc3eae8 100644 --- a/.release-plz.toml +++ b/.release-plz.toml @@ -2,10 +2,10 @@ release = false release_always = false changelog_update = true -# Tags and GitHub Releases carry version metadata and changelog text only. -# release-plz does not build or attach binary assets. -git_tag_enable = true -git_release_enable = true +# release-plz publishes source crates only. The protected App finalizer owns +# annotated tags and immutable, zero-asset GitHub Releases. +git_tag_enable = false +git_release_enable = false git_release_type = "auto" publish_allow_dirty = false publish_no_verify = false @@ -21,9 +21,9 @@ publish = true publish_all_features = true changelog_update = true changelog_path = "crates/yaml-sigil-core/CHANGELOG.md" -git_tag_enable = true +git_tag_enable = false git_tag_name = "yaml-sigil-core-v{{ version }}" -git_release_enable = true +git_release_enable = false git_release_name = "yaml-sigil-core-v{{ version }}" git_release_body = "{{ changelog }}" git_release_type = "auto" @@ -35,9 +35,9 @@ release = true publish = true changelog_update = true changelog_path = "crates/yaml-sigil-transcription/CHANGELOG.md" -git_tag_enable = true +git_tag_enable = false git_tag_name = "yaml-sigil-transcription-v{{ version }}" -git_release_enable = true +git_release_enable = false git_release_name = "yaml-sigil-transcription-v{{ version }}" git_release_body = "{{ changelog }}" git_release_type = "auto" @@ -50,9 +50,9 @@ publish = true publish_all_features = true changelog_update = true changelog_path = "crates/yaml-sigil-signing/CHANGELOG.md" -git_tag_enable = true +git_tag_enable = false git_tag_name = "yaml-sigil-signing-v{{ version }}" -git_release_enable = true +git_release_enable = false git_release_name = "yaml-sigil-signing-v{{ version }}" git_release_body = "{{ changelog }}" git_release_type = "auto" @@ -65,9 +65,9 @@ publish = true publish_all_features = true changelog_update = true changelog_path = "crates/yaml-sigil-verification/CHANGELOG.md" -git_tag_enable = true +git_tag_enable = false git_tag_name = "yaml-sigil-verification-v{{ version }}" -git_release_enable = true +git_release_enable = false git_release_name = "yaml-sigil-verification-v{{ version }}" git_release_body = "{{ changelog }}" git_release_type = "auto" diff --git a/AGENTS.md b/AGENTS.md index 62fc56c..ee00eac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -361,8 +361,10 @@ NVIDIA's `linux-amd64-cpu8` runner and GitHub's moving `macos-latest` and release versions, package contents, Clippy, tests, unused dependencies, and both dependency audits against that platform's resolved dependency graph. Linux commit-policy, Markdown, Protobuf, provider-workflow, and aggregation -jobs run on `linux-amd64-cpu4`. The local command does not launch other -operating systems. +jobs run on `linux-amd64-cpu4`. A separate GitHub-hosted Linux, macOS, and +Windows matrix runs the protected checkout verifier regressions; its Windows +leg creates an actual directory junction and a short-name-shaped path. The +local command does not launch other operating systems. Treat every GitHub Action `uses:` pin update as a potential validation-behavior change, even when the workflow inputs remain unchanged. While evaluating a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6237f50..d8e467d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,13 +67,42 @@ head and comment `/ok to test `. Only that exact lowercase, 40-character SHA command starts candidate validation; every new head requires a new review and command. +Changes to workflow policy, protected validation tools, manifests, lockfiles, +toolchain or dependency policy, release automation, `xtask`, Buf +configuration, or other paths classified as security-sensitive use a different +boundary. Each commit must preserve the original human author while a current +repository writer becomes the verified committer, and its message must contain +exact DCO trailers for both identities. For a fork, maintainer edits must remain +enabled on the original pull request. After reviewing that adopted history, a +writer comments `/ok to test-and-adopt `. Ordinary changes must not +use the adoption command, and sensitive changes must not use the ordinary +command. + +Record the authorization comment ID and time. GitHub event delivery may take +up to 20 minutes, so the absence of a run or acknowledgement during that +window is not a reason to repeat the command. After 25 minutes, inspect the +Actions run list and the original comment, and distinguish a queued run from a +missing event before posting at most one replacement command for the still +current head. + +An authorization is invalid after any head, base, protected-policy, comment +body or timestamp, repository identity, or writer-permission change. Never +accept a late acknowledgement or job result for an invalidated binding. + Candidate jobs check out the exact authorized head on GitHub-hosted workers without repository credentials, secrets, OIDC, write permissions, cache saves, or retained artifacts. Every human-authored pull-request commit must form a -linear history from current `main`, be GitHub Verified, and contain a -`Signed-off-by` trailer that exactly matches its Git author. The contributor's -fork branch remains the pull-request head; a writer's command authorizes testing -only and does not authorize integration. +linear history from current `main`, be GitHub Verified, and contain the exact +DCO identity required for direct or adopted history. The contributor's +fork branch remains the pull-request head; a writer's command authorizes +testing only and does not authorize integration. + +Before final authorization, fetch current upstream `main`, rebase the original +contributor branch with `git rebase --gpg-sign /main`, and push the +rewritten branch back to the same fork with `--force-with-lease`. Confirm every +rewritten commit is GitHub Verified and DCO-compliant, then request testing for +the new exact SHA. Do not copy the contribution onto a repository-owned branch +merely to run CI. Changes to the candidate validation implementation or its protected tool and workflow configuration also run the candidate's exact `cargo xtask ci` on @@ -81,6 +110,17 @@ GitHub-hosted Linux, macOS, and Windows workers. This isolated supplement does not replace the protected-main validator. A maintainer reviews the completed results and separately decides whether to integrate the pull request. +Protected checkout verifier regressions also run on those three host +platforms. The Windows leg uses a real directory junction and a +short-name-shaped path to prove fail-closed handling without retaining +artifacts. + +Repository Actions execution protection is an additional platform control, +not the source of `/ok to test` authority. When that policy is in **Evaluate** +mode, its warnings are telemetry only: they neither allow nor block a workflow. +The protected `issue_comment` controller and its exact-SHA reauthorization +remain the operational boundary. + #### Signing Off Your Work * We require that all contributors "sign-off" on their commits. This certifies that the contribution is your original work, or you have rights to submit it under the same license, or a compatible license. diff --git a/RELEASING.md b/RELEASING.md index 80593af..e4c9b31 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,11 +9,13 @@ versioned release transaction: - `yaml-sigil-verification`. Official publications create one annotated version tag and one source-only -GitHub Release per crate from its reviewed changelog. Neither release-plz nor -any other release step builds or attaches binary assets. The workflow retains -no build artifacts or separately generated archives. GitHub's automatic source -archives are expected. Keep the workspace default, conformance, test-key, and -xtask packages unpublished. +GitHub Release per crate from its reviewed changelog. Release-plz has +source-crate publication authority only. After exact registry confirmation, a +protected GitHub App finalizer creates the annotated tags and immutable, +zero-asset Releases. No release step builds or attaches binary assets. The +workflow retains no build artifacts or separately generated archives. GitHub's +automatic source archives are expected. Keep the workspace default, +conformance, test-key, and xtask packages unpublished. Cargo disables implicit binary targets in every publishable crate. Release validation also rejects unexpected package identities, binary targets, and @@ -83,19 +85,63 @@ The workflow remains a successful no-op while the GitHub App configuration is absent. It also waits without advancing the train until the shared version on `main` is available and non-yanked for all four crates on crates.io. -A push or `repository_dispatch` event may create one default `patch` proposal -when no exact App-owned proposal exists. Once that proposal exists, background +The manually bounded `release-pr.yml` entrypoint accepts pushes to `main` and +writer dispatches. It calls `release-proposal.yml`, which is call-only and has +no public event entrypoint. After a complete official publication, the enabled +`publish.yml` receiver authenticates the closed, versioned +`official-release-published` payload before calling that same reusable +workflow. The dispatch name is unchanged; its payload is an internal +sender/receiver contract, not a public external trigger contract. + +A trusted background entrypoint may create one default `patch` proposal when +no exact App-owned proposal exists. Once that proposal exists, background events leave it untouched. A repository writer must dispatch `Release proposal` with an explicit `patch`, `minor`, or `major` selection to revise the proposal. The workflow uses that dispatch input directly and does not store release intent in pull-request text. -Release proposals enter `protected-automation`, which is restricted to exact -`main` and supplies only the App credential. Official publication enters -`crates-io`, whose configured approval gates the OIDC-enabled publication job. -Validation enters neither environment and receives no OIDC permission. The -separately configured `crates-io-pr` environment is not referenced by a -workflow. +Proposal mutation, release intent, finalization, and notification enter +`protected-automation` only when they need the narrowly scoped App credential. +Official source-crate publication enters `crates-io`, whose configured approval +gates the OIDC-enabled publication job. Validation and readiness enter neither +environment and receive no OIDC permission. + +### Bound workflow activation + +Keep the event entrypoints `release-pr.yml` and `publish.yml` manually disabled +between bounded release operations. The reusable `release-proposal.yml` remains +enabled but is call-only. Check all three actual GitHub states, including +disabled workflows, with: + +```shell +gh workflow list --repo NVIDIA/yaml-sigil-rs --all +``` + +Enable only the workflow needed for the current operation. To create or revise +the next RC proposal from exact current `main`, keep `publish.yml` disabled and +run: + +```shell +gh workflow enable release-pr.yml --repo NVIDIA/yaml-sigil-rs +gh workflow run release-pr.yml --repo NVIDIA/yaml-sigil-rs \ + --ref main -f mode=next-candidate -f bump=patch +``` + +Replace `patch` only with the reviewed `minor` or `major` intent. Stable +promotion uses `mode=promote-stable` and `bump=patch`. Wait for the selected +run to close, then disable the proposal entrypoint immediately: + +```shell +gh workflow disable release-pr.yml --repo NVIDIA/yaml-sigil-rs +``` + +Do not rely on a push that occurred while `release-pr.yml` was disabled; use a +fresh explicit dispatch after enabling it. Do not enable proposal and +publication entrypoints at the same time. The validation and publication +procedures enable only `publish.yml`. A successful publication keeps +`publish.yml` enabled until its authenticated receiver run completes; that +receiver may call the reusable proposal workflow while `release-pr.yml` remains +disabled. Every proposal resolves its comparison baseline from the last complete set of official annotated tags: @@ -327,8 +373,9 @@ The default release progression is: `MAJOR.MINOR.(PATCH+1)-rc.1`; - a published `MAJOR.MINOR.PATCH-rc.N` advances to `MAJOR.MINOR.PATCH-rc.(N+1)`; and -- a background event creates a default patch proposal only when the App-owned - proposal does not already exist. +- a trusted push or authenticated post-publication notification creates a + default patch proposal only when the App-owned proposal does not already + exist. The workflow creates every new proposal as a draft and converts an exact existing App proposal to draft before changing its branch. It marks a proposal @@ -385,13 +432,20 @@ Before validation or publication, confirm: `.github/workflows/publish.yml` and the `crates-io` environment; - the `crates-io` environment requires its configured approval and has no long-lived registry token; and +- repository administrators have reviewed the exact proposed release-tag + creation and update/deletion rulesets and prospective immutable-Release + setting, without changing them as part of workflow validation; +- `.github/legacy-release-inventory.json` still pins all twelve historical, + mutable, zero-asset source-only Releases; and - no intended version, tag, or GitHub Release exists, except when deliberately recovering a partial run. Run validation from `main`: ```shell -gh workflow run publish.yml --ref main -f operation=validate +gh workflow enable publish.yml --repo NVIDIA/yaml-sigil-rs +gh workflow run publish.yml --repo NVIDIA/yaml-sigil-rs \ + --ref main -f operation=validate ``` Validation compares all four candidates with the detached last official tagged @@ -400,22 +454,60 @@ release-plz dry run. A validation-only Cargo home patches only the unpublished implementation crates to their reviewed workspace paths. It never patches `yaml-sigil-traits`, so Cargo must resolve that exact version from crates.io. Validation has no OIDC permission, uploads nothing, and does not enter the -publication environment. +publication environment. The readiness job also verifies the pinned legacy +Release inventory and prints a digest binding the captured release SHA, run ID, +run attempt, and required repository settings. It does not read or change +administrator-only settings. + +If validation fails or publication will not begin immediately, disable +`publish.yml` before investigating. When an authorized publication follows the +successful validation immediately, leave it active only through that one +publication and authenticated receiver run. ## Publish an official release Dispatch publication from `main`: ```shell -gh workflow run publish.yml --ref main -f operation=publish +gh workflow run publish.yml --repo NVIDIA/yaml-sigil-rs \ + --ref main -f operation=publish ``` The validation job runs first. The publication job starts only after validation succeeds and the `crates-io` environment is approved. Only that job -receives `id-token: write` and `contents: write`. Release-plz exchanges the job -identity for a short-lived crates.io credential, publishes the four source -packages in dependency order, and creates each configured annotated tag and -source-only GitHub Release. Prerelease versions become GitHub prereleases. +receives `id-token: write`; it retains `contents: read` and +`pull-requests: read`. Release-plz exchanges the job identity for a short-lived +crates.io credential and publishes only the four source packages in dependency +order. It cannot create tags or GitHub Releases. + +Before approving the pending deployment, a repository administrator must run +the tracked read-only preflight from the exact current `main` checkout with the +four values displayed by the selected readiness run: + +```shell +GH_TOKEN="$(gh auth token)" \ +python3 .github/scripts/release_settings_preflight.py \ + --repository NVIDIA/yaml-sigil-rs \ + --release-sha \ + --run-id \ + --run-attempt \ + --expected-evidence-sha256 +``` + +The preflight must report `repository_admin_settings=valid`, reproduce the +workflow evidence digest, and bind its readback to the active exact-SHA run. It +verifies immutable Releases, the exact main and per-package release-tag +rulesets, the Release App bypass, and absence of a required-check name +collision. It performs no mutation. Approve the `crates-io` deployment before +the printed `approve_before_utc` deadline, at most five minutes after the +readback. Any run, attempt, head, workflow, setting, or deadline change requires +a fresh readback. + +Approve only the pending deployment on the selected exact-SHA run. Use +`gh run view --web` to confirm the readiness job passed, the run still +identifies current `main`, and the administrator readback remains inside its +deadline. An earlier authorization or another run's deployment is not a +substitute for this per-run gate. Both validation and publication independently require exact current `main` to be the merge result of one reviewed App proposal or the documented signed @@ -427,24 +519,53 @@ the traits dependency, and remote `main`. Its ephemeral release-plz configuration authorizes only the already-checked checkout and prevents release-plz from selecting or checking out another commit. +For the manual fallback, source authorization rechecks both the merger's and +proposal owner's current repository write permission after its final `main` +and pull-request reread. + The OIDC-authorized publication and recovery steps use a new unpatched Cargo home. They resolve workspace dependencies through crates.io in publication order instead of substituting local paths. Only the no-OIDC validation job uses the validation-only `[patch.crates-io]` configuration. The publication invocation deliberately omits release-plz's `--dry-run` CLI -flag. After a successful publication, the workflow requests the next release -proposal. +flag. After exact registry confirmation, separate App-authenticated jobs attest +the release intent, create each annotated tag and immutable zero-asset Release, +and emit the authenticated internal notification. These jobs receive no OIDC +credential; the finalizer's App token has repository `contents: write` and the +notifier's separately minted token is isolated to notification. + +If publication succeeds, wait for the resulting authenticated receiver run to +complete before disabling `publish.yml`. If the publication run fails before +notification, disable it after the failure is understood. Then confirm both +event entrypoints are disabled and the call-only reusable workflow remains +active: + +```shell +gh workflow disable publish.yml --repo NVIDIA/yaml-sigil-rs +gh workflow list --repo NVIDIA/yaml-sigil-rs --all +``` + +The authenticated receiver may create the next default proposal while +`release-pr.yml` remains disabled. It never replaces an existing exact +App-owned proposal. Use the bounded proposal procedure later for an explicit +bump or revision; do not leave either event entrypoint enabled. ## Verify and recover The workflow waits for crates.io to expose all four versions as non-yanked and -confirms Cargo can resolve them. It then requires each configured tag to be an -annotated tag whose object targets exact publication `main`. Each GitHub Release -must use its crate-specific tag and name, contain the exact reviewed version -section from that crate's changelog, have the expected prerelease state, and -have no attached assets. Record the workflow run, packages, tags, and Releases -in the workspace release records. +confirms Cargo can resolve them. The App finalizer then requires each configured +tag to be an annotated tag whose object targets the captured publication +commit. Each App-authored GitHub Release must be immutable, use its +crate-specific tag and name, contain the exact reviewed version section from +that crate's changelog, have the expected prerelease state, and have no attached +assets. Record the workflow run, packages, tags, Releases, readback digest, and +captured SHA in the workspace release records. + +The immutable-Release setting is prospective. The twelve historical releases +in the pinned inventory remain mutable and are never rewritten; their exact +tags, source archives, bodies, author, state, and zero-asset inventories are +checked before every new finalization. Never blindly retry a failed publication. Inspect crates.io, all four tags, and all four GitHub Releases first. An existing crate version cannot be overwritten, @@ -465,6 +586,11 @@ states: tags and source-only GitHub Releases after independently rechecking crates.io package checksums and exact source provenance. +For every published crate considered during recovery, exact Cargo `1.95.0` +reproduces the source archive in an ephemeral directory. The complete archive +entry map must match, including the opaque Cargo-generated `Cargo.lock` bytes +and Cargo archive metadata; no archive entry is excluded from comparison. + Recovery never moves or replaces an existing ref, edits an existing Release, deletes an object, or uploads an asset. A non-prefix partial publication, a source mismatch, a lightweight or wrong-target tag, a mismatched Release body diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock index 136e579..f7d6738 100644 --- a/xtask/Cargo.lock +++ b/xtask/Cargo.lock @@ -992,6 +992,7 @@ dependencies = [ "buf-tools", "clap", "flate2", + "rustix", "semver", "serde", "serde_json", @@ -1000,6 +1001,7 @@ dependencies = [ "tempfile", "toml", "toml_edit", + "windows-sys 0.61.2", ] [[package]] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index c20553c..610b33c 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -37,3 +37,19 @@ tar = "0.4" tempfile = "3.23" toml = { version = "1", default-features = false, features = ["parse", "serde"] } toml_edit = { version = "0.25.13", default-features = false, features = ["display", "parse"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "=1.1.4", features = ["fs", "process"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_IO", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } diff --git a/xtask/src/bounded_process.rs b/xtask/src/bounded_process.rs index 6631268..0b205e8 100644 --- a/xtask/src/bounded_process.rs +++ b/xtask/src/bounded_process.rs @@ -1,12 +1,16 @@ // SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES // SPDX-License-Identifier: Apache-2.0 -//! Bounded subprocess output capture for candidate-controlled validation. +//! Bounded subprocess-tree execution for release-reachable validation. use std::io::{self, Read}; -use std::process::{Child, Command, ExitStatus, Stdio}; -use std::sync::mpsc::{self, Sender}; -use std::thread::{self, JoinHandle}; +use std::process::{Command, ExitStatus}; +use std::time::Duration; + +const PIPE_CLOSE_GRACE: Duration = Duration::from_millis(250); +const POST_CANCEL_TIMEOUT: Duration = Duration::from_secs(2); +const POLL_INTERVAL: Duration = Duration::from_millis(10); +const MAX_INPUT_BYTES: usize = 4 * 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct OutputLimits { @@ -19,14 +23,9 @@ pub(crate) const VALIDATION_OUTPUT_LIMITS: OutputLimits = OutputLimits { stderr: 64 * 1024, }; -#[derive(Debug)] -pub(crate) struct Output { - pub(crate) status: ExitStatus, - pub(crate) stdout: Vec, - pub(crate) stderr: Vec, -} +pub(crate) type Output = std::process::Output; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Stream { Stdout, Stderr, @@ -48,60 +47,57 @@ impl Stream { } } +#[cfg(any(windows, test))] #[derive(Debug)] struct BoundedPipe { bytes: Vec, exceeded: bool, } -type ReaderMessage = (Stream, io::Result); - -fn read_bounded_pipe(reader: impl Read, limit: usize) -> io::Result { +#[cfg(any(windows, test))] +fn read_bounded_pipe(mut reader: impl Read, limit: usize) -> io::Result { let sentinel = limit .checked_add(1) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid output limit"))?; let mut bytes = Vec::with_capacity(sentinel.min(8 * 1024)); - reader.take(sentinel as u64).read_to_end(&mut bytes)?; + reader + .by_ref() + .take(sentinel as u64) + .read_to_end(&mut bytes)?; Ok(BoundedPipe { exceeded: bytes.len() > limit, bytes, }) } -fn spawn_reader( - reader: impl Read + Send + 'static, - stream: Stream, - limit: usize, - sender: Sender, -) -> JoinHandle<()> { - thread::spawn(move || { - let _ = sender.send((stream, read_bounded_pipe(reader, limit))); - }) -} - -fn kill_and_reap(child: &mut Child) -> io::Result { - if let Some(status) = child.try_wait()? { - return Ok(status); - } - if let Err(kill_error) = child.kill() { - return match child.try_wait()? { - Some(status) => Ok(status), - None => Err(kill_error), - }; +fn validate_limits(limits: OutputLimits, input: Option<&[u8]>) -> io::Result<()> { + limits + .stdout + .checked_add(1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid stdout limit"))?; + limits + .stderr + .checked_add(1) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid stderr limit"))?; + if input.is_some_and(|bytes| bytes.len() > MAX_INPUT_BYTES) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("subprocess stdin exceeded its {MAX_INPUT_BYTES}-byte limit"), + )); } - child.wait() -} - -fn join_reader(reader: JoinHandle<()>, stream: Stream) -> io::Result<()> { - reader - .join() - .map_err(|_| io::Error::other(format!("{} reader thread panicked", stream.label()))) + Ok(()) } -fn record_failure(failure: &mut Option, error: io::Error) { - if failure.is_none() { - *failure = Some(error); - } +fn status_poll( + result: io::Result>, + program: &str, +) -> io::Result> { + result.map_err(|error| { + io::Error::new( + error.kind(), + format!("poll {program} subprocess status: {error}"), + ) + }) } pub(crate) fn require_within_limit(bytes: &[u8], limit: usize, label: &str) -> io::Result<()> { @@ -114,123 +110,970 @@ pub(crate) fn require_within_limit(bytes: &[u8], limit: usize, label: &str) -> i Ok(()) } -/// Capture both output streams without retaining more than each limit plus one -/// sentinel byte. An overflow or reader failure terminates and reaps the child -/// before both reader threads are joined. +/// Capture both output streams while containing descendants and retaining no +/// more than each configured limit plus one sentinel byte. pub(crate) fn output(command: &mut Command, limits: OutputLimits) -> io::Result { - let program = command.get_program().to_string_lossy().into_owned(); - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::other(format!("{program} stdout was not captured")))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| io::Error::other(format!("{program} stderr was not captured")))?; - - let (sender, receiver) = mpsc::channel(); - let stdout_reader = spawn_reader(stdout, Stream::Stdout, limits.stdout, sender.clone()); - let stderr_reader = spawn_reader(stderr, Stream::Stderr, limits.stderr, sender.clone()); - drop(sender); - - let mut stdout = None; - let mut stderr = None; - let mut status = None; - let mut failure = None; - - for _ in 0..2 { - let (stream, capture) = match receiver.recv() { - Ok(message) => message, - Err(error) => { - record_failure( - &mut failure, - io::Error::other(format!("output reader stopped before reporting: {error}")), - ); - if status.is_none() { - match kill_and_reap(&mut child) { - Ok(reaped) => status = Some(reaped), - Err(error) => record_failure(&mut failure, error), + validate_limits(limits, None)?; + platform::output(command, limits, None) +} + +/// Write bounded input and capture both bounded output streams under the same +/// process-tree containment contract as output. +pub(crate) fn output_with_input( + command: &mut Command, + input: &[u8], + limits: OutputLimits, +) -> io::Result { + validate_limits(limits, Some(input))?; + platform::output(command, limits, Some(input)) +} + +#[cfg(unix)] +mod platform { + use super::*; + + use std::io::Write; + use std::os::fd::AsFd; + use std::os::unix::process::CommandExt; + use std::process::{Child, ChildStdin, Stdio}; + use std::thread; + use std::time::Instant; + + use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; + use rustix::process::{Pid, Signal, kill_process_group, setpgid}; + + struct Capture { + reader: Option, + bytes: Vec, + limit: usize, + stream: Stream, + } + + impl Capture { + fn new(reader: R, stream: Stream, limit: usize) -> io::Result { + let sentinel = limit.checked_add(1).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid output limit") + })?; + Ok(Self { + reader: Some(reader), + bytes: Vec::with_capacity(sentinel.min(8 * 1024)), + limit, + stream, + }) + } + + fn complete(&self) -> bool { + self.reader.is_none() + } + + fn exceeded(&self) -> bool { + self.bytes.len() > self.limit + } + + fn drain(&mut self) -> io::Result { + let Some(reader) = self.reader.as_mut() else { + return Ok(false); + }; + let sentinel = self.limit + 1; + if self.bytes.len() >= sentinel { + return Ok(false); + } + + let mut progressed = false; + loop { + let remaining = sentinel - self.bytes.len(); + let mut buffer = [0_u8; 8192]; + let wanted = remaining.min(buffer.len()); + match reader.read(&mut buffer[..wanted]) { + Ok(0) => { + self.reader = None; + return Ok(true); + } + Ok(count) => { + self.bytes.extend_from_slice(&buffer[..count]); + progressed = true; + if self.bytes.len() >= sentinel { + return Ok(true); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + return Ok(progressed); + } + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("read {}: {error}", self.stream.label()), + )); + } + } + } + } + } + + trait CaptureState { + fn has_exceeded(&self) -> bool; + fn stream(&self) -> Stream; + } + + impl CaptureState for Capture { + fn has_exceeded(&self) -> bool { + self.exceeded() + } + + fn stream(&self) -> Stream { + self.stream + } + } + + fn set_nonblocking(handle: &impl AsFd) -> io::Result<()> { + let flags = fcntl_getfl(handle)?; + fcntl_setfl(handle, flags | OFlags::NONBLOCK)?; + Ok(()) + } + + fn prepare(command: &mut Command) { + // SAFETY: the post-fork hook performs only the async-signal-safe + // setpgid system call and returns its operating-system error. + unsafe { + command.pre_exec(|| { + setpgid(None, None).map_err(io::Error::from)?; + Ok(()) + }); + } + } + + fn terminate_tree(child: &mut Child, group: Pid) { + let _ = kill_process_group(group, Signal::KILL); + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + } + } + + fn reap_until(child: &mut Child, deadline: Instant) -> io::Result { + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(error) => return Err(error), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "direct child did not exit before the post-cancellation deadline", + )); + } + thread::sleep(POLL_INTERVAL); + } + } + + fn write_input( + writer: &mut Option, + input: &[u8], + offset: &mut usize, + ) -> io::Result { + let Some(stdin) = writer.as_mut() else { + return Ok(false); + }; + let mut progressed = false; + while *offset < input.len() { + match stdin.write(&input[*offset..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "subprocess stdin stopped accepting input", + )); + } + Ok(count) => { + *offset += count; + progressed = true; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(progressed), + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => { + *writer = None; + return Ok(true); + } + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("write subprocess stdin: {error}"), + )); + } + } + } + *writer = None; + Ok(true) + } + + fn cleanup_error(primary: io::Error, cleanup: io::Result) -> io::Error { + match cleanup { + Ok(_) => primary, + Err(error) => io::Error::other(format!("{primary}; cleanup failed: {error}")), + } + } + + pub(super) fn output( + command: &mut Command, + limits: OutputLimits, + input: Option<&[u8]>, + ) -> io::Result { + output_with_status_poll(command, limits, input, |child| child.try_wait()) + } + + pub(super) fn output_with_status_poll( + command: &mut Command, + limits: OutputLimits, + input: Option<&[u8]>, + mut poll: impl FnMut(&mut Child) -> io::Result>, + ) -> io::Result { + let program = command.get_program().to_string_lossy().into_owned(); + prepare(command); + command + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn()?; + let group = Pid::from_child(&child); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other(format!("{program} stdout was not captured")))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other(format!("{program} stderr was not captured")))?; + let mut stdin = if input.is_some() { + Some( + child + .stdin + .take() + .ok_or_else(|| io::Error::other(format!("{program} stdin was not captured")))?, + ) + } else { + None + }; + + let setup = (|| -> io::Result<()> { + set_nonblocking(&stdout)?; + set_nonblocking(&stderr)?; + if let Some(handle) = stdin.as_ref() { + set_nonblocking(handle)?; + } + Ok(()) + })(); + if let Err(error) = setup { + terminate_tree(&mut child, group); + drop(stdout); + drop(stderr); + drop(stdin); + return Err(cleanup_error( + error, + reap_until(&mut child, Instant::now() + POST_CANCEL_TIMEOUT), + )); + } + + let mut stdout = Capture::new(stdout, Stream::Stdout, limits.stdout)?; + let mut stderr = Capture::new(stderr, Stream::Stderr, limits.stderr)?; + let input = input.unwrap_or_default(); + let mut input_offset = 0_usize; + if input.is_empty() { + stdin = None; + } + + let mut status = None; + let mut direct_exit = None; + let mut failure = None; + + loop { + let mut progressed = false; + match stdout.drain() { + Ok(value) => progressed |= value, + Err(error) => failure = Some(error), + } + if failure.is_none() { + match stderr.drain() { + Ok(value) => progressed |= value, + Err(error) => failure = Some(error), + } + } + if failure.is_none() { + match write_input(&mut stdin, input, &mut input_offset) { + Ok(value) => progressed |= value, + Err(error) => failure = Some(error), + } + } + + for capture in [&stdout as &dyn CaptureState, &stderr as &dyn CaptureState] { + if failure.is_none() && capture.has_exceeded() { + failure = Some(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{program} {} exceeded its {}-byte limit", + capture.stream().label(), + capture.stream().limit(limits) + ), + )); + } + } + if failure.is_some() { + break; + } + + if status.is_none() { + match status_poll(poll(&mut child), &program) { + Ok(Some(reaped)) => { + status = Some(reaped); + direct_exit = Some(Instant::now()); + progressed = true; + } + Ok(None) => {} + // Route polling errors through process-tree termination and + // bounded reaping instead of returning past cleanup. + Err(error) => { + failure = Some(error); + break; } } + } + + if status.is_some() && stdout.complete() && stderr.complete() { + break; + } + if direct_exit.is_some_and(|started| started.elapsed() >= PIPE_CLOSE_GRACE) { + failure = Some(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "{program} descendants retained output pipes after the direct child exited" + ), + )); break; } + if !progressed { + thread::sleep(POLL_INTERVAL); + } + } + + if let Some(error) = failure { + terminate_tree(&mut child, group); + drop(stdout); + drop(stderr); + drop(stdin); + let cleanup = match status { + Some(reaped) => Ok(reaped), + None => reap_until(&mut child, Instant::now() + POST_CANCEL_TIMEOUT), + }; + return Err(cleanup_error(error, cleanup)); + } + + Ok(Output { + status: status.ok_or_else(|| io::Error::other("direct child was not reaped"))?, + stdout: stdout.bytes, + stderr: stderr.bytes, + }) + } +} + +#[cfg(windows)] +mod platform { + use super::*; + + use std::io::Write; + use std::mem::size_of; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::os::windows::process::CommandExt; + use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Stdio}; + use std::ptr::null; + use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; + use std::thread::{self, JoinHandle}; + use std::time::Instant; + + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, + }; + use windows_sys::Win32::System::IO::CancelSynchronousIo; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, + }; + use windows_sys::Win32::System::Threading::{ + CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME, + }; + + enum WorkerMessage { + Capture(Stream, io::Result), + Input(io::Result<()>), + } + + struct Job { + handle: OwnedHandle, + } + + impl Job { + fn create() -> io::Result { + let raw = unsafe { CreateJobObjectW(null(), null()) }; + if raw.is_null() { + return Err(io::Error::last_os_error()); + } + let handle = unsafe { OwnedHandle::from_raw_handle(raw as RawHandle) }; + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if information.BasicLimitInformation.LimitFlags + & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) + != 0 + { + return Err(io::Error::other( + "bounded job object unexpectedly permits process breakaway", + )); + } + let configured = unsafe { + SetInformationJobObject( + handle.as_raw_handle() as HANDLE, + JobObjectExtendedLimitInformation, + (&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + }; + if configured == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { handle }) + } + + fn assign(&self, child: &Child) -> io::Result<()> { + let assigned = unsafe { + AssignProcessToJobObject( + self.handle.as_raw_handle() as HANDLE, + child.as_raw_handle() as HANDLE, + ) + }; + if assigned == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + fn terminate(&self) -> io::Result<()> { + let terminated = + unsafe { TerminateJobObject(self.handle.as_raw_handle() as HANDLE, 1) }; + if terminated == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + } + + fn resume_primary_thread(child: &Child) -> io::Result<()> { + let raw_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if raw_snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot as RawHandle) }; + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..THREADENTRY32::default() }; + let mut available = + unsafe { Thread32First(snapshot.as_raw_handle() as HANDLE, &mut entry) }; + while available != 0 { + if entry.th32OwnerProcessID == child.id() { + let raw_thread = + unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if raw_thread.is_null() { + return Err(io::Error::last_os_error()); + } + let thread = unsafe { OwnedHandle::from_raw_handle(raw_thread as RawHandle) }; + let previous = unsafe { ResumeThread(thread.as_raw_handle() as HANDLE) }; + if previous == u32::MAX { + return Err(io::Error::last_os_error()); + } + if previous != 1 { + return Err(io::Error::other(format!( + "suspended child thread had unsupported suspend count {previous}" + ))); + } + return Ok(()); + } + entry.dwSize = size_of::() as u32; + available = unsafe { Thread32Next(snapshot.as_raw_handle() as HANDLE, &mut entry) }; + } + Err(io::Error::other( + "could not locate the suspended child primary thread", + )) + } + + fn spawn_reader( + reader: impl Read + Send + 'static, + stream: Stream, + limit: usize, + sender: Sender, + ) -> JoinHandle<()> { + thread::spawn(move || { + let _ = sender.send(WorkerMessage::Capture( + stream, + read_bounded_pipe(reader, limit), + )); + }) + } + + fn spawn_writer( + mut writer: ChildStdin, + input: Vec, + sender: Sender, + ) -> JoinHandle<()> { + thread::spawn(move || { + let result = writer.write_all(&input).and_then(|()| writer.flush()); + let _ = sender.send(WorkerMessage::Input(result)); + }) + } + + fn cancel_worker(worker: &JoinHandle<()>) { + // ERROR_NOT_FOUND is benign when the worker is between synchronous + // calls or has already finished, so cancellation is best-effort here. + let _ = unsafe { CancelSynchronousIo(worker.as_raw_handle() as HANDLE) }; + } + + fn join_worker(worker: Option>, label: &str) -> io::Result<()> { + if let Some(worker) = worker { + worker + .join() + .map_err(|_| io::Error::other(format!("{label} worker thread panicked")))?; + } + Ok(()) + } - match capture { - Ok(capture) => { - if capture.exceeded { - record_failure( - &mut failure, - io::Error::new( + fn reap_until(child: &mut Child, deadline: Instant) -> io::Result { + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(error) => return Err(error), + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "direct child did not exit before the post-cancellation deadline", + )); + } + thread::sleep(POLL_INTERVAL); + } + } + + struct Captures { + stdout: Option>, + stderr: Option>, + stdout_finished: bool, + stderr_finished: bool, + input_finished: bool, + } + + impl Captures { + fn finished(&self, input_expected: bool) -> bool { + self.stdout_finished && self.stderr_finished && (!input_expected || self.input_finished) + } + + fn record( + &mut self, + message: WorkerMessage, + limits: OutputLimits, + program: &str, + ) -> Option { + match message { + WorkerMessage::Capture(stream, Ok(capture)) => { + let exceeded = capture.exceeded; + match stream { + Stream::Stdout => { + self.stdout_finished = true; + self.stdout = Some(capture.bytes); + } + Stream::Stderr => { + self.stderr_finished = true; + self.stderr = Some(capture.bytes); + } + } + if exceeded { + Some(io::Error::new( io::ErrorKind::InvalidData, format!( "{program} {} exceeded its {}-byte limit", stream.label(), stream.limit(limits) ), - ), - ); - if status.is_none() { - match kill_and_reap(&mut child) { - Ok(reaped) => status = Some(reaped), - Err(error) => record_failure(&mut failure, error), - } + )) + } else { + None } } - match stream { - Stream::Stdout => stdout = Some(capture.bytes), - Stream::Stderr => stderr = Some(capture.bytes), - } - } - Err(error) => { - record_failure( - &mut failure, - io::Error::new(error.kind(), format!("read {}: {error}", stream.label())), - ); - if status.is_none() { - match kill_and_reap(&mut child) { - Ok(reaped) => status = Some(reaped), - Err(error) => record_failure(&mut failure, error), + WorkerMessage::Capture(stream, Err(error)) => { + match stream { + Stream::Stdout => self.stdout_finished = true, + Stream::Stderr => self.stderr_finished = true, } + Some(io::Error::new( + error.kind(), + format!("read {}: {error}", stream.label()), + )) + } + WorkerMessage::Input(Ok(())) => { + self.input_finished = true; + None + } + WorkerMessage::Input(Err(error)) if error.kind() == io::ErrorKind::BrokenPipe => { + self.input_finished = true; + None + } + WorkerMessage::Input(Err(error)) => { + self.input_finished = true; + Some(io::Error::new( + error.kind(), + format!("write subprocess stdin: {error}"), + )) } } } } - if status.is_none() { - match child.wait() { - Ok(reaped) => status = Some(reaped), - Err(error) => record_failure(&mut failure, error), + fn receive_until( + receiver: &Receiver, + captures: &mut Captures, + limits: OutputLimits, + program: &str, + input_expected: bool, + deadline: Instant, + ) { + while !captures.finished(input_expected) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining.min(POLL_INTERVAL)) { + Ok(message) => { + let _ = captures.record(message, limits, program); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } } } - if let Err(error) = join_reader(stdout_reader, Stream::Stdout) { - record_failure(&mut failure, error); - } - if let Err(error) = join_reader(stderr_reader, Stream::Stderr) { - record_failure(&mut failure, error); + + fn cleanup_error( + primary: io::Error, + cleanup: io::Result, + workers_finished: bool, + join: io::Result<()>, + ) -> io::Error { + let mut details = vec![primary.to_string()]; + if let Err(error) = cleanup { + details.push(format!("cleanup failed: {error}")); + } + if !workers_finished { + details.push("I/O workers did not cancel before the hard deadline".to_string()); + } + if let Err(error) = join { + details.push(format!("worker cleanup failed: {error}")); + } + io::Error::other(details.join("; ")) } - if let Some(error) = failure { - return Err(error); + + pub(super) fn output( + command: &mut Command, + limits: OutputLimits, + input: Option<&[u8]>, + ) -> io::Result { + output_with_status_poll(command, limits, input, |child| child.try_wait()) } - Ok(Output { - status: status.ok_or_else(|| io::Error::other("child was not reaped"))?, - stdout: stdout.ok_or_else(|| io::Error::other("stdout reader returned no bytes"))?, - stderr: stderr.ok_or_else(|| io::Error::other("stderr reader returned no bytes"))?, - }) + pub(super) fn output_with_status_poll( + command: &mut Command, + limits: OutputLimits, + input: Option<&[u8]>, + mut poll: impl FnMut(&mut Child) -> io::Result>, + ) -> io::Result { + let program = command.get_program().to_string_lossy().into_owned(); + let job = Job::create()?; + command + .creation_flags(CREATE_SUSPENDED) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn()?; + if let Err(error) = job.assign(&child) { + let _ = child.kill(); + let cleanup = child.wait(); + return Err(match cleanup { + Ok(_) => error, + Err(cleanup) => io::Error::other(format!("{error}; cleanup failed: {cleanup}")), + }); + } + + let stdout: ChildStdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other(format!("{program} stdout was not captured")))?; + let stderr: ChildStderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other(format!("{program} stderr was not captured")))?; + let stdin: Option = if input.is_some() { + Some( + child + .stdin + .take() + .ok_or_else(|| io::Error::other(format!("{program} stdin was not captured")))?, + ) + } else { + None + }; + + let (sender, receiver) = mpsc::channel(); + let mut stdout_worker = Some(spawn_reader( + stdout, + Stream::Stdout, + limits.stdout, + sender.clone(), + )); + let mut stderr_worker = Some(spawn_reader( + stderr, + Stream::Stderr, + limits.stderr, + sender.clone(), + )); + let mut stdin_worker = stdin + .map(|writer| spawn_writer(writer, input.unwrap_or_default().to_vec(), sender.clone())); + drop(sender); + + if let Err(error) = resume_primary_thread(&child) { + let _ = job.terminate(); + if let Some(worker) = stdout_worker.as_ref() { + cancel_worker(worker); + } + if let Some(worker) = stderr_worker.as_ref() { + cancel_worker(worker); + } + if let Some(worker) = stdin_worker.as_ref() { + cancel_worker(worker); + } + drop(job); + let _ = child.kill(); + let cleanup = reap_until(&mut child, Instant::now() + POST_CANCEL_TIMEOUT); + return Err(cleanup_error(error, cleanup, false, Ok(()))); + } + + let input_expected = stdin_worker.is_some(); + let mut captures = Captures { + stdout: None, + stderr: None, + stdout_finished: false, + stderr_finished: false, + input_finished: !input_expected, + }; + let mut status = None; + let mut direct_exit = None; + let mut failure = None; + + loop { + match receiver.recv_timeout(POLL_INTERVAL) { + Ok(message) => { + if let Some(error) = captures.record(message, limits, &program) + && failure.is_none() + { + failure = Some(error); + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + if !captures.finished(input_expected) && failure.is_none() { + failure = Some(io::Error::other( + "subprocess I/O workers stopped before reporting", + )); + } + } + } + + if status.is_none() { + match status_poll(poll(&mut child), &program) { + Ok(Some(reaped)) => { + status = Some(reaped); + direct_exit = Some(Instant::now()); + } + Ok(None) => {} + // Route polling errors through job termination and bounded + // reaping instead of returning past cleanup. + Err(error) => { + failure = Some(error); + break; + } + } + } + if failure.is_some() { + break; + } + if status.is_some() && captures.finished(input_expected) { + break; + } + if direct_exit.is_some_and(|started| started.elapsed() >= PIPE_CLOSE_GRACE) { + failure = Some(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "{program} descendants retained output pipes after the direct child exited" + ), + )); + break; + } + } + + if let Some(error) = failure { + let _ = job.terminate(); + if !captures.stdout_finished + && let Some(worker) = stdout_worker.as_ref() + { + cancel_worker(worker); + } + if !captures.stderr_finished + && let Some(worker) = stderr_worker.as_ref() + { + cancel_worker(worker); + } + if input_expected + && !captures.input_finished + && let Some(worker) = stdin_worker.as_ref() + { + cancel_worker(worker); + } + drop(job); + if status.is_none() { + let _ = child.kill(); + } + let deadline = Instant::now() + POST_CANCEL_TIMEOUT; + let cleanup = match status { + Some(reaped) => Ok(reaped), + None => reap_until(&mut child, deadline), + }; + receive_until( + &receiver, + &mut captures, + limits, + &program, + input_expected, + deadline, + ); + + let mut join_result = Ok(()); + if captures.stdout_finished + && let Err(error) = join_worker(stdout_worker.take(), "stdout") + { + join_result = Err(error); + } + if captures.stderr_finished + && let Err(error) = join_worker(stderr_worker.take(), "stderr") + && join_result.is_ok() + { + join_result = Err(error); + } + if captures.input_finished + && let Err(error) = join_worker(stdin_worker.take(), "stdin") + && join_result.is_ok() + { + join_result = Err(error); + } + return Err(cleanup_error( + error, + cleanup, + captures.finished(input_expected), + join_result, + )); + } + + drop(job); + join_worker(stdout_worker.take(), "stdout")?; + join_worker(stderr_worker.take(), "stderr")?; + join_worker(stdin_worker.take(), "stdin")?; + + Ok(Output { + status: status.ok_or_else(|| io::Error::other("direct child was not reaped"))?, + stdout: captures + .stdout + .ok_or_else(|| io::Error::other("stdout worker returned no bytes"))?, + stderr: captures + .stderr + .ok_or_else(|| io::Error::other("stderr worker returned no bytes"))?, + }) + } } #[cfg(test)] mod tests { use super::*; + use std::fs; use std::io::{Cursor, Write}; - use std::time::{Duration, Instant}; + use std::path::{Path, PathBuf}; + use std::thread; + use std::time::Instant; + + fn test_command(name: &str) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args(["--ignored", "--exact", name, "--nocapture", "--quiet"]); + command + } + + fn wait_for_pid(path: &Path) -> u32 { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Ok(value) = fs::read_to_string(path) + && let Ok(pid) = value.trim().parse() + { + return pid; + } + assert!( + Instant::now() < deadline, + "grandchild did not record its process ID" + ); + thread::sleep(POLL_INTERVAL); + } + } + + #[cfg(unix)] + fn process_exists(pid: u32) -> bool { + rustix::process::Pid::from_raw(pid as i32) + .is_some_and(|pid| rustix::process::test_kill_process(pid).is_ok()) + } + + #[cfg(windows)] + fn process_exists(pid: u32) -> bool { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + + use windows_sys::Win32::Foundation::{HANDLE, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if raw.is_null() { + return false; + } + let handle = unsafe { OwnedHandle::from_raw_handle(raw as RawHandle) }; + let mut code = 0_u32; + let query_succeeded = + unsafe { GetExitCodeProcess(handle.as_raw_handle() as HANDLE, &mut code) != 0 }; + query_succeeded && code == STILL_ACTIVE as u32 + } + + fn wait_for_exit(pid: u32) { + let deadline = Instant::now() + POST_CANCEL_TIMEOUT; + while process_exists(pid) && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + assert!(!process_exists(pid), "contained grandchild {pid} survived"); + } #[test] fn bounded_pipe_reads_only_limit_plus_sentinel() { @@ -248,39 +1091,269 @@ mod tests { } #[test] - fn output_kills_and_reaps_a_child_that_keeps_running_after_overflow() { - let mut command = Command::new(std::env::current_exe().unwrap()); - command.args([ - "--ignored", - "--exact", - "bounded_process::tests::oversized_stderr_then_sleep_child", - "--nocapture", - ]); + fn status_poll_failure_terminates_and_reaps_the_process_tree() { + let temporary = tempfile::tempdir().unwrap(); + let parent_pid_file = temporary.path().join("parent.pid"); + let descendant_pid_file = temporary.path().join("descendant.pid"); + let mut command = test_command("bounded_process::tests::spawn_poll_failure_tree"); + command.env("YAML_SIGIL_TEST_PARENT_PID_FILE", &parent_pid_file); + command.env("YAML_SIGIL_TEST_PID_FILE", &descendant_pid_file); + let observed_parent = parent_pid_file.clone(); + let observed_descendant = descendant_pid_file.clone(); + let started = Instant::now(); + + let error = platform::output_with_status_poll( + &mut command, + OutputLimits { + stdout: 4096, + stderr: 4096, + }, + None, + |child| { + if observed_parent.is_file() && observed_descendant.is_file() { + Err(io::Error::new( + io::ErrorKind::Interrupted, + "synthetic status-poll failure", + )) + } else { + child.try_wait() + } + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("synthetic status-poll failure"), "{error}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "status-poll failure did not terminate the process tree promptly" + ); + wait_for_exit(wait_for_pid(&parent_pid_file)); + wait_for_exit(wait_for_pid(&descendant_pid_file)); + } + + #[test] + fn output_kills_a_pipe_inheriting_grandchild_after_overflow() { + let temporary = tempfile::tempdir().unwrap(); + let pid_file = temporary.path().join("grandchild.pid"); + let mut command = test_command("bounded_process::tests::spawn_oversized_grandchild"); + command.env("YAML_SIGIL_TEST_PID_FILE", &pid_file); + let started = Instant::now(); + let error = output( + &mut command, + OutputLimits { + stdout: 512, + stderr: 512, + }, + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("exceeded its 512-byte limit"), "{error}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "overflowing process tree was not terminated promptly" + ); + wait_for_exit(wait_for_pid(&pid_file)); + } + + #[test] + fn bounded_input_is_delivered_without_changing_output_limits() { + let temporary = tempfile::tempdir().unwrap(); + let output_file = temporary.path().join("stdin.bin"); + let mut command = test_command("bounded_process::tests::echo_stdin_child"); + command.env("YAML_SIGIL_TEST_INPUT_FILE", &output_file); + let result = output_with_input( + &mut command, + b"bounded input", + OutputLimits { + stdout: 4096, + stderr: 4096, + }, + ) + .unwrap(); + assert!(result.status.success()); + assert_eq!(fs::read(output_file).unwrap(), b"bounded input"); + } + + #[cfg(unix)] + #[test] + fn escaped_session_cannot_hold_the_validator_open() { + let temporary = tempfile::tempdir().unwrap(); + let pid_file = temporary.path().join("escapee.pid"); + let mut command = test_command("bounded_process::tests::spawn_session_escapee"); + command.env("YAML_SIGIL_TEST_PID_FILE", &pid_file); let started = Instant::now(); let error = output( &mut command, OutputLimits { - stdout: 64 * 1024, - stderr: 8, + stdout: 4096, + stderr: 4096, }, ) .unwrap_err() .to_string(); - assert!(error.contains("stderr exceeded its 8-byte limit")); + assert!(error.contains("retained output pipes"), "{error}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "escaped descendant held the validator open" + ); + + let pid = wait_for_pid(&pid_file); + if let Some(pid) = rustix::process::Pid::from_raw(pid as i32) { + let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL); + } + } + + #[cfg(windows)] + #[test] + fn child_cannot_break_away_from_the_job() { + let mut command = test_command("bounded_process::tests::attempt_job_breakaway"); + let result = output( + &mut command, + OutputLimits { + stdout: 4096, + stderr: 4096, + }, + ) + .unwrap(); + assert!(result.status.success()); + assert!( + result + .stdout + .windows(b"breakaway-rejected\n".len()) + .any(|window| window == b"breakaway-rejected\n") + ); assert!( - started.elapsed() < Duration::from_secs(10), - "overflowing child was not terminated promptly" + !result + .stdout + .windows(b"breakaway-allowed\n".len()) + .any(|window| window == b"breakaway-allowed\n") + ); + } + + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn spawn_oversized_grandchild() { + let mut child = test_command("bounded_process::tests::oversized_grandchild"); + child.env( + "YAML_SIGIL_TEST_PID_FILE", + std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap(), + ); + let _ = child.status(); + } + + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn spawn_poll_failure_tree() { + fs::write( + std::env::var_os("YAML_SIGIL_TEST_PARENT_PID_FILE").unwrap(), + std::process::id().to_string(), + ) + .unwrap(); + let mut child = test_command("bounded_process::tests::poll_failure_descendant"); + child.env( + "YAML_SIGIL_TEST_PID_FILE", + std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap(), + ); + let _ = child.status(); + } + + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn poll_failure_descendant() { + fs::write( + std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap(), + std::process::id().to_string(), + ) + .unwrap(); + thread::sleep(Duration::from_secs(30)); + } + + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn oversized_grandchild() { + let pid_file = PathBuf::from(std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap()); + fs::write(pid_file, std::process::id().to_string()).unwrap(); + let bytes = vec![b'x'; 1024]; + std::io::stdout().write_all(&bytes).unwrap(); + std::io::stdout().flush().unwrap(); + std::io::stderr().write_all(&bytes).unwrap(); + std::io::stderr().flush().unwrap(); + thread::sleep(Duration::from_secs(30)); + } + + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn echo_stdin_child() { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + fs::write( + std::env::var_os("YAML_SIGIL_TEST_INPUT_FILE").unwrap(), + bytes, + ) + .unwrap(); + } + + #[cfg(unix)] + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + // This helper deliberately orphans a new-session child so the parent + // regression can prove that timeout cleanup finds process-tree escapees. + #[allow(clippy::zombie_processes)] + fn spawn_session_escapee() { + use std::os::unix::process::CommandExt; + + let mut child = test_command("bounded_process::tests::sleeping_escapee"); + child.env( + "YAML_SIGIL_TEST_PID_FILE", + std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap(), ); + // SAFETY: the post-fork hook performs only the async-signal-safe + // setsid system call and returns its operating-system error. + unsafe { + child.pre_exec(|| { + rustix::process::setsid().map_err(io::Error::from)?; + Ok(()) + }); + } + child.spawn().unwrap(); + } + + #[cfg(unix)] + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn sleeping_escapee() { + let pid_file = PathBuf::from(std::env::var_os("YAML_SIGIL_TEST_PID_FILE").unwrap()); + fs::write(pid_file, std::process::id().to_string()).unwrap(); + thread::sleep(Duration::from_secs(30)); + } + + #[cfg(windows)] + #[test] + #[ignore = "spawned explicitly by the bounded-process regression"] + fn attempt_job_breakaway() { + use std::os::windows::process::CommandExt; + + use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB; + + let mut child = test_command("bounded_process::tests::sleep_child"); + child.creation_flags(CREATE_BREAKAWAY_FROM_JOB); + match child.spawn() { + Ok(mut child) => { + let _ = child.kill(); + let _ = child.wait(); + println!("breakaway-allowed"); + } + Err(_) => println!("breakaway-rejected"), + } } + #[cfg(windows)] #[test] - #[ignore = "spawned explicitly by the bounded-process lifecycle regression"] - fn oversized_stderr_then_sleep_child() { - let mut stderr = std::io::stderr().lock(); - stderr.write_all(b"123456789").unwrap(); - stderr.flush().unwrap(); - drop(stderr); - std::thread::sleep(Duration::from_secs(30)); + #[ignore = "spawned explicitly by the bounded-process regression"] + fn sleep_child() { + thread::sleep(Duration::from_secs(30)); } } diff --git a/xtask/src/crate_archive.rs b/xtask/src/crate_archive.rs index 4eaed08..d4ddeed 100644 --- a/xtask/src/crate_archive.rs +++ b/xtask/src/crate_archive.rs @@ -14,15 +14,26 @@ use flate2::read::GzDecoder; use serde::Deserialize; use sha2::{Digest, Sha256}; +use crate::bounded_process::{self, OutputLimits}; use crate::release_policy::PackagePolicy; pub(crate) const MAX_CRATE_BYTES: usize = 32 * 1024 * 1024; const MAX_JSON_BYTES: usize = 2 * 1024 * 1024; const MAX_ERROR_BYTES: usize = 64 * 1024; +const MAX_TOTAL_RESPONSE_BYTES: usize = 32 * 1024 * 1024; const MAX_CRATE_FILES: usize = 10_000; const MAX_CRATE_UNPACKED_BYTES: u64 = 128 * 1024 * 1024; const MAX_CRATE_DECOMPRESSED_BYTES: u64 = 160 * 1024 * 1024; const READ_ATTEMPTS: usize = 3; +pub(crate) const CARGO_ARCHIVE_MTIME: u64 = 1_153_704_088; +const CARGO_ARCHIVE_MODES: &[u32] = &[0o644, 0o755]; +const GNU_NUL_ZERO: [u8; 8] = [0; 8]; +const GNU_OCTAL_ZERO: [u8; 8] = *b"0000000\0"; +const GNU_UID_RANGE: std::ops::Range = 108..116; +const GNU_GID_RANGE: std::ops::Range = 116..124; +const GNU_TYPEFLAG_OFFSET: usize = 156; +const GNU_DEVICE_MAJOR_RANGE: std::ops::Range = 329..337; +const GNU_DEVICE_MINOR_RANGE: std::ops::Range = 337..345; const USER_AGENT: &str = "yaml-sigil-release-workflow/1.0"; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -41,39 +52,58 @@ pub(crate) trait Registry { fn download(&mut self, package: &str, version: &str) -> Result, String>; } -pub(crate) struct CratesIo; +pub(crate) struct CratesIo { + total_response_bytes: usize, +} impl CratesIo { pub(crate) fn new() -> Self { - Self + Self { + total_response_bytes: 0, + } + } + + fn account_response(&mut self, bytes: usize) -> Result<(), String> { + self.total_response_bytes = self + .total_response_bytes + .checked_add(bytes) + .ok_or_else(|| "aggregate registry response size overflowed".to_string())?; + if self.total_response_bytes > MAX_TOTAL_RESPONSE_BYTES { + return Err("aggregate registry response bytes exceeded 32 MiB".to_string()); + } + Ok(()) } - fn request_json(&self, path: &str) -> Result>, String> { + fn request_json(&mut self, path: &str) -> Result>, String> { let url = format!("https://crates.io/api/v1{path}"); let mut last = None; for attempt in 1..=READ_ATTEMPTS { - let output = Command::new("curl") - .args([ - "--disable", - "--silent", - "--show-error", - "--proto", - "=https", - "--proto-redir", - "=https", - "--max-time", - "30", - "--write-out", - "\n%{http_code}", - "--user-agent", - USER_AGENT, - &url, - ]) - .output() - .map_err(|error| format!("run crates.io request: {error}"))?; - if output.stdout.len() > MAX_JSON_BYTES || output.stderr.len() > MAX_ERROR_BYTES { - return Err("crates.io response exceeded its bound".to_string()); - } + let mut command = Command::new("curl"); + command.args([ + "--disable", + "--silent", + "--show-error", + "--proto", + "=https", + "--proto-redir", + "=https", + "--max-time", + "30", + "--write-out", + "\n%{http_code}", + "--user-agent", + USER_AGENT, + &url, + ]); + let output = bounded_process::output( + &mut command, + OutputLimits { + stdout: MAX_JSON_BYTES, + stderr: MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("run crates.io request: {error}"))?; + self.account_response(output.stdout.len())?; if !output.status.success() { let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); if attempt < READ_ATTEMPTS && transient_detail(&detail) { @@ -127,30 +157,34 @@ impl Registry for CratesIo { let url = format!("https://crates.io/api/v1/crates/{package}/{version}/download"); let mut last = None; for attempt in 1..=READ_ATTEMPTS { - let output = Command::new("curl") - .args([ - "--disable", - "--silent", - "--show-error", - "--fail", - "--location", - "--proto", - "=https", - "--proto-redir", - "=https", - "--max-time", - "60", - "--max-filesize", - &MAX_CRATE_BYTES.to_string(), - "--user-agent", - USER_AGENT, - &url, - ]) - .output() - .map_err(|error| format!("run crates.io archive download: {error}"))?; - if output.stdout.len() > MAX_CRATE_BYTES || output.stderr.len() > MAX_ERROR_BYTES { - return Err("crates.io archive response exceeded its bound".to_string()); - } + let mut command = Command::new("curl"); + command.args([ + "--disable", + "--silent", + "--show-error", + "--fail", + "--location", + "--proto", + "=https", + "--proto-redir", + "=https", + "--max-time", + "60", + "--max-filesize", + &MAX_CRATE_BYTES.to_string(), + "--user-agent", + USER_AGENT, + &url, + ]); + let output = bounded_process::output( + &mut command, + OutputLimits { + stdout: MAX_CRATE_BYTES, + stderr: MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("run crates.io archive download: {error}"))?; + self.account_response(output.stdout.len())?; if output.status.success() { return Ok(output.stdout); } @@ -205,7 +239,7 @@ pub(crate) fn require_archive( policy: &PackagePolicy, version: &str, commit: &str, -) -> Result<(String, BTreeMap>), String> { +) -> Result<(String, BTreeMap), String> { let record = registry .exact_version(policy.package, version)? .ok_or_else(|| format!("crates.io lacks {} {version}", policy.package))?; @@ -223,10 +257,29 @@ pub(crate) fn require_archive( policy.package )); } - let files = inspect_archive(&archive, policy, version, commit)?; + let files = inspect_archive_entries(&archive, policy, version, commit)?; Ok((record.checksum, files)) } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ArchiveFile { + pub(crate) body: Vec, + metadata: ArchiveMetadata, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ArchiveMetadata { + entry_type: u8, + mode: u32, + uid: Vec, + gid: Vec, + mtime: u64, + username: Vec, + groupname: Vec, + device_major: Vec, + device_minor: Vec, +} + pub(crate) fn inspect_archive( archive: &[u8], policy: &PackagePolicy, @@ -242,6 +295,36 @@ pub(crate) fn inspect_archive( ) } +pub(crate) fn inspect_archive_entries( + archive: &[u8], + policy: &PackagePolicy, + version: &str, + commit: &str, +) -> Result, String> { + inspect_archive_entries_with_limit( + archive, + policy, + version, + Some(commit), + MAX_CRATE_DECOMPRESSED_BYTES, + ) +} + +pub(crate) fn archive_vcs_commit( + archive: &[u8], + policy: &PackagePolicy, + version: &str, +) -> Result { + let files = inspect_archive_entries_with_limit( + archive, + policy, + version, + None, + MAX_CRATE_DECOMPRESSED_BYTES, + )?; + Ok(parse_vcs_info(&files, policy)?.git.sha1) +} + fn inspect_archive_with_limit( archive: &[u8], policy: &PackagePolicy, @@ -249,6 +332,22 @@ fn inspect_archive_with_limit( commit: &str, decompressed_limit: u64, ) -> Result>, String> { + inspect_archive_entries_with_limit(archive, policy, version, Some(commit), decompressed_limit) + .map(|entries| { + entries + .into_iter() + .map(|(path, file)| (path, file.body)) + .collect() + }) +} + +fn inspect_archive_entries_with_limit( + archive: &[u8], + policy: &PackagePolicy, + version: &str, + commit: Option<&str>, + decompressed_limit: u64, +) -> Result, String> { if archive.is_empty() || archive.len() > MAX_CRATE_BYTES { return Err(format!( "{} source archive is empty or oversized", @@ -299,18 +398,9 @@ fn inspect_archive_with_limit( let raw_path = std::str::from_utf8(&raw_path) .map_err(|_| format!("{} source archive has a non-UTF-8 path", policy.package))? .to_string(); - let directory = entry.header().entry_type().is_dir(); let path = raw_path.strip_suffix('/').unwrap_or(&raw_path); validate_archive_path(path, &prefix, policy.package)?; - if directory { - continue; - } - if !entry.header().entry_type().is_file() { - return Err(format!( - "{} source archive contains a non-file entry", - policy.package - )); - } + let metadata = cargo_archive_metadata(entry.header(), policy.package)?; let relative = path .strip_prefix(&format!("{prefix}/")) .ok_or_else(|| format!("{} source archive path lacks its root", policy.package))?; @@ -325,7 +415,11 @@ fn inspect_archive_with_limit( policy.package ) })?; - if body.len() as u64 != size || files.insert(relative.to_string(), body).is_some() { + if body.len() as u64 != size + || files + .insert(relative.to_string(), ArchiveFile { body, metadata }) + .is_some() + { return Err(format!( "{} source archive contains a duplicate or truncated file", policy.package @@ -335,7 +429,13 @@ fn inspect_archive_with_limit( if count == 0 { return Err(format!("{} source archive is empty", policy.package)); } - require_vcs_info(&files, policy, commit)?; + let vcs = parse_vcs_info(&files, policy)?; + if commit.is_some_and(|expected| vcs.git.sha1 != expected) { + return Err(format!( + "{} source archive is not bound to the exact clean release commit", + policy.package + )); + } Ok(files) })(); @@ -356,6 +456,89 @@ fn inspect_archive_with_limit( inspection } +fn cargo_archive_metadata(header: &tar::Header, package: &str) -> Result { + if header.as_gnu().is_none() { + return Err(format!( + "{package} source archive entry does not use Cargo's GNU header format" + )); + } + // The GNU tar header stores the raw type flag at byte 156. Cargo 1.95 + // emits the canonical ASCII `0` regular-file spelling, not the NUL alias. + let entry_type = header.as_bytes()[GNU_TYPEFLAG_OFFSET]; + if entry_type != tar::EntryType::file().as_byte() { + return Err(format!( + "{package} source archive contains a noncanonical entry type" + )); + } + let mode = header + .mode() + .map_err(|error| format!("{package} source archive has an invalid mode: {error}"))?; + if !CARGO_ARCHIVE_MODES.contains(&mode) { + return Err(format!( + "{package} source archive contains a noncanonical or unsafe mode" + )); + } + // Cargo 1.95 emits NUL zeroes for generated entries and octal zeroes for + // entries copied from disk. Preserve the raw form so reproduction catches + // a representation change even though both decode to numeric zero. + let uid = header.as_bytes()[GNU_UID_RANGE].to_vec(); + let gid = header.as_bytes()[GNU_GID_RANGE].to_vec(); + if !cargo_zero_field(&uid) || !cargo_zero_field(&gid) { + return Err(format!( + "{package} source archive contains noncanonical ownership: UID {uid:?}, GID {gid:?}" + )); + } + let mtime = header + .mtime() + .map_err(|error| format!("{package} source archive has an invalid mtime: {error}"))?; + if mtime != CARGO_ARCHIVE_MTIME { + return Err(format!( + "{package} source archive contains a noncanonical mtime" + )); + } + let username = header + .username_bytes() + .ok_or_else(|| format!("{package} source archive omits Cargo's owner representation"))?; + let groupname = header + .groupname_bytes() + .ok_or_else(|| format!("{package} source archive omits Cargo's group representation"))?; + if !username.is_empty() || !groupname.is_empty() { + return Err(format!( + "{package} source archive contains noncanonical owner or group names" + )); + } + if header + .link_name_bytes() + .is_some_and(|name| !name.is_empty()) + { + return Err(format!( + "{package} source archive regular entry contains a link target" + )); + } + let device_major = header.as_bytes()[GNU_DEVICE_MAJOR_RANGE].to_vec(); + let device_minor = header.as_bytes()[GNU_DEVICE_MINOR_RANGE].to_vec(); + if !cargo_zero_field(&device_major) || !cargo_zero_field(&device_minor) { + return Err(format!( + "{package} source archive contains noncanonical device metadata" + )); + } + Ok(ArchiveMetadata { + entry_type, + mode, + uid, + gid, + mtime, + username: username.to_vec(), + groupname: groupname.to_vec(), + device_major, + device_minor, + }) +} + +fn cargo_zero_field(field: &[u8]) -> bool { + field == GNU_NUL_ZERO || field == GNU_OCTAL_ZERO +} + fn validate_archive_path(path: &str, prefix: &str, package: &str) -> Result<(), String> { if path.is_empty() || path.starts_with('/') @@ -384,30 +567,40 @@ struct VcsGit { dirty: bool, } -fn require_vcs_info( - files: &BTreeMap>, +fn parse_vcs_info( + files: &BTreeMap, policy: &PackagePolicy, - commit: &str, -) -> Result<(), String> { - let body = files.get(".cargo_vcs_info.json").ok_or_else(|| { - format!( - "{} source archive lacks .cargo_vcs_info.json", - policy.package - ) - })?; +) -> Result { + let body = &files + .get(".cargo_vcs_info.json") + .ok_or_else(|| { + format!( + "{} source archive lacks .cargo_vcs_info.json", + policy.package + ) + })? + .body; let vcs: VcsInfo = serde_json::from_slice(body).map_err(|error| { format!( "{} source archive has invalid VCS metadata: {error}", policy.package ) })?; - if vcs.git.sha1 != commit || vcs.git.dirty || vcs.path_in_vcs != policy.path_in_vcs { + if vcs.git.sha1.len() != 40 + || !vcs + .git + .sha1 + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + || vcs.git.dirty + || vcs.path_in_vcs != policy.path_in_vcs + { return Err(format!( - "{} source archive is not bound to the exact clean release commit", + "{} source archive contains invalid VCS identity", policy.package )); } - Ok(()) + Ok(vcs) } pub(crate) fn is_checksum(value: &str) -> bool { @@ -418,28 +611,32 @@ pub(crate) fn is_checksum(value: &str) -> bool { } pub(crate) fn require_clean_source(root: &Path, commit: &str) -> Result<(), String> { - let head = Command::new("git") - .current_dir(root) - .args(["rev-parse", "HEAD"]) - .output() - .map_err(|error| format!("read release source commit: {error}"))?; - if head.stdout.len() > MAX_ERROR_BYTES - || head.stderr.len() > MAX_ERROR_BYTES - || !head.status.success() - || String::from_utf8_lossy(&head.stdout).trim() != commit - { + let mut head_command = Command::new("git"); + head_command.current_dir(root).args(["rev-parse", "HEAD"]); + let head = bounded_process::output( + &mut head_command, + OutputLimits { + stdout: MAX_ERROR_BYTES, + stderr: MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("read release source commit: {error}"))?; + if !head.status.success() || String::from_utf8_lossy(&head.stdout).trim() != commit { return Err("release source is not at the expected commit".to_string()); } - let status = Command::new("git") + let mut status_command = Command::new("git"); + status_command .current_dir(root) - .args(["status", "--porcelain"]) - .output() - .map_err(|error| format!("read release source status: {error}"))?; - if status.stdout.len() > MAX_JSON_BYTES - || status.stderr.len() > MAX_ERROR_BYTES - || !status.status.success() - || !status.stdout.is_empty() - { + .args(["status", "--porcelain"]); + let status = bounded_process::output( + &mut status_command, + OutputLimits { + stdout: MAX_JSON_BYTES, + stderr: MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("read release source status: {error}"))?; + if !status.status.success() || !status.stdout.is_empty() { return Err("release source is not a clean checkout".to_string()); } Ok(()) @@ -451,23 +648,243 @@ mod tests { use flate2::Compression; use flate2::write::GzEncoder; + #[derive(Clone, Copy)] + struct TestMetadata { + gnu: bool, + entry_type: u8, + mode: u32, + uid: [u8; 8], + gid: [u8; 8], + mtime: u64, + username: &'static str, + groupname: &'static str, + device_major: [u8; 8], + device_minor: [u8; 8], + } + + impl Default for TestMetadata { + fn default() -> Self { + Self { + gnu: true, + entry_type: tar::EntryType::file().as_byte(), + mode: 0o644, + uid: [0; 8], + gid: [0; 8], + mtime: CARGO_ARCHIVE_MTIME, + username: "", + groupname: "", + device_major: [0; 8], + device_minor: [0; 8], + } + } + } + + fn append_test_file( + builder: &mut tar::Builder>>, + path: &str, + body: &[u8], + metadata: TestMetadata, + ) { + let mut header = if metadata.gnu { + tar::Header::new_gnu() + } else { + tar::Header::new_ustar() + }; + header.set_entry_type(tar::EntryType::new(metadata.entry_type)); + header.as_mut_bytes()[GNU_TYPEFLAG_OFFSET] = metadata.entry_type; + header.set_mode(metadata.mode); + header.set_mtime(metadata.mtime); + header.set_username(metadata.username).unwrap(); + header.set_groupname(metadata.groupname).unwrap(); + header.as_mut_bytes()[GNU_UID_RANGE].copy_from_slice(&metadata.uid); + header.as_mut_bytes()[GNU_GID_RANGE].copy_from_slice(&metadata.gid); + header.as_mut_bytes()[GNU_DEVICE_MAJOR_RANGE].copy_from_slice(&metadata.device_major); + header.as_mut_bytes()[GNU_DEVICE_MINOR_RANGE].copy_from_slice(&metadata.device_minor); + header.set_size(body.len() as u64); + header.set_cksum(); + builder.append_data(&mut header, path, body).unwrap(); + } + fn archive_with_files(files: &[(&str, &[u8])]) -> Vec { let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut builder = tar::Builder::new(encoder); for (path, body) in files { - let mut header = tar::Header::new_gnu(); - header.set_mode(0o644); - header.set_size(body.len() as u64); - header.set_cksum(); - builder.append_data(&mut header, path, *body).unwrap(); + append_test_file(&mut builder, path, body, TestMetadata::default()); } builder.into_inner().unwrap().finish().unwrap() } + fn archive_with_metadata(path: &str, body: &[u8], metadata: TestMetadata) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + append_test_file(&mut builder, path, body, metadata); + builder.into_inner().unwrap().finish().unwrap() + } + fn archive(path: &str, vcs: &[u8]) -> Vec { archive_with_files(&[(path, vcs)]) } + struct FixtureDirectory(std::path::PathBuf); + + impl Drop for FixtureDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn run_fixture_command(command: &mut Command, label: &str) -> Vec { + let output = bounded_process::output(command, bounded_process::VALIDATION_OUTPUT_LIMITS) + .unwrap_or_else(|error| panic!("{label}: {error}")); + assert!( + output.status.success(), + "{label}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout + } + + #[test] + fn cargo_1_95_archive_matches_observed_cross_platform_contract() { + if std::env::var_os("YAML_SIGIL_REQUIRE_CARGO_1_95_ARCHIVE").is_none() { + return; + } + + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let version = + run_fixture_command(Command::new(&cargo).arg("--version"), "read Cargo version"); + let version = String::from_utf8(version).expect("Cargo version is UTF-8"); + assert!( + version.starts_with("cargo 1.95.0 "), + "archive contract must be observed with exact Cargo 1.95.0, got {version:?}" + ); + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "yaml-sigil-cargo-archive-contract-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(root.join("src")).expect("create fixture source directory"); + let _fixture = FixtureDirectory(root.clone()); + std::fs::write( + root.join("Cargo.toml"), + b"[package]\nname = \"cargo-archive-contract\"\nversion = \"0.1.0\"\nedition = \"2024\"\nlicense = \"Apache-2.0\"\ndescription = \"Cargo 1.95 archive contract fixture\"\n", + ) + .expect("write fixture manifest"); + let source = root.join("src/lib.rs"); + std::fs::write(&source, b"pub fn fixture() -> bool { true }\n") + .expect("write fixture source"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mut permissions = std::fs::metadata(&source) + .expect("read fixture source permissions") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&source, permissions).expect("make fixture source executable"); + } + + run_fixture_command( + Command::new("git") + .current_dir(&root) + .args(["init", "--quiet"]), + "initialize fixture repository", + ); + run_fixture_command( + Command::new("git") + .current_dir(&root) + .args(["config", "core.autocrlf", "false"]), + "configure fixture line endings", + ); + run_fixture_command( + Command::new("git").current_dir(&root).args([ + "config", + "user.name", + "Cargo archive fixture", + ]), + "configure fixture author name", + ); + run_fixture_command( + Command::new("git").current_dir(&root).args([ + "config", + "user.email", + "fixture@example.invalid", + ]), + "configure fixture author email", + ); + run_fixture_command( + Command::new("git") + .current_dir(&root) + .args(["add", "Cargo.toml", "src/lib.rs"]), + "stage fixture source", + ); + run_fixture_command( + Command::new("git").current_dir(&root).args([ + "commit", + "--quiet", + "--no-gpg-sign", + "-m", + "fixture", + ]), + "commit fixture source", + ); + let commit = run_fixture_command( + Command::new("git") + .current_dir(&root) + .args(["rev-parse", "HEAD"]), + "read fixture commit", + ); + let commit = String::from_utf8(commit) + .expect("fixture commit is UTF-8") + .trim() + .to_string(); + + run_fixture_command( + Command::new(&cargo).current_dir(&root).args([ + "package", + "--no-verify", + "--offline", + "--target-dir", + "target", + ]), + "package Cargo archive fixture", + ); + let archive = std::fs::read(root.join("target/package/cargo-archive-contract-0.1.0.crate")) + .expect("read packaged fixture"); + let policy = PackagePolicy { + package: "cargo-archive-contract", + tag_prefix: "v", + changelog: "CHANGELOG.md", + path_in_vcs: "", + }; + let entries = inspect_archive_entries(&archive, &policy, "0.1.0", &commit) + .expect("Cargo 1.95 fixture follows the encoded archive contract"); + + assert_eq!(entries["Cargo.toml"].metadata.mode, 0o644); + assert_eq!(entries["Cargo.toml.orig"].metadata.mode, 0o644); + assert_eq!(entries["Cargo.lock"].metadata.mode, 0o644); + assert_eq!(entries[".cargo_vcs_info.json"].metadata.mode, 0o644); + for generated in [".cargo_vcs_info.json", "Cargo.lock", "Cargo.toml"] { + assert_eq!(entries[generated].metadata.uid, GNU_NUL_ZERO); + assert_eq!(entries[generated].metadata.gid, GNU_NUL_ZERO); + assert_eq!(entries[generated].metadata.device_major, GNU_NUL_ZERO); + assert_eq!(entries[generated].metadata.device_minor, GNU_NUL_ZERO); + } + for copied in ["Cargo.toml.orig", "src/lib.rs"] { + assert_eq!(entries[copied].metadata.uid, GNU_OCTAL_ZERO); + assert_eq!(entries[copied].metadata.gid, GNU_OCTAL_ZERO); + assert_eq!(entries[copied].metadata.device_major, GNU_OCTAL_ZERO); + assert_eq!(entries[copied].metadata.device_minor, GNU_OCTAL_ZERO); + } + #[cfg(unix)] + assert_eq!(entries["src/lib.rs"].metadata.mode, 0o755); + #[cfg(windows)] + assert_eq!(entries["src/lib.rs"].metadata.mode, 0o644); + } + #[test] fn archive_requires_exact_vcs_commit_and_path() { let package = &crate::release_policy::TRAITS_POLICY.packages[0]; @@ -488,6 +905,142 @@ mod tests { assert!(validate_archive_path("other-0.4.0/Cargo.toml", &root, package).is_err()); } + #[test] + fn cargo_metadata_contract_rejects_each_independent_drift() { + let package = &crate::release_policy::TRAITS_POLICY.packages[0]; + let commit = "a".repeat(40); + let vcs = format!("{{\"git\":{{\"sha1\":\"{commit}\"}},\"path_in_vcs\":\"\"}}"); + let path = format!("{}-0.4.0/.cargo_vcs_info.json", package.package); + + let mut cases = Vec::new(); + cases.push(( + "header format", + TestMetadata { + gnu: false, + ..TestMetadata::default() + }, + )); + cases.push(( + "entry type", + TestMetadata { + entry_type: b'\0', + ..TestMetadata::default() + }, + )); + cases.push(( + "mode", + TestMetadata { + mode: 0o600, + ..TestMetadata::default() + }, + )); + let mut metadata = TestMetadata::default(); + metadata.uid[0] = b'1'; + cases.push(("uid", metadata)); + let mut metadata = TestMetadata::default(); + metadata.gid[0] = b'1'; + cases.push(("gid", metadata)); + let mut metadata = TestMetadata::default(); + metadata.mtime += 1; + cases.push(("mtime", metadata)); + cases.push(( + "username", + TestMetadata { + username: "root", + ..TestMetadata::default() + }, + )); + cases.push(( + "groupname", + TestMetadata { + groupname: "root", + ..TestMetadata::default() + }, + )); + let mut metadata = TestMetadata::default(); + metadata.device_major[0] = b'1'; + cases.push(("device major", metadata)); + let mut metadata = TestMetadata::default(); + metadata.device_minor[0] = b'1'; + cases.push(("device minor", metadata)); + + for (label, metadata) in cases { + let bytes = archive_with_metadata(&path, vcs.as_bytes(), metadata); + assert!( + inspect_archive(&bytes, package, "0.4.0", &commit).is_err(), + "accepted drift in {label}" + ); + } + } + + #[test] + fn cargo_regular_modes_are_exact_and_metadata_affects_equality() { + let package = &crate::release_policy::TRAITS_POLICY.packages[0]; + let commit = "a".repeat(40); + let vcs = format!("{{\"git\":{{\"sha1\":\"{commit}\"}},\"path_in_vcs\":\"\"}}"); + let path = format!("{}-0.4.0/.cargo_vcs_info.json", package.package); + let regular = archive_with_metadata(&path, vcs.as_bytes(), TestMetadata::default()); + let executable = archive_with_metadata( + &path, + vcs.as_bytes(), + TestMetadata { + mode: 0o755, + ..TestMetadata::default() + }, + ); + + let regular = inspect_archive_entries(®ular, package, "0.4.0", &commit).unwrap(); + let executable = inspect_archive_entries(&executable, package, "0.4.0", &commit).unwrap(); + assert_ne!(regular, executable); + assert_eq!( + regular[".cargo_vcs_info.json"].body, + executable[".cargo_vcs_info.json"].body + ); + } + + #[test] + fn cargo_zero_representations_are_exact_and_affect_equality() { + let package = &crate::release_policy::TRAITS_POLICY.packages[0]; + let commit = "a".repeat(40); + let vcs = format!("{{\"git\":{{\"sha1\":\"{commit}\"}},\"path_in_vcs\":\"\"}}"); + let path = format!("{}-0.4.0/.cargo_vcs_info.json", package.package); + let nul = archive_with_metadata(&path, vcs.as_bytes(), TestMetadata::default()); + let octal = archive_with_metadata( + &path, + vcs.as_bytes(), + TestMetadata { + uid: GNU_OCTAL_ZERO, + gid: GNU_OCTAL_ZERO, + device_major: GNU_OCTAL_ZERO, + device_minor: GNU_OCTAL_ZERO, + ..TestMetadata::default() + }, + ); + + let nul = inspect_archive_entries(&nul, package, "0.4.0", &commit).unwrap(); + let octal = inspect_archive_entries(&octal, package, "0.4.0", &commit).unwrap(); + assert_ne!(nul, octal); + } + + #[test] + fn special_archive_entry_types_are_rejected() { + let package = &crate::release_policy::TRAITS_POLICY.packages[0]; + let commit = "a".repeat(40); + let vcs = format!("{{\"git\":{{\"sha1\":\"{commit}\"}},\"path_in_vcs\":\"\"}}"); + let path = format!("{}-0.4.0/.cargo_vcs_info.json", package.package); + for entry_type in *b"23456" { + let bytes = archive_with_metadata( + &path, + vcs.as_bytes(), + TestMetadata { + entry_type, + ..TestMetadata::default() + }, + ); + assert!(inspect_archive(&bytes, package, "0.4.0", &commit).is_err()); + } + } + #[test] fn compressed_archive_expansion_within_stream_limit_is_accepted() { let package = &crate::release_policy::TRAITS_POLICY.packages[0]; diff --git a/xtask/src/github.rs b/xtask/src/github.rs index 5a04d4a..4915caf 100644 --- a/xtask/src/github.rs +++ b/xtask/src/github.rs @@ -9,6 +9,7 @@ mod intent; mod models; mod release_objects; mod release_pr; +mod release_train; mod source; mod transport; @@ -18,6 +19,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use crate::bounded_process::{self, OutputLimits}; use clap::{Args, Subcommand, ValueEnum}; use transport::GhCli; @@ -43,6 +45,8 @@ enum GithubCommand { ReleaseSource(ReleaseSourceArgs), /// Verify or recover source-only official release objects. ReleaseObjects(ReleaseObjectsArgs), + /// Capture, attest, finalize, or notify one source-only release train. + ReleaseTrain(ReleaseTrainArgs), } #[derive(Args)] @@ -127,6 +131,131 @@ enum ReleaseObjectsCommand { }, } +#[derive(Args)] +struct ReleaseTrainArgs { + #[command(subcommand)] + command: ReleaseTrainCommand, +} + +#[derive(Subcommand)] +enum ReleaseTrainCommand { + /// Discover fresh or partial-publication source before plan capture. + Discover { + #[arg(long)] + repository: String, + #[arg(long)] + commit: String, + }, + /// Capture one canonical release plan from exact protected source. + Capture { + #[arg(long)] + repository: String, + #[arg(long)] + commit: String, + #[arg(long)] + baseline_version: String, + #[arg(long)] + baseline_commit: String, + }, + /// Recompute a captured plan and permit only exact registry progression. + Verify { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + baseline_version: String, + #[arg(long)] + baseline_commit: String, + }, + /// Wait at most 20 minutes for the complete planned registry train. + Wait { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + }, + /// Prepare the canonical release-train intent before token minting. + PrepareIntent { + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + origin_run_id: String, + #[arg(long)] + origin_run_attempt: String, + #[arg(long)] + ruleset_evidence_sha256: String, + }, + /// Create or verify the App-authored release-train intent Check. + CreateIntent { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + intent: String, + #[arg(long)] + expected_app_slug: String, + #[arg(long)] + expected_installation_id: String, + }, + /// Re-read and verify the exact durable intent with a read-only token. + VerifyIntent { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + intent: String, + #[arg(long)] + check_id: String, + }, + /// Finalize the exact published registry prefix with a contents-only token. + Finalize { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + intent: String, + #[arg(long)] + expected_app_slug: String, + #[arg(long)] + expected_installation_id: String, + }, + /// Emit one closed complete-train repository dispatch. + Notify { + #[arg(long)] + repository: String, + #[arg(long)] + plan: String, + #[arg(long)] + plan_digest: String, + #[arg(long)] + intent: String, + #[arg(long)] + intent_check_id: String, + #[arg(long)] + finalized_entries: String, + #[arg(long)] + expected_app_slug: String, + #[arg(long)] + expected_installation_id: String, + }, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub(super) enum ReconcileMode { Prepublish, @@ -185,6 +314,132 @@ pub fn run(root: &Path, args: GithubArgs) -> Result<(), String> { &mut github, ), }, + GithubCommand::ReleaseTrain(args) => match args.command { + ReleaseTrainCommand::Discover { repository, commit } => { + release_train::discover_command(root, &repository, &commit, &mut github) + } + ReleaseTrainCommand::Capture { + repository, + commit, + baseline_version, + baseline_commit, + } => release_train::capture_command( + root, + &repository, + &commit, + &baseline_version, + &baseline_commit, + &mut github, + ), + ReleaseTrainCommand::Verify { + repository, + plan, + plan_digest, + baseline_version, + baseline_commit, + } => release_train::verify_command( + root, + &repository, + &plan, + &plan_digest, + &baseline_version, + &baseline_commit, + &mut github, + ), + ReleaseTrainCommand::Wait { + repository, + plan, + plan_digest, + } => release_train::wait_command(&repository, &plan, &plan_digest), + ReleaseTrainCommand::PrepareIntent { + plan, + plan_digest, + origin_run_id, + origin_run_attempt, + ruleset_evidence_sha256, + } => release_train::prepare_intent_command( + root, + release_train::PrepareIntentInput { + plan: &plan, + plan_digest: &plan_digest, + origin_run_id: &origin_run_id, + origin_run_attempt: &origin_run_attempt, + ruleset_evidence_sha256: &ruleset_evidence_sha256, + }, + ), + ReleaseTrainCommand::CreateIntent { + repository, + plan, + plan_digest, + intent, + expected_app_slug, + expected_installation_id, + } => release_train::create_intent_command( + release_train::CreateIntentInput { + repository: &repository, + plan: &plan, + plan_digest: &plan_digest, + intent: &intent, + expected_app_slug: &expected_app_slug, + expected_installation_id: &expected_installation_id, + }, + &mut github, + ), + ReleaseTrainCommand::VerifyIntent { + repository, + plan, + plan_digest, + intent, + check_id, + } => release_train::verify_intent_command( + &repository, + &plan, + &plan_digest, + &intent, + &check_id, + &mut github, + ), + ReleaseTrainCommand::Finalize { + repository, + plan, + plan_digest, + intent, + expected_app_slug, + expected_installation_id, + } => release_train::finalize_command( + release_train::FinalizeInput { + repository: &repository, + plan: &plan, + plan_digest: &plan_digest, + intent: &intent, + expected_app_slug: &expected_app_slug, + expected_installation_id: &expected_installation_id, + }, + &mut github, + ), + ReleaseTrainCommand::Notify { + repository, + plan, + plan_digest, + intent, + intent_check_id, + finalized_entries, + expected_app_slug, + expected_installation_id, + } => release_train::notify_command( + release_train::NotifyInput { + repository: &repository, + plan: &plan, + plan_digest: &plan_digest, + intent: &intent, + intent_check_id: &intent_check_id, + finalized_entries: &finalized_entries, + expected_app_slug: &expected_app_slug, + expected_installation_id: &expected_installation_id, + }, + &mut github, + ), + }, } } @@ -269,17 +524,16 @@ pub(super) fn append_outputs(values: &[(&str, &str)]) -> Result<(), String> { } pub(super) fn command_output(root: &Path, program: &str, args: &[&str]) -> Result { - let output = Command::new(program) - .current_dir(root) - .args(args) - .output() - .map_err(|error| format!("run {program}: {error}"))?; - if output.stdout.len() > transport::MAX_RESPONSE_BYTES - || output.stderr.len() > transport::MAX_ERROR_BYTES - { - return Err(format!("{program} output exceeded its bound")); - } - Ok(output) + let mut command = Command::new(program); + command.current_dir(root).args(args); + bounded_process::output( + &mut command, + OutputLimits { + stdout: transport::MAX_RESPONSE_BYTES, + stderr: transport::MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("run {program}: {error}")) } pub(super) fn git_output(root: &Path, args: &[&str]) -> Result { diff --git a/xtask/src/github/release_objects.rs b/xtask/src/github/release_objects.rs index 6128d98..02cc84f 100644 --- a/xtask/src/github/release_objects.rs +++ b/xtask/src/github/release_objects.rs @@ -15,15 +15,17 @@ use serde_json::{Value, json}; use tempfile::Builder; use toml_edit::DocumentMut; -use crate::crate_archive::{CratesIo, Registry, require_archive, require_clean_source}; +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; +use crate::crate_archive::{ + CratesIo, Registry, inspect_archive_entries, require_archive, require_clean_source, +}; use crate::github::consts::RepositoryKind; use crate::github::identity::token_signature; use crate::github::models::{GitObject, GitRef, Signature}; use crate::github::transport::{Transport, percent_encode}; use crate::github::{ReconcileMode, git_line, is_sha, repository_policy_for_root}; use crate::release_policy::{PackagePolicy, ReleasePolicy, detect}; - -const MAX_COMMAND_OUTPUT: usize = 4 * 1024 * 1024; +use crate::safe_file; pub(super) fn reconcile_command( root: &Path, @@ -80,12 +82,12 @@ fn token_identity(root: &Path, github: &mut impl Transport) -> Result { - policy: &'a PackagePolicy, - version: String, - tag: String, - body: String, - prerelease: bool, +pub(super) struct ReleaseSpec<'a> { + pub(super) policy: &'a PackagePolicy, + pub(super) version: String, + pub(super) tag: String, + pub(super) body: String, + pub(super) prerelease: bool, } impl ReleaseSpec<'_> { @@ -97,7 +99,7 @@ impl ReleaseSpec<'_> { } } -fn release_specs<'a>( +pub(super) fn release_specs<'a>( root: &Path, policy: &'a ReleasePolicy, version: &str, @@ -275,35 +277,44 @@ fn require_registry_publication( Ok(checksums) } -fn require_reproduced_archive( +pub(super) fn require_reproduced_archive( root: &Path, registry: &mut impl Registry, spec: &ReleaseSpec<'_>, commit: &str, ) -> Result { - let (checksum, mut published) = require_archive(registry, spec.policy, &spec.version, commit)?; + let (checksum, published) = require_archive(registry, spec.policy, &spec.version, commit)?; let archive = package_source(root, spec)?; - let mut reproduced = - crate::crate_archive::inspect_archive(&archive, spec.policy, &spec.version, commit)?; - require_generated_lock(&published, spec)?; - require_generated_lock(&reproduced, spec)?; - published.remove("Cargo.lock"); - reproduced.remove("Cargo.lock"); + let reproduced = inspect_archive_entries(&archive, spec.policy, &spec.version, commit)?; + require_matching_archive_entries(&published, &reproduced, spec)?; + Ok(checksum) +} + +fn require_matching_archive_entries( + published: &BTreeMap, + reproduced: &BTreeMap, + spec: &ReleaseSpec<'_>, +) -> Result<(), String> { if published != reproduced { + if published.get("Cargo.lock") != reproduced.get("Cargo.lock") { + return Err(format!( + "exact Cargo.lock entry differs between published and reproduced {} {} archives", + spec.policy.package, spec.version + )); + } return Err(format!( - "local source content differs from {} {}", + "local source content or Cargo archive metadata differs from {} {}", spec.policy.package, spec.version )); } - Ok(checksum) + Ok(()) } -fn package_source(root: &Path, spec: &ReleaseSpec<'_>) -> Result, String> { +pub(super) fn package_source(root: &Path, spec: &ReleaseSpec<'_>) -> Result, String> { let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let version = Command::new(&cargo) - .current_dir(root) - .arg("--version") - .output() + let mut version_command = Command::new(&cargo); + version_command.current_dir(root).arg("--version"); + let version = bounded_process::output(&mut version_command, VALIDATION_OUTPUT_LIMITS) .map_err(|error| format!("read Cargo version: {error}"))?; let version_line = String::from_utf8_lossy(&version.stdout); if !version.status.success() || !version_line.starts_with("cargo 1.95.0 ") { @@ -314,15 +325,13 @@ fn package_source(root: &Path, spec: &ReleaseSpec<'_>) -> Result, String .tempdir() .map_err(|error| format!("create source-package directory: {error}"))?; let target = temporary.path().join("target"); - let output = Command::new(&cargo) + let mut package_command = Command::new(&cargo); + package_command .current_dir(root) .env("CARGO_TARGET_DIR", &target) - .args(["package", "--no-verify", "--package", spec.policy.package]) - .output() + .args(["package", "--no-verify", "--package", spec.policy.package]); + let output = bounded_process::output(&mut package_command, VALIDATION_OUTPUT_LIMITS) .map_err(|error| format!("package {}: {error}", spec.policy.package))?; - if output.stdout.len() > MAX_COMMAND_OUTPUT || output.stderr.len() > MAX_COMMAND_OUTPUT { - return Err("Cargo package output exceeded its bound".to_string()); - } if !output.status.success() { let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(format!( @@ -341,57 +350,6 @@ fn package_source(root: &Path, spec: &ReleaseSpec<'_>) -> Result, String fs::read(&archive).map_err(|error| format!("read reproduced source archive: {error}")) } -fn require_generated_lock( - files: &BTreeMap>, - spec: &ReleaseSpec<'_>, -) -> Result<(), String> { - let lock = files.get("Cargo.lock").ok_or_else(|| { - format!( - "{} source package lacks generated Cargo.lock", - spec.policy.package - ) - })?; - let body = std::str::from_utf8(lock).map_err(|_| { - format!( - "{} source package has non-UTF-8 Cargo.lock", - spec.policy.package - ) - })?; - let document = body.parse::().map_err(|error| { - format!( - "{} source package has invalid Cargo.lock: {error}", - spec.policy.package - ) - })?; - let packages = document - .get("package") - .and_then(toml_edit::Item::as_array_of_tables) - .ok_or_else(|| { - format!( - "{} source package has no lock packages", - spec.policy.package - ) - })?; - let matches: Vec<_> = packages - .iter() - .filter(|package| { - package.get("name").and_then(toml_edit::Item::as_str) == Some(spec.policy.package) - && package.get("version").and_then(toml_edit::Item::as_str) - == Some(spec.version.as_str()) - }) - .collect(); - if matches.len() != 1 - || matches[0].contains_key("source") - || matches[0].contains_key("checksum") - { - return Err(format!( - "{} source package has an unbound generated Cargo.lock", - spec.policy.package - )); - } - Ok(()) -} - fn recheck_registry( registry: &mut impl Registry, specs: &[ReleaseSpec<'_>], @@ -633,8 +591,8 @@ fn require_main(github: &mut impl Transport, repository: &str, commit: &str) -> Ok(()) } -fn manifest_version(root: &Path, kind: RepositoryKind) -> Result { - let body = fs::read_to_string(root.join("Cargo.toml")) +pub(super) fn manifest_version(root: &Path, kind: RepositoryKind) -> Result { + let body = safe_file::read_manifest(root, Path::new("Cargo.toml")) .map_err(|error| format!("read release manifest: {error}"))?; let document = body .parse::() @@ -655,7 +613,7 @@ fn manifest_version(root: &Path, kind: RepositoryKind) -> Result Ok(value.to_string()) } -fn parse_version(value: &str) -> Result { +pub(super) fn parse_version(value: &str) -> Result { let version = Version::parse(value) .map_err(|error| format!("unsupported release version {value}: {error}"))?; if !version.build.is_empty() { @@ -882,4 +840,20 @@ mod tests { .is_err() ); } + + #[test] + fn published_archive_comparison_includes_generated_cargo_lock() { + let release = spec(&crate::release_policy::TRAITS_POLICY.packages[0]); + let published = BTreeMap::from([ + ("Cargo.lock".to_string(), b"published lock".to_vec()), + ("Cargo.toml".to_string(), b"manifest".to_vec()), + ]); + let mut reproduced = published.clone(); + assert!(require_matching_archive_entries(&published, &reproduced, &release).is_ok()); + + reproduced.insert("Cargo.lock".to_string(), b"different lock".to_vec()); + let error = + require_matching_archive_entries(&published, &reproduced, &release).unwrap_err(); + assert!(error.contains("exact Cargo.lock entry differs")); + } } diff --git a/xtask/src/github/release_pr.rs b/xtask/src/github/release_pr.rs index b17b3b7..ac57371 100644 --- a/xtask/src/github/release_pr.rs +++ b/xtask/src/github/release_pr.rs @@ -4,6 +4,7 @@ //! Create and finalize exact GitHub-signed release proposal commits. use std::env; +#[cfg(test)] use std::fs; use std::path::Path; use std::process::Command; @@ -13,6 +14,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use toml_edit::DocumentMut; +use crate::bounded_process::{self, OutputLimits}; use crate::github::consts::{ RELEASE_AUTHORIZATION_SENTENCE, RELEASE_TITLE_PREFIX, RepositoryKind, RepositoryPolicy, repository_policy, @@ -25,9 +27,12 @@ use crate::github::{ env_bool, env_required, git_line, git_output, is_positive_integer, is_sha, output_detail, validate_ref_component, workflow_repository, }; +use crate::safe_file; const MAX_CHANGED_FILES: usize = 16; const MAX_TOTAL_CONTENT_BYTES: usize = 4 * 1024 * 1024; +const REPLAY_TRAILER: &str = "YamlSigil-Release-Replay: "; +const REPLAY_COMMENT_PREFIX: &str = "\n", + self.base_body.trim_end() + ), + None => self.base_body.clone(), + } + } +} + +fn optional_replay_key() -> Result, String> { + let Some(value) = env::var_os("RELEASE_REPLAY_KEY") else { + return Ok(None); + }; + let value = value + .into_string() + .map_err(|_| "RELEASE_REPLAY_KEY is not valid UTF-8".to_string())?; + if value.is_empty() { + return Ok(None); + } + if !is_digest(&value) { + return Err("RELEASE_REPLAY_KEY must be a lowercase SHA-256".to_string()); + } + Ok(Some(value)) +} + +fn is_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } fn parse_release_target(value: &str) -> Result { @@ -107,14 +161,8 @@ fn parse_release_target(value: &str) -> Result { } fn manifest_version(root: &Path, kind: RepositoryKind) -> Result { - let path = root.join("Cargo.toml"); - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("read release manifest metadata: {error}"))?; - if !metadata.file_type().is_file() || metadata.len() > MAX_FILE_BYTES { - return Err("release manifest is missing, indirect, or oversized".to_string()); - } - let body = - fs::read_to_string(&path).map_err(|error| format!("read release manifest: {error}"))?; + let body = safe_file::read_manifest(root, Path::new("Cargo.toml")) + .map_err(|error| format!("read release manifest: {error}"))?; let document = body .parse::() .map_err(|error| format!("parse release manifest: {error}"))?; @@ -145,6 +193,89 @@ fn release_pull_text(policy: &RepositoryPolicy, target: &str) -> (String, String (title, body) } +fn commit_message(context: &Context, dco: &str) -> String { + let mut message = format!("{}\n\n{dco}", context.title); + if let Some(key) = context.replay_key.as_deref() { + message.push_str(&format!("\n{REPLAY_TRAILER}{key}")); + } + message +} + +fn replay_trailer(message: &str) -> Result, String> { + let mut found = None; + for line in message.lines() { + if let Some(value) = line.strip_prefix(REPLAY_TRAILER) { + if found.is_some() || !is_digest(value) { + return Err("release commit has an invalid or duplicate replay trailer".to_string()); + } + found = Some(value.to_string()); + } else if line.contains("YamlSigil-Release-Replay") { + return Err("release commit has a malformed replay trailer".to_string()); + } + } + Ok(found) +} + +fn replay_comment(body: &str) -> Result, String> { + let mut found = None; + for line in body.lines() { + if let Some(value) = line.strip_prefix(REPLAY_COMMENT_PREFIX) { + let value = value + .strip_suffix(" -->") + .ok_or_else(|| "release pull request has a malformed replay comment".to_string())?; + if found.is_some() || !is_digest(value) { + return Err( + "release pull request has an invalid or duplicate replay comment".to_string(), + ); + } + found = Some(value.to_string()); + } else if line.contains("yaml-sigil-release-replay-v1:") { + return Err("release pull request has a malformed replay comment".to_string()); + } + } + Ok(found) +} + +fn require_exact_replay_key(message: &str, expected: Option<&str>) -> Result<(), String> { + if replay_trailer(message)?.as_deref() != expected { + return Err("release commit replay identity is unexpected".to_string()); + } + Ok(()) +} + +fn resolve_update_replay_key( + requested: Option<&str>, + target_exists: bool, + target_ahead: u64, + target_marker: Option<&str>, + pull: Option<&PullRequest>, +) -> Result, String> { + if let Some(pull) = pull { + let body_marker = replay_comment(pull.body.as_deref().unwrap_or_default())?; + if target_ahead != 1 || body_marker.as_deref() != target_marker { + return Err("the active release proposal has inconsistent replay evidence".to_string()); + } + if let Some(requested) = requested { + if target_marker == Some(requested) { + return Err("release notification replay is already durably consumed".to_string()); + } + return Err("an active release proposal has a different replay identity".to_string()); + } + return Ok(body_marker); + } + + let Some(requested) = requested else { + return Ok(None); + }; + if !target_exists || target_ahead == 0 { + return Ok(Some(requested.to_string())); + } + if target_ahead == 1 && target_marker == Some(requested) { + return Ok(Some(requested.to_string())); + } + Err("release proposal branch is not the exact abandoned replay".to_string()) +} + #[derive(Clone, Debug)] pub(super) struct Bot { pub(super) login: String, @@ -180,15 +311,24 @@ fn update( } require_ref(github, &context.repository, "main", &context.main)?; let changes = generated_changes(root, &context.repository)?; - let target = inspect_target(github, &context.repository, bot)?; + let (target, target_ahead, target_replay_key) = + inspect_target(github, &context.repository, bot)?; let mut pull = inspect_open_pull(github, &context.repository, target.as_ref(), bot)?; + let replay_key = resolve_update_replay_key( + context.replay_key.as_deref(), + target.is_some(), + target_ahead, + target_replay_key.as_deref(), + pull.as_ref(), + )?; + let context = context.with_replay_key(replay_key); let effective_draft = context.hold_draft || context.draft; if context.hold_draft && pull.as_ref().is_some_and(|value| !value.draft) { let number = pull.as_ref().expect("present pull").number; let node = pull.as_ref().expect("present pull").node_id.clone(); transition_review_state(github, &context.repository, number, &node, true)?; let held = get_pull(github, &context.repository, number)?; - require_owned_pull(&held, context, bot, target.as_ref(), true)?; + require_owned_pull(&held, &context, bot, target.as_ref(), true)?; pull = Some(held); } @@ -198,7 +338,7 @@ fn update( let operation = finish_update( root, - context, + &context, bot, github, &changes, @@ -210,9 +350,11 @@ fn update( let cleanup = delete_ref_exact(github, &context.repository, &staging); match (operation, cleanup) { (Ok(result), Ok(())) => { + let replay_key = result.replay_key.as_deref().unwrap_or(""); append_outputs(&[ ("commit_sha", &result.commit), ("pr_number", &result.pull.to_string()), + ("release_replay_key", replay_key), ])?; eprintln!( "github: created or updated PR #{} at Verified commit {}", @@ -231,6 +373,7 @@ fn update( struct UpdateResult { commit: String, pull: u64, + replay_key: Option, } #[allow(clippy::too_many_arguments)] @@ -270,13 +413,20 @@ fn finish_update( } let dco = format!("Signed-off-by: {} <{}>", bot.login, bot.email); - let message = format!("{}\n\n{dco}", context.title); + let message = commit_message(context, &dco); let commit: CreatedCommit = github.mutate( "POST", &format!("repos/{}/git/commits", context.repository), &json!({"message": message, "tree": tree.sha, "parents": [context.main]}), )?; - require_created_app_commit(&commit, bot, &context.main, &tree.sha, &dco)?; + require_created_app_commit( + &commit, + bot, + &context.main, + &tree.sha, + &dco, + context.replay_key.as_deref(), + )?; require_ref(github, &context.repository, "main", &context.main)?; update_ref_exact(github, &context.repository, staging, &commit.sha)?; let visible: RepositoryCommit = github.get(&format!( @@ -284,6 +434,7 @@ fn finish_update( context.repository, commit.sha ))?; require_repository_app_commit(&visible, bot, Some(&context.main))?; + require_exact_replay_key(&visible.commit.message, context.replay_key.as_deref())?; fetch_staging(root, staging, &commit.sha)?; require_ref(github, &context.repository, "main", &context.main)?; @@ -309,6 +460,7 @@ fn finish_update( Ok(UpdateResult { commit: commit.sha, pull: number, + replay_key: context.replay_key.clone(), }) } @@ -334,6 +486,10 @@ fn finalize( let release_commit: RepositoryCommit = github.get(&format!("repos/{}/commits/{commit}", context.repository))?; require_repository_app_commit(&release_commit, bot, Some(&context.main))?; + require_exact_replay_key( + &release_commit.commit.message, + context.replay_key.as_deref(), + )?; let held = get_pull(github, &context.repository, number)?; require_final_pull(&held, context, bot, &commit, true)?; if !context.draft { @@ -374,6 +530,8 @@ fn generated_changes(root: &Path, repository: &str) -> Result Result MAX_FILE_BYTES { - return Err(format!("generated path {path} is missing or oversized")); - } total = total - .checked_add(metadata.len() as usize) + .checked_add(content.len()) .ok_or_else(|| "generated content size overflowed".to_string())?; if total > MAX_TOTAL_CONTENT_BYTES { return Err("generated release content exceeded its total bound".to_string()); } - let content = fs::read_to_string(root.join(path)) - .map_err(|error| format!("generated path {path} is not UTF-8: {error}"))?; if content.contains('\0') { return Err(format!("generated path {path} contains NUL")); } @@ -441,7 +595,7 @@ fn inspect_target( github: &mut impl Transport, repository: &str, bot: &Bot, -) -> Result, String> { +) -> Result<(Option, u64, Option), String> { let refs: Vec = github.get(&format!( "repos/{repository}/git/matching-refs/heads/{}", percent_encode(RELEASE_BRANCH) @@ -450,7 +604,7 @@ fn inspect_target( return Err("GitHub returned ambiguous release branch state".to_string()); } let Some(target) = refs.into_iter().next() else { - return Ok(None); + return Ok((None, 0, None)); }; require_exact_ref(&target, RELEASE_BRANCH, &target.object.sha)?; let compare: Compare = github.get(&format!( @@ -474,12 +628,15 @@ fn inspect_target( "{RELEASE_BRANCH} contains a non-App commit and will not be overwritten" )); } - if compare.ahead_by == 1 { + let replay_key = if compare.ahead_by == 1 { let commit: RepositoryCommit = github.get(&format!("repos/{repository}/commits/{}", target.object.sha))?; require_repository_app_commit(&commit, bot, None)?; - } - Ok(Some(target)) + replay_trailer(&commit.commit.message)? + } else { + None + }; + Ok((Some(target), compare.ahead_by, replay_key)) } fn inspect_open_pull( @@ -528,15 +685,16 @@ fn mutate_pull( commit: &str, draft: bool, ) -> Result { + let body = context.body(); let number = if let Some(existing) = existing { let mutation: Result = github.mutate( "PATCH", &format!("repos/{}/pulls/{}", context.repository, existing.number), - &json!({"title": context.title, "body": context.body}), + &json!({"title": context.title, "body": body.as_str()}), ); let reread = get_pull(github, &context.repository, existing.number)?; if mutation.is_err() - && (reread.title != context.title || reread.body.as_deref() != Some(&context.body)) + && (reread.title != context.title || reread.body.as_deref() != Some(body.as_str())) { return Err(mutation.expect_err("checked mutation error")); } @@ -549,7 +707,7 @@ fn mutate_pull( "title": context.title, "head": RELEASE_BRANCH, "base": "main", - "body": context.body, + "body": body.as_str(), "draft": draft, }), ); @@ -683,9 +841,10 @@ fn require_final_pull( draft: bool, ) -> Result<(), String> { require_owned_pull_identity(pull, &context.repository, bot, commit)?; + let body = context.body(); if pull.base.sha != context.main || pull.title != context.title - || pull.body.as_deref() != Some(&context.body) + || pull.body.as_deref() != Some(body.as_str()) || pull.draft != draft { return Err("GitHub returned an unexpected release pull-request state".to_string()); @@ -822,7 +981,8 @@ fn push_with_lease( let lease = format!("--force-with-lease=refs/heads/{branch}:{expected_old}"); let url = format!("https://github.com/{repository}.git"); let refspec = format!("{commit}:refs/heads/{branch}"); - let output = Command::new("git") + let mut command = Command::new("git"); + command .current_dir(root) .env("GIT_TERMINAL_PROMPT", "0") .args([ @@ -835,14 +995,15 @@ fn push_with_lease( &lease, &url, &refspec, - ]) - .output() - .map_err(|error| format!("run atomic release branch push: {error}"))?; - if output.stdout.len() > crate::github::transport::MAX_RESPONSE_BYTES - || output.stderr.len() > crate::github::transport::MAX_ERROR_BYTES - { - return Err("Git push output exceeded its bound".to_string()); - } + ]); + let output = bounded_process::output( + &mut command, + OutputLimits { + stdout: crate::github::transport::MAX_RESPONSE_BYTES, + stderr: crate::github::transport::MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("run atomic release branch push: {error}"))?; if !output.status.success() { return Err(format!( "the App-owned release branch changed before its atomic update: {}", @@ -863,7 +1024,9 @@ fn require_created_app_commit( parent: &str, tree: &str, dco: &str, + replay_key: Option<&str>, ) -> Result<(), String> { + let observed_replay_key = replay_trailer(&commit.message)?; if !is_sha(&commit.sha) || commit.author.name != bot.login || commit.author.email != bot.email @@ -872,6 +1035,7 @@ fn require_created_app_commit( || !commit.verification.verified || commit.verification.reason != "valid" || dco_lines(&commit.message) != [dco] + || observed_replay_key.as_deref() != replay_key || commit.tree.sha != tree || commit.parents.len() != 1 || commit.parents[0].sha != parent @@ -886,6 +1050,7 @@ pub(super) fn require_repository_app_commit( bot: &Bot, parent: Option<&str>, ) -> Result<(), String> { + replay_trailer(&commit.commit.message)?; let author = commit.author.as_ref(); let committer = commit.committer.as_ref(); let expected_dco = format!("Signed-off-by: {} <{}>", bot.login, bot.email); @@ -1058,14 +1223,93 @@ mod tests { } } + fn replay_pull(body: &str) -> PullRequest { + serde_json::from_value(json!({ + "number": 17, + "state": "open", + "user": {"login": APP_LOGIN, "id": APP_ID}, + "head": { + "ref": RELEASE_BRANCH, + "sha": "a".repeat(40), + "repo": {"full_name": TRAITS_REPOSITORY} + }, + "base": { + "ref": "main", + "sha": "c".repeat(40), + "repo": {"full_name": TRAITS_REPOSITORY} + }, + "commits": 1, + "node_id": "PR_node", + "draft": true, + "title": "release proposal", + "body": body + })) + .unwrap() + } + #[test] fn app_commit_requires_signature_dco_and_immutable_identities() { let mut commit = app_commit(); assert!(require_repository_app_commit(&commit, &bot(), Some(&"c".repeat(40))).is_ok()); + commit + .commit + .message + .push_str(&format!("\n{REPLAY_TRAILER}{}", "1".repeat(64))); + assert!(require_repository_app_commit(&commit, &bot(), Some(&"c".repeat(40))).is_ok()); + commit + .commit + .message + .push_str("\nYamlSigil-Release-Replay: invalid"); + assert!(require_repository_app_commit(&commit, &bot(), None).is_err()); commit.commit.verification.verified = false; assert!(require_repository_app_commit(&commit, &bot(), None).is_err()); } + #[test] + fn dispatch_replay_is_new_recoverable_or_durably_consumed() { + let key = "1".repeat(64); + assert_eq!( + resolve_update_replay_key(Some(&key), false, 0, None, None).unwrap(), + Some(key.clone()) + ); + assert_eq!( + resolve_update_replay_key(Some(&key), true, 1, Some(&key), None).unwrap(), + Some(key.clone()) + ); + assert!( + resolve_update_replay_key(Some(&key), true, 1, Some(&"2".repeat(64)), None).is_err() + ); + + let body = format!("proposal\n\n{REPLAY_COMMENT_PREFIX}{key} -->\n"); + let pull = replay_pull(&body); + assert!( + resolve_update_replay_key(Some(&key), true, 1, Some(&key), Some(&pull)) + .unwrap_err() + .contains("durably consumed") + ); + assert_eq!( + resolve_update_replay_key(None, true, 1, Some(&key), Some(&pull)).unwrap(), + Some(key) + ); + } + + #[test] + fn replay_markers_reject_noncanonical_or_duplicate_forms() { + let key = "1".repeat(64); + assert_eq!( + replay_trailer(&format!("proposal\n\n{REPLAY_TRAILER}{key}")).unwrap(), + Some(key.clone()) + ); + assert!(replay_trailer("proposal\nYamlSigil-Release-Replay: INVALID").is_err()); + assert!( + replay_comment(&format!( + "{REPLAY_COMMENT_PREFIX}{key} -->\n{REPLAY_COMMENT_PREFIX}{key} -->" + )) + .is_err() + ); + assert!(replay_comment("").is_err()); + } + #[test] fn generated_paths_are_repository_scoped() { assert!(allowed_generated_path( diff --git a/xtask/src/github/release_train.rs b/xtask/src/github/release_train.rs new file mode 100644 index 0000000..aa1a50d --- /dev/null +++ b/xtask/src/github/release_train.rs @@ -0,0 +1,1737 @@ +// SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +// SPDX-License-Identifier: Apache-2.0 + +//! Captured release plans, durable App intent, and source-only finalization. + +use std::collections::BTreeSet; +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; +use crate::crate_archive::{CratesIo, Registry}; +use crate::github::consts::{APP_EMAIL, APP_ID, APP_LOGIN, APP_SLUG}; +use crate::github::release_objects::{ + ReleaseSpec, manifest_version, package_source, release_specs, require_reproduced_archive, +}; +use crate::github::source::{SourceAuthorization, authorize_source}; +use crate::github::transport::{Transport, percent_encode}; +use crate::github::{ + append_outputs, git_output, is_positive_integer, is_sha, repository_policy_for_root, +}; +use crate::package_content_policy::PACKAGE_SPECS; +use crate::release_policy::detect; +use crate::safe_file::TrustedRoot; + +const PLAN_SCHEMA: u64 = 1; +const INTENT_SCHEMA: u64 = 1; +const NOTIFICATION_SCHEMA: u64 = 1; +const MAX_PLAN_BYTES: usize = 48 * 1024; +const MAX_INTENT_BYTES: usize = 64 * 1024; +const MAX_NOTIFICATION_BYTES: usize = 8 * 1024; +const MAX_RELEASE_PACKAGES: usize = 8; +const MAX_RELEASE_BODY_BYTES: usize = 16 * 1024; +const REGISTRY_POLL_COUNT: usize = 60; +const REGISTRY_POLL_SECONDS: u64 = 20; +const APP_PUBLIC_ID: u64 = 4_653_064; +const INTENT_NAME: &str = "Release finalization intent"; +const RELEASE_PLZ_VERSION: &str = "0.3.160"; + +pub(super) struct PrepareIntentInput<'a> { + pub(super) plan: &'a str, + pub(super) plan_digest: &'a str, + pub(super) origin_run_id: &'a str, + pub(super) origin_run_attempt: &'a str, + pub(super) ruleset_evidence_sha256: &'a str, +} + +pub(super) struct CreateIntentInput<'a> { + pub(super) repository: &'a str, + pub(super) plan: &'a str, + pub(super) plan_digest: &'a str, + pub(super) intent: &'a str, + pub(super) expected_app_slug: &'a str, + pub(super) expected_installation_id: &'a str, +} + +pub(super) struct FinalizeInput<'a> { + pub(super) repository: &'a str, + pub(super) plan: &'a str, + pub(super) plan_digest: &'a str, + pub(super) intent: &'a str, + pub(super) expected_app_slug: &'a str, + pub(super) expected_installation_id: &'a str, +} + +pub(super) struct NotifyInput<'a> { + pub(super) repository: &'a str, + pub(super) plan: &'a str, + pub(super) plan_digest: &'a str, + pub(super) intent: &'a str, + pub(super) intent_check_id: &'a str, + pub(super) finalized_entries: &'a str, + pub(super) expected_app_slug: &'a str, + pub(super) expected_installation_id: &'a str, +} + +pub(super) fn discover_command( + root: &Path, + repository: &str, + current_commit: &str, + github: &mut impl Transport, +) -> Result<(), String> { + if !is_sha(current_commit) { + return Err("current release source must be one lowercase full SHA".to_string()); + } + repository_policy_for_root(root, repository)?; + let policy = detect(root)?; + let version = manifest_version( + root, + crate::github::consts::repository_policy(repository) + .ok_or_else(|| "release discovery repository lacks compiled policy".to_string())? + .kind, + )?; + let specs = release_specs(root, policy, &version)?; + let mut registry = CratesIo::new(); + let mut discovered = None; + let mut present = 0usize; + for spec in &specs { + let Some(record) = registry.exact_version(spec.policy.package, &spec.version)? else { + break; + }; + if record.num != spec.version + || record.yanked + || !crate::crate_archive::is_checksum(&record.checksum) + { + return Err(format!( + "{} registry state is not one exact non-yanked version", + spec.policy.package + )); + } + let archive = registry.download(spec.policy.package, &spec.version)?; + if sha256(&archive) != record.checksum { + return Err(format!( + "{} registry archive checksum changed during discovery", + spec.policy.package + )); + } + let commit = + crate::crate_archive::archive_vcs_commit(&archive, spec.policy, &spec.version)?; + if discovered + .as_ref() + .is_some_and(|expected| expected != &commit) + { + return Err("published release prefix identifies multiple source commits".to_string()); + } + discovered = Some(commit); + present += 1; + } + for spec in specs.iter().skip(present) { + if registry + .exact_version(spec.policy.package, &spec.version)? + .is_some() + { + return Err("registry packages do not form the release dependency prefix".to_string()); + } + } + let release_sha = discovered.unwrap_or_else(|| current_commit.to_string()); + require_captured_ancestry(github, repository, &release_sha)?; + let state = if present == 0 { + "absent" + } else if present == specs.len() { + "complete" + } else { + "partial" + }; + append_outputs(&[ + ("captured_release_sha", &release_sha), + ("registry_state", state), + ]) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ReleasePlan { + schema_version: u64, + repository: String, + release_sha: String, + authorization: PlanAuthorization, + release_plz_version: String, + release_config_sha256: String, + publish_workflow_sha256: String, + proposal_workflow_sha256: String, + tagger_epoch: u64, + tagger_date: String, + packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PlanAuthorization { + pull_request: u64, + proposal_commit: String, + base_commit: String, + owner_id: u64, + merger_id: u64, +} + +impl From for PlanAuthorization { + fn from(value: SourceAuthorization) -> Self { + Self { + pull_request: value.pull_request, + proposal_commit: value.proposal_commit, + base_commit: value.base_commit, + owner_id: value.owner_id, + merger_id: value.merger_id, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PlanPackage { + package: String, + version: String, + tag: String, + prerelease: bool, + source_archive_sha256: String, + package_inventory_sha256: String, + release_body: String, + release_body_sha256: String, + registry: RegistryBaseline, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RegistryBaseline { + state: RegistryState, + checksum: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +enum RegistryState { + Absent, + Present, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct IntentRecord { + schema_version: u64, + repository: String, + release_sha: String, + plan_digest: String, + external_id: String, + origin_run_id: u64, + origin_run_attempt: u64, + ruleset_evidence_sha256: String, + plan: ReleasePlan, + tags: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct TagIntent { + package: String, + tag: String, + tag_object_id: String, + tag_message: String, + release_body_sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct FinalizedEntry { + package: String, + version: String, + release_id: u64, + tag: String, + tag_object_id: String, + release_body_sha256: String, +} + +pub(super) fn capture_command( + root: &Path, + repository: &str, + commit: &str, + baseline_version: &str, + baseline_commit: &str, + github: &mut impl Transport, +) -> Result<(), String> { + let mut registry = CratesIo::new(); + let observed = capture_plan( + root, + repository, + commit, + baseline_version, + baseline_commit, + github, + &mut registry, + )?; + let recovered = recover_existing_intent(github, repository, &observed)?; + if recovered.is_none() { + require_new_train_objects_absent(github, repository, &observed)?; + } + let plan = if let Some((_, _, record)) = &recovered { + require_static_plan_match(&record.plan, &observed)?; + require_registry_transition(&record.plan, &observed)?; + record.plan.clone() + } else { + observed.clone() + }; + let (body, digest) = encode_plan(&plan)?; + let registry_state = if observed + .packages + .iter() + .all(|package| package.registry.state == RegistryState::Present) + { + "complete" + } else if observed + .packages + .iter() + .all(|package| package.registry.state == RegistryState::Absent) + { + "absent" + } else { + "partial" + }; + let (existing_intent, existing_intent_check_id, existing_intent_digest) = recovered + .as_ref() + .map(|(check, body, _)| (body.as_str(), check.id.to_string(), sha256(body.as_bytes()))) + .unwrap_or(("", String::new(), String::new())); + append_outputs(&[ + ("captured_release_sha", &plan.release_sha), + ("plan", &body), + ("plan_digest", &digest), + ("registry_state", registry_state), + ("existing_intent", existing_intent), + ("existing_intent_check_id", &existing_intent_check_id), + ("existing_intent_digest", &existing_intent_digest), + ]) +} + +fn require_new_train_objects_absent( + github: &mut impl Transport, + repository: &str, + plan: &ReleasePlan, +) -> Result<(), String> { + for package in &plan.packages { + let reference = github.get_optional::(&format!( + "repos/{repository}/git/ref/tags/{}", + percent_encode(&package.tag) + ))?; + let release = github.get_optional::(&format!( + "repos/{repository}/releases/tags/{}", + percent_encode(&package.tag) + ))?; + if reference.is_some() || release.is_some() { + return Err(format!( + "new release train tag or Release {} already exists without App intent", + package.tag + )); + } + } + Ok(()) +} + +fn recover_existing_intent( + github: &mut impl Transport, + repository: &str, + observed: &ReleasePlan, +) -> Result, String> { + let path = format!( + "repos/{repository}/commits/{}/check-runs?check_name={}&filter=all&per_page=100", + observed.release_sha, + percent_encode(INTENT_NAME) + ); + let checks: CheckRuns = github.get(&path)?; + if checks.total_count != checks.check_runs.len() as u64 || checks.total_count > 100 { + return Err("intent Check inventory is incomplete or oversized".to_string()); + } + let mut recovered = Vec::new(); + for check in checks.check_runs { + if check.name != INTENT_NAME + || check.head_sha != observed.release_sha + || check.app.id != APP_PUBLIC_ID + || check.app.slug != APP_SLUG + { + continue; + } + let body = check + .output + .summary + .clone() + .ok_or_else(|| "App-owned release intent Check lacks its summary".to_string())?; + let record: IntentRecord = serde_json::from_str(&body) + .map_err(|error| format!("existing release intent schema is invalid: {error}"))?; + let (plan_body, plan_digest) = encode_plan(&record.plan)?; + if plan_digest != record.plan_digest + || decode_plan(&plan_body, &plan_digest)? != record.plan + { + return Err("existing release intent embeds a noncanonical plan".to_string()); + } + let record = decode_intent(&body, &record.plan, &record.plan_digest)?; + validate_intent_check(&check, &record, &body)?; + require_static_plan_match(&record.plan, observed)?; + require_registry_transition(&record.plan, observed)?; + recovered.push((check, body, record)); + } + if recovered.len() > 1 { + return Err("multiple App-owned release intents claim one train".to_string()); + } + Ok(recovered.pop()) +} + +pub(super) fn verify_command( + root: &Path, + repository: &str, + plan_text: &str, + plan_digest: &str, + baseline_version: &str, + baseline_commit: &str, + github: &mut impl Transport, +) -> Result<(), String> { + let expected = decode_plan(plan_text, plan_digest)?; + let mut registry = CratesIo::new(); + let actual = capture_plan( + root, + repository, + &expected.release_sha, + baseline_version, + baseline_commit, + github, + &mut registry, + )?; + require_static_plan_match(&expected, &actual)?; + require_registry_transition(&expected, &actual)?; + append_outputs(&[("captured_release_sha", &expected.release_sha)]) +} + +pub(super) fn wait_command( + repository: &str, + plan_text: &str, + plan_digest: &str, +) -> Result<(), String> { + let plan = decode_plan(plan_text, plan_digest)?; + if plan.repository != repository { + return Err("registry wait repository differs from the release plan".to_string()); + } + let policy = release_policy_for_repository(repository)?; + let mut registry = CratesIo::new(); + wait_for_registry(&plan, policy, &mut registry, REGISTRY_POLL_COUNT, || { + thread::sleep(Duration::from_secs(REGISTRY_POLL_SECONDS)) + })?; + append_outputs(&[("complete", "true")]) +} + +fn wait_for_registry( + plan: &ReleasePlan, + policy: &crate::release_policy::ReleasePolicy, + registry: &mut impl Registry, + poll_count: usize, + mut pause: impl FnMut(), +) -> Result<(), String> { + if policy.packages.len() != plan.packages.len() { + return Err("registry wait package family is incomplete".to_string()); + } + let mut inspected = vec![false; plan.packages.len()]; + if observe_registry(plan, policy, registry, &mut inspected, false)? { + observe_registry(plan, policy, registry, &mut inspected, true)?; + return Ok(()); + } + for _ in 0..poll_count { + pause(); + if observe_registry(plan, policy, registry, &mut inspected, false)? { + observe_registry(plan, policy, registry, &mut inspected, true)?; + return Ok(()); + } + } + Err("crates.io did not expose the complete release train within 20 minutes".to_string()) +} + +fn observe_registry( + plan: &ReleasePlan, + policy: &crate::release_policy::ReleasePolicy, + registry: &mut impl Registry, + inspected: &mut [bool], + final_confirmation: bool, +) -> Result { + let mut missing = false; + for (index, (package, package_policy)) in plan.packages.iter().zip(policy.packages).enumerate() + { + let record = registry.exact_version(&package.package, &package.version)?; + let Some(record) = record else { + if inspected[index] { + return Err("a previously observed registry package disappeared".to_string()); + } + missing = true; + continue; + }; + if missing { + return Err("registry packages do not form the planned dependency prefix".to_string()); + } + if package.package != package_policy.package + || record.num != package.version + || record.yanked + || record.checksum != package.source_archive_sha256 + { + return Err(format!( + "{} registry state differs from the release plan", + package.package + )); + } + if !inspected[index] || final_confirmation { + let archive = registry.download(&package.package, &package.version)?; + if sha256(&archive) != package.source_archive_sha256 { + return Err(format!( + "{} registry archive differs from the release plan", + package.package + )); + } + crate::crate_archive::inspect_archive_entries( + &archive, + package_policy, + &package.version, + &plan.release_sha, + )?; + inspected[index] = true; + } + } + Ok(!missing && inspected.iter().all(|value| *value)) +} + +fn capture_plan( + root: &Path, + repository: &str, + commit: &str, + baseline_version: &str, + baseline_commit: &str, + github: &mut impl Transport, + registry: &mut impl Registry, +) -> Result { + if !is_sha(commit) { + return Err("release plan commit must be one lowercase full SHA".to_string()); + } + let repository_policy = repository_policy_for_root(root, repository)?; + crate::crate_archive::require_clean_source(root, commit)?; + let version = manifest_version(root, repository_policy.kind)?; + let authorization = authorize_source( + github, + repository, + commit, + root, + baseline_version, + baseline_commit, + )?; + let policy = detect(root)?; + let specs = release_specs(root, policy, &version)?; + if specs.is_empty() || specs.len() > MAX_RELEASE_PACKAGES { + return Err("release plan package count is outside its bound".to_string()); + } + let trusted = TrustedRoot::open(root).map_err(|error| format!("open release root: {error}"))?; + let config = trusted + .read_manifest(Path::new(".release-plz.toml")) + .map_err(|error| format!("read release-plz config: {error}"))?; + require_source_only_config(&config, &specs)?; + let publish = trusted + .read_manifest(Path::new(".github/workflows/publish.yml")) + .map_err(|error| format!("read publication workflow: {error}"))?; + let proposal = trusted + .read_manifest(Path::new(".github/workflows/release-proposal.yml")) + .map_err(|error| format!("read proposal workflow: {error}"))?; + let (tagger_epoch, tagger_date) = commit_timestamp(root, commit)?; + let mut packages = Vec::with_capacity(specs.len()); + for spec in &specs { + let archive = package_source(root, spec)?; + let source_archive_sha256 = sha256(&archive); + let inventory = PACKAGE_SPECS + .iter() + .find(|item| item.name == spec.policy.package) + .ok_or_else(|| format!("{} lacks package-content policy", spec.policy.package))?; + let record = registry.exact_version(spec.policy.package, &spec.version)?; + let registry = match record { + None => RegistryBaseline { + state: RegistryState::Absent, + checksum: None, + }, + Some(record) => { + let checksum = require_reproduced_archive(root, registry, spec, commit)?; + if checksum != record.checksum { + return Err(format!( + "{} registry checksum changed during plan capture", + spec.policy.package + )); + } + RegistryBaseline { + state: RegistryState::Present, + checksum: Some(checksum), + } + } + }; + packages.push(PlanPackage { + package: spec.policy.package.to_string(), + version: spec.version.clone(), + tag: spec.tag.clone(), + prerelease: spec.prerelease, + source_archive_sha256, + package_inventory_sha256: sha256(inventory.inventory.as_bytes()), + release_body: spec.body.clone(), + release_body_sha256: sha256(spec.body.as_bytes()), + registry, + }); + } + require_registry_prefix(&packages)?; + Ok(ReleasePlan { + schema_version: PLAN_SCHEMA, + repository: repository.to_string(), + release_sha: commit.to_string(), + authorization: authorization.into(), + release_plz_version: RELEASE_PLZ_VERSION.to_string(), + release_config_sha256: sha256(config.as_bytes()), + publish_workflow_sha256: sha256(publish.as_bytes()), + proposal_workflow_sha256: sha256(proposal.as_bytes()), + tagger_epoch, + tagger_date, + packages, + }) +} + +fn require_source_only_config(config: &str, specs: &[ReleaseSpec<'_>]) -> Result<(), String> { + let document = config + .parse::() + .map_err(|error| format!("parse release-plz config: {error}"))?; + let workspace = document + .get("workspace") + .and_then(toml_edit::Item::as_table) + .ok_or_else(|| "release-plz config lacks workspace policy".to_string())?; + if workspace + .get("release_always") + .and_then(toml_edit::Item::as_bool) + != Some(false) + || workspace + .get("git_tag_enable") + .and_then(toml_edit::Item::as_bool) + != Some(false) + || workspace + .get("git_release_enable") + .and_then(toml_edit::Item::as_bool) + != Some(false) + || workspace.contains_key("pr_branch_prefix") + { + return Err("tracked release-plz workspace policy is not proposal-safe".to_string()); + } + let packages = document + .get("package") + .and_then(toml_edit::Item::as_array_of_tables) + .ok_or_else(|| "release-plz config lacks package overrides".to_string())?; + if packages.len() != specs.len() { + return Err("release-plz package override set is incomplete".to_string()); + } + for (package, spec) in packages.iter().zip(specs) { + if package.get("name").and_then(toml_edit::Item::as_str) != Some(spec.policy.package) + || package + .get("git_tag_enable") + .and_then(toml_edit::Item::as_bool) + != Some(false) + || package + .get("git_release_enable") + .and_then(toml_edit::Item::as_bool) + != Some(false) + { + return Err("release-plz package source-only policy is not exact".to_string()); + } + } + Ok(()) +} + +fn commit_timestamp(root: &Path, commit: &str) -> Result<(u64, String), String> { + let output = git_output(root, &["show", "-s", "--format=%ct%n%cI", commit])?; + let lines: Vec<_> = output.lines().collect(); + if lines.len() != 2 + || lines[0].starts_with('0') + || !lines[0].bytes().all(|byte| byte.is_ascii_digit()) + || lines[1].len() > 64 + || lines[1].contains(['\0', '\r', '\n']) + { + return Err("release commit has a noncanonical timestamp".to_string()); + } + let epoch = lines[0] + .parse::() + .map_err(|_| "release commit timestamp is invalid".to_string())?; + Ok((epoch, lines[1].to_string())) +} + +fn encode_plan(plan: &ReleasePlan) -> Result<(String, String), String> { + let body = + serde_json::to_string(plan).map_err(|error| format!("encode release plan: {error}"))?; + if body.len() > MAX_PLAN_BYTES { + return Err("release plan exceeds its byte bound".to_string()); + } + Ok((body.clone(), sha256(body.as_bytes()))) +} + +fn decode_plan(body: &str, digest: &str) -> Result { + if body.is_empty() || body.len() > MAX_PLAN_BYTES || !is_digest(digest) { + return Err("release plan input is empty, oversized, or has an invalid digest".to_string()); + } + if sha256(body.as_bytes()) != digest { + return Err("release plan digest does not match its exact bytes".to_string()); + } + let plan: ReleasePlan = serde_json::from_str(body) + .map_err(|error| format!("release plan schema is invalid: {error}"))?; + let (canonical, canonical_digest) = encode_plan(&plan)?; + if canonical != body || canonical_digest != digest { + return Err("release plan is not canonical".to_string()); + } + validate_plan(&plan)?; + Ok(plan) +} + +fn validate_plan(plan: &ReleasePlan) -> Result<(), String> { + if plan.schema_version != PLAN_SCHEMA + || !is_sha(&plan.release_sha) + || plan.repository.is_empty() + || plan.release_plz_version != RELEASE_PLZ_VERSION + || !is_digest(&plan.release_config_sha256) + || !is_digest(&plan.publish_workflow_sha256) + || !is_digest(&plan.proposal_workflow_sha256) + || plan.tagger_epoch == 0 + || plan.tagger_date.is_empty() + || plan.tagger_date.len() > 64 + || plan.packages.is_empty() + || plan.packages.len() > MAX_RELEASE_PACKAGES + || plan.authorization.pull_request == 0 + || !is_sha(&plan.authorization.proposal_commit) + || !is_sha(&plan.authorization.base_commit) + || plan.authorization.owner_id == 0 + || plan.authorization.merger_id == 0 + { + return Err("release plan binding is invalid".to_string()); + } + let mut names = BTreeSet::new(); + let mut tags = BTreeSet::new(); + for package in &plan.packages { + if package.package.is_empty() + || package.version.is_empty() + || package.tag.is_empty() + || !is_digest(&package.source_archive_sha256) + || !is_digest(&package.package_inventory_sha256) + || !is_digest(&package.release_body_sha256) + || package.release_body.is_empty() + || package.release_body.len() > MAX_RELEASE_BODY_BYTES + || sha256(package.release_body.as_bytes()) != package.release_body_sha256 + || !names.insert(&package.package) + || !tags.insert(&package.tag) + || (package.registry.state == RegistryState::Absent + && package.registry.checksum.is_some()) + || (package.registry.state == RegistryState::Present + && package + .registry + .checksum + .as_deref() + .is_none_or(|checksum| !is_digest(checksum))) + { + return Err("release plan package binding is invalid".to_string()); + } + } + require_registry_prefix(&plan.packages) +} + +fn require_registry_prefix(packages: &[PlanPackage]) -> Result<(), String> { + let mut absent = false; + for package in packages { + match package.registry.state { + RegistryState::Present if absent => { + return Err("published packages do not form a dependency-order prefix".to_string()); + } + RegistryState::Absent => absent = true, + RegistryState::Present => {} + } + } + Ok(()) +} + +fn require_static_plan_match(expected: &ReleasePlan, actual: &ReleasePlan) -> Result<(), String> { + let mut expected = expected.clone(); + let mut actual = actual.clone(); + for package in &mut expected.packages { + package.registry = RegistryBaseline { + state: RegistryState::Absent, + checksum: None, + }; + } + for package in &mut actual.packages { + package.registry = RegistryBaseline { + state: RegistryState::Absent, + checksum: None, + }; + } + if expected != actual { + return Err("release plan source or authorization changed".to_string()); + } + Ok(()) +} + +fn require_registry_transition(expected: &ReleasePlan, actual: &ReleasePlan) -> Result<(), String> { + for (old, new) in expected.packages.iter().zip(&actual.packages) { + match (old.registry.state, new.registry.state) { + (RegistryState::Absent, RegistryState::Absent) + | (RegistryState::Absent, RegistryState::Present) => {} + (RegistryState::Present, RegistryState::Present) + if old.registry.checksum == new.registry.checksum => {} + _ => return Err("registry state is not a valid release-plan progression".to_string()), + } + } + require_registry_prefix(&actual.packages) +} + +pub(super) fn prepare_intent_command( + root: &Path, + input: PrepareIntentInput<'_>, +) -> Result<(), String> { + let plan = decode_plan(input.plan, input.plan_digest)?; + let record = build_intent( + root, + &plan, + input.plan_digest, + positive(input.origin_run_id, "origin run ID")?, + positive(input.origin_run_attempt, "origin run attempt")?, + input.ruleset_evidence_sha256, + )?; + let record_body = canonical_intent(&record)?; + let record_digest = sha256(record_body.as_bytes()); + append_outputs(&[ + ("intent", &record_body), + ("intent_digest", &record_digest), + ("intent_external_id", &record.external_id), + ]) +} + +pub(super) fn create_intent_command( + input: CreateIntentInput<'_>, + github: &mut impl Transport, +) -> Result<(), String> { + let plan = decode_plan(input.plan, input.plan_digest)?; + let record = decode_intent(input.intent, &plan, input.plan_digest)?; + if plan.repository != input.repository { + return Err("intent repository differs from the release plan".to_string()); + } + validate_app_token( + github, + input.repository, + input.expected_app_slug, + input.expected_installation_id, + )?; + let record_body = canonical_intent(&record)?; + let check = create_or_require_intent(github, input.repository, &record, &record_body)?; + let check_id = check.id.to_string(); + let record_digest = sha256(record_body.as_bytes()); + append_outputs(&[ + ("intent", &record_body), + ("intent_digest", &record_digest), + ("intent_check_id", &check_id), + ("intent_external_id", &record.external_id), + ]) +} + +fn build_intent( + root: &Path, + plan: &ReleasePlan, + plan_digest: &str, + origin_run_id: u64, + origin_run_attempt: u64, + ruleset_evidence_sha256: &str, +) -> Result { + if !is_digest(ruleset_evidence_sha256) { + return Err("ruleset evidence must be one SHA-256 digest".to_string()); + } + let external_id = sha256( + format!( + "release-intent-v{INTENT_SCHEMA}\0{}\0{}\0{plan_digest}", + plan.repository, plan.release_sha + ) + .as_bytes(), + ); + let mut tags = Vec::with_capacity(plan.packages.len()); + for package in &plan.packages { + let tag_message = format!( + "chore: Release package {} version {}", + package.package, package.version + ); + let payload = annotated_tag_payload(plan, &package.tag, &tag_message)?; + let object_id = hash_git_object(root, "tag", payload.as_bytes())?; + tags.push(TagIntent { + package: package.package.clone(), + tag: package.tag.clone(), + tag_object_id: object_id, + tag_message, + release_body_sha256: package.release_body_sha256.clone(), + }); + } + Ok(IntentRecord { + schema_version: INTENT_SCHEMA, + repository: plan.repository.clone(), + release_sha: plan.release_sha.clone(), + plan_digest: plan_digest.to_string(), + external_id, + origin_run_id, + origin_run_attempt, + ruleset_evidence_sha256: ruleset_evidence_sha256.to_string(), + plan: plan.clone(), + tags, + }) +} + +fn annotated_tag_payload(plan: &ReleasePlan, tag: &str, message: &str) -> Result { + if tag.contains(['\0', '\r', '\n']) || message.contains(['\0', '\r']) { + return Err("tag intent contains an invalid line".to_string()); + } + let offset = plan + .tagger_date + .get(plan.tagger_date.len().saturating_sub(6)..) + .ok_or_else(|| "tagger date lacks an offset".to_string())?; + if offset.as_bytes().get(3) != Some(&b':') + || !matches!(offset.as_bytes().first(), Some(b'+') | Some(b'-')) + { + return Err("tagger date has a noncanonical offset".to_string()); + } + let git_offset = format!("{}{}", &offset[..3], &offset[4..]); + Ok(format!( + "object {}\ntype commit\ntag {tag}\ntagger {APP_LOGIN} <{APP_EMAIL}> {} {git_offset}\n\n{message}\n", + plan.release_sha, plan.tagger_epoch + )) +} + +fn hash_git_object(root: &Path, kind: &str, body: &[u8]) -> Result { + let mut command = Command::new("git"); + command + .current_dir(root) + .args(["hash-object", "--stdin", "-t", kind]); + let output = bounded_process::output_with_input(&mut command, body, VALIDATION_OUTPUT_LIMITS) + .map_err(|error| format!("hash Git {kind} object: {error}"))?; + if !output.status.success() { + return Err("Git could not hash the release intent object".to_string()); + } + let value = + String::from_utf8(output.stdout).map_err(|_| "Git object ID is not UTF-8".to_string())?; + let value = value.trim_end_matches(['\r', '\n']); + if !is_sha(value) { + return Err("Git returned an invalid intent object ID".to_string()); + } + Ok(value.to_string()) +} + +fn canonical_intent(record: &IntentRecord) -> Result { + let body = serde_json::to_string(record).map_err(|error| format!("encode intent: {error}"))?; + if body.len() > MAX_INTENT_BYTES { + return Err("release intent exceeds its byte bound".to_string()); + } + Ok(body) +} + +#[derive(Debug, Deserialize)] +struct CheckRuns { + total_count: u64, + check_runs: Vec, +} + +#[derive(Debug, Deserialize)] +struct CheckRun { + id: u64, + name: String, + head_sha: String, + external_id: String, + status: String, + conclusion: Option, + app: CheckApp, + output: CheckOutput, +} + +#[derive(Debug, Deserialize)] +struct CheckApp { + id: u64, + slug: String, +} + +#[derive(Debug, Deserialize)] +struct CheckOutput { + title: Option, + summary: Option, +} + +fn create_or_require_intent( + github: &mut impl Transport, + repository: &str, + record: &IntentRecord, + body: &str, +) -> Result { + let path = format!( + "repos/{repository}/commits/{}/check-runs?check_name={}&filter=all&per_page=100", + record.release_sha, + percent_encode(INTENT_NAME) + ); + let checks: CheckRuns = github.get(&path)?; + if checks.total_count != checks.check_runs.len() as u64 || checks.total_count > 100 { + return Err("intent Check inventory is incomplete or oversized".to_string()); + } + let mut matching = Vec::new(); + for check in checks.check_runs { + if check.app.id == APP_PUBLIC_ID && check.app.slug == APP_SLUG { + if check.external_id != record.external_id { + return Err( + "conflicting App-owned release intent exists for this train".to_string() + ); + } + matching.push(check); + } + } + if matching.len() > 1 { + return Err("duplicate release intent Checks claim one external ID".to_string()); + } + if let Some(check) = matching.pop() { + validate_intent_check(&check, record, body)?; + return Ok(check); + } + let check: CheckRun = github.mutate( + "POST", + &format!("repos/{repository}/check-runs"), + &json!({ + "name": INTENT_NAME, + "head_sha": record.release_sha, + "status": "completed", + "conclusion": "neutral", + "external_id": record.external_id, + "output": { + "title": "Attested source-only release train", + "summary": body, + }, + }), + )?; + validate_intent_check(&check, record, body)?; + Ok(check) +} + +fn validate_intent_check( + check: &CheckRun, + record: &IntentRecord, + body: &str, +) -> Result<(), String> { + if check.id == 0 + || check.name != INTENT_NAME + || check.head_sha != record.release_sha + || check.external_id != record.external_id + || check.status != "completed" + || check.conclusion.as_deref() != Some("neutral") + || check.app.id != APP_PUBLIC_ID + || check.app.slug != APP_SLUG + || check.output.title.as_deref() != Some("Attested source-only release train") + || check.output.summary.as_deref() != Some(body) + { + return Err("release intent Check does not match the exact App attestation".to_string()); + } + Ok(()) +} + +pub(super) fn verify_intent_command( + repository: &str, + plan_text: &str, + plan_digest: &str, + intent_text: &str, + check_id: &str, + github: &mut impl Transport, +) -> Result<(), String> { + let plan = decode_plan(plan_text, plan_digest)?; + let intent = decode_intent(intent_text, &plan, plan_digest)?; + let check_id = positive(check_id, "intent Check ID")?; + let check: CheckRun = github.get(&format!("repos/{repository}/check-runs/{check_id}"))?; + validate_intent_check(&check, &intent, intent_text)?; + let check_id = check_id.to_string(); + let intent_digest = sha256(intent_text.as_bytes()); + append_outputs(&[ + ("intent", intent_text), + ("intent_digest", &intent_digest), + ("intent_check_id", &check_id), + ]) +} + +fn decode_intent( + body: &str, + plan: &ReleasePlan, + plan_digest: &str, +) -> Result { + if body.is_empty() || body.len() > MAX_INTENT_BYTES { + return Err("release intent is empty or oversized".to_string()); + } + let record: IntentRecord = serde_json::from_str(body) + .map_err(|error| format!("release intent schema is invalid: {error}"))?; + if canonical_intent(&record)? != body + || record.schema_version != INTENT_SCHEMA + || record.repository != plan.repository + || record.release_sha != plan.release_sha + || record.plan_digest != plan_digest + || !is_digest(&record.external_id) + || !is_digest(&record.ruleset_evidence_sha256) + || record.origin_run_id == 0 + || record.origin_run_attempt == 0 + || record.plan != *plan + || record.tags.len() != plan.packages.len() + { + return Err("release intent binding is invalid".to_string()); + } + for ((tag, package), index) in record.tags.iter().zip(&plan.packages).zip(0..) { + if tag.package != package.package + || tag.tag != package.tag + || !is_sha(&tag.tag_object_id) + || tag.release_body_sha256 != package.release_body_sha256 + || tag.tag_message + != format!( + "chore: Release package {} version {}", + package.package, package.version + ) + { + return Err(format!("release intent tag {index} is not canonical")); + } + } + Ok(record) +} + +pub(super) fn finalize_command( + input: FinalizeInput<'_>, + github: &mut impl Transport, +) -> Result<(), String> { + let plan = decode_plan(input.plan, input.plan_digest)?; + let intent = decode_intent(input.intent, &plan, input.plan_digest)?; + if plan.repository != input.repository { + return Err("finalizer repository differs from the release plan".to_string()); + } + validate_app_token( + github, + input.repository, + input.expected_app_slug, + input.expected_installation_id, + )?; + require_captured_ancestry(github, input.repository, &plan.release_sha)?; + let policy = release_policy_for_repository(input.repository)?; + if policy.packages.len() != plan.packages.len() { + return Err("release plan package family is incomplete".to_string()); + } + let mut registry = CratesIo::new(); + let mut present = 0usize; + for (package, package_policy) in plan.packages.iter().zip(policy.packages) { + if let Some(record) = registry.exact_version(&package.package, &package.version)? { + if package.package != package_policy.package + || record.num != package.version + || record.yanked + || record.checksum != package.source_archive_sha256 + { + return Err(format!( + "{} registry state differs from the release plan", + package.package + )); + } + let archive = registry.download(&package.package, &package.version)?; + if sha256(&archive) != package.source_archive_sha256 { + return Err(format!( + "{} registry archive differs from the release plan", + package.package + )); + } + crate::crate_archive::inspect_archive_entries( + &archive, + package_policy, + &package.version, + &plan.release_sha, + )?; + present += 1; + } else { + break; + } + } + for package in plan.packages.iter().skip(present) { + if registry + .exact_version(&package.package, &package.version)? + .is_some() + { + return Err("registry packages do not form the planned dependency prefix".to_string()); + } + } + let mut entries = Vec::with_capacity(present); + for (package, tag_intent) in plan.packages.iter().zip(&intent.tags).take(present) { + let tag_object_id = reconcile_tag(github, input.repository, &plan, package, tag_intent)?; + let release = reconcile_release(github, input.repository, package)?; + entries.push(FinalizedEntry { + package: package.package.clone(), + version: package.version.clone(), + release_id: release.id, + tag: package.tag.clone(), + tag_object_id, + release_body_sha256: package.release_body_sha256.clone(), + }); + } + let entries_text = serde_json::to_string(&entries) + .map_err(|error| format!("encode finalization entries: {error}"))?; + append_outputs(&[ + ( + "complete", + if present == plan.packages.len() { + "true" + } else { + "false" + }, + ), + ("finalized_entries", &entries_text), + ]) +} + +fn release_policy_for_repository( + repository: &str, +) -> Result<&'static crate::release_policy::ReleasePolicy, String> { + let policy = crate::github::consts::repository_policy(repository) + .ok_or_else(|| "finalizer repository lacks compiled policy".to_string())?; + match policy.release_family { + crate::release_policy::ReleaseFamily::Traits => Ok(&crate::release_policy::TRAITS_POLICY), + crate::release_policy::ReleaseFamily::RustWorkspace => { + Ok(&crate::release_policy::RUST_POLICY) + } + } +} + +fn require_captured_ancestry( + github: &mut impl Transport, + repository: &str, + release_sha: &str, +) -> Result<(), String> { + #[derive(Deserialize)] + struct Comparison { + status: String, + base_commit: CommitIdentity, + merge_base_commit: CommitIdentity, + } + #[derive(Deserialize)] + struct CommitIdentity { + sha: String, + } + let comparison: Comparison = + github.get(&format!("repos/{repository}/compare/{release_sha}...main"))?; + if !matches!(comparison.status.as_str(), "ahead" | "identical") + || comparison.base_commit.sha != release_sha + || comparison.merge_base_commit.sha != release_sha + { + return Err("captured release SHA is no longer protected-main ancestry".to_string()); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct AnnotatedTag { + sha: String, + tag: String, + message: String, + tagger: Tagger, + object: TagTarget, +} + +#[derive(Debug, Deserialize)] +struct Tagger { + name: String, + email: String, + date: String, +} + +#[derive(Debug, Deserialize)] +struct TagTarget { + #[serde(rename = "type")] + kind: String, + sha: String, +} + +fn reconcile_tag( + github: &mut impl Transport, + repository: &str, + plan: &ReleasePlan, + package: &PlanPackage, + intent: &TagIntent, +) -> Result { + let object_path = format!("repos/{repository}/git/tags/{}", intent.tag_object_id); + let object = github.get_optional::(&object_path)?; + if let Some(object) = &object { + validate_tag_object(object, plan, package, intent)?; + } + let ref_path = format!( + "repos/{repository}/git/ref/tags/{}", + percent_encode(&package.tag) + ); + let reference = github.get_optional::(&ref_path)?; + if let Some(reference) = reference { + if reference.name != format!("refs/tags/{}", package.tag) + || reference.object.kind != "tag" + || reference.object.sha != intent.tag_object_id + || object.is_none() + { + return Err(format!("tag {} conflicts with its App intent", package.tag)); + } + return Ok(intent.tag_object_id.clone()); + } + if object.is_none() { + let created: AnnotatedTag = github.mutate( + "POST", + &format!("repos/{repository}/git/tags"), + &json!({ + "tag": package.tag, + "message": intent.tag_message, + "object": plan.release_sha, + "type": "commit", + "tagger": { + "name": APP_LOGIN, + "email": APP_EMAIL, + "date": plan.tagger_date, + }, + }), + )?; + validate_tag_object(&created, plan, package, intent)?; + } + let created: crate::github::models::GitRef = github.mutate( + "POST", + &format!("repos/{repository}/git/refs"), + &json!({ + "ref": format!("refs/tags/{}", package.tag), + "sha": intent.tag_object_id, + }), + )?; + if created.name != format!("refs/tags/{}", package.tag) + || created.object.kind != "tag" + || created.object.sha != intent.tag_object_id + { + return Err("GitHub returned a mismatched annotated-tag ref".to_string()); + } + Ok(intent.tag_object_id.clone()) +} + +fn validate_tag_object( + object: &AnnotatedTag, + plan: &ReleasePlan, + package: &PlanPackage, + intent: &TagIntent, +) -> Result<(), String> { + if object.sha != intent.tag_object_id + || object.tag != package.tag + || object.message != intent.tag_message + || object.tagger.name != APP_LOGIN + || object.tagger.email != APP_EMAIL + || object.tagger.date != plan.tagger_date + || object.object.kind != "commit" + || object.object.sha != plan.release_sha + { + return Err(format!( + "annotated tag {} is not the attested object", + package.tag + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Deserialize)] +struct Release { + id: u64, + tag_name: String, + target_commitish: String, + name: String, + body: String, + draft: bool, + prerelease: bool, + immutable: bool, + author: ReleaseAuthor, + assets: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct ReleaseAuthor { + login: String, + id: u64, + #[serde(rename = "type")] + kind: String, +} + +fn release_create_payload(package: &PlanPackage) -> Value { + json!({ + "tag_name": package.tag, + "name": package.tag, + "body": package.release_body, + "draft": false, + "prerelease": package.prerelease, + }) +} + +fn reconcile_release( + github: &mut impl Transport, + repository: &str, + package: &PlanPackage, +) -> Result { + let path = format!( + "repos/{repository}/releases/tags/{}", + percent_encode(&package.tag) + ); + if let Some(release) = github.get_optional::(&path)? { + validate_release(&release, package)?; + return Ok(release); + } + let release: Release = github.mutate( + "POST", + &format!("repos/{repository}/releases"), + &release_create_payload(package), + )?; + validate_release(&release, package)?; + let readback: Release = github.get(&path)?; + validate_release(&readback, package)?; + if readback.id != release.id { + return Err("GitHub Release identity changed during readback".to_string()); + } + Ok(readback) +} + +fn validate_release(release: &Release, package: &PlanPackage) -> Result<(), String> { + if release.id == 0 + || release.tag_name != package.tag + || release.target_commitish != "main" + || release.name != package.tag + || release.body != package.release_body + || sha256(release.body.as_bytes()) != package.release_body_sha256 + || release.draft + || release.prerelease != package.prerelease + || !release.immutable + || release.author.login != APP_LOGIN + || release.author.id != APP_ID + || release.author.kind != "Bot" + || !release.assets.is_empty() + { + return Err(format!( + "GitHub Release {} is not exact and immutable", + package.tag + )); + } + Ok(()) +} + +pub(super) fn notify_command( + input: NotifyInput<'_>, + github: &mut impl Transport, +) -> Result<(), String> { + let plan = decode_plan(input.plan, input.plan_digest)?; + let intent = decode_intent(input.intent, &plan, input.plan_digest)?; + let check_id = positive(input.intent_check_id, "intent Check ID")?; + validate_app_token( + github, + input.repository, + input.expected_app_slug, + input.expected_installation_id, + )?; + let entries: Vec = serde_json::from_str(input.finalized_entries) + .map_err(|error| format!("finalized entry schema is invalid: {error}"))?; + if serde_json::to_string(&entries).ok().as_deref() != Some(input.finalized_entries) + || entries.len() != plan.packages.len() + { + return Err("finalized entry inventory is incomplete or noncanonical".to_string()); + } + for ((entry, package), tag) in entries.iter().zip(&plan.packages).zip(&intent.tags) { + if entry.package != package.package + || entry.version != package.version + || entry.release_id == 0 + || entry.tag != package.tag + || entry.tag_object_id != tag.tag_object_id + || entry.release_body_sha256 != package.release_body_sha256 + { + return Err("finalized entry differs from the attested release train".to_string()); + } + } + let payload = json!({ + "event_type": "official-release-published", + "client_payload": { + "schema_version": NOTIFICATION_SCHEMA, + "repository": input.repository, + "captured_sha": plan.release_sha, + "release_plan_digest": input.plan_digest, + "intent_check_id": check_id, + "intent_external_id": intent.external_id, + "releases": entries, + } + }); + let bytes = serde_json::to_vec(&payload) + .map_err(|error| format!("encode release notification: {error}"))?; + if bytes.len() > MAX_NOTIFICATION_BYTES { + return Err("release notification exceeds its byte bound".to_string()); + } + github.mutate_empty( + "POST", + &format!("repos/{}/dispatches", input.repository), + &payload, + ) +} + +#[derive(Debug, Deserialize)] +struct Installation { + id: u64, + app_id: u64, + app_slug: String, +} + +#[derive(Debug, Deserialize)] +struct InstallationRepositories { + total_count: u64, + repositories: Vec, +} + +#[derive(Debug, Deserialize)] +struct InstallationRepository { + full_name: String, +} + +#[derive(Debug, Deserialize)] +struct PublicApp { + id: u64, + slug: String, +} + +#[derive(Debug, Deserialize)] +struct ViewerResponse { + data: Option, + #[serde(default)] + errors: Option, +} + +#[derive(Debug, Deserialize)] +struct ViewerData { + viewer: Viewer, +} + +#[derive(Debug, Deserialize)] +struct Viewer { + login: String, + #[serde(rename = "databaseId")] + id: u64, +} + +pub(super) fn validate_app_token( + github: &mut impl Transport, + repository: &str, + expected_app_slug: &str, + expected_installation_id: &str, +) -> Result<(), String> { + if expected_app_slug != APP_SLUG || !is_positive_integer(expected_installation_id) { + return Err("App Action outputs are missing or unexpected".to_string()); + } + let expected_installation = expected_installation_id + .parse::() + .map_err(|_| "App installation ID is invalid".to_string())?; + let public: PublicApp = github.get(&format!("apps/{APP_SLUG}"))?; + let installation: Installation = github.get("installation")?; + let repositories: InstallationRepositories = github.get("installation/repositories")?; + let viewer: ViewerResponse = github.graphql(&json!({ + "query": "query { viewer { login databaseId } }", + }))?; + let viewer = viewer + .data + .map(|data| data.viewer) + .filter(|_| viewer.errors.is_none()) + .ok_or_else(|| "App token viewer query returned errors".to_string())?; + if public.id != APP_PUBLIC_ID + || public.slug != APP_SLUG + || installation.id != expected_installation + || installation.app_id != APP_PUBLIC_ID + || installation.app_slug != APP_SLUG + || repositories.total_count != 1 + || repositories.repositories.len() != 1 + || repositories.repositories[0].full_name != repository + || viewer.login != APP_LOGIN + || viewer.id != APP_ID + { + return Err("App token identity or repository scope is not exact".to_string()); + } + Ok(()) +} + +fn positive(value: &str, label: &str) -> Result { + if !is_positive_integer(value) { + return Err(format!("{label} must be a positive canonical integer")); + } + value + .parse::() + .map_err(|_| format!("{label} exceeds its bound")) +} + +fn is_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MissingRegistry { + reads: usize, + } + + impl Registry for MissingRegistry { + fn exact_version( + &mut self, + _package: &str, + _version: &str, + ) -> Result, String> { + self.reads += 1; + Ok(None) + } + + fn download(&mut self, _package: &str, _version: &str) -> Result, String> { + panic!("an absent registry package must not be downloaded") + } + } + + fn plan() -> ReleasePlan { + ReleasePlan { + schema_version: PLAN_SCHEMA, + repository: "NVIDIA/yaml-sigil-traits".to_string(), + release_sha: "a".repeat(40), + authorization: PlanAuthorization { + pull_request: 50, + proposal_commit: "b".repeat(40), + base_commit: "c".repeat(40), + owner_id: 1, + merger_id: 2, + }, + release_plz_version: RELEASE_PLZ_VERSION.to_string(), + release_config_sha256: "d".repeat(64), + publish_workflow_sha256: "e".repeat(64), + proposal_workflow_sha256: "f".repeat(64), + tagger_epoch: 1_777_777_777, + tagger_date: "2026-05-02T00:29:37+00:00".to_string(), + packages: vec![PlanPackage { + package: "yaml-sigil-traits".to_string(), + version: "0.4.0".to_string(), + tag: "v0.4.0".to_string(), + prerelease: false, + source_archive_sha256: "1".repeat(64), + package_inventory_sha256: "2".repeat(64), + release_body: "notes".to_string(), + release_body_sha256: sha256(b"notes"), + registry: RegistryBaseline { + state: RegistryState::Absent, + checksum: None, + }, + }], + } + } + + #[test] + fn release_plan_rejects_unknown_noncanonical_and_wrong_digest_inputs() { + let plan = plan(); + let (body, digest) = encode_plan(&plan).unwrap(); + assert_eq!(decode_plan(&body, &digest).unwrap(), plan); + assert!(decode_plan(&format!("{body} "), &digest).is_err()); + assert!(decode_plan(&body, &"0".repeat(64)).is_err()); + let unknown = body.replacen('{', "{\"unknown\":true,", 1); + assert!(decode_plan(&unknown, &sha256(unknown.as_bytes())).is_err()); + } + + #[test] + fn registry_progression_accepts_only_monotonic_dependency_prefixes() { + let expected = plan(); + let mut actual = expected.clone(); + actual.packages[0].registry = RegistryBaseline { + state: RegistryState::Present, + checksum: Some("4".repeat(64)), + }; + assert!(require_registry_transition(&expected, &actual).is_ok()); + assert!(require_registry_transition(&actual, &expected).is_err()); + } + + #[test] + fn registry_wait_has_one_initial_observation_and_a_bounded_poll_count() { + let plan = plan(); + let mut registry = MissingRegistry { reads: 0 }; + let mut pauses = 0; + let error = wait_for_registry( + &plan, + &crate::release_policy::TRAITS_POLICY, + &mut registry, + 2, + || pauses += 1, + ) + .unwrap_err(); + assert!(error.contains("within 20 minutes")); + assert_eq!(registry.reads, 3); + assert_eq!(pauses, 2); + } + + #[test] + fn release_creation_omits_target_commitish_even_when_main_advances() { + let package = plan().packages.remove(0); + let payload = release_create_payload(&package); + assert_eq!(payload["tag_name"], "v0.4.0"); + assert!(payload.get("target_commitish").is_none()); + } + + #[test] + fn notification_schema_rejects_unknown_and_legacy_payloads() { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ClosedPayload { + schema_version: u64, + repository: String, + captured_sha: String, + release_plan_digest: String, + intent_check_id: u64, + intent_external_id: String, + releases: Vec, + } + let canonical = json!({ + "schema_version": 1, + "repository": "NVIDIA/yaml-sigil-traits", + "captured_sha": "a".repeat(40), + "release_plan_digest": "b".repeat(64), + "intent_check_id": 1, + "intent_external_id": "c".repeat(64), + "releases": [], + }); + let ClosedPayload { + schema_version, + repository, + captured_sha, + release_plan_digest, + intent_check_id, + intent_external_id, + releases, + } = serde_json::from_value(canonical).unwrap(); + assert_eq!(schema_version, 1); + assert_eq!(repository, "NVIDIA/yaml-sigil-traits"); + assert_eq!(captured_sha, "a".repeat(40)); + assert_eq!(release_plan_digest, "b".repeat(64)); + assert_eq!(intent_check_id, 1); + assert_eq!(intent_external_id, "c".repeat(64)); + assert!(releases.is_empty()); + let legacy = json!({"version": "0.4.0"}); + assert!(serde_json::from_value::(legacy).is_err()); + let unknown = json!({ + "schema_version": 1, + "repository": "NVIDIA/yaml-sigil-traits", + "captured_sha": "a".repeat(40), + "release_plan_digest": "b".repeat(64), + "intent_check_id": 1, + "intent_external_id": "c".repeat(64), + "releases": [], + "unknown": true, + }); + assert!(serde_json::from_value::(unknown).is_err()); + } +} diff --git a/xtask/src/github/source.rs b/xtask/src/github/source.rs index fde47df..913f8b0 100644 --- a/xtask/src/github/source.rs +++ b/xtask/src/github/source.rs @@ -4,7 +4,6 @@ //! Authorize publication from immutable commits and exact Git trees. use std::collections::{BTreeMap, BTreeSet}; -use std::fs; use std::path::Path; use semver::Version; @@ -20,6 +19,7 @@ use crate::github::{ APP_EMAIL, APP_ID, APP_LOGIN, RELEASE_BRANCH, WEB_FLOW_EMAIL, WEB_FLOW_ID, WEB_FLOW_LOGIN, WEB_FLOW_NAME, git_line, is_sha, repository_policy_for_root, }; +use crate::safe_file; const MAX_MANUAL_COMMITS: usize = 100; const WRITER_PERMISSIONS: &[&str] = &["admin", "maintain", "write"]; @@ -32,7 +32,7 @@ pub(super) fn authorize_command( baseline_commit: &str, github: &mut impl Transport, ) -> Result<(), String> { - let number = authorize_source( + let authorization = authorize_source( github, repository, commit, @@ -40,18 +40,30 @@ pub(super) fn authorize_command( baseline_version, baseline_commit, )?; - eprintln!("github: authorized exact merged release proposal PR #{number}"); + eprintln!( + "github: authorized exact merged release proposal PR #{}", + authorization.pull_request + ); Ok(()) } -fn authorize_source( +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SourceAuthorization { + pub(super) pull_request: u64, + pub(super) proposal_commit: String, + pub(super) base_commit: String, + pub(super) owner_id: u64, + pub(super) merger_id: u64, +} + +pub(super) fn authorize_source( github: &mut impl Transport, repository: &str, commit: &str, root: &Path, baseline_version: &str, baseline_commit: &str, -) -> Result { +) -> Result { if !is_sha(commit) || !is_sha(baseline_commit) { return Err("repository or release commit is unsupported".to_string()); } @@ -151,8 +163,19 @@ fn authorize_source( .merged_by .as_ref() .ok_or_else(|| "release pull request lost its merger".to_string())?; - require_writer(github, repository, final_merger, "release merger")?; - Ok(number) + require_current_release_writers( + github, + repository, + final_merger, + manual_proposal.then_some(&final_pull.user), + )?; + Ok(SourceAuthorization { + pull_request: number, + proposal_commit: proposal.sha.clone(), + base_commit: base_sha.to_string(), + owner_id: pull.user.id, + merger_id: final_merger.id, + }) } fn release_version(value: &str) -> Result { @@ -173,7 +196,7 @@ fn release_version(value: &str) -> Result { } fn manifest_release_version(root: &Path, repository: &str) -> Result { - let body = fs::read_to_string(root.join("Cargo.toml")) + let body = safe_file::read_manifest(root, Path::new("Cargo.toml")) .map_err(|error| format!("read release manifest: {error}"))?; let document = body .parse::() @@ -273,6 +296,19 @@ fn require_writer( Ok(()) } +fn require_current_release_writers( + github: &mut impl Transport, + repository: &str, + merger: &User, + manual_owner: Option<&User>, +) -> Result<(), String> { + require_writer(github, repository, merger, "release merger")?; + if let Some(owner) = manual_owner { + require_writer(github, repository, owner, "release owner")?; + } + Ok(()) +} + fn fetch_commit_sequence( github: &mut impl Transport, repository: &str, @@ -499,6 +535,21 @@ mod tests { } } + fn user(login: &str, id: u64) -> User { + User { + login: login.to_string(), + id, + name: None, + } + } + + fn permission(user: &User, value: &str) -> Value { + json!({ + "permission": value, + "user": {"login": user.login, "id": user.id}, + }) + } + #[test] fn release_versions_accept_only_stable_or_canonical_rc() { assert!(release_version("0.4.0").is_ok()); @@ -609,4 +660,47 @@ mod tests { assert!(require_exact_tree_diff(&mut linked, TRAITS_REPOSITORY, &base, &proposal).is_err()); linked.finish(); } + + #[test] + fn final_manual_authorization_rechecks_merger_and_owner() { + let merger = user("release-merger", 41); + let owner = user("release-owner", 42); + let path = |user: &User| { + format!( + "repos/{TRAITS_REPOSITORY}/collaborators/{}/permission", + percent_encode(&user.login) + ) + }; + let mut github = FakeTransport::new([ + Expected::json("GET", &path(&merger), permission(&merger, "maintain")), + Expected::json("GET", &path(&owner), permission(&owner, "write")), + ]); + + require_current_release_writers(&mut github, TRAITS_REPOSITORY, &merger, Some(&owner)) + .unwrap(); + github.finish(); + } + + #[test] + fn final_manual_authorization_rejects_owner_permission_loss() { + let merger = user("release-merger", 41); + let owner = user("release-owner", 42); + let path = |user: &User| { + format!( + "repos/{TRAITS_REPOSITORY}/collaborators/{}/permission", + percent_encode(&user.login) + ) + }; + let mut github = FakeTransport::new([ + Expected::json("GET", &path(&merger), permission(&merger, "admin")), + Expected::json("GET", &path(&owner), permission(&owner, "read")), + ]); + + let error = + require_current_release_writers(&mut github, TRAITS_REPOSITORY, &merger, Some(&owner)) + .unwrap_err(); + + assert_eq!(error, "release owner lacks current write authority"); + github.finish(); + } } diff --git a/xtask/src/github/transport.rs b/xtask/src/github/transport.rs index 10feadf..7d6620f 100644 --- a/xtask/src/github/transport.rs +++ b/xtask/src/github/transport.rs @@ -3,19 +3,22 @@ //! Injected, bounded transport around the GitHub CLI. -use std::io::Write; -use std::process::{Command, Stdio}; +use std::process::Command; use std::thread; use std::time::Duration; use serde::Serialize; use serde::de::DeserializeOwned; +use crate::bounded_process::{self, OutputLimits}; + pub(crate) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; pub(crate) const MAX_ERROR_BYTES: usize = 64 * 1024; const MAX_PAGES: usize = 20; const PAGE_SIZE: usize = 100; const READ_ATTEMPTS: usize = 3; +const GITHUB_API_VERSION: &str = "2026-03-10"; +const GITHUB_JSON_MEDIA_TYPE: &str = "application/vnd.github+json"; pub(crate) trait Transport { fn get(&mut self, path: &str) -> Result; @@ -27,6 +30,12 @@ pub(crate) trait Transport { path: &str, payload: &P, ) -> Result; + fn mutate_empty( + &mut self, + method: &str, + path: &str, + payload: &P, + ) -> Result<(), String>; fn delete(&mut self, path: &str) -> Result<(), String>; fn graphql(&mut self, payload: &P) -> Result; } @@ -35,14 +44,17 @@ pub(crate) struct GhCli; impl GhCli { pub(crate) fn new() -> Result { - let output = Command::new("gh") - .args(["--version"]) - .output() - .map_err(|error| format!("run gh: {error}"))?; - if output.stdout.len() > MAX_ERROR_BYTES - || output.stderr.len() > MAX_ERROR_BYTES - || !output.status.success() - { + let mut command = Command::new("gh"); + command.args(["--version"]); + let output = bounded_process::output( + &mut command, + OutputLimits { + stdout: MAX_ERROR_BYTES, + stderr: MAX_ERROR_BYTES, + }, + ) + .map_err(|error| format!("run gh: {error}"))?; + if !output.status.success() { return Err("gh is unavailable".to_string()); } Ok(Self) @@ -67,35 +79,28 @@ impl GhCli { let mut last = None; for attempt in 1..=attempts { let mut command = Command::new("gh"); - command.args(["api", "--method", method, path]); + command.args([ + "api", + "--method", + method, + "--header", + &format!("Accept: {GITHUB_JSON_MEDIA_TYPE}"), + "--header", + &format!("X-GitHub-Api-Version: {GITHUB_API_VERSION}"), + path, + ]); if payload.is_some() { command.args(["--input", "-"]); - command.stdin(Stdio::piped()); - } else { - command.stdin(Stdio::null()); } - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = command - .spawn() - .map_err(|error| RequestError::Permanent(format!("run gh api: {error}")))?; - if let Some(body) = payload { - child - .stdin - .take() - .expect("piped stdin") - .write_all(body) - .map_err(|error| { - RequestError::Permanent(format!("write gh api request: {error}")) - })?; - } - let output = child - .wait_with_output() - .map_err(|error| RequestError::Permanent(format!("wait for gh api: {error}")))?; - if output.stdout.len() > MAX_RESPONSE_BYTES || output.stderr.len() > MAX_ERROR_BYTES { - return Err(RequestError::Permanent( - "GitHub response exceeded its bound".to_string(), - )); + let limits = OutputLimits { + stdout: MAX_RESPONSE_BYTES, + stderr: MAX_ERROR_BYTES, + }; + let output = match payload { + Some(body) => bounded_process::output_with_input(&mut command, body, limits), + None => bounded_process::output(&mut command, limits), } + .map_err(|error| RequestError::Permanent(format!("run gh api: {error}")))?; if output.status.success() { return Ok(output.stdout); } @@ -174,6 +179,29 @@ impl Transport for GhCli { self.decode(method, path, &response) } + fn mutate_empty( + &mut self, + method: &str, + path: &str, + payload: &P, + ) -> Result<(), String> { + if method != "POST" { + return Err("GitHub no-content mutation method is unsupported".to_string()); + } + let body = serde_json::to_vec(payload) + .map_err(|error| format!("serialize GitHub request: {error}"))?; + if body.len() > MAX_RESPONSE_BYTES { + return Err("GitHub request exceeded its bound".to_string()); + } + let response = self + .request(method, path, Some(&body), false) + .map_err(RequestError::message)?; + if !response.is_empty() { + return Err(format!("{method} {path} unexpectedly returned a body")); + } + Ok(()) + } + fn delete(&mut self, path: &str) -> Result<(), String> { self.request("DELETE", path, None, false) .map(|_| ()) @@ -432,6 +460,17 @@ pub(crate) mod fake { Self::decode(value, method, path) } + fn mutate_empty( + &mut self, + method: &str, + path: &str, + payload: &P, + ) -> Result<(), String> { + let payload = serde_json::to_value(payload) + .map_err(|error| format!("serialize fake mutation: {error}"))?; + self.call(method, path, Some(payload)).map(|_| ()) + } + fn delete(&mut self, path: &str) -> Result<(), String> { self.call("DELETE", path, None).map(|_| ()) } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1f5319e..4bd405c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -13,6 +13,7 @@ mod release; mod release_baseline; mod release_policy; mod release_proposal; +mod safe_file; mod spec_update; mod versions; diff --git a/xtask/src/release.rs b/xtask/src/release.rs index 1ced950..676dc01 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; @@ -19,7 +19,9 @@ use semver::Version; use serde_json::Value; use toml_edit::{DocumentMut, Item, Value as TomlValue}; +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; use crate::release_policy::{RUST_POLICY, ReleaseToolchain, TRAITS_POLICY}; +use crate::safe_file; const REGISTRY_USER_AGENT: &str = "yaml-sigil-release-workflow/1.0"; const REGISTRY_ATTEMPTS: usize = 30; @@ -201,10 +203,9 @@ struct SystemRunner; impl Runner for SystemRunner { fn output(&mut self, program: &OsStr, args: &[OsString], root: &Path) -> Result { - let output = Command::new(program) - .current_dir(root) - .args(args) - .output() + let mut command = Command::new(program); + command.current_dir(root).args(args); + let output = bounded_process::output(&mut command, VALIDATION_OUTPUT_LIMITS) .with_context(|| format!("run {}", program.to_string_lossy()))?; Ok(CommandResult { success: output.status.success(), @@ -888,6 +889,31 @@ fn require_publication_fields( if workspace.get("release_always").and_then(Item::as_bool) != Some(release_always) { bail!("reviewed config must set release_always = {release_always}"); } + for field in ["git_tag_enable", "git_release_enable"] { + if workspace.get(field).and_then(Item::as_bool) != Some(false) { + bail!("reviewed workspace config must set {field} = false"); + } + } + let packages = document + .get("package") + .and_then(Item::as_array_of_tables) + .ok_or_else(|| anyhow!("release config has no package overrides"))?; + if packages.len() != RUST_POLICY.packages.len() { + bail!("release config has an unexpected package override set"); + } + for (package, policy) in packages.iter().zip(RUST_POLICY.packages) { + if package.get("name").and_then(Item::as_str) != Some(policy.package) { + bail!("release config package overrides are not exact or ordered"); + } + for field in ["git_tag_enable", "git_release_enable"] { + if package.get(field).and_then(Item::as_bool) != Some(false) { + bail!( + "reviewed {} config must set {field} = false", + policy.package + ); + } + } + } match (workspace.get("pr_branch_prefix"), branch_prefix) { (None, None) => Ok(()), (Some(value), Some(expected)) if value.as_str() == Some(expected) => Ok(()), @@ -896,6 +922,14 @@ fn require_publication_fields( } } +fn release_config_relative(root: &Path, source: &Path) -> Result { + let relative = source.strip_prefix(root).unwrap_or(source); + if relative.is_absolute() { + bail!("release config must be inside the trusted checkout"); + } + Ok(relative.to_path_buf()) +} + fn source_newline(body: &str) -> Result<&'static str> { if body.contains("\r\n") { let without_crlf = body.replace("\r\n", ""); @@ -911,24 +945,26 @@ fn source_newline(body: &str) -> Result<&'static str> { } fn prepare_publication_config(root: &Path, source: &Path, output: &Path) -> Result<()> { - let source = resolve_path(root, source) - .canonicalize() - .with_context(|| format!("resolve release config {}", source.display()))?; + let source_relative = release_config_relative(root, source)?; let output = resolve_path(root, output); if output.exists() { bail!("publication config already exists: {}", output.display()); } - let body = fs::read_to_string(&source) + let body = safe_file::TrustedRoot::open(root) + .and_then(|trusted| trusted.read_manifest(&source_relative)) .with_context(|| format!("read release config {}", source.display()))?; let original: DocumentMut = body .parse() .with_context(|| format!("parse release config {}", source.display()))?; require_publication_fields(&original, false, None)?; - let valid_ref = Command::new("git") - .current_dir(root) - .args(["check-ref-format", "--branch", "release-plz-publication"]) - .output() + let mut valid_ref_command = Command::new("git"); + valid_ref_command.current_dir(root).args([ + "check-ref-format", + "--branch", + "release-plz-publication", + ]); + let valid_ref = bounded_process::output(&mut valid_ref_command, VALIDATION_OUTPUT_LIMITS) .context("run git check-ref-format")?; if !valid_ref.status.success() { bail!( @@ -936,10 +972,13 @@ fn prepare_publication_config(root: &Path, source: &Path, output: &Path) -> Resu process_output_detail(&valid_ref) ); } - let invalid_ref = Command::new("git") - .current_dir(root) - .args(["check-ref-format", "--branch", ":release-plz-publication"]) - .output() + let mut invalid_ref_command = Command::new("git"); + invalid_ref_command.current_dir(root).args([ + "check-ref-format", + "--branch", + ":release-plz-publication", + ]); + let invalid_ref = bounded_process::output(&mut invalid_ref_command, VALIDATION_OUTPUT_LIMITS) .context("run git check-ref-format")?; if invalid_ref.status.success() { bail!("the publication branch prefix is a valid Git ref"); @@ -984,22 +1023,30 @@ fn write_new_verified_file( .with_context(|| format!("create output directory {}", parent.display()))?; } let mut file = OpenOptions::new() + .read(true) .write(true) .create_new(true) .open(path) .with_context(|| format!("create {}", path.display()))?; if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) { + drop(file); let _ = fs::remove_file(path); return Err(error).with_context(|| format!("write {}", path.display())); } - drop(file); let result = (|| { - let actual = fs::read(path).with_context(|| format!("read {}", path.display()))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("rewind {}", path.display()))?; + let mut actual = Vec::with_capacity(bytes.len() + 1); + Read::by_ref(&mut file) + .take((bytes.len() + 1) as u64) + .read_to_end(&mut actual) + .with_context(|| format!("reread {}", path.display()))?; if actual != bytes { bail!("generated file bytes changed while writing"); } verify(&actual) })(); + drop(file); if result.is_err() { let _ = fs::remove_file(path); } @@ -1668,16 +1715,37 @@ mod tests { assert!(require_current_main(Path::new("."), &head, "--upload-pack=bad").is_err()); } + fn reviewed_publication_config(newline: &str) -> String { + let mut lines = vec![ + "[workspace]".to_string(), + "release = false".to_string(), + "release_always = false".to_string(), + "git_tag_enable = false".to_string(), + "git_release_enable = false".to_string(), + String::new(), + ]; + for policy in RUST_POLICY.packages { + lines.extend([ + "[[package]]".to_string(), + format!("name = {:?}", policy.package), + "release = true".to_string(), + "git_tag_enable = false".to_string(), + "git_release_enable = false".to_string(), + String::new(), + ]); + } + lines.join(newline) + } + #[test] fn publication_config_changes_only_reviewed_switches() { let temporary = temp_root("publication-config"); let source = temporary.join("release-plz.toml"); let output = temporary.join("generated.toml"); - let body = "[workspace]\nrelease = false\nrelease_always = false\n\n[[package]]\nname = \"yaml-sigil-core\"\nrelease = true\n"; - fs::write(&source, body).unwrap(); + let body = reviewed_publication_config("\n"); + fs::write(&source, &body).unwrap(); - prepare_publication_config(Path::new(env!("CARGO_MANIFEST_DIR")), &source, &output) - .unwrap(); + prepare_publication_config(&temporary, &source, &output).unwrap(); let actual = fs::read_to_string(&output).unwrap(); assert_eq!( @@ -1689,7 +1757,7 @@ mod tests { ) .replacen("release_always = false", "release_always = true", 1) ); - assert!(prepare_publication_config(Path::new("."), &source, &output).is_err()); + assert!(prepare_publication_config(&temporary, &source, &output).is_err()); cleanup(temporary); } @@ -1698,13 +1766,8 @@ mod tests { let temporary = temp_root("publication-crlf"); let source = temporary.join("release-plz.toml"); let output = temporary.join("generated.toml"); - fs::write( - &source, - b"[workspace]\r\nrelease = false\r\nrelease_always = false\r\n", - ) - .unwrap(); - prepare_publication_config(Path::new(env!("CARGO_MANIFEST_DIR")), &source, &output) - .unwrap(); + fs::write(&source, reviewed_publication_config("\r\n")).unwrap(); + prepare_publication_config(&temporary, &source, &output).unwrap(); let actual = fs::read(&output).unwrap(); assert!(actual.windows(2).any(|window| window == b"\r\n")); assert!( @@ -1721,12 +1784,8 @@ mod tests { ) .unwrap(); assert!( - prepare_publication_config( - Path::new(env!("CARGO_MANIFEST_DIR")), - &ambiguous, - &temporary.join("rejected.toml") - ) - .is_err() + prepare_publication_config(&temporary, &ambiguous, &temporary.join("rejected.toml")) + .is_err() ); cleanup(temporary); } diff --git a/xtask/src/release_baseline.rs b/xtask/src/release_baseline.rs index 95eee20..56d5ca2 100644 --- a/xtask/src/release_baseline.rs +++ b/xtask/src/release_baseline.rs @@ -17,8 +17,10 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use toml_edit::DocumentMut; +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; use crate::crate_archive::{CratesIo, Registry, RegistryVersion, inspect_archive, is_checksum}; use crate::release_policy::{ReleaseFamily, ReleasePolicy, detect}; +use crate::safe_file; const INVENTORY_SCHEMA: u64 = 3; const READ_ONLY_PUSH_URL: &str = "disabled://yaml-sigil-release-proposal"; @@ -292,7 +294,7 @@ fn prepare( return Err("detached baseline checkout is not exact and clean".to_string()); } let manifest = output.join("Cargo.toml"); - if manifest_version(&manifest, policy.family)? != version { + if manifest_version(output, policy.family)? != version { return Err("detached baseline manifest does not match its official tag".to_string()); } @@ -545,7 +547,7 @@ fn require_excluded_current_version( head: &str, tags: &BTreeMap, ) -> Result<(), String> { - if manifest_version(&root.join("Cargo.toml"), policy.family)? != version { + if manifest_version(root, policy.family)? != version { return Err("excluded retry version does not match current source".to_string()); } for package in policy.packages { @@ -705,8 +707,9 @@ fn require_repository_state(root: &Path, args: &ParsedArgs) -> Result<(), String Ok(()) } -fn manifest_version(path: &Path, family: ReleaseFamily) -> Result { - let body = fs::read_to_string(path) +fn manifest_version(root: &Path, family: ReleaseFamily) -> Result { + let path = Path::new("Cargo.toml"); + let body = safe_file::read_manifest(root, path) .map_err(|error| format!("read baseline manifest {}: {error}", path.display()))?; let document = body .parse::() @@ -822,15 +825,10 @@ fn git_status(root: &Path, args: &[&str]) -> Result<(), String> { } fn git_process(root: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .current_dir(root) - .args(args) - .output() - .map_err(|error| format!("run git {}: {error}", args.join(" ")))?; - if output.stdout.len() > MAX_GIT_OUTPUT || output.stderr.len() > MAX_GIT_OUTPUT { - return Err("Git output exceeded its bound".to_string()); - } - Ok(output) + let mut command = Command::new("git"); + command.current_dir(root).args(args); + bounded_process::output(&mut command, VALIDATION_OUTPUT_LIMITS) + .map_err(|error| format!("run git {}: {error}", args.join(" "))) } fn detail(output: &Output) -> String { @@ -1012,7 +1010,9 @@ mod tests { let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut builder = tar::Builder::new(encoder); let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::file()); header.set_mode(0o644); + header.set_mtime(crate::crate_archive::CARGO_ARCHIVE_MTIME); header.set_size(vcs.len() as u64); header.set_cksum(); builder diff --git a/xtask/src/release_policy.rs b/xtask/src/release_policy.rs index b1580cc..c3855b1 100644 --- a/xtask/src/release_policy.rs +++ b/xtask/src/release_policy.rs @@ -9,6 +9,8 @@ use std::process::Command; use serde::Deserialize; use serde_json::Value; +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ReleaseFamily { Traits, @@ -99,10 +101,11 @@ pub(crate) const RUST_POLICY: ReleasePolicy = ReleasePolicy { }; pub(crate) fn detect(root: &Path) -> Result<&'static ReleasePolicy, String> { - let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + let mut command = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + command .current_dir(root) - .args(["metadata", "--no-deps", "--format-version", "1"]) - .output() + .args(["metadata", "--no-deps", "--format-version", "1"]); + let output = bounded_process::output(&mut command, VALIDATION_OUTPUT_LIMITS) .map_err(|error| format!("run Cargo metadata for release policy: {error}"))?; if !output.status.success() { let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); @@ -110,9 +113,6 @@ pub(crate) fn detect(root: &Path) -> Result<&'static ReleasePolicy, String> { "Cargo metadata failed while selecting release policy: {detail}" )); } - if output.stdout.len() > 4 * 1024 * 1024 { - return Err("Cargo release metadata exceeded its bound".to_string()); - } let metadata: Metadata = serde_json::from_slice(&output.stdout) .map_err(|error| format!("Cargo returned invalid release metadata: {error}"))?; detect_from_metadata(&metadata) diff --git a/xtask/src/release_proposal.rs b/xtask/src/release_proposal.rs index cf861ff..f69eba5 100644 --- a/xtask/src/release_proposal.rs +++ b/xtask/src/release_proposal.rs @@ -12,10 +12,9 @@ use clap::{Args, Subcommand, ValueEnum}; use semver::Version; use serde::Serialize; +use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; use crate::release_policy::{ReleaseFamily, ReleasePolicy, detect}; -const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024; - #[derive(Args)] pub struct ProposalArgs { #[command(subcommand)] @@ -269,15 +268,10 @@ fn command_line(root: &Path, program: &str, args: &[&str]) -> Result Result { - let output = Command::new(program) - .current_dir(root) - .args(args) - .output() - .map_err(|error| format!("run {program}: {error}"))?; - if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES { - return Err(format!("{program} output exceeded its bound")); - } - Ok(output) + let mut command = Command::new(program); + command.current_dir(root).args(args); + bounded_process::output(&mut command, VALIDATION_OUTPUT_LIMITS) + .map_err(|error| format!("run {program}: {error}")) } fn detail(output: &Output) -> String { diff --git a/xtask/src/safe_file.rs b/xtask/src/safe_file.rs new file mode 100644 index 0000000..91d3d45 --- /dev/null +++ b/xtask/src/safe_file.rs @@ -0,0 +1,439 @@ +// SPDX-FileCopyrightText: Copyright 2026 NVIDIA CORPORATION & AFFILIATES +// SPDX-License-Identifier: Apache-2.0 + +//! Atomic, root-anchored, no-follow reads for release-reachable manifests. + +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Read}; +use std::path::{Component, Path}; + +pub(crate) const MANIFEST_LIMIT: usize = 1024 * 1024; + +fn invalid_input(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.into()) +} + +fn relative_components(path: &Path) -> io::Result> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(invalid_input( + "safe file path must be a nonempty relative path", + )); + } + let mut components = Vec::new(); + for component in path.components() { + let Component::Normal(value) = component else { + return Err(invalid_input( + "safe file path may not contain prefixes, roots, or traversal", + )); + }; + let text = value + .to_str() + .ok_or_else(|| invalid_input("safe file path must be valid UTF-8"))?; + if text.contains(':') || text.contains('\0') { + return Err(invalid_input( + "safe file path may not contain alternate data streams or NUL", + )); + } + components.push(value.to_owned()); + } + if components.is_empty() { + return Err(invalid_input("safe file path has no normal components")); + } + Ok(components) +} + +fn read_utf8_bounded(mut file: File, limit: usize, label: &Path) -> io::Result { + let sentinel = limit + .checked_add(1) + .ok_or_else(|| invalid_input("safe file limit is invalid"))?; + let mut bytes = Vec::with_capacity(sentinel.min(8 * 1024)); + file.by_ref() + .take(sentinel as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{} exceeds its {limit}-byte limit", label.display()), + )); + } + String::from_utf8(bytes).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{} is not valid UTF-8: {error}", label.display()), + ) + }) +} + +pub(crate) struct TrustedRoot { + platform: platform::Root, +} + +impl TrustedRoot { + pub(crate) fn open(path: &Path) -> io::Result { + Ok(Self { + platform: platform::Root::open(path)?, + }) + } + + pub(crate) fn read_utf8(&self, path: &Path, limit: usize) -> io::Result { + let components = relative_components(path)?; + let file = self.platform.open_file(&components)?; + read_utf8_bounded(file, limit, path) + } + + pub(crate) fn read_manifest(&self, path: &Path) -> io::Result { + self.read_utf8(path, MANIFEST_LIMIT) + } +} + +pub(crate) fn read_manifest(root: &Path, path: &Path) -> io::Result { + TrustedRoot::open(root)?.read_manifest(path) +} + +#[cfg(unix)] +mod platform { + use super::*; + + use rustix::fd::OwnedFd; + use rustix::fs::{FileType, Mode, OFlags, fstat, open, openat}; + + pub(super) struct Root { + directory: OwnedFd, + } + + fn require_type(descriptor: &OwnedFd, directory: bool) -> io::Result<()> { + let metadata = fstat(descriptor)?; + let file_type = FileType::from_raw_mode(metadata.st_mode); + if (directory && !file_type.is_dir()) || (!directory && !file_type.is_file()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + if directory { + "safe path component is not a directory" + } else { + "safe final path is not a regular file" + }, + )); + } + Ok(()) + } + + impl Root { + pub(super) fn open(path: &Path) -> io::Result { + let directory = open( + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + require_type(&directory, true)?; + Ok(Self { directory }) + } + + pub(super) fn open_file(&self, components: &[OsString]) -> io::Result { + let mut traversed: Option = None; + for component in &components[..components.len() - 1] { + let parent = traversed.as_ref().unwrap_or(&self.directory); + let next = openat( + parent, + component, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + require_type(&next, true)?; + traversed = Some(next); + } + let parent = traversed.as_ref().unwrap_or(&self.directory); + let file = openat( + parent, + &components[components.len() - 1], + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + Mode::empty(), + )?; + require_type(&file, false)?; + Ok(File::from(file)) + } + } +} + +#[cfg(windows)] +mod platform { + use super::*; + + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::ptr::{null, null_mut}; + + use windows_sys::Wdk::Foundation::OBJECT_ATTRIBUTES; + use windows_sys::Wdk::Storage::FileSystem::{ + FILE_DIRECTORY_FILE, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT, + FILE_SYNCHRONOUS_IO_NONALERT, NtCreateFile, + }; + use windows_sys::Win32::Foundation::{ + HANDLE, INVALID_HANDLE_VALUE, OBJ_CASE_INSENSITIVE, UNICODE_STRING, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, + FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_STANDARD_INFO, + FILE_TYPE_DISK, FileAttributeTagInfo, FileStandardInfo, GetFileInformationByHandleEx, + GetFileType, OPEN_EXISTING, SYNCHRONIZE, + }; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; + + pub(super) struct Root { + directory: OwnedHandle, + } + + fn raw_handle(handle: &OwnedHandle) -> HANDLE { + handle.as_raw_handle() as HANDLE + } + + fn require_handle_type(handle: &OwnedHandle, directory: bool) -> io::Result<()> { + let mut attributes = FILE_ATTRIBUTE_TAG_INFO::default(); + let attribute_ok = unsafe { + GetFileInformationByHandleEx( + raw_handle(handle), + FileAttributeTagInfo, + (&mut attributes as *mut FILE_ATTRIBUTE_TAG_INFO).cast(), + size_of::() as u32, + ) + }; + if attribute_ok == 0 { + return Err(io::Error::last_os_error()); + } + if attributes.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "safe path component is a reparse point", + )); + } + if unsafe { GetFileType(raw_handle(handle)) } != FILE_TYPE_DISK { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "safe path handle is not a disk file", + )); + } + let mut standard = FILE_STANDARD_INFO::default(); + let standard_ok = unsafe { + GetFileInformationByHandleEx( + raw_handle(handle), + FileStandardInfo, + (&mut standard as *mut FILE_STANDARD_INFO).cast(), + size_of::() as u32, + ) + }; + if standard_ok == 0 { + return Err(io::Error::last_os_error()); + } + if standard.Directory != directory { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + if directory { + "safe path component is not a directory" + } else { + "safe final path is not a regular file" + }, + )); + } + Ok(()) + } + + fn nt_open_relative( + parent: &OwnedHandle, + component: &OsString, + directory: bool, + ) -> io::Result { + let mut name: Vec = component.encode_wide().collect(); + let byte_length = name + .len() + .checked_mul(size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| invalid_input("safe Windows path component is too long"))?; + let mut unicode = UNICODE_STRING { + Length: byte_length, + MaximumLength: byte_length, + Buffer: name.as_mut_ptr(), + }; + let attributes = OBJECT_ATTRIBUTES { + Length: size_of::() as u32, + RootDirectory: raw_handle(parent), + ObjectName: &mut unicode, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: null_mut(), + SecurityQualityOfService: null_mut(), + }; + let mut status_block = IO_STATUS_BLOCK::default(); + let mut opened: HANDLE = null_mut(); + let desired = + FILE_READ_ATTRIBUTES | SYNCHRONIZE | if directory { 0 } else { FILE_READ_DATA }; + let options = FILE_OPEN_REPARSE_POINT + | FILE_SYNCHRONOUS_IO_NONALERT + | if directory { + FILE_DIRECTORY_FILE + } else { + FILE_NON_DIRECTORY_FILE + }; + let status = unsafe { + NtCreateFile( + &mut opened, + desired, + &attributes, + &mut status_block, + null(), + FILE_ATTRIBUTE_NORMAL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + FILE_OPEN, + options, + null(), + 0, + ) + }; + if status < 0 || opened.is_null() || opened == INVALID_HANDLE_VALUE { + return Err(io::Error::other(format!( + "NtCreateFile failed with NTSTATUS {status:#x}", + ))); + } + let handle = unsafe { OwnedHandle::from_raw_handle(opened as RawHandle) }; + require_handle_type(&handle, directory)?; + Ok(handle) + } + + impl Root { + pub(super) fn open(path: &Path) -> io::Result { + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let opened = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_READ_ATTRIBUTES | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if opened == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let directory = unsafe { OwnedHandle::from_raw_handle(opened as RawHandle) }; + require_handle_type(&directory, true)?; + Ok(Self { directory }) + } + + pub(super) fn open_file(&self, components: &[OsString]) -> io::Result { + let mut traversed: Option = None; + for component in &components[..components.len() - 1] { + let parent = traversed.as_ref().unwrap_or(&self.directory); + traversed = Some(nt_open_relative(parent, component, true)?); + } + let parent = traversed.as_ref().unwrap_or(&self.directory); + let file = nt_open_relative(parent, &components[components.len() - 1], false)?; + Ok(File::from(file)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + + #[test] + fn exact_limit_limit_plus_one_and_utf8_are_bounded() { + let temporary = tempfile::tempdir().unwrap(); + fs::write(temporary.path().join("exact.toml"), vec![b'x'; 8]).unwrap(); + fs::write(temporary.path().join("large.toml"), vec![b'x'; 9]).unwrap(); + fs::write(temporary.path().join("invalid.toml"), [0xff]).unwrap(); + let root = TrustedRoot::open(temporary.path()).unwrap(); + + assert_eq!(root.read_utf8(Path::new("exact.toml"), 8).unwrap().len(), 8); + assert_eq!( + root.read_utf8(Path::new("large.toml"), 8) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + assert_eq!( + root.read_utf8(Path::new("invalid.toml"), 8) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + } + + #[test] + fn directories_absolute_paths_traversal_and_streams_are_rejected() { + let temporary = tempfile::tempdir().unwrap(); + fs::create_dir(temporary.path().join("directory")).unwrap(); + fs::write(temporary.path().join("Cargo.toml"), b"[package]\n").unwrap(); + let root = TrustedRoot::open(temporary.path()).unwrap(); + + assert!(root.read_manifest(Path::new("directory")).is_err()); + assert!(root.read_manifest(Path::new("../Cargo.toml")).is_err()); + assert!( + root.read_manifest(&temporary.path().join("Cargo.toml")) + .is_err() + ); + assert!(root.read_manifest(Path::new("Cargo.toml:stream")).is_err()); + assert_eq!( + fs::read(temporary.path().join("Cargo.toml")).unwrap(), + b"[package]\n" + ); + } + + #[cfg(unix)] + #[test] + fn unix_symlinks_intermediate_symlinks_and_devices_are_rejected() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + fs::write(temporary.path().join("real.toml"), b"[package]\n").unwrap(); + symlink("real.toml", temporary.path().join("link.toml")).unwrap(); + fs::create_dir(temporary.path().join("real-directory")).unwrap(); + fs::write( + temporary.path().join("real-directory").join("Cargo.toml"), + b"[package]\n", + ) + .unwrap(); + symlink("real-directory", temporary.path().join("linked-directory")).unwrap(); + let root = TrustedRoot::open(temporary.path()).unwrap(); + + assert!(root.read_manifest(Path::new("link.toml")).is_err()); + assert!( + root.read_manifest(Path::new("linked-directory/Cargo.toml")) + .is_err() + ); + assert!( + TrustedRoot::open(Path::new("/dev")) + .unwrap() + .read_manifest(Path::new("null")) + .is_err() + ); + } + + #[cfg(windows)] + #[test] + fn windows_file_and_intermediate_reparse_points_are_rejected() { + use std::os::windows::fs::{symlink_dir, symlink_file}; + + let temporary = tempfile::tempdir().unwrap(); + fs::write(temporary.path().join("real.toml"), b"[package]\n").unwrap(); + symlink_file("real.toml", temporary.path().join("link.toml")).unwrap(); + fs::create_dir(temporary.path().join("real-directory")).unwrap(); + fs::write( + temporary.path().join("real-directory").join("Cargo.toml"), + b"[package]\n", + ) + .unwrap(); + symlink_dir("real-directory", temporary.path().join("linked-directory")).unwrap(); + let root = TrustedRoot::open(temporary.path()).unwrap(); + + assert!(root.read_manifest(Path::new("link.toml")).is_err()); + assert!( + root.read_manifest(Path::new("linked-directory/Cargo.toml")) + .is_err() + ); + } +} diff --git a/xtask/src/versions.rs b/xtask/src/versions.rs index a003668..11ba24d 100644 --- a/xtask/src/versions.rs +++ b/xtask/src/versions.rs @@ -18,6 +18,7 @@ use toml_edit::{DocumentMut, Item, Value as TomlValue}; use crate::bounded_process::{self, VALIDATION_OUTPUT_LIMITS}; use crate::release::exact_output_line; use crate::release_policy::{RUST_POLICY, RUST_TOOLCHAIN, TRAITS_POLICY}; +use crate::safe_file; const TRAITS_PACKAGE: &str = TRAITS_POLICY.packages[0].package; @@ -185,8 +186,8 @@ fn sync_workspace_dependency_versions_with_runner( runner: &mut impl CargoRunner, ) -> Result { let path = root.join("Cargo.toml"); - let cargo_toml = - fs::read_to_string(&path).context("read workspace Cargo.toml for version sync")?; + let cargo_toml = safe_file::read_manifest(root, Path::new("Cargo.toml")) + .context("read workspace Cargo.toml for version sync")?; let mut document = cargo_toml .parse::() .context("parse workspace Cargo.toml for version sync")?; @@ -409,7 +410,7 @@ fn validate_internal_dependency_metadata_json( } fn read_workspace_version(root: &Path) -> Result { - let manifest = fs::read_to_string(root.join("Cargo.toml")) + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) .context("read workspace Cargo.toml for release version")?; let value = workspace_package_version(&manifest) .ok_or_else(|| anyhow!("missing [workspace.package] version in root Cargo.toml"))?; @@ -421,7 +422,8 @@ fn read_workspace_version(root: &Path) -> Result { fn write_workspace_version(root: &Path, version: &Version) -> Result<()> { let path = root.join("Cargo.toml"); - let manifest = fs::read_to_string(&path).context("read workspace Cargo.toml")?; + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) + .context("read workspace Cargo.toml")?; let mut in_section = false; let mut replaced = false; let mut lines = Vec::new(); @@ -957,7 +959,7 @@ fn stable_version(version: &Version) -> Result { /// workspace crates exchange incompatible Rust types even when both copies /// display the same semantic version. fn validate_crates_io_traits_dependency(root: &Path) -> Result<()> { - let manifest = fs::read_to_string(root.join("Cargo.toml")) + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) .context("read workspace Cargo.toml for traits source validation")?; let document: toml::Value = toml::from_str(&manifest) .context("parse workspace Cargo.toml for traits source validation")?; @@ -1004,7 +1006,7 @@ fn validate_stable_traits_dependency(root: &Path, workspace_version: &Version) - return Ok(()); } - let manifest = fs::read_to_string(root.join("Cargo.toml")) + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) .context("read workspace Cargo.toml for traits version validation")?; let (_, requirement) = workspace_traits_dependency(&manifest)?; let traits_version = exact_traits_version(&requirement)?; @@ -1018,7 +1020,7 @@ fn validate_stable_traits_dependency(root: &Path, workspace_version: &Version) - /// Validate the exact split-crate pin before stable promotion mutates files. fn validate_promotable_traits_dependency(root: &Path) -> Result<()> { - let manifest = fs::read_to_string(root.join("Cargo.toml")) + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) .context("read workspace Cargo.toml for traits stable promotion")?; let (_, requirement) = workspace_traits_dependency(&manifest)?; let traits_version = exact_traits_version(&requirement)?; @@ -1033,7 +1035,7 @@ fn validate_promotable_traits_dependency(root: &Path) -> Result<()> { /// Strip an `rc.N` suffix from the exact split-crate requirement. fn promote_traits_dependency_to_stable(root: &Path) -> Result { let path = root.join("Cargo.toml"); - let manifest = fs::read_to_string(&path) + let manifest = safe_file::read_manifest(root, Path::new("Cargo.toml")) .context("read workspace Cargo.toml for traits stable promotion")?; let (line_index, requirement) = workspace_traits_dependency(&manifest)?; let traits_version = exact_traits_version(&requirement)?;