diff --git a/.github/actions/build-evidence-bundle/action.yml b/.github/actions/build-evidence-bundle/action.yml index 05fcb8f..5fa5194 100644 --- a/.github/actions/build-evidence-bundle/action.yml +++ b/.github/actions/build-evidence-bundle/action.yml @@ -1,6 +1,9 @@ name: Build evidence bundle description: Stage deterministic evidence artifacts and archive the final bundle. inputs: + commit_sha: + description: Exact commit SHA recorded in every evidence artifact. + required: true workflow_name: description: Workflow name recorded in the evidence summary. required: true @@ -19,6 +22,12 @@ inputs: runs: using: composite steps: + - name: Validate evidence commit provenance + shell: bash + env: + COMMIT_SHA: ${{ inputs.commit_sha }} + run: test -n "${COMMIT_SHA}" + - name: Prepare evidence inputs shell: bash run: | @@ -48,6 +57,7 @@ runs: - name: Build suite summary JSON shell: bash env: + COMMIT_SHA: ${{ inputs.commit_sha }} WORKFLOW_NAME: ${{ inputs.workflow_name }} TAG_VALUE: ${{ inputs.tag }} INCLUDE_SDK_SUITE: ${{ inputs.include_sdk_suite }} @@ -69,7 +79,7 @@ runs: include_sdk_suite = os.environ["INCLUDE_SDK_SUITE"].lower() == "true" workflow_name = os.environ["WORKFLOW_NAME"] tag = os.environ.get("TAG_VALUE", "") - commit = os.environ["GITHUB_SHA"] + commit = os.environ["COMMIT_SHA"] suite_summary = { "commit_sha": commit, @@ -158,10 +168,12 @@ runs: - name: Build interop evidence summary shell: bash + env: + COMMIT_SHA: ${{ inputs.commit_sha }} run: | set -euo pipefail python3 tools/ci/build_interop_summary.py \ - --commit-sha "${GITHUB_SHA}" \ + --commit-sha "${COMMIT_SHA}" \ --out-dir artifacts/evidence \ --suite-rust artifacts/evidence/suite-run-rust.json \ --suite-ts artifacts/evidence/suite-run-ts.json \ @@ -212,7 +224,9 @@ runs: - name: Archive evidence shell: bash + env: + COMMIT_SHA: ${{ inputs.commit_sha }} run: | set -euo pipefail cd artifacts - zip -r "evidence-${GITHUB_SHA}.zip" evidence + zip -r "evidence-${COMMIT_SHA}.zip" evidence diff --git a/.github/actions/python-policy-checks/action.yml b/.github/actions/python-policy-checks/action.yml index b679755..ab5d0ff 100644 --- a/.github/actions/python-policy-checks/action.yml +++ b/.github/actions/python-policy-checks/action.yml @@ -5,7 +5,7 @@ inputs: description: Run the main ruleset drift check for main. required: false default: "false" - gh_token: + ruleset_token: description: Token used by the main ruleset drift check. required: false default: "" @@ -16,7 +16,7 @@ inputs: main_ruleset_contexts: description: Comma-separated required status contexts. required: false - default: python-tooling,rust-core,evidence-bundle,capid-csprng-audit + default: CI gate runs: using: composite steps: @@ -57,10 +57,14 @@ runs: shell: bash run: python3 tools/ci/check_codeowners_coverage.py - - name: Check Dependabot automerge policy consistency + - name: Check Dependabot update policy consistency shell: bash run: python3 tools/ci/check_dependabot_policy.py + - name: Check public CI economy policy + shell: bash + run: python3 tools/ci/check_ci_economy_policy.py + - name: Check golden image publication policy shell: bash run: python3 tools/ci/check_golden_images_policy.py @@ -153,11 +157,18 @@ runs: shell: bash run: python3 tools/ci/check_sdk_ai_boundary.py + - name: Require main ruleset drift token (main) + if: inputs.run_main_ruleset_drift == 'true' && inputs.ruleset_token == '' + shell: bash + run: | + echo "RULESET_DRIFT_ERR_TOKEN_MISSING: ruleset drift is mandatory on main." >&2 + exit 1 + - name: Check main ruleset drift (main) - if: inputs.run_main_ruleset_drift == 'true' && inputs.gh_token != '' + if: inputs.run_main_ruleset_drift == 'true' && inputs.ruleset_token != '' shell: bash env: - GH_TOKEN: ${{ inputs.gh_token }} + GH_TOKEN: ${{ inputs.ruleset_token }} run: | set -euo pipefail python3 tools/ci/check_branch_protection_drift.py \ @@ -173,6 +184,18 @@ runs: --expected-ruleset-name "main protection" \ --expected-merge-methods "merge,squash,rebase" + - name: Check full CI environment drift (main) + if: inputs.run_main_ruleset_drift == 'true' && inputs.ruleset_token != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.ruleset_token }} + run: | + set -euo pipefail + python3 tools/ci/check_full_ci_environment.py \ + --repo "${GITHUB_REPOSITORY}" \ + --environment full-ci \ + --expected-reviewer "${GITHUB_REPOSITORY_OWNER}" + - name: Ensure no macOS metadata files shell: bash run: | diff --git a/.github/actions/rust-core-verification/action.yml b/.github/actions/rust-core-verification/action.yml index 020ccdf..612bbb9 100644 --- a/.github/actions/rust-core-verification/action.yml +++ b/.github/actions/rust-core-verification/action.yml @@ -1,6 +1,9 @@ name: Rust core verification description: Run the Rust reference implementation tests and conformance suite. inputs: + commit_sha: + description: Exact commit SHA recorded in the Rust suite summary. + required: true build_wasm: description: Build the wasm target after the Rust suite. required: false @@ -8,6 +11,12 @@ inputs: runs: using: composite steps: + - name: Validate Rust evidence commit provenance + shell: bash + env: + COMMIT_SHA: ${{ inputs.commit_sha }} + run: test -n "${COMMIT_SHA}" + - name: Set up Rust toolchain uses: ./.github/actions/setup-rust-toolchain @@ -28,11 +37,13 @@ runs: - name: Rust strict conformance suite shell: bash + env: + COMMIT_SHA: ${{ inputs.commit_sha }} run: | set -euo pipefail python3 tools/ci/run_runner_suite.py \ --vectors-root conformance/vectors \ - --commit-sha "${GITHUB_SHA}" \ + --commit-sha "${COMMIT_SHA}" \ --out artifacts/rust-suite-summary.json \ --runner-cmd core/rust/target/debug/grain-runner run --strict --vector diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 04822e2..e7f8bd7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,9 +3,16 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" - rebase-strategy: "auto" - open-pull-requests-limit: 5 + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" labels: - "dependencies" - "ci" @@ -13,9 +20,15 @@ updates: - package-ecosystem: "cargo" directory: "/core/rust" schedule: - interval: "weekly" - rebase-strategy: "auto" - open-pull-requests-limit: 5 + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-cargo-patches: + patterns: + - "*" + update-types: + - "patch" labels: - "dependencies" - "rust" @@ -23,9 +36,16 @@ updates: - package-ecosystem: "npm" directory: "/runner/typescript" schedule: - interval: "weekly" - rebase-strategy: "auto" - open-pull-requests-limit: 5 + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-npm: + patterns: + - "*" + update-types: + - "minor" + - "patch" labels: - "dependencies" - "typescript" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 39347b9..1731336 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -45,3 +45,4 @@ If this PR touches any of: encoding / CID / COSE / ledger / E2E / manifest / lim - [ ] NES and CDDL are consistent (no drift) - [ ] docs/llm updated as needed, including `DOC_SYNC` for contract changes - [ ] Rationale documented (ADR or spec rationale) +- [ ] If the workflow requested full verification, a maintainer approved the protected `full-ci` environment after the final commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75792f7..bf86ef1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,84 @@ on: push: branches: [main] pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read actions: read + pull-requests: read + +env: + CI_COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} jobs: + scope: + name: Scope CI + runs-on: ubuntu-latest + outputs: + full_required: ${{ steps.scope.outputs.full_required }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Classify this exact event + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + PR_CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" != "pull_request" ]]; then + python3 tools/ci/classify_ci_scope.py \ + --event-name "$EVENT_NAME" \ + --github-output "$GITHUB_OUTPUT" + else + files_json="$(mktemp)" + trap 'rm -f "$files_json"' EXIT + gh api --paginate --slurp \ + "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" > "$files_json" + + python3 tools/ci/classify_ci_scope.py \ + --event-name "$EVENT_NAME" \ + --expected-count "$PR_CHANGED_FILES" \ + --files-json "$files_json" \ + --github-output "$GITHUB_OUTPUT" + fi + + full-ci-approval: + name: Approve full CI + needs: [scope, python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke] + if: >- + always() && + github.event_name == 'pull_request' && + needs.scope.result == 'success' && + needs.scope.outputs.full_required == 'true' && + needs.python-tooling.result == 'success' && + needs.capid-csprng-audit.result == 'success' && + needs.rust-core.result == 'success' && + needs.ts-c01.result == 'success' && + needs.ts-full.result == 'success' && + needs.wasm-smoke.result == 'success' + permissions: {} + environment: + name: full-ci + deployment: false + runs-on: ubuntu-latest + steps: + - name: Record approval for this workflow run + run: echo "Full CI approved for ${CI_COMMIT_SHA}." + python-tooling: runs-on: ubuntu-latest steps: @@ -20,7 +92,7 @@ jobs: - uses: ./.github/actions/python-policy-checks with: run_main_ruleset_drift: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - gh_token: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} + ruleset_token: ${{ github.token }} - name: Run Python guard tests run: | @@ -45,6 +117,10 @@ jobs: python3 -m unittest tools.ci.test_check_release_train_docs python3 -m unittest tools.ci.test_check_workflow_action_pinning python3 -m unittest tools.ci.test_check_dependabot_policy + python3 -m unittest tools.ci.test_check_ci_economy_policy + python3 -m unittest tools.ci.test_check_full_ci_environment + python3 -m unittest tools.ci.test_classify_ci_scope + python3 -m unittest tools.ci.test_evaluate_ci_gate python3 -m unittest tools.ci.test_check_golden_images_policy capid-csprng-audit: @@ -70,12 +146,15 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - uses: ./.github/actions/rust-core-verification + with: + commit_sha: ${{ env.CI_COMMIT_SHA }} - name: Upload rust suite summary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: rust-suite-summary path: artifacts/rust-suite-summary.json + retention-days: ${{ github.event_name == 'pull_request' && 14 || 30 }} ts-c01: runs-on: ubuntu-latest @@ -106,6 +185,7 @@ jobs: with: name: ts-c01-results path: artifacts/* + retention-days: ${{ github.event_name == 'pull_request' && 14 || 30 }} ts-full: runs-on: ubuntu-latest @@ -146,7 +226,7 @@ jobs: run: | python3 tools/ci/run_runner_suite.py \ --vectors-root conformance/vectors \ - --commit-sha "${GITHUB_SHA}" \ + --commit-sha "${CI_COMMIT_SHA}" \ --out artifacts/suite-run-ts.json \ --runner-cmd node runner/typescript/dist/src/cli.js run --strict --vector @@ -206,6 +286,7 @@ jobs: with: name: ts-full-results path: artifacts/* + retention-days: ${{ github.event_name == 'pull_request' && 14 || 30 }} wasm-smoke: runs-on: ubuntu-latest @@ -243,8 +324,20 @@ jobs: with: name: wasm-smoke-results path: artifacts/wasm* + retention-days: ${{ github.event_name == 'pull_request' && 14 || 30 }} sdk-platform: + needs: [scope, full-ci-approval] + if: >- + always() && + needs.scope.result == 'success' && + ( + github.event_name != 'pull_request' || + ( + needs.scope.outputs.full_required == 'true' && + needs.full-ci-approval.result == 'success' + ) + ) runs-on: macos-15 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 @@ -321,32 +414,37 @@ jobs: with: name: sdk-platform-results path: artifacts/sdk-platform + retention-days: 30 - name: Upload client SDK release package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: sdk-release-package path: artifacts/sdk-release/* + retention-days: 30 - name: Upload SDK registry dry-run results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: sdk-registry-dry-run-results path: artifacts/sdk-registry-dry-runs + retention-days: 30 - name: Upload external client certification uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: external-client-certification path: artifacts/sdk-platform/external-client-certification + retention-days: 30 fuzz-smoke: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: scope + if: needs.scope.result == 'success' && github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: - ref: ${{ github.sha }} + ref: ${{ env.CI_COMMIT_SHA }} fetch-depth: 0 - name: Set up Python @@ -368,27 +466,39 @@ jobs: with: name: fuzz-smoke-results path: artifacts/fuzz-smoke + retention-days: 30 verify-script-smoke: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: scope + if: needs.scope.result == 'success' && github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: - ref: ${{ github.sha }} + ref: ${{ env.CI_COMMIT_SHA }} + persist-credentials: false - name: Build grain-certify container locally - run: docker build -f docker/grain-certify.Dockerfile -t grain-certify-ci:${GITHUB_SHA} . + run: docker build -f docker/grain-certify.Dockerfile -t "grain-certify-ci:${CI_COMMIT_SHA}" . - name: Run one-command verify smoke - run: ./scripts/verify --image grain-certify-ci:${GITHUB_SHA} --out-dir artifacts/verify-smoke + run: ./scripts/verify --image "grain-certify-ci:${CI_COMMIT_SHA}" --out-dir artifacts/verify-smoke - name: Verify evidence hash exists run: test -f artifacts/verify-smoke/evidence/evidence_content.sha256 evidence-bundle: + if: >- + always() && + needs.python-tooling.result == 'success' && + needs.capid-csprng-audit.result == 'success' && + needs.rust-core.result == 'success' && + needs.ts-c01.result == 'success' && + needs.ts-full.result == 'success' && + needs.wasm-smoke.result == 'success' && + needs.sdk-platform.result == 'success' runs-on: ubuntu-latest - needs: [python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke, sdk-platform] + needs: [scope, python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke, sdk-platform] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with: @@ -423,6 +533,7 @@ jobs: - uses: ./.github/actions/build-evidence-bundle with: + commit_sha: ${{ env.CI_COMMIT_SHA }} workflow_name: ci include_sdk_suite: "true" hash_order: full-first @@ -430,5 +541,49 @@ jobs: - name: Upload evidence bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: evidence-${{ github.sha }} - path: artifacts/evidence-${{ github.sha }}.zip + name: evidence-${{ env.CI_COMMIT_SHA }} + path: artifacts/evidence-${{ env.CI_COMMIT_SHA }}.zip + retention-days: 30 + + ci-gate: + name: CI gate + if: always() + runs-on: ubuntu-latest + needs: + - scope + - full-ci-approval + - python-tooling + - capid-csprng-audit + - rust-core + - ts-c01 + - ts-full + - wasm-smoke + - sdk-platform + - fuzz-smoke + - verify-script-smoke + - evidence-bundle + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Evaluate the fail-closed CI contract + env: + FULL_REQUIRED: ${{ needs.scope.outputs.full_required }} + run: | + python3 tools/ci/evaluate_ci_gate.py \ + --event-name "${{ github.event_name }}" \ + --full-required "${FULL_REQUIRED}" \ + --job "scope=${{ needs.scope.result }}" \ + --job "full-ci-approval=${{ needs.full-ci-approval.result }}" \ + --job "python-tooling=${{ needs.python-tooling.result }}" \ + --job "capid-csprng-audit=${{ needs.capid-csprng-audit.result }}" \ + --job "rust-core=${{ needs.rust-core.result }}" \ + --job "ts-c01=${{ needs.ts-c01.result }}" \ + --job "ts-full=${{ needs.ts-full.result }}" \ + --job "wasm-smoke=${{ needs.wasm-smoke.result }}" \ + --job "sdk-platform=${{ needs.sdk-platform.result }}" \ + --job "fuzz-smoke=${{ needs.fuzz-smoke.result }}" \ + --job "verify-script-smoke=${{ needs.verify-script-smoke.result }}" \ + --job "evidence-bundle=${{ needs.evidence-bundle.result }}" diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml deleted file mode 100644 index f285117..0000000 --- a/.github/workflows/dependabot-automerge.yml +++ /dev/null @@ -1,190 +0,0 @@ -name: dependabot-automerge - -on: - workflow_run: - workflows: ["ci"] - types: [completed] - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - issues: write - actions: read - -jobs: - dependabot-safe-lane: - if: > - github.event_name == 'workflow_dispatch' || - ( - github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' - ) - runs-on: ubuntu-latest - env: - REPO: ${{ github.repository }} - DEPENDABOT_AUTOMERGE_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} - BLOCK_SEMVER_MAJOR_ACTIONS: "true" - steps: - - name: Collect candidate PRs from trusted workflow_run - id: collect - run: | - set -euo pipefail - rm -f candidate-prs.txt - - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "Manual dispatch does not auto-select PRs." - echo "has_candidates=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - actor_login="$(jq -r '.workflow_run.actor.login // empty' "$GITHUB_EVENT_PATH")" - if [[ "$actor_login" != "dependabot[bot]" && "$actor_login" != "app/dependabot" ]]; then - echo "No Dependabot actor for this run ($actor_login); skipping." - echo "has_candidates=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - jq -r '.workflow_run.pull_requests[].number // empty' "$GITHUB_EVENT_PATH" | sort -u > candidate-prs.txt - if [[ ! -s candidate-prs.txt ]]; then - echo "No PRs attached to workflow_run." - echo "has_candidates=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "has_candidates=true" >> "$GITHUB_OUTPUT" - - - name: Token presence check (strict) - if: steps.collect.outputs.has_candidates == 'true' - run: | - set -euo pipefail - if [[ -z "${DEPENDABOT_AUTOMERGE_TOKEN:-}" ]]; then - echo "::error::DEPS_ERR_TOKEN_MISSING: repository secret DEPENDABOT_AUTOMERGE_TOKEN is required." - exit 1 - fi - - - name: Token permission check (strict) - if: steps.collect.outputs.has_candidates == 'true' - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} - run: | - set -euo pipefail - if ! gh api "repos/$REPO/actions/workflows" >/dev/null 2>&1; then - echo "::error::DEPS_ERR_TOKEN_INSUFFICIENT_PERMS: token must include workflows write-capable permissions." - exit 1 - fi - - - name: Evaluate policy and execute automerge lane - if: steps.collect.outputs.has_candidates == 'true' - env: - GH_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} - run: | - set -euo pipefail - bot_login="$(gh api user --jq '.login')" - while read -r pr; do - [[ -n "$pr" ]] || continue - - pr_user="$(gh api "repos/$REPO/pulls/$pr" --jq '.user.login')" - pr_state="$(gh api "repos/$REPO/pulls/$pr" --jq '.state')" - base_repo="$(gh api "repos/$REPO/pulls/$pr" --jq '.base.repo.full_name // empty')" - head_repo="$(gh api "repos/$REPO/pulls/$pr" --jq '.head.repo.full_name // empty')" - pr_title="$(gh api "repos/$REPO/pulls/$pr" --jq '.title')" - - if [[ "$pr_state" != "open" ]]; then - continue - fi - - if [[ "$pr_user" != "dependabot[bot]" && "$pr_user" != "app/dependabot" ]]; then - continue - fi - - reasons=() - safe=true - mapfile -t files < <(gh api "repos/$REPO/pulls/$pr/files?per_page=100" --paginate --jq '.[].filename') - - if [[ "${#files[@]}" -eq 0 ]]; then - safe=false - reasons+=("no-changed-files") - fi - - if [[ "$base_repo" != "$REPO" || "$head_repo" != "$REPO" ]]; then - safe=false - reasons+=("external-repo-head-or-base") - fi - - for f in "${files[@]}"; do - case "$f" in - .github/dependabot.yml|.github/ISSUE_TEMPLATE/*) - ;; - .github/workflows/*|.github/actions/*) - safe=false - reasons+=("executable-automation-change:$f") - ;; - *) - safe=false - reasons+=("path-not-allowlisted:$f") - ;; - esac - - case "$f" in - spec/*|conformance/*|core/*|runner/*|docs/llm/*|tools/*) - safe=false - reasons+=("denylist-hit:$f") - ;; - *) - ;; - esac - done - - if [[ "${BLOCK_SEMVER_MAJOR_ACTIONS}" == "true" ]]; then - if [[ "$pr_title" =~ from[[:space:]]v?([0-9]+)([^[:space:]]*)[[:space:]]to[[:space:]]v?([0-9]+) ]]; then - from_major="${BASH_REMATCH[1]}" - to_major="${BASH_REMATCH[3]}" - if [[ "$from_major" != "$to_major" ]]; then - safe=false - reasons+=("semver-major:$from_major->$to_major") - fi - fi - fi - - gh pr edit "$pr" --repo "$REPO" --add-label dependencies --add-label ci >/dev/null - - if [[ "$safe" != "true" ]]; then - gh pr edit "$pr" --repo "$REPO" --add-label needs-manual >/dev/null - reason_text="$(printf '%s\n' "${reasons[@]}" | sed 's/^/- /')" - gh pr comment "$pr" --repo "$REPO" --body "Dependabot automerge policy: manual-review lane.\n\nReasons:\n$reason_text" >/dev/null - continue - fi - - merge_state="$(gh api "repos/$REPO/pulls/$pr" --jq '.mergeable_state')" - if [[ "$merge_state" == "behind" ]]; then - head_sha="$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')" - if ! gh api -X PUT "repos/$REPO/pulls/$pr/update-branch" -f expected_head_sha="$head_sha" >/dev/null; then - echo "::error::DEPS_ERR_UPDATE_BRANCH_FAILED: pr=$pr" - exit 1 - fi - gh pr comment "$pr" --repo "$REPO" --body "@dependabot rebase" >/dev/null - gh pr comment "$pr" --repo "$REPO" --body "Dependabot automerge: branch update requested." >/dev/null - continue - fi - - approved_state="$( - gh api "repos/$REPO/pulls/$pr/reviews?per_page=100" \ - --paginate \ - --jq "[.[] | select(.user.login == \"$bot_login\")][-1].state // \"\"" - )" - if [[ "$approved_state" != "APPROVED" ]]; then - if ! gh pr review "$pr" --repo "$REPO" --approve --body "Auto-approved (safe non-executable Dependabot metadata path under TOR-DEPS-STRICT-FINAL)."; then - echo "::error::DEPS_ERR_APPROVE_FAILED: pr=$pr" - exit 1 - fi - fi - - if ! gh pr merge "$pr" --repo "$REPO" --auto --rebase; then - echo "::error::DEPS_ERR_ENABLE_AUTOMERGE_FAILED: pr=$pr" - exit 1 - fi - - gh pr comment "$pr" --repo "$REPO" --body "Auto-approved and auto-merge enabled (safe non-executable Dependabot metadata change)." >/dev/null - done < candidate-prs.txt diff --git a/.github/workflows/release-evidence.yml b/.github/workflows/release-evidence.yml index bbbd9fc..e10dd64 100644 --- a/.github/workflows/release-evidence.yml +++ b/.github/workflows/release-evidence.yml @@ -108,6 +108,7 @@ jobs: - uses: ./.github/actions/rust-core-verification with: + commit_sha: ${{ github.sha }} build_wasm: "true" - uses: ./.github/actions/setup-node-typescript @@ -229,6 +230,7 @@ jobs: - uses: ./.github/actions/build-evidence-bundle with: + commit_sha: ${{ github.sha }} workflow_name: release-evidence tag: ${{ github.ref_name }} hash_order: c01-first diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2ebb5..ef28e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ This project follows a protocol-frozen posture: v0.1 core invariants do not change. ## [Unreleased] +- CI economy and dependency maintenance: + - replaced four branch-protection contexts with one fail-closed `CI gate` while preserving all underlying Linux, platform SDK, smoke, and evidence checks. + - gated macOS and evidence work per PR commit through the protected `full-ci` environment, with automatic full runs on `main` and cancellation of obsolete PR runs. + - kept public PR execution on GitHub-hosted runners, required approval for every external fork workflow, and documented the private-executor boundary for any future self-hosted capacity. + - moved routine Dependabot updates to monthly grouped PRs, disabled automatic rebases, capped open version PRs, and removed the unused privileged automerge workflow. - Repository boundary: - kept Grain focused on public protocol, conformance, SDK, template, and contract surfaces by moving first-party production app and broker implementation code outside the public repository. - documented the public/private product boundary and trademark posture for downstream app builders. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba35208..5caffd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,15 +53,21 @@ If the change affects users, builders, or maintainers, update the matching human If the PR changes encoding, CID, COSE, ledger, E2E, manifest, limits, conformance, or schemas, add an ADR. See `adr/0000-template.md`. -## Maintainer-only automation note +## Maintainer-only CI note -Dependency automation uses repository secret `DEPENDABOT_AUTOMERGE_TOKEN`. -If that token is missing or under-scoped, the automation stops and tells you why: +Routine dependency updates are grouped monthly and merged manually. There is no +privileged Dependabot automerge token. See +`docs/human/dependencies-policy.md`. -- `DEPS_ERR_TOKEN_MISSING` -- `DEPS_ERR_TOKEN_INSUFFICIENT_PERMS` +Workflow runs from every external fork contributor require maintainer approval +before any job starts. This is separate from the full-CI approval below. -See `docs/human/dependencies-policy.md`. +For code, executable automation, protocol, conformance, SDK, script, or unknown +paths, the workflow first runs its Linux graph, then waits at `Approve full CI`. +After reviewing the final diff, a maintainer approves the protected `full-ci` +environment from the run's `Review deployments` prompt. Only then can the +GitHub-hosted macOS and evidence jobs start. Do not attach a persistent +self-hosted runner to this public repository. If you are working in a sandbox where `.git` is readable but not writable, use `scripts/git-sandbox-safe ...` to run git through a writable mirror in `/tmp`. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index d42caaf..a56fd10 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -57,11 +57,14 @@ Current baseline on `main`: - changes to `main` go through PRs - direct pushes to `main` are disabled -- required checks: - - `python-tooling` - - `rust-core` - - `evidence-bundle` - - `capid-csprng-audit` +- required check: `CI gate` +- `CI gate` requires the automatic Linux jobs and fails closed +- for full-scope PRs, `CI gate` also requires successful GitHub-hosted macOS SDK + and evidence jobs +- full-scope PRs require maintainer approval through the protected `full-ci` + environment after the final commit before `CI gate` can pass +- pull-request jobs use GitHub-hosted runners; persistent self-hosted runners + are not attached to this public repository - required approving reviews: `0` - dismiss stale reviews: `true` - code owner reviews: `false` diff --git a/adr/conformance/0004-dependabot-strict-fail-closed.md b/adr/conformance/0004-dependabot-strict-fail-closed.md index e003478..674f586 100644 --- a/adr/conformance/0004-dependabot-strict-fail-closed.md +++ b/adr/conformance/0004-dependabot-strict-fail-closed.md @@ -1,6 +1,6 @@ # ADR 0004: Dependabot Automerge Strict Fail-Closed Lane -- Status: Accepted +- Status: Superseded by `adr/conformance/0006-ci-economy-and-trusted-runners.md` - Date: 2026-02-24 - Related TOR: `TOR-DEPS-STRICT-FINAL` diff --git a/adr/conformance/0006-ci-economy-and-trusted-runners.md b/adr/conformance/0006-ci-economy-and-trusted-runners.md new file mode 100644 index 0000000..4c3d4a3 --- /dev/null +++ b/adr/conformance/0006-ci-economy-and-trusted-runners.md @@ -0,0 +1,76 @@ +# ADR 0006: CI Economy and Trusted Runner Boundary + +- Status: Accepted +- Date: 2026-08-01 +- Decision ID: `GRAIN-CI-ECONOMY-A01` +- Supersedes: `adr/conformance/0004-dependabot-strict-fail-closed.md` +- Affects: CI, repository governance, dependency maintenance, and evidence timing +- Protocol invariants touched: none +- Conformance vectors impacted: none + +## Context + +Every pull request previously started the full CI graph, including a macOS SDK +job and evidence packaging. Dependabot opened routine PRs weekly and rebased +them automatically, so bot activity could create repeated full runs without a +maintainer action. The privileged Dependabot automerge workflow also failed in +practice because its write-capable token was intentionally absent. + +Grain is public. A persistent self-hosted runner attached directly to a public +repository would allow pull-request code to reach a long-lived machine. The +project therefore needs an explicit trust boundary as well as a quieter CI +policy. + +## Decision + +1. Keep pull-request execution on GitHub-hosted runners. Do not attach a + persistent self-hosted runner to the public repository. Require maintainer + approval before any workflow from every external fork contributor starts. +2. Run all Linux verification jobs automatically for every PR. +3. Classify docs and approved metadata paths as lightweight. Treat every code, + executable automation, protocol, conformance, SDK, script, and unknown path + as requiring full CI. +4. Put a lightweight `full-ci-approval` job behind the protected `full-ci` + GitHub environment. For a full-scope PR, request approval only after the + automatic Linux graph succeeds, then wait for a maintainer to approve that + environment before starting the GitHub-hosted macOS SDK gate and evidence + bundle. A later commit cancels the old run and requires approval again. Run + the full graph, including the additional smoke jobs, automatically on every + push to `main` and on manual dispatch. Give every non-PR run a unique + concurrency group so no `main` SHA is discarded while another run is queued. + Keep the approval job tokenless, without checkout or references to + environment secrets and variables; downstream code jobs do not bind the + environment. Disable deployment-object creation for this CI-only approval. +5. Use one fail-closed branch-protection context, `CI gate`, which verifies the + required job results instead of accepting skipped work. +6. Cancel obsolete PR runs, bound CI artifact retention, and leave tag release + evidence workflows strict and unchanged. +7. Group routine version updates monthly, disable Dependabot rebases, limit the + open queue, keep security updates immediate, and require manual merge. +8. Remove the privileged Dependabot automerge workflow and its PAT dependency. +9. If self-hosted capacity is added later, use a separate private executor + repository, an isolated non-admin identity, and an ephemeral environment. + +## Consequences + +### Positive + +- Routine bot activity cannot silently start another macOS run. +- A single stable context prevents branch-protection drift between internal job + names and the actual release gate. +- Full platform and evidence proof remains mandatory for code changes and every + commit merged to `main`. +- No standing write-capable automerge token is required. + +### Negative / trade-offs + +- A maintainer must explicitly approve the protected environment after the + final code commit. +- Routine dependency updates arrive less often, although security updates stay + immediate. +- A future private executor requires separate infrastructure and isolation. + +## Compatibility + +This changes repository process only. It does not change protocol bytes, +conformance behavior, SDK APIs, package versions, or release tag semantics. diff --git a/conformance/security-regressions.v1.json b/conformance/security-regressions.v1.json index 5df5db8..e616689 100644 --- a/conformance/security-regressions.v1.json +++ b/conformance/security-regressions.v1.json @@ -76,11 +76,14 @@ }, { "id": "GRAIN-SEC-09", - "title": "Dependabot automerge cannot self-approve executable workflow or action updates", + "title": "Dependency automation cannot self-merge or reach a persistent public-repository runner", "evidence": [ {"kind": "path", "path": "tools/ci/check_dependabot_policy.py"}, {"kind": "path", "path": "tools/ci/test_check_dependabot_policy.py"}, - {"kind": "path", "path": ".github/workflows/dependabot-automerge.yml"} + {"kind": "path", "path": "tools/ci/check_ci_economy_policy.py"}, + {"kind": "path", "path": "tools/ci/check_full_ci_environment.py"}, + {"kind": "path", "path": ".github/dependabot.yml"}, + {"kind": "path", "path": ".github/workflows/ci.yml"} ] }, { diff --git a/docs/human/audit/AUDIT-PACKET-v0.1.md b/docs/human/audit/AUDIT-PACKET-v0.1.md index 63c796b..0afb3ed 100644 --- a/docs/human/audit/AUDIT-PACKET-v0.1.md +++ b/docs/human/audit/AUDIT-PACKET-v0.1.md @@ -174,8 +174,10 @@ None for v0.1, except external cryptographic breaks (future major bump class). - Tag namespaces are intentionally split: - protocol line: `protocol-*` - repository milestones: `repo-*` -- Required CI checks on `main`: - - `python-tooling` - - `rust-core` - - `evidence-bundle` - - `capid-csprng-audit` +- Required CI check on `main`: + - `CI gate` +- `CI gate` always requires the automatic Linux jobs. +- Full-scope PRs additionally require approval through the protected `full-ci` + environment, then successful `sdk-platform` and `evidence-bundle` jobs. +- `main` pushes additionally require `sdk-platform`, `evidence-bundle`, + `fuzz-smoke`, and `verify-script-smoke`; the approval job is skipped. diff --git a/docs/human/dependencies-policy.md b/docs/human/dependencies-policy.md index bcc6bdf..617c6f9 100644 --- a/docs/human/dependencies-policy.md +++ b/docs/human/dependencies-policy.md @@ -1,92 +1,56 @@ # Dependencies Policy -This page defines the safe automation boundary for Dependabot PRs. -The idea is simple: let boring updates stay boring, and force human review for risky ones. +This page defines the low-noise update policy for dependency pull requests. +The goals are to keep security updates prompt, batch routine maintenance, and +avoid privileged merge automation. ADR references: -- `adr/conformance/0003-dependabot-autonomous-safe-lane.md` -- `adr/conformance/0004-dependabot-strict-fail-closed.md` +- `adr/conformance/0006-ci-economy-and-trusted-runners.md` -## Goal +## Version updates -Dependabot safe-lane updates should remove stale review and merge friction while preserving: +Dependabot uses a monthly version-update cadence for three ecosystems: -- the `main protection` ruleset -- required checks -- linear history -- evidence workflows -- frozen-core safeguards +- GitHub Actions at `/`: grouped minor and patch updates +- Cargo at `/core/rust`: grouped patch updates +- npm at `/runner/typescript`: grouped minor and patch updates -## Two-lane policy +Every entry keeps `rebase-strategy: disabled` and its +open-pull-requests-limit at or below 2. This prevents background rebases from +starting new CI runs and limits the active maintenance queue. Major updates and +Cargo minor updates stay separate for manual review. -### 1) Safe auto-merge lane (Dependabot only) +## Security updates -- author is the Dependabot bot account -- changed files are only in the allowlist: - - `.github/dependabot.yml` - - `.github/ISSUE_TEMPLATE/**` -- required checks still run before merge -- branch update is requested automatically when behind +Dependabot security updates remain immediate and are not delayed by the monthly +version-update schedule. Repository vulnerability alerts and automated security +updates must remain enabled in GitHub settings. Security PRs still require the +same tests and manual merge as every other dependency PR. -### 2) Manual review lane +## CI behavior -- any PR touching non-allowlisted paths -- any PR touching executable automation (`.github/workflows/**` or `.github/actions/**`) -- any PR touching frozen-critical zones (`spec/**`, `conformance/**`, `core/**`, `runner/**`, `docs/llm/**`, `tools/**`) -- semver-major workflow dependency bumps require manual review +- Every external fork contributor requires maintainer approval before any + workflow jobs start. +- Every PR runs the automatic Linux verification jobs. +- Docs and approved repository metadata changes can pass without the macOS job. +- Code, executable automation, protocol, conformance, SDK, script, or unknown + paths wait at the protected `full-ci` environment after the automatic Linux + jobs succeed. +- After reviewing the final diff, a maintainer selects `Review deployments` and + approves `full-ci`. The GitHub-hosted macOS SDK gate and evidence bundle start + only after that approval. +- A new commit or Dependabot branch update cancels the old run and requires a + new environment approval. +- `CI gate` fails closed if required work fails, is cancelled, or is skipped. -## Automation workflow +## Merge policy -- Workflow: `/.github/workflows/dependabot-automerge.yml` -- Trigger: trusted `workflow_run` for successful `ci` pull_request runs -- Token strategy: - - canonical and required: repository secret `DEPENDABOT_AUTOMERGE_TOKEN` - - no fallback path +There is no privileged automerge workflow and no automerge PAT. Maintainers use +manual merge after `CI gate` succeeds and the diff is reviewed. This avoids a +standing write-capable token and keeps dependency updates auditable. -Recommended token permissions: - -- Fine-grained PAT: - - `Contents: Read & Write` - - `Pull requests: Read & Write` - - `Workflows: Read & Write` - - `Metadata: Read` -- Classic PAT: - - `repo` - - `workflow` - -Provisioning path: - -- GitHub repository -> `Settings` -> `Secrets and variables` -> `Actions` -> `New repository secret` -- secret name must be exactly `DEPENDABOT_AUTOMERGE_TOKEN` - -Safety design: - -- does not check out PR head -- uses GitHub API only -- validates changed files against allowlist, denylist, and the executable automation manual lane -- updates the branch when behind (`update-branch` plus `@dependabot rebase` request) -- auto-approves safe PRs -- enables auto-merge (`--auto --rebase`) -- posts deterministic audit-trail comments - -## Explicit diagnostics - -The workflow hard-fails with these diagnostics: - -- `DEPS_ERR_TOKEN_MISSING` when `DEPENDABOT_AUTOMERGE_TOKEN` is absent -- `DEPS_ERR_TOKEN_INSUFFICIENT_PERMS` when token permission probe fails - -There is no warning-only path and no fallback token. - -Major-bump behavior: - -- semver-major workflow dependency bumps require manual review -- the workflow keeps `BLOCK_SEMVER_MAJOR_ACTIONS=true` - -## Governance notes - -- CODEOWNERS documents ownership for core paths even though code owner review is not currently required on `main` -- the safe `.github` dependency path is policy-guarded by allowlist and required checks -- executable automation changes are never auto-approved by this safe lane -- dependency automation must not change protocol semantics +The public Grain repository must not run pull-request code on a persistent +self-hosted runner. The explicit full lane uses GitHub-hosted `macos-15`. A +future private executor must live in a separate private repository and use an +isolated runner identity. diff --git a/docs/human/maintainer-start-here.md b/docs/human/maintainer-start-here.md index 31084c4..9b3892c 100644 --- a/docs/human/maintainer-start-here.md +++ b/docs/human/maintainer-start-here.md @@ -42,9 +42,14 @@ If you only do one thing before reviewing or merging changes, do steps 1 through 1. Run `./scripts/doctor`. 2. Run `./scripts/bootstrap` if the host toolchain is not ready yet. 3. Read the PR for scope, risk, and docs sync. -4. Run the relevant checks, or ask for them if the PR does not include proof. -5. Make sure the diff is one logical change. -6. Merge only when the story in code, tests, and docs matches. +4. For an external fork, review workflow changes and choose `Approve workflows + to run` before any CI starts. +5. Run the relevant checks, or ask for them if the PR does not include proof. +6. After the Linux jobs pass, if the run waits at `Approve full CI`, review the + final diff, select `Review deployments`, and approve the protected `full-ci` + environment. +7. Make sure the diff is one logical change. +8. Merge only when the story in code, tests, and docs matches. ### Debug a red branch @@ -71,6 +76,7 @@ If you only do one thing before reviewing or merging changes, do steps 1 through - `CONTRIBUTING.md`: contributor rules and local hygiene - `docs/human/release-process.md`: release runbook - `docs/human/repository-settings.md`: GitHub settings baseline +- `docs/human/dependencies-policy.md`: low-noise dependency and explicit full-CI policy - `docs/human/portability-pack.md`: verification and evidence model - `examples/reference-fixtures/README.md`: repo-native examples and fixture catalog - `conformance/interop-matrix.v1.json`: Rust, TypeScript, and WASM proof lanes diff --git a/docs/human/portability-pack.md b/docs/human/portability-pack.md index e2c325f..83c3d32 100644 --- a/docs/human/portability-pack.md +++ b/docs/human/portability-pack.md @@ -109,6 +109,15 @@ npm --prefix runner/typescript run run:wasm-subset ## Evidence model +Pull requests always run the Linux verification graph. Docs and approved +metadata-only changes can finish without platform evidence. Code, executable +automation, protocol, conformance, SDK, script, and unknown paths require a +maintainer to approve the protected `full-ci` environment after the final +commit and a successful Linux graph; that approval starts the GitHub-hosted +macOS SDK job and same-commit evidence bundle. Every push to `main` runs the +full graph automatically. The public repository does not use persistent +self-hosted pull-request runners. + `evidence_content.sha256` is computed from deterministic artifacts only: - vector manifests diff --git a/docs/human/release-process.md b/docs/human/release-process.md index e4554e2..f81fd49 100644 --- a/docs/human/release-process.md +++ b/docs/human/release-process.md @@ -8,7 +8,8 @@ The goal is simple: keep releases boring, repeatable, and easy to audit. Make sure all of these are true: 1. Your local tree is clean. -2. `main` is green. +2. `main` is green and its `CI gate` includes the full SDK, fuzz, verify-smoke, + and evidence jobs. 3. The `main protection` ruleset is enabled with the intended settings. 4. Your tag signing key is configured. 5. Your release machine is aligned with the repo pins from `.nvmrc`, @@ -100,6 +101,9 @@ Before promoting `repo-rc-*` to `repo-v*`, run the stabilization checks. 1. PR smoke gate: - already runs in CI under the `ts-full` context - command family: `python3 tools/stabilization/run_rc_stab.py --mode smoke ...` + - PRs whose scope requires full verification also need maintainer approval + through the protected `full-ci` environment after the final commit so + `CI gate` proves the GitHub-hosted macOS SDK and evidence jobs 2. Deep stabilization during an active RC window: - workflow: `.github/workflows/rc-stabilization-deep-check.yml` - includes deep fuzz, reproducibility check, and rollback rehearsal diff --git a/docs/human/repository-settings.md b/docs/human/repository-settings.md index f5b61b0..851fe4e 100644 --- a/docs/human/repository-settings.md +++ b/docs/human/repository-settings.md @@ -6,12 +6,12 @@ If you maintain the repo, this page saves you from guessing. ## 1) Current `main` ruleset and repo settings -`main` should require these checks: +`main` should require the single final check `CI gate`. -- `python-tooling` -- `rust-core` -- `evidence-bundle` -- `capid-csprng-audit` +`CI gate` is fail-closed: it requires every automatic Linux job, and it also +requires the GitHub-hosted macOS SDK and evidence jobs when the PR scope needs +full verification. Individual job names remain visible for diagnosis but are +not separate branch-protection contracts. The live `main protection` ruleset should be: @@ -28,6 +28,11 @@ Related repo-level settings: - delete branch on merge: enabled - auto-merge: enabled +- fork PR workflow approval: `all_external_contributors` +- environment `full-ci`: one required maintainer reviewer, self-review allowed, + no branch restriction, no secrets, and no variables +- vulnerability alerts: enabled +- Dependabot security updates: enabled `GOVERNANCE.md` should describe the same live baseline. @@ -42,6 +47,31 @@ PROTECTION_PROFILE=autonomous bash tools/github/apply_branch_protection.sh +``` + +The `main` CI run performs the same ruleset check with the built-in +`github.token` and also verifies the reviewer and protection rules on the +protected `full-ci` environment. The built-in token cannot list environment +secret or variable metadata or the owner-only fork approval setting, so it does +not claim to verify those values. + +Apply or repair the protected environment with the repository script: + +```bash +bash tools/github/apply_full_ci_environment.sh +``` + +The script keeps the environment unrestricted for fork PRs, requires the named +reviewer, permits self-review for a single-maintainer repository, and uses the +owner's authenticated GitHub CLI session to require approval for every external +fork contributor and fail if the environment contains any secrets or variables. +It does not delete unexpected data automatically. + If the maintainer team grows and you want review-required mode later: ```bash @@ -82,6 +112,10 @@ Historical imported milestone tags have GitHub release pages now, but some older - CI must generate `evidence-.zip` on: - merges to `main` - pushes of `protocol-*`, `repo-*`, `protocol-rc-*`, and `repo-rc-*` tags +- A full-scope PR can also generate the same-commit evidence bundle after a + maintainer approves the protected `full-ci` environment. Full scope includes + code, executable automation, protocol, conformance, SDK, script, and unknown + paths. - Tag release evidence must also attach the same-commit SDK source release package assets, including the TypeScript source SDK packet, after the strict platform SDK gate passes. @@ -128,31 +162,31 @@ CI enforces: - The repository must not rely on clean or smudge filters for correctness. - LF policy comes from `.gitattributes`, not custom filters. -## 8) Advanced automation details +## 8) CI and dependency automation -- Workflow: `/.github/workflows/dependabot-automerge.yml` - Policy doc: `docs/human/dependencies-policy.md` -- Trigger: trusted `workflow_run` for successful `ci` pull_request runs -- Required automation secret: `DEPENDABOT_AUTOMERGE_TOKEN` - -Safe lane: - -- Dependabot author only -- allowlisted non-executable `.github` metadata paths only -- auto-approve plus auto-merge after required checks -- branch update or rebase requested automatically when behind -- semver-major workflow dependency bumps require manual review - -Explicit failure mode: - -- missing secret -> `DEPS_ERR_TOKEN_MISSING` -- insufficient permissions -> `DEPS_ERR_TOKEN_INSUFFICIENT_PERMS` - -Manual lane: - -- any non-allowlisted or critical path changes -- any executable automation changes under `.github/workflows/**` or `.github/actions/**` -- semver-major workflow dependency bumps +- Routine dependency version updates: monthly and grouped per ecosystem +- Automatic rebases: disabled +- Open version-update limit: at most `2` per ecosystem +- Dependency merge: manual after `CI gate` +- Privileged Dependabot automerge workflow or PAT: none +- Pull-request runners: GitHub-hosted only + +For a PR that requires full verification, the `Approve full CI` job appears +only after the automatic Linux jobs succeed and then waits before allocating +its runner. Open the workflow run, select `Review deployments`, select +`full-ci`, and choose `Approve and deploy` after reviewing the final commit. A +later commit cancels the old run and creates a new approval request, so a rebase +or bot update cannot silently start another macOS run or reuse old proof. The +environment has no secrets or variables. The approval job has no token, does +not check out repository code, and does not reference environment data; jobs +that execute PR code only depend on its result and do not bind the environment. +The CI-only approval uses `deployment: false`, so it does not create deployment +history entries while the required-reviewer rule still applies. + +External fork PRs have an earlier, separate gate: after reviewing the diff, a +maintainer must choose `Approve workflows to run` before any Linux job starts. +This applies to every external contributor, not only first-time contributors. ## 9) Dependency and intake hygiene @@ -160,5 +194,7 @@ Manual lane: - GitHub Actions - Rust (`core/rust`) - TS runner (`runner/typescript`) +- Vulnerability alerts and Dependabot security updates remain enabled so the + monthly routine schedule does not delay security fixes. - Issue forms live in `/.github/ISSUE_TEMPLATE/` - Blank issues are acceptable if GitHub falls back instead of rendering forms diff --git a/docs/human/sdk/version-matrix.md b/docs/human/sdk/version-matrix.md index 11f7cb4..77c05fc 100644 --- a/docs/human/sdk/version-matrix.md +++ b/docs/human/sdk/version-matrix.md @@ -90,9 +90,11 @@ That command proves: checkout The `ci` workflow runs the same strict platform SDK gate in the `sdk-platform` -job on a Swift 6-capable macOS runner, packages the SDK release artifacts after -that strict gate, and re-checks the release manifest before final evidence -build. The `release-evidence` tag workflow runs the same strict SDK gate before +job on a GitHub-hosted Swift 6-capable macOS runner for every push to `main`, a +manual workflow dispatch, or a full-scope PR after a maintainer approves the +protected `full-ci` environment. The job packages SDK release artifacts after +the strict gate and re-checks the release manifest before the final evidence build. The +`release-evidence` tag workflow runs the same strict SDK gate before attaching SDK source package assets to the GitHub release, so tag consumers can audit the SDK package, evidence bundle, and manifest against one commit. After downloading release assets, use `tools/ci/check_release_evidence_assets.py` to diff --git a/docs/llm/CHANGE_POLICY.md b/docs/llm/CHANGE_POLICY.md index 1f8666c..ab05284 100644 --- a/docs/llm/CHANGE_POLICY.md +++ b/docs/llm/CHANGE_POLICY.md @@ -62,8 +62,10 @@ If `conformance/SPEC.md` changes, or if the input/output or diagnostics contract If a PR changes CI gates, evidence artifacts, branch protection policy, tag namespace policy, or provenance docs: - update `docs/human/repository-settings.md` +- update `docs/human/dependencies-policy.md` when dependency cadence, runner trust, or explicit full-CI approval changes - update `MIGRATION.md` when provenance statements change - keep required CI context names stable unless governance update is explicit +- keep `CI gate` fail-closed and do not attach persistent self-hosted pull-request runners to the public repository - keep SDK release artifact claims tied to strict same-commit SDK verification and package metadata checks - update `docs/llm/DOC_SYNC.md` - update `CHANGELOG.md` diff --git a/docs/llm/CONFORMANCE.md b/docs/llm/CONFORMANCE.md index 791d4b0..8b80ea5 100644 --- a/docs/llm/CONFORMANCE.md +++ b/docs/llm/CONFORMANCE.md @@ -118,17 +118,23 @@ Contract: ## CI and provenance contract -Required CI contexts on `main`: -- `python-tooling` -- `rust-core` -- `evidence-bundle` -- `capid-csprng-audit` - -Additional CI jobs such as `ts-c01` and `ts-full` still run in CI, but they are -not separate branch-protection contexts on `main` today. +Required CI context on `main`: +- `CI gate` + +`CI gate` is the stable branch-protection contract. It always requires the +automatic Linux jobs. Every external fork contributor first needs maintainer +approval before any workflow starts. For code, executable automation, protocol, +conformance, SDK, script, and unknown PR paths, `CI gate` also requires +successful protected-environment approval after the Linux graph plus +`sdk-platform` and `evidence-bundle` jobs. A new commit cancels the old run and +requires approval again. Pushes to `main` and manual dispatches run the full +graph, including fuzz and verify-script smoke jobs, with a unique concurrency +group per non-PR run. Evidence policy: - CI emits commit-bound bundle `evidence-.zip` +- PR evidence is emitted only after an explicit full run; every `main` push + emits full evidence automatically - bundle includes suite summaries, vector manifests/hashes, toolchain/lock hashes, Rust↔TS divergence summaries - local `.local-architect-reports/**` are non-normative and MUST NOT be committed - containerized portability certify path: `scripts/certify` (strict, clean-tree required, no permissive fallback) diff --git a/docs/llm/DOC_SYNC.md b/docs/llm/DOC_SYNC.md index 5568269..653bf4e 100644 --- a/docs/llm/DOC_SYNC.md +++ b/docs/llm/DOC_SYNC.md @@ -95,9 +95,13 @@ Update: Update: - `.github/workflows/*` that changed +- `docs/human/dependencies-policy.md` when dependency cadence, runner trust, or full-CI approval changes - `docs/human/repository-settings.md` - `docs/human/release-process.md` - `docs/human/portability-pack.md` +- `docs/llm/CONFORMANCE.md` when required contexts or full-graph conditions change +- `GOVERNANCE.md` when the live ruleset contract changes +- a superseding ADR when accepted CI or dependency automation policy changes - `docs/human/repro-checklist.md` if clean-clone verification changed - `MIGRATION.md` if the repository provenance note changed - `docs/llm/CHANGE_POLICY.md` diff --git a/docs/llm/FILE_MAP.md b/docs/llm/FILE_MAP.md index fd5c940..5c63cbb 100644 --- a/docs/llm/FILE_MAP.md +++ b/docs/llm/FILE_MAP.md @@ -24,7 +24,7 @@ Hi teammate LLM. If you are deciding what to trust first, use this order. - Human onboarding and contributor process docs. Helpful, but they do not override spec or vectors. If you need the maintainer front door, start with `docs/human/maintainer-start-here.md`. 9. `core/rust/`, `core/ts/grain-ts-core/`, `runner/typescript/`, `core/`, `sdk/` - Implementations. They must conform to the contract above. -10. `.github/workflows/`, `.githooks/*`, `scripts/setup_local_hygiene.sh`, `tools/ci/check_history_hygiene.py`, `MIGRATION.md`, `docs/human/rationale/TOR-PORTABILITY-A01.md`, `docs/human/repository-settings.md`, `docs/human/portability-pack.md`, `docs/human/porting-grain.md` +10. `.github/workflows/`, `.githooks/*`, `scripts/setup_local_hygiene.sh`, `tools/ci/check_history_hygiene.py`, `tools/ci/check_ci_economy_policy.py`, `tools/ci/check_dependabot_policy.py`, `tools/ci/check_branch_protection_drift.py`, `tools/ci/check_full_ci_environment.py`, `tools/ci/classify_ci_scope.py`, `tools/ci/evaluate_ci_gate.py`, `tools/github/apply_branch_protection.sh`, `tools/github/apply_full_ci_environment.sh`, `MIGRATION.md`, `docs/human/rationale/TOR-PORTABILITY-A01.md`, `docs/human/repository-settings.md`, `docs/human/dependencies-policy.md`, `docs/human/portability-pack.md`, `docs/human/porting-grain.md` - Provenance, local hygiene enforcement, and policy enforcement (CI gates, evidence artifacts, branch protection). 11. `stabilization/RC-STAB-A01/*`, `tools/stabilization/run_rc_stab.py`, `.github/workflows/rc-stabilization-deep-check.yml` - RC pressure-test tooling plus a historical RC stabilization record. Use this as reference material when a new RC window is opened. diff --git a/docs/llm/README.md b/docs/llm/README.md index 885de2e..53cb0ac 100644 --- a/docs/llm/README.md +++ b/docs/llm/README.md @@ -44,8 +44,15 @@ Use these shortcuts when you already know the job: - `docs/llm/DOC_SYNC.md` - CI, release, or provenance change: - `docs/llm/FILE_MAP.md` + - `docs/llm/CONFORMANCE.md` - `docs/llm/CHANGE_POLICY.md` - `docs/llm/DOC_SYNC.md` + - `docs/human/repository-settings.md` + - `docs/human/dependencies-policy.md` + - `docs/human/release-process.md` + - `docs/human/portability-pack.md` + - `GOVERNANCE.md` + - the applicable CI, release, or provenance ADR - Contributor workflow or repo hygiene change: - `CONTRIBUTING.md` - `docs/human/maintainer-start-here.md` diff --git a/scripts/internal/verify_dev.sh b/scripts/internal/verify_dev.sh index 06415b9..715ffe3 100755 --- a/scripts/internal/verify_dev.sh +++ b/scripts/internal/verify_dev.sh @@ -153,6 +153,7 @@ python3 tools/ci/check_history_hygiene.py python3 tools/ci/check_crlf_tracked.py python3 tools/ci/check_codeowners_coverage.py python3 tools/ci/check_dependabot_policy.py +python3 tools/ci/check_ci_economy_policy.py python3 tools/ci/check_node_runtime_pin.py python3 tools/ci/check_toolchain_bootstrap.py python3 tools/ci/check_workflow_action_pinning.py diff --git a/scripts/internal/verify_in_container.sh b/scripts/internal/verify_in_container.sh index 1856554..bdf48ab 100755 --- a/scripts/internal/verify_in_container.sh +++ b/scripts/internal/verify_in_container.sh @@ -22,6 +22,7 @@ python3 tools/ci/check_history_hygiene.py python3 tools/ci/check_crlf_tracked.py python3 tools/ci/check_codeowners_coverage.py python3 tools/ci/check_dependabot_policy.py +python3 tools/ci/check_ci_economy_policy.py python3 tools/ci/check_node_runtime_pin.py python3 tools/ci/check_toolchain_bootstrap.py python3 tools/ci/check_workflow_action_pinning.py diff --git a/tools/ci/check_branch_protection_drift.py b/tools/ci/check_branch_protection_drift.py index 515a3f1..3e76a26 100644 --- a/tools/ci/check_branch_protection_drift.py +++ b/tools/ci/check_branch_protection_drift.py @@ -25,7 +25,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--branch", default="main") parser.add_argument( "--expected-contexts", - default="python-tooling,rust-core,evidence-bundle,capid-csprng-audit", + default="CI gate", help="Comma-separated required context names in the default-branch ruleset.", ) parser.add_argument("--require-strict", action="store_true", default=True) @@ -76,7 +76,7 @@ def main() -> int: if not gh_token: print( "main ruleset drift check failed: GH_TOKEN is missing. " - "Provide DEPENDABOT_AUTOMERGE_TOKEN (or equivalent) with repository-ruleset read access.", + "Provide a token with repository-ruleset read access.", file=sys.stderr, ) return 2 diff --git a/tools/ci/check_ci_economy_policy.py b/tools/ci/check_ci_economy_policy.py new file mode 100644 index 0000000..389a64e --- /dev/null +++ b/tools/ci/check_ci_economy_policy.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Check that public PR CI is explicit, cancellable, and fail-closed.""" + +from __future__ import annotations + +import argparse +import ast +import re +import sys +from pathlib import Path + + +REQUIRED_CONTEXT = "CI gate" +ALLOWED_HOSTED_RUNNERS = {"ubuntu-latest", "macos-15"} +GATE_EVALUATOR_CONSTANTS = ("BASE_JOBS", "FULL_JOBS", "MAIN_JOBS", "APPROVAL_JOB") + + +def job_block(text: str, job_id: str) -> str: + match = re.search( + rf"(?ms)^ {re.escape(job_id)}:\s*$.*?(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", + text, + ) + return match.group(0) if match else "" + + +def check_upload_retention(text: str) -> list[str]: + lines = text.splitlines() + errors: list[str] = [] + upload_count = 0 + for index, line in enumerate(lines): + if "uses: actions/upload-artifact@" not in line: + continue + upload_count += 1 + window = "\n".join(lines[index + 1 : index + 9]) + if "retention-days:" not in window: + errors.append(f"ci: upload-artifact at line {index + 1} needs retention-days") + if upload_count == 0: + errors.append("ci: expected at least one upload-artifact step") + return errors + + +def check_ci_workflow(path: Path) -> list[str]: + if not path.exists(): + return [f"ci: missing workflow {path}"] + text = path.read_text(encoding="utf-8") + errors: list[str] = [] + + required_tokens = ( + "workflow_dispatch:", + "types: [opened, reopened, synchronize]", + "group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-run-{0}', github.run_id) }}", + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + "pull-requests: read", + "CI_COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}", + "GH_TOKEN: ${{ github.token }}", + "PR_CHANGED_FILES: ${{ github.event.pull_request.changed_files }}", + "gh api --paginate --slurp", + "tools/ci/classify_ci_scope.py", + '--expected-count "$PR_CHANGED_FILES"', + "ruleset_token: ${{ github.token }}", + ) + for token in required_tokens: + if token not in text: + errors.append(f"ci: missing token: {token}") + + for token in ("self-hosted", "pull_request_target:", "workflow_run:", "schedule:", "labeled"): + if token in text: + errors.append(f"ci: forbidden trigger or runner token present: {token}") + + if text.count("runs-on: macos-15") != 1: + errors.append("ci: sdk-platform must be the only macOS job") + + sdk = job_block(text, "sdk-platform") + for token in ( + "needs: [scope, full-ci-approval]", + "always() &&", + "needs.scope.result == 'success'", + "github.event_name != 'pull_request'", + "needs.scope.outputs.full_required == 'true'", + "needs.full-ci-approval.result == 'success'", + "runs-on: macos-15", + ): + if token not in sdk: + errors.append(f"ci:sdk-platform: missing token: {token}") + + evidence = job_block(text, "evidence-bundle") + for token in ( + "always() &&", + "needs.sdk-platform.result == 'success'", + ): + if token not in evidence: + errors.append(f"ci:evidence-bundle: missing token: {token}") + + approval = job_block(text, "full-ci-approval") + for token in ( + "name: Approve full CI", + "needs: [scope, python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke]", + "always() &&", + "github.event_name == 'pull_request'", + "needs.scope.result == 'success'", + "needs.scope.outputs.full_required == 'true'", + "needs.python-tooling.result == 'success'", + "needs.capid-csprng-audit.result == 'success'", + "needs.rust-core.result == 'success'", + "needs.ts-c01.result == 'success'", + "needs.ts-full.result == 'success'", + "needs.wasm-smoke.result == 'success'", + "permissions: {}", + "environment:", + "name: full-ci", + "deployment: false", + ): + if token not in approval: + errors.append(f"ci:full-ci-approval: missing token: {token}") + for token in ("uses:", "secrets.", "vars."): + if token in approval: + errors.append(f"ci:full-ci-approval: forbidden token: {token}") + + main_only_condition = "if: needs.scope.result == 'success' && github.event_name != 'pull_request'" + for job_id in ("fuzz-smoke", "verify-script-smoke"): + if main_only_condition not in job_block(text, job_id): + errors.append(f"ci:{job_id}: missing main/manual-only condition") + + for job_id in ("scope", "verify-script-smoke"): + if "persist-credentials: false" not in job_block(text, job_id): + errors.append(f"ci:{job_id}: checkout must disable credential persistence") + + gate = job_block(text, "ci-gate") + for token in ( + "name: CI gate", + "if: always()", + "persist-credentials: false", + "FULL_REQUIRED: ${{ needs.scope.outputs.full_required }}", + '--full-required "${FULL_REQUIRED}"', + "tools/ci/evaluate_ci_gate.py", + '--job "full-ci-approval=${{ needs.full-ci-approval.result }}"', + '--job "evidence-bundle=${{ needs.evidence-bundle.result }}"', + ): + if token not in gate: + errors.append(f"ci:ci-gate: missing token: {token}") + if '--full-required "${{ needs.scope.outputs.full_required }}"' in gate: + errors.append("ci:ci-gate: scope output must not be interpolated directly into shell") + + errors.extend(check_upload_retention(text)) + return errors + + +def evaluator_job_ids(path: Path) -> tuple[set[str], list[str]]: + if not path.exists(): + return set(), [f"ci-gate-parity: missing evaluator {path}"] + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError as exc: + return set(), [f"ci-gate-parity: invalid evaluator syntax: {exc}"] + + values: dict[str, object] = {} + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if not isinstance(target, ast.Name) or target.id not in GATE_EVALUATOR_CONSTANTS: + continue + try: + values[target.id] = ast.literal_eval(node.value) + except (TypeError, ValueError): + return set(), [f"ci-gate-parity: evaluator constant is not literal: {target.id}"] + + missing = sorted(set(GATE_EVALUATOR_CONSTANTS) - values.keys()) + if missing: + return set(), [f"ci-gate-parity: missing evaluator constants: {missing}"] + + for name in ("BASE_JOBS", "FULL_JOBS", "MAIN_JOBS"): + if not isinstance(values[name], tuple): + return set(), [f"ci-gate-parity: evaluator constant must be a tuple: {name}"] + if not isinstance(values["APPROVAL_JOB"], str): + return set(), ["ci-gate-parity: APPROVAL_JOB must be a string"] + + jobs = set(values["BASE_JOBS"] + values["FULL_JOBS"] + values["MAIN_JOBS"]) + jobs.add(values["APPROVAL_JOB"]) + if not all(isinstance(job, str) and job for job in jobs): + return set(), ["ci-gate-parity: evaluator job names must be non-empty strings"] + return jobs, [] + + +def check_ci_gate_job_parity(ci: Path, evaluator: Path) -> list[str]: + if not ci.exists(): + return [f"ci-gate-parity: missing workflow {ci}"] + expected, errors = evaluator_job_ids(evaluator) + if errors: + return errors + + text = ci.read_text(encoding="utf-8") + jobs_marker = re.search(r"(?m)^jobs:[ \t]*$", text) + if not jobs_marker: + return ["ci-gate-parity: workflow has no jobs mapping"] + workflow_jobs = set( + re.findall( + r"(?m)^ ['\"]?([A-Za-z0-9_-]+)['\"]?:[ \t]*$", + text[jobs_marker.end() :], + ) + ) + upstream_jobs = workflow_jobs - {"ci-gate"} + gate = job_block(text, "ci-gate") + gate_needs = set(re.findall(r"(?m)^ - ([A-Za-z0-9_-]+)[ \t]*$", gate)) + gate_arguments = set(re.findall(r'--job "([A-Za-z0-9_-]+)=', gate)) + + comparisons = ( + ("workflow upstream jobs", upstream_jobs), + ("ci-gate needs", gate_needs), + ("ci-gate --job arguments", gate_arguments), + ) + parity_errors: list[str] = [] + for label, actual in comparisons: + if actual != expected: + parity_errors.append( + f"ci-gate-parity: {label} mismatch: " + f"missing={sorted(expected - actual)} extra={sorted(actual - expected)}" + ) + if "ci-gate" not in workflow_jobs: + parity_errors.append("ci-gate-parity: workflow is missing ci-gate") + return parity_errors + + +def check_no_self_hosted(workflows_dir: Path) -> list[str]: + errors: list[str] = [] + for workflow in sorted(workflows_dir.glob("*.y*ml")): + text = workflow.read_text(encoding="utf-8") + for match in re.finditer(r"(?m)^[ \t]*runs-on:[ \t]*(.*?)[ \t]*$", text): + runner = match.group(1).strip('"\'') + if runner not in ALLOWED_HOSTED_RUNNERS: + errors.append( + "workflow: public repository runner is not in the hosted allowlist: " + f"{workflow}: {runner}" + ) + return errors + + +def check_required_context( + *, + action: Path, + drift_checker: Path, + apply_script: Path, + governance: Path, + settings_doc: Path, +) -> list[str]: + checks = ( + (action, "default: CI gate"), + (drift_checker, 'default="CI gate"'), + (apply_script, '{"context": "CI gate"}'), + (governance, "required check: `CI gate`"), + (settings_doc, "require the single final check `CI gate`"), + ) + errors: list[str] = [] + for path, token in checks: + if not path.exists(): + errors.append(f"context: missing file {path}") + continue + if token not in path.read_text(encoding="utf-8"): + errors.append(f"context: {path} missing token: {token}") + return errors + + +def check_ruleset_token_contract(action: Path, ci: Path) -> list[str]: + errors: list[str] = [] + for path in (action, ci): + if not path.exists(): + errors.append(f"ruleset-token: missing file {path}") + if errors: + return errors + + action_text = action.read_text(encoding="utf-8") + ci_text = ci.read_text(encoding="utf-8") + for token in ( + "ruleset_token:", + "RULESET_DRIFT_ERR_TOKEN_MISSING", + "inputs.run_main_ruleset_drift == 'true' && inputs.ruleset_token == ''", + "GH_TOKEN: ${{ inputs.ruleset_token }}", + "tools/ci/check_full_ci_environment.py", + '--environment full-ci', + ): + if token not in action_text: + errors.append(f"ruleset-token: action missing token: {token}") + if "ruleset_token: ${{ github.token }}" not in ci_text: + errors.append("ruleset-token: CI must use the built-in read token") + if "DEPENDABOT_AUTOMERGE_TOKEN" in action_text or "DEPENDABOT_AUTOMERGE_TOKEN" in ci_text: + errors.append("ruleset-token: obsolete privileged Dependabot token is forbidden") + return errors + + +def check_owner_settings_contract( + *, + checker: Path, + apply_script: Path, + settings_doc: Path, +) -> list[str]: + checks = ( + ( + checker, + ( + "all_external_contributors", + "--check-owner-only-settings", + "fork-pr-contributor-approval", + "/secrets", + "/variables", + ), + ), + ( + apply_script, + ( + "approval_policy=all_external_contributors", + "--check-owner-only-settings", + ), + ), + ( + settings_doc, + ( + "fork PR workflow approval: `all_external_contributors`", + "every external contributor", + ), + ), + ) + errors: list[str] = [] + for path, tokens in checks: + if not path.exists(): + errors.append(f"owner-settings: missing file {path}") + continue + text = path.read_text(encoding="utf-8") + for token in tokens: + if token not in text: + errors.append(f"owner-settings: {path} missing token: {token}") + return errors + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--ci", default=".github/workflows/ci.yml") + parser.add_argument("--gate-evaluator", default="tools/ci/evaluate_ci_gate.py") + parser.add_argument("--workflows-dir", default=".github/workflows") + parser.add_argument("--action", default=".github/actions/python-policy-checks/action.yml") + parser.add_argument("--drift-checker", default="tools/ci/check_branch_protection_drift.py") + parser.add_argument("--apply-script", default="tools/github/apply_branch_protection.sh") + parser.add_argument("--full-ci-checker", default="tools/ci/check_full_ci_environment.py") + parser.add_argument( + "--full-ci-apply-script", + default="tools/github/apply_full_ci_environment.sh", + ) + parser.add_argument("--governance", default="GOVERNANCE.md") + parser.add_argument("--settings-doc", default="docs/human/repository-settings.md") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + errors = [ + *check_ci_workflow(Path(args.ci)), + *check_ci_gate_job_parity(Path(args.ci), Path(args.gate_evaluator)), + *check_no_self_hosted(Path(args.workflows_dir)), + *check_ruleset_token_contract(Path(args.action), Path(args.ci)), + *check_owner_settings_contract( + checker=Path(args.full_ci_checker), + apply_script=Path(args.full_ci_apply_script), + settings_doc=Path(args.settings_doc), + ), + *check_required_context( + action=Path(args.action), + drift_checker=Path(args.drift_checker), + apply_script=Path(args.apply_script), + governance=Path(args.governance), + settings_doc=Path(args.settings_doc), + ), + ] + if errors: + print("CI economy policy check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print("CI economy policy check: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/check_dependabot_policy.py b/tools/ci/check_dependabot_policy.py index 81d59c7..04e9b55 100644 --- a/tools/ci/check_dependabot_policy.py +++ b/tools/ci/check_dependabot_policy.py @@ -1,103 +1,171 @@ #!/usr/bin/env python3 -"""Check Dependabot auto-merge workflow and docs policy consistency.""" +"""Check the low-noise, manual-merge Dependabot policy.""" from __future__ import annotations import argparse +import re import sys from pathlib import Path -REQUIRED_WORKFLOW_TOKENS = ( - "workflow_run:", - "workflows: [\"ci\"]", - "dependabot[bot]", - "app/dependabot", - ".github/dependabot.yml|.github/ISSUE_TEMPLATE/*", - ".github/workflows/*|.github/actions/*", - "executable-automation-change:$f", - "spec/*|conformance/*|core/*|runner/*|docs/llm/*|tools/*", - "DEPENDABOT_AUTOMERGE_TOKEN", - 'BLOCK_SEMVER_MAJOR_ACTIONS: "true"', - "DEPS_ERR_TOKEN_MISSING", - "DEPS_ERR_TOKEN_INSUFFICIENT_PERMS", - "repos/$REPO/actions/workflows", - "@dependabot rebase", - "gh pr merge", - "--auto --rebase", -) -FORBIDDEN_WORKFLOW_TOKENS = ( - "pull_request_target", - "GH_FALLBACK_TOKEN", - "github.token", - "${GH_BOT_TOKEN:-$GH_FALLBACK_TOKEN}", -) +EXPECTED_UPDATES = { + ("github-actions", "/"): {"minor", "patch"}, + ("cargo", "/core/rust"): {"patch"}, + ("npm", "/runner/typescript"): {"minor", "patch"}, +} REQUIRED_DOC_TOKENS = ( - "allowlist", - ".github/workflows/**", - ".github/dependabot.yml", - "workflow_run", - "no fallback", - "DEPENDABOT_AUTOMERGE_TOKEN", - "Workflows: Read & Write", - "DEPS_ERR_TOKEN_MISSING", - "DEPS_ERR_TOKEN_INSUFFICIENT_PERMS", - "manual", - "spec/**", - "conformance/**", - "core/**", - "runner/**", - "docs/llm/**", - "tools/**", - "executable automation", - "semver-major workflow dependency bumps require manual review", + "monthly version-update cadence", + "security updates remain immediate", + "manual merge", + "rebase-strategy: disabled", + "open-pull-requests-limit at or below 2", + "no privileged automerge workflow", ) +MERGE_PRIMITIVES = ( + re.compile(r"\bgh[ \t]+pr[ \t]+merge\b", re.IGNORECASE), + re.compile(r"\bgh[ \t]+api\b[^\n]*/pulls/[^\n]*/merge\b", re.IGNORECASE), + re.compile(r"\bcurl\b[^\n]*api\.github\.com[^\n]*/pulls/[^\n]*/merge\b", re.IGNORECASE), + re.compile(r"\bgithub\.request\b[^\n]*/pulls/[^\n]*/merge\b", re.IGNORECASE), + re.compile(r"\bpulls\.merge[ \t]*\(", re.IGNORECASE), + re.compile(r"\b(?:enablePullRequestAutoMerge|mergePullRequest)\b", re.IGNORECASE), + re.compile(r"(?m)^[ \t]*uses:[ \t]*\S*(?:auto-?merge|automerge)\S*@", re.IGNORECASE), +) -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(allow_abbrev=False) - parser.add_argument("--workflow", default=".github/workflows/dependabot-automerge.yml") - parser.add_argument("--policy-doc", default="docs/human/dependencies-policy.md") - return parser.parse_args() +def unquote(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value -def require_tokens(path: Path, tokens: tuple[str, ...], label: str) -> list[str]: - if not path.exists(): - return [f"{label}: missing file {path}"] - text = path.read_text(encoding="utf-8") - missing: list[str] = [] - for token in tokens: - if token not in text: - missing.append(f"{label}: missing token: {token}") - return missing + +def scalar(block: str, key: str) -> str | None: + match = re.search(rf"(?m)^[ \t]+(?:-[ \t]+)?{re.escape(key)}:[ \t]*(.+?)[ \t]*$", block) + return unquote(match.group(1)) if match else None + + +def split_update_blocks(text: str) -> list[str]: + starts = [match.start() for match in re.finditer(r"(?m)^ - package-ecosystem:", text)] + return [ + text[start : starts[index + 1] if index + 1 < len(starts) else len(text)] + for index, start in enumerate(starts) + ] -def forbid_tokens(path: Path, tokens: tuple[str, ...], label: str) -> list[str]: +def update_types(block: str) -> set[str]: + match = re.search( + r"(?m)^ update-types:\s*$\n(?P(?:^ - .+\n?)+)", + block, + ) + if not match: + return set() + values: set[str] = set() + for line in match.group("items").splitlines(): + values.add(unquote(line.split("-", 1)[1])) + return values + + +def check_config(path: Path) -> list[str]: if not path.exists(): - return [f"{label}: missing file {path}"] - text = path.read_text(encoding="utf-8") - found: list[str] = [] - for token in tokens: - if token in text: - found.append(f"{label}: forbidden token present: {token}") - return found + return [f"config: missing file {path}"] + text = path.read_text(encoding="utf-8") + blocks = split_update_blocks(text) + errors: list[str] = [] + actual_keys: set[tuple[str, str]] = set() + + for block in blocks: + ecosystem = scalar(block, "package-ecosystem") + directory = scalar(block, "directory") + if ecosystem is None or directory is None: + errors.append("config: every update entry needs package-ecosystem and directory") + continue + + key = (ecosystem, directory) + actual_keys.add(key) + label = f"config:{ecosystem}:{directory}" + + if scalar(block, "interval") != "monthly": + errors.append(f"{label}: interval must be monthly") + if scalar(block, "rebase-strategy") != "disabled": + errors.append(f"{label}: rebase-strategy must be disabled") + + limit = scalar(block, "open-pull-requests-limit") + if limit is None or not limit.isdigit() or not 1 <= int(limit) <= 2: + errors.append(f"{label}: open-pull-requests-limit must be 1 or 2") + + if " groups:" not in block or ' - "*"' not in block: + errors.append(f"{label}: routine update group with wildcard pattern is required") + + expected_types = EXPECTED_UPDATES.get(key) + if expected_types is not None and update_types(block) != expected_types: + errors.append( + f"{label}: grouped update-types must be {sorted(expected_types)}" + ) + + if actual_keys != set(EXPECTED_UPDATES): + errors.append( + "config: expected update entries " + f"{sorted(EXPECTED_UPDATES)}, actual={sorted(actual_keys)}" + ) + return errors + + +def check_docs(path: Path) -> list[str]: + if not path.exists(): + return [f"policy-doc: missing file {path}"] + text = path.read_text(encoding="utf-8") + return [ + f"policy-doc: missing token: {token}" + for token in REQUIRED_DOC_TOKENS + if token not in text + ] -def main() -> int: - args = parse_args() - workflow = Path(args.workflow) - policy_doc = Path(args.policy_doc) +def check_no_automerge(workflows_dir: Path) -> list[str]: errors: list[str] = [] - errors.extend(require_tokens(workflow, REQUIRED_WORKFLOW_TOKENS, "workflow")) - errors.extend(forbid_tokens(workflow, FORBIDDEN_WORKFLOW_TOKENS, "workflow")) - errors.extend(require_tokens(policy_doc, REQUIRED_DOC_TOKENS, "policy-doc")) + automerge = workflows_dir / "dependabot-automerge.yml" + if automerge.exists(): + errors.append(f"workflow: privileged Dependabot automerge must be absent: {automerge}") + + for workflow in sorted(workflows_dir.glob("*.y*ml")): + text = workflow.read_text(encoding="utf-8") + if "DEPENDABOT_AUTOMERGE_TOKEN" in text: + errors.append(f"workflow: obsolete automerge token present: {workflow}") + active_text = "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + if "dependabot" not in active_text.lower(): + continue + if any(pattern.search(active_text) for pattern in MERGE_PRIMITIVES): + errors.append( + f"workflow: Dependabot-triggered merge behavior is forbidden: {workflow}" + ) + return errors + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--config", default=".github/dependabot.yml") + parser.add_argument("--policy-doc", default="docs/human/dependencies-policy.md") + parser.add_argument("--workflows-dir", default=".github/workflows") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + errors = [ + *check_config(Path(args.config)), + *check_docs(Path(args.policy_doc)), + *check_no_automerge(Path(args.workflows_dir)), + ] if errors: print("Dependabot policy check failed:", file=sys.stderr) - for err in errors: - print(f"- {err}", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) return 1 print("Dependabot policy check: OK") diff --git a/tools/ci/check_full_ci_environment.py b/tools/ci/check_full_ci_environment.py new file mode 100644 index 0000000..2b00610 --- /dev/null +++ b/tools/ci/check_full_ci_environment.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Validate GitHub settings that authorize full PR CI.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + + +GH_API_TIMEOUT_SECONDS = 30 + + +def validate_environment( + payload: object, + *, + expected_name: str, + expected_reviewer: str, +) -> list[str]: + if not isinstance(payload, dict): + return ["FULL_CI_ENV_ERR_INVALID_RESPONSE"] + + errors: list[str] = [] + if payload.get("name") != expected_name: + errors.append( + f"FULL_CI_ENV_ERR_NAME: expected={expected_name} actual={payload.get('name')}" + ) + if payload.get("deployment_branch_policy") is not None: + errors.append("FULL_CI_ENV_ERR_BRANCH_RESTRICTION: environment must support fork PRs") + + rules = payload.get("protection_rules") + if not isinstance(rules, list): + return [*errors, "FULL_CI_ENV_ERR_PROTECTION_RULES"] + + reviewer_rules = [rule for rule in rules if isinstance(rule, dict) and rule.get("type") == "required_reviewers"] + if len(reviewer_rules) != 1: + errors.append( + f"FULL_CI_ENV_ERR_REVIEWER_RULE_COUNT: expected=1 actual={len(reviewer_rules)}" + ) + return errors + + unexpected = [ + str(rule.get("type")) + for rule in rules + if isinstance(rule, dict) and rule.get("type") != "required_reviewers" + ] + if unexpected: + errors.append(f"FULL_CI_ENV_ERR_UNEXPECTED_RULES: {sorted(unexpected)}") + + rule = reviewer_rules[0] + if rule.get("prevent_self_review") is not False: + errors.append("FULL_CI_ENV_ERR_SELF_REVIEW: single-maintainer self-review must remain allowed") + + reviewers = rule.get("reviewers") + actual_reviewers: set[tuple[str, str]] = set() + if isinstance(reviewers, list): + for item in reviewers: + if not isinstance(item, dict): + continue + reviewer = item.get("reviewer") + if isinstance(reviewer, dict): + actual_reviewers.add((str(item.get("type")), str(reviewer.get("login")))) + expected_reviewers = {("User", expected_reviewer)} + if actual_reviewers != expected_reviewers: + errors.append( + "FULL_CI_ENV_ERR_REVIEWERS: " + f"expected={sorted(expected_reviewers)} actual={sorted(actual_reviewers)}" + ) + return errors + + +def validate_owner_only_settings( + *, + secrets_payload: object, + variables_payload: object, + fork_approval_payload: object, +) -> list[str]: + errors: list[str] = [] + for label, payload in ( + ("SECRETS", secrets_payload), + ("VARIABLES", variables_payload), + ): + if not isinstance(payload, dict) or not isinstance(payload.get("total_count"), int): + errors.append(f"FULL_CI_ENV_ERR_{label}_RESPONSE") + continue + if payload["total_count"] != 0: + errors.append( + f"FULL_CI_ENV_ERR_{label}_COUNT: expected=0 actual={payload['total_count']}" + ) + + expected_policy = "all_external_contributors" + if not isinstance(fork_approval_payload, dict): + errors.append("FULL_CI_ENV_ERR_FORK_APPROVAL_RESPONSE") + elif fork_approval_payload.get("approval_policy") != expected_policy: + errors.append( + "FULL_CI_ENV_ERR_FORK_APPROVAL_POLICY: " + f"expected={expected_policy} actual={fork_approval_payload.get('approval_policy')}" + ) + return errors + + +def gh_json(endpoint: str) -> Any: + try: + proc = subprocess.run( + [ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2026-03-10", + endpoint, + ], + text=True, + capture_output=True, + env=os.environ.copy(), + timeout=GH_API_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"gh api timed out after {GH_API_TIMEOUT_SECONDS}s for {endpoint}" + ) from exc + except OSError as exc: + raise RuntimeError(f"gh api failed to start for {endpoint}: {exc}") from exc + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "gh api failed") + try: + return json.loads(proc.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"gh api returned invalid JSON for {endpoint}: {exc}") from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "")) + parser.add_argument("--environment", default="full-ci") + parser.add_argument("--expected-reviewer") + parser.add_argument( + "--check-owner-only-settings", + action="store_true", + help="Also verify environment data counts and external-fork approval policy.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.repo or "/" not in args.repo: + print("FULL_CI_ENV_ERR_REPO: --repo owner/name is required", file=sys.stderr) + return 2 + if not os.environ.get("GH_TOKEN"): + print("FULL_CI_ENV_ERR_TOKEN: GH_TOKEN is required", file=sys.stderr) + return 2 + + expected_reviewer = args.expected_reviewer or args.repo.split("/", 1)[0] + try: + payload = gh_json(f"repos/{args.repo}/environments/{args.environment}") + except RuntimeError as exc: + print(f"FULL_CI_ENV_ERR_QUERY: {exc}", file=sys.stderr) + return 2 + + errors = validate_environment( + payload, + expected_name=args.environment, + expected_reviewer=expected_reviewer, + ) + if args.check_owner_only_settings: + try: + secrets_payload = gh_json( + f"repos/{args.repo}/environments/{args.environment}/secrets" + ) + variables_payload = gh_json( + f"repos/{args.repo}/environments/{args.environment}/variables" + ) + fork_approval_payload = gh_json( + f"repos/{args.repo}/actions/permissions/fork-pr-contributor-approval" + ) + except RuntimeError as exc: + print(f"FULL_CI_ENV_ERR_OWNER_QUERY: {exc}", file=sys.stderr) + return 2 + errors.extend( + validate_owner_only_settings( + secrets_payload=secrets_payload, + variables_payload=variables_payload, + fork_approval_payload=fork_approval_payload, + ) + ) + if errors: + print("Full CI environment check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print("Full CI environment check: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/classify_ci_scope.py b/tools/ci/classify_ci_scope.py new file mode 100644 index 0000000..d9537c1 --- /dev/null +++ b/tools/ci/classify_ci_scope.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Classify whether a CI event needs explicit full-platform verification.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +SAFE_PREFIXES = ( + "adr/", + "docs/", + ".github/ISSUE_TEMPLATE/", +) +SAFE_FILES = { + ".github/CODEOWNERS", + ".github/dependabot.yml", + ".github/pull_request_template.md", + "CHANGELOG.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "GOVERNANCE.md", + "LICENSE", + "MIGRATION.md", + "README.md", + "SECURITY.md", + "TRADEMARKS.md", +} + + +def extract_filenames(payload: object, expected_count: int | None = None) -> list[str]: + if not isinstance(payload, list): + raise ValueError("CI_SCOPE_ERR_INVALID_FILES_RESPONSE") + + filenames: list[str] = [] + row_count = 0 + for page in payload: + if not isinstance(page, list): + raise ValueError("CI_SCOPE_ERR_INVALID_FILES_RESPONSE") + for item in page: + row_count += 1 + filename = item.get("filename") if isinstance(item, dict) else None + if not isinstance(filename, str) or not filename: + raise ValueError("CI_SCOPE_ERR_INVALID_FILENAME") + filenames.append(filename) + previous_filename = item.get("previous_filename") + if previous_filename is not None: + if not isinstance(previous_filename, str) or not previous_filename: + raise ValueError("CI_SCOPE_ERR_INVALID_PREVIOUS_FILENAME") + filenames.append(previous_filename) + + if row_count == 0: + raise ValueError("CI_SCOPE_ERR_NO_CHANGED_FILES") + if expected_count is not None and row_count != expected_count: + raise ValueError( + f"CI_SCOPE_ERR_FILE_COUNT_MISMATCH: expected={expected_count} actual={row_count}" + ) + return filenames + + +def requires_full_ci(filenames: list[str]) -> bool: + return any( + filename not in SAFE_FILES and not filename.startswith(SAFE_PREFIXES) + for filename in filenames + ) + + +def classify( + *, + event_name: str, + files_payload: object | None, + expected_count: int | None = None, +) -> bool: + if event_name != "pull_request": + if event_name not in {"push", "workflow_dispatch"}: + raise ValueError(f"CI_SCOPE_ERR_UNSUPPORTED_EVENT: {event_name}") + return True + + filenames = extract_filenames(files_payload, expected_count) + full_required = requires_full_ci(filenames) + return full_required + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--event-name", required=True) + parser.add_argument("--expected-count", type=int) + parser.add_argument("--files-json") + parser.add_argument("--github-output", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + files_payload = None + if args.files_json: + files_payload = json.loads(Path(args.files_json).read_text(encoding="utf-8")) + + full_required = classify( + event_name=args.event_name, + files_payload=files_payload, + expected_count=args.expected_count, + ) + with Path(args.github_output).open("a", encoding="utf-8") as output: + output.write(f"full_required={str(full_required).lower()}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/evaluate_ci_gate.py b/tools/ci/evaluate_ci_gate.py new file mode 100644 index 0000000..150a0a5 --- /dev/null +++ b/tools/ci/evaluate_ci_gate.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Evaluate the final CI status without treating skipped required work as success.""" + +from __future__ import annotations + +import argparse +import sys + + +BASE_JOBS = ( + "scope", + "python-tooling", + "capid-csprng-audit", + "rust-core", + "ts-c01", + "ts-full", + "wasm-smoke", +) +FULL_JOBS = ("sdk-platform", "evidence-bundle") +MAIN_JOBS = ("fuzz-smoke", "verify-script-smoke") +APPROVAL_JOB = "full-ci-approval" +ALLOWED_JOBS = frozenset((*BASE_JOBS, *FULL_JOBS, *MAIN_JOBS, APPROVAL_JOB)) +KNOWN_RESULTS = {"success", "failure", "cancelled", "skipped"} + + +def parse_bool(value: str, label: str) -> bool: + normalized = value.strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + raise ValueError(f"CI_GATE_ERR_INVALID_{label.upper()}: {value!r}") + + +def parse_jobs(values: list[str]) -> dict[str, str]: + jobs: dict[str, str] = {} + for value in values: + name, separator, result = value.partition("=") + if not separator or not name or result not in KNOWN_RESULTS: + raise ValueError(f"CI_GATE_ERR_INVALID_JOB_RESULT: {value!r}") + if name not in ALLOWED_JOBS: + raise ValueError(f"CI_GATE_ERR_UNKNOWN_JOB: {name}") + if name in jobs: + raise ValueError(f"CI_GATE_ERR_DUPLICATE_JOB: {name}") + jobs[name] = result + return jobs + + +def require_result(jobs: dict[str, str], name: str, expected: str, errors: list[str]) -> None: + actual = jobs.get(name, "missing") + if actual != expected: + errors.append(f"CI_GATE_ERR_JOB_RESULT: {name} expected={expected} actual={actual}") + + +def evaluate( + *, + event_name: str, + full_required: bool, + jobs: dict[str, str], +) -> list[str]: + errors: list[str] = [] + + for name in BASE_JOBS: + require_result(jobs, name, "success", errors) + + if event_name == "pull_request": + if full_required: + require_result(jobs, APPROVAL_JOB, "success", errors) + for name in FULL_JOBS: + require_result(jobs, name, "success", errors) + else: + require_result(jobs, APPROVAL_JOB, "skipped", errors) + for name in FULL_JOBS: + require_result(jobs, name, "skipped", errors) + + for name in MAIN_JOBS: + require_result(jobs, name, "skipped", errors) + return errors + + if event_name not in {"push", "workflow_dispatch"}: + errors.append(f"CI_GATE_ERR_UNSUPPORTED_EVENT: {event_name}") + + if not full_required: + errors.append( + "CI_GATE_ERR_INVALID_FULL_SCOPE: main and manual runs must execute the full graph" + ) + + require_result(jobs, APPROVAL_JOB, "skipped", errors) + for name in FULL_JOBS + MAIN_JOBS: + require_result(jobs, name, "success", errors) + return errors + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--event-name", required=True) + parser.add_argument("--full-required", required=True) + parser.add_argument("--job", action="append", default=[]) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + full_required = parse_bool(args.full_required, "full_required") + jobs = parse_jobs(args.job) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + + errors = evaluate( + event_name=args.event_name, + full_required=full_required, + jobs=jobs, + ) + if errors: + print("CI gate failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print("CI gate: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/test_check_ci_economy_policy.py b/tools/ci/test_check_ci_economy_policy.py new file mode 100644 index 0000000..1c777d2 --- /dev/null +++ b/tools/ci/test_check_ci_economy_policy.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""Tests for the public-repository CI economy policy guard.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from tools.ci import check_ci_economy_policy as policy + + +GOOD_CI = """\ +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: +concurrency: + group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + pull-requests: read +env: + CI_COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} +jobs: + scope: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0000000000000000000000000000000000000000 + with: + persist-credentials: false + - env: + GH_TOKEN: ${{ github.token }} + PR_CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + run: | + gh api --paginate --slurp endpoint + python3 tools/ci/classify_ci_scope.py + --expected-count "$PR_CHANGED_FILES" + ruleset_token: ${{ github.token }} + full-ci-approval: + name: Approve full CI + needs: [scope, python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke] + if: >- + always() && + github.event_name == 'pull_request' && + needs.scope.result == 'success' && + needs.scope.outputs.full_required == 'true' && + needs.python-tooling.result == 'success' && + needs.capid-csprng-audit.result == 'success' && + needs.rust-core.result == 'success' && + needs.ts-c01.result == 'success' && + needs.ts-full.result == 'success' && + needs.wasm-smoke.result == 'success' + permissions: {} + environment: + name: full-ci + deployment: false + runs-on: ubuntu-latest + sdk-platform: + needs: [scope, full-ci-approval] + if: >- + always() && + needs.scope.result == 'success' && + ( + github.event_name != 'pull_request' || + ( + needs.scope.outputs.full_required == 'true' && + needs.full-ci-approval.result == 'success' + ) + ) + runs-on: macos-15 + evidence-bundle: + if: >- + always() && + needs.sdk-platform.result == 'success' + runs-on: ubuntu-latest + fuzz-smoke: + if: needs.scope.result == 'success' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + verify-script-smoke: + if: needs.scope.result == 'success' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0000000000000000000000000000000000000000 + with: + persist-credentials: false + upload-proof: + runs-on: ubuntu-latest + steps: + - uses: actions/upload-artifact@0000000000000000000000000000000000000000 + with: + path: proof + retention-days: 14 + ci-gate: + name: CI gate + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@0000000000000000000000000000000000000000 + with: + persist-credentials: false + - env: + FULL_REQUIRED: ${{ needs.scope.outputs.full_required }} + run: | + python3 tools/ci/evaluate_ci_gate.py \\ + --full-required "${FULL_REQUIRED}" \\ + --job "full-ci-approval=${{ needs.full-ci-approval.result }}" \\ + --job "evidence-bundle=${{ needs.evidence-bundle.result }}" +""" + + +class CIEconomyPolicyTests(unittest.TestCase): + def test_good_workflow_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text(GOOD_CI, encoding="utf-8") + self.assertEqual([], policy.check_ci_workflow(path)) + + def test_live_gate_job_sets_match_the_evaluator(self) -> None: + self.assertEqual( + [], + policy.check_ci_gate_job_parity( + Path(".github/workflows/ci.yml"), + Path("tools/ci/evaluate_ci_gate.py"), + ), + ) + + def test_new_workflow_job_forgotten_by_gate_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + current = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + path.write_text( + current.replace( + " ci-gate:\n", + " future-unreviewed-job:\n runs-on: ubuntu-latest\n\n ci-gate:\n", + 1, + ), + encoding="utf-8", + ) + errors = policy.check_ci_gate_job_parity( + path, + Path("tools/ci/evaluate_ci_gate.py"), + ) + self.assertTrue(any("workflow upstream jobs mismatch" in error for error in errors)) + + def test_self_hosted_runner_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + workflow = root / "unsafe.yml" + workflow.write_text("runs-on: [self-hosted, grain]\n", encoding="utf-8") + self.assertTrue(policy.check_no_self_hosted(root)) + + def test_custom_runner_label_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + workflow = root / "unsafe.yml" + workflow.write_text("runs-on: grain-private-macos\n", encoding="utf-8") + self.assertTrue(policy.check_no_self_hosted(root)) + + def test_block_form_runner_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + workflow = root / "unsafe.yml" + workflow.write_text( + "runs-on:\n group: private\n labels: [self-hosted]\n", + encoding="utf-8", + ) + self.assertTrue(policy.check_no_self_hosted(root)) + + def test_self_hosted_word_in_comment_does_not_create_a_false_positive(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + workflow = root / "renamed.yml" + workflow.write_text( + "# Never add self-hosted runners to this public workflow.\n" + "runs-on: ubuntu-latest\n", + encoding="utf-8", + ) + self.assertEqual([], policy.check_no_self_hosted(root)) + + def test_missing_concurrency_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + " cancel-in-progress: ${{ github.event_name == 'pull_request' }}", + " cancel-in-progress: false", + ), + encoding="utf-8", + ) + self.assertTrue(any("cancel-in-progress" in error for error in policy.check_ci_workflow(path))) + + def test_shared_non_pr_concurrency_group_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + "format('ci-run-{0}', github.run_id)", + "format('ci-main-{0}', github.ref)", + ), + encoding="utf-8", + ) + self.assertTrue(any("group:" in error for error in policy.check_ci_workflow(path))) + + def test_mac_job_without_explicit_approval_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + " needs.full-ci-approval.result == 'success'\n", + " true\n", + 1, + ), + encoding="utf-8", + ) + self.assertTrue(any("sdk-platform" in error for error in policy.check_ci_workflow(path))) + + def test_mac_job_without_automatic_non_pr_path_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace(" github.event_name != 'pull_request' ||\n", "", 1), + encoding="utf-8", + ) + self.assertTrue(any("sdk-platform" in error for error in policy.check_ci_workflow(path))) + + def test_approval_job_must_be_pr_only(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace(" github.event_name == 'pull_request' &&\n", "", 1), + encoding="utf-8", + ) + self.assertTrue(any("full-ci-approval" in error for error in policy.check_ci_workflow(path))) + + def test_missing_protected_environment_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace(" environment:\n name: full-ci\n", "", 1), + encoding="utf-8", + ) + self.assertTrue(any("full-ci-approval" in error for error in policy.check_ci_workflow(path))) + + def test_approval_without_successful_base_graph_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + " needs: [scope, python-tooling, capid-csprng-audit, rust-core, ts-c01, ts-full, wasm-smoke]\n", + " needs: scope\n", + 1, + ), + encoding="utf-8", + ) + self.assertTrue(any("full-ci-approval" in error for error in policy.check_ci_workflow(path))) + + def test_approval_job_with_token_or_environment_data_access_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + " permissions: {}\n", + " permissions:\n contents: read\n steps:\n - run: echo ${{ secrets.CI_SECRET }}\n", + 1, + ), + encoding="utf-8", + ) + errors = policy.check_ci_workflow(path) + self.assertTrue(any("full-ci-approval" in error for error in errors)) + + def test_gate_without_always_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace(" ci-gate:\n name: CI gate\n if: always()", " ci-gate:\n name: CI gate"), + encoding="utf-8", + ) + self.assertTrue(any("ci-gate" in error for error in policy.check_ci_workflow(path))) + + def test_new_read_only_checkouts_must_not_persist_credentials(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace(" persist-credentials: false\n", "", 1), + encoding="utf-8", + ) + self.assertTrue( + any("credential persistence" in error for error in policy.check_ci_workflow(path)) + ) + + def test_scope_output_must_reach_shell_through_environment(self) -> None: + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "ci.yml" + path.write_text( + GOOD_CI.replace( + '--full-required "${FULL_REQUIRED}"', + '--full-required "${{ needs.scope.outputs.full_required }}"', + 1, + ), + encoding="utf-8", + ) + self.assertTrue( + any("interpolated directly" in error for error in policy.check_ci_workflow(path)) + ) + + def test_required_context_mismatch_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + files = { + "action": "default: CI gate\n", + "drift": 'default="CI gate"\n', + "apply": '{"context": "old"}\n', + "governance": "required check: `CI gate`\n", + "settings": "require the single final check `CI gate`\n", + } + paths: dict[str, Path] = {} + for name, text in files.items(): + paths[name] = root / name + paths[name].write_text(text, encoding="utf-8") + errors = policy.check_required_context( + action=paths["action"], + drift_checker=paths["drift"], + apply_script=paths["apply"], + governance=paths["governance"], + settings_doc=paths["settings"], + ) + self.assertTrue(any("apply" in error for error in errors)) + + def test_obsolete_dependabot_token_is_rejected_for_ruleset_drift(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + action = root / "action.yml" + ci = root / "ci.yml" + action.write_text("ruleset_token:\nDEPENDABOT_AUTOMERGE_TOKEN\n", encoding="utf-8") + ci.write_text("ruleset_token: ${{ github.token }}\n", encoding="utf-8") + errors = policy.check_ruleset_token_contract(action, ci) + self.assertTrue(any("obsolete privileged" in error for error in errors)) + + def test_missing_ruleset_contract_file_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + missing_action = root / "missing-action.yml" + ci = root / "ci.yml" + ci.write_text("ruleset_token: ${{ github.token }}\n", encoding="utf-8") + errors = policy.check_ruleset_token_contract(missing_action, ci) + self.assertTrue(any("missing file" in error for error in errors)) + + def test_owner_only_setting_drift_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + checker = root / "checker.py" + apply_script = root / "apply.sh" + settings = root / "settings.md" + checker.write_text( + "all_external_contributors --check-owner-only-settings " + "fork-pr-contributor-approval /secrets /variables\n", + encoding="utf-8", + ) + apply_script.write_text( + "approval_policy=first_time_contributors --check-owner-only-settings\n", + encoding="utf-8", + ) + settings.write_text( + "fork PR workflow approval: `all_external_contributors`; every external contributor\n", + encoding="utf-8", + ) + errors = policy.check_owner_settings_contract( + checker=checker, + apply_script=apply_script, + settings_doc=settings, + ) + self.assertTrue(any("approval_policy=all_external_contributors" in error for error in errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_check_dependabot_policy.py b/tools/ci/test_check_dependabot_policy.py index 41bb8bb..dc0d445 100644 --- a/tools/ci/test_check_dependabot_policy.py +++ b/tools/ci/test_check_dependabot_policy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Tests for Dependabot auto-merge policy guard.""" +"""Tests for the low-noise Dependabot policy guard.""" from __future__ import annotations @@ -10,73 +10,159 @@ from tools.ci import check_dependabot_policy as policy -GOOD_WORKFLOW = """ -on: - workflow_run: - workflows: ["ci"] -env: - DEPENDABOT_AUTOMERGE_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }} - BLOCK_SEMVER_MAJOR_ACTIONS: "true" -steps: - - run: | - repos/$REPO/actions/workflows - dependabot[bot] - app/dependabot - case "$f" in - .github/dependabot.yml|.github/ISSUE_TEMPLATE/*) - ;; - .github/workflows/*|.github/actions/*) - reasons+=("executable-automation-change:$f") - ;; - esac - case "$f" in - spec/*|conformance/*|core/*|runner/*|docs/llm/*|tools/*) - ;; - esac - echo DEPS_ERR_TOKEN_MISSING - echo DEPS_ERR_TOKEN_INSUFFICIENT_PERMS - echo "@dependabot rebase" - gh pr merge --auto --rebase +GOOD_CONFIG = """\ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" + - package-ecosystem: "cargo" + directory: "/core/rust" + schedule: + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-cargo-patches: + patterns: + - "*" + update-types: + - "patch" + - package-ecosystem: "npm" + directory: "/runner/typescript" + schedule: + interval: "monthly" + rebase-strategy: "disabled" + open-pull-requests-limit: 2 + groups: + routine-npm: + patterns: + - "*" + update-types: + - "minor" + - "patch" """ -GOOD_DOC = """ -allowlist .github/workflows/** .github/dependabot.yml .github/ISSUE_TEMPLATE/** .github/actions/** -workflow_run no fallback DEPENDABOT_AUTOMERGE_TOKEN Workflows: Read & Write -DEPS_ERR_TOKEN_MISSING DEPS_ERR_TOKEN_INSUFFICIENT_PERMS manual -spec/** conformance/** core/** runner/** docs/llm/** tools/** -executable automation changes are manual; semver-major workflow dependency bumps require manual review. +GOOD_DOC = """\ +Use a monthly version-update cadence. Dependabot security updates remain immediate. +Every dependency PR requires manual merge. Keep rebase-strategy: disabled and +open-pull-requests-limit at or below 2. There is no privileged automerge workflow. """ class DependabotPolicyTests(unittest.TestCase): - def test_current_policy_accepts_manual_executable_automation_lane(self) -> None: + def write_layout(self, root: Path, config: str = GOOD_CONFIG) -> tuple[Path, Path, Path]: + config_path = root / ".github" / "dependabot.yml" + workflows_dir = root / ".github" / "workflows" + doc_path = root / "docs" / "human" / "dependencies-policy.md" + workflows_dir.mkdir(parents=True) + doc_path.parent.mkdir(parents=True) + config_path.write_text(config, encoding="utf-8") + doc_path.write_text(GOOD_DOC, encoding="utf-8") + return config_path, doc_path, workflows_dir + + def test_current_policy_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as td: + config, doc, workflows = self.write_layout(Path(td)) + self.assertEqual([], policy.check_config(config)) + self.assertEqual([], policy.check_docs(doc)) + self.assertEqual([], policy.check_no_automerge(workflows)) + + def test_weekly_schedule_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + config, _, _ = self.write_layout( + Path(td), GOOD_CONFIG.replace('interval: "monthly"', 'interval: "weekly"', 1) + ) + self.assertTrue(any("interval must be monthly" in error for error in policy.check_config(config))) + + def test_excessive_open_pr_limit_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as td: - root = Path(td) - workflow = root / "dependabot-automerge.yml" - doc = root / "dependencies-policy.md" - workflow.write_text(GOOD_WORKFLOW, encoding="utf-8") - doc.write_text(GOOD_DOC, encoding="utf-8") + config, _, _ = self.write_layout( + Path(td), GOOD_CONFIG.replace("open-pull-requests-limit: 2", "open-pull-requests-limit: 5", 1) + ) + self.assertTrue(any("must be 1 or 2" in error for error in policy.check_config(config))) - errors: list[str] = [] - errors.extend(policy.require_tokens(workflow, policy.REQUIRED_WORKFLOW_TOKENS, "workflow")) - errors.extend(policy.forbid_tokens(workflow, policy.FORBIDDEN_WORKFLOW_TOKENS, "workflow")) - errors.extend(policy.require_tokens(doc, policy.REQUIRED_DOC_TOKENS, "policy-doc")) + def test_lower_open_pr_limit_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as td: + config, _, _ = self.write_layout( + Path(td), + GOOD_CONFIG.replace("open-pull-requests-limit: 2", "open-pull-requests-limit: 1"), + ) + self.assertEqual([], policy.check_config(config)) - self.assertEqual([], errors) + def test_missing_group_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as td: + config, _, _ = self.write_layout(Path(td), GOOD_CONFIG.replace(" groups:", " no-groups:", 1)) + self.assertTrue(any("routine update group" in error for error in policy.check_config(config))) - def test_flags_policy_without_executable_manual_reason(self) -> None: + def test_privileged_automerge_workflow_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as td: - root = Path(td) - workflow = root / "dependabot-automerge.yml" - workflow.write_text( - GOOD_WORKFLOW.replace('reasons+=("executable-automation-change:$f")', "true"), + _, _, workflows = self.write_layout(Path(td)) + (workflows / "dependabot-automerge.yml").write_text("name: unsafe\n", encoding="utf-8") + self.assertTrue(policy.check_no_automerge(workflows)) + + def test_all_automerge_violations_are_reported_together(self) -> None: + with tempfile.TemporaryDirectory() as td: + _, _, workflows = self.write_layout(Path(td)) + (workflows / "dependabot-automerge.yml").write_text("name: unsafe\n", encoding="utf-8") + (workflows / "renamed.yml").write_text( + "if: github.actor == 'dependabot[bot]'\n" + "run: gh pr merge --squash $PR\n", encoding="utf-8", ) + errors = policy.check_no_automerge(workflows) + self.assertEqual(2, len(errors)) + + def test_renamed_dependabot_merge_workflows_are_rejected(self) -> None: + cases = { + "cli.yml": "run: gh pr merge --squash $PR\n", + "rest.yml": "run: gh api -X PUT repos/acme/repo/pulls/${PR}/merge\n", + "curl.yml": "run: curl -X PUT https://api.github.com/repos/acme/repo/pulls/${PR}/merge\n", + "request.yml": "script: github.request('PUT /repos/acme/repo/pulls/1/merge')\n", + "octokit.yml": "script: github.rest.pulls.merge({pull_number: 1})\n", + "graphql.yml": "run: gh api graphql -f query='mutation { enablePullRequestAutoMerge }'\n", + "action.yml": "uses: pascalgn/automerge-action@0000000000000000000000000000000000000000\n", + } + for filename, merge_step in cases.items(): + with self.subTest(filename=filename), tempfile.TemporaryDirectory() as td: + _, _, workflows = self.write_layout(Path(td)) + (workflows / filename).write_text( + "if: github.actor == 'dependabot[bot]'\n" + merge_step, + encoding="utf-8", + ) + self.assertTrue(policy.check_no_automerge(workflows)) - self.assertIn( - "workflow: missing token: executable-automation-change:$f", - policy.require_tokens(workflow, policy.REQUIRED_WORKFLOW_TOKENS, "workflow"), + def test_dependabot_workflow_without_merge_behavior_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as td: + _, _, workflows = self.write_layout(Path(td)) + (workflows / "dependency-metadata.yml").write_text( + "permissions:\n pull-requests: write\n" + "if: github.actor == 'dependabot[bot]'\n" + "run: gh pr edit --add-label dependencies $PR\n", + encoding="utf-8", + ) + self.assertEqual([], policy.check_no_automerge(workflows)) + + def test_comment_only_merge_example_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as td: + _, _, workflows = self.write_layout(Path(td)) + (workflows / "dependency-metadata.yml").write_text( + "if: github.actor == 'dependabot[bot]'\n" + "# Never run: gh pr merge $PR\n" + "run: gh pr edit --add-label dependencies $PR\n", + encoding="utf-8", ) + self.assertEqual([], policy.check_no_automerge(workflows)) if __name__ == "__main__": diff --git a/tools/ci/test_check_full_ci_environment.py b/tools/ci/test_check_full_ci_environment.py new file mode 100644 index 0000000..b1ff037 --- /dev/null +++ b/tools/ci/test_check_full_ci_environment.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Tests for the full-CI protected environment contract.""" + +from __future__ import annotations + +import copy +import unittest +from unittest import mock + +from tools.ci import check_full_ci_environment as policy + + +GOOD_ENVIRONMENT = { + "name": "full-ci", + "deployment_branch_policy": None, + "protection_rules": [ + { + "type": "required_reviewers", + "prevent_self_review": False, + "reviewers": [ + {"type": "User", "reviewer": {"login": "IvGolovach"}}, + ], + } + ], +} + + +class FullCIEnvironmentTests(unittest.TestCase): + def validate(self, payload: object) -> list[str]: + return policy.validate_environment( + payload, + expected_name="full-ci", + expected_reviewer="IvGolovach", + ) + + def test_expected_environment_is_accepted(self) -> None: + self.assertEqual([], self.validate(GOOD_ENVIRONMENT)) + + def test_missing_required_reviewer_fails_closed(self) -> None: + payload = copy.deepcopy(GOOD_ENVIRONMENT) + payload["protection_rules"][0]["reviewers"] = [] + self.assertTrue(any("FULL_CI_ENV_ERR_REVIEWERS" in error for error in self.validate(payload))) + + def test_branch_restriction_is_rejected_for_fork_support(self) -> None: + payload = copy.deepcopy(GOOD_ENVIRONMENT) + payload["deployment_branch_policy"] = { + "protected_branches": True, + "custom_branch_policies": False, + } + self.assertTrue( + any("FULL_CI_ENV_ERR_BRANCH_RESTRICTION" in error for error in self.validate(payload)) + ) + + def test_prevent_self_review_is_rejected_for_single_maintainer_repo(self) -> None: + payload = copy.deepcopy(GOOD_ENVIRONMENT) + payload["protection_rules"][0]["prevent_self_review"] = True + self.assertTrue(any("FULL_CI_ENV_ERR_SELF_REVIEW" in error for error in self.validate(payload))) + + def test_empty_owner_only_settings_are_accepted(self) -> None: + self.assertEqual( + [], + policy.validate_owner_only_settings( + secrets_payload={"total_count": 0, "secrets": []}, + variables_payload={"total_count": 0, "variables": []}, + fork_approval_payload={"approval_policy": "all_external_contributors"}, + ), + ) + + def test_environment_data_is_rejected(self) -> None: + errors = policy.validate_owner_only_settings( + secrets_payload={"total_count": 1, "secrets": [{"name": "UNSAFE"}]}, + variables_payload={"total_count": 1, "variables": [{"name": "UNSAFE"}]}, + fork_approval_payload={"approval_policy": "all_external_contributors"}, + ) + self.assertTrue(any("SECRETS_COUNT" in error for error in errors)) + self.assertTrue(any("VARIABLES_COUNT" in error for error in errors)) + + def test_first_time_only_fork_approval_is_rejected(self) -> None: + errors = policy.validate_owner_only_settings( + secrets_payload={"total_count": 0}, + variables_payload={"total_count": 0}, + fork_approval_payload={"approval_policy": "first_time_contributors"}, + ) + self.assertTrue(any("FORK_APPROVAL_POLICY" in error for error in errors)) + + def test_github_api_timeout_fails_with_a_clear_error(self) -> None: + timeout = policy.subprocess.TimeoutExpired( + cmd=["gh", "api"], + timeout=policy.GH_API_TIMEOUT_SECONDS, + ) + with mock.patch.object(policy.subprocess, "run", side_effect=timeout) as run: + with self.assertRaisesRegex(RuntimeError, "gh api timed out after 30s"): + policy.gh_json("repos/example/project/environments/full-ci") + self.assertEqual(policy.GH_API_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"]) + + def test_github_api_process_start_failure_is_normalized(self) -> None: + with mock.patch.object( + policy.subprocess, + "run", + side_effect=FileNotFoundError("gh"), + ): + with self.assertRaisesRegex(RuntimeError, "gh api failed to start"): + policy.gh_json("repos/example/project/environments/full-ci") + + def test_github_api_invalid_json_is_normalized(self) -> None: + completed = policy.subprocess.CompletedProcess( + args=["gh", "api"], + returncode=0, + stdout="not-json", + stderr="", + ) + with mock.patch.object(policy.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(RuntimeError, "gh api returned invalid JSON"): + policy.gh_json("repos/example/project/environments/full-ci") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_classify_ci_scope.py b/tools/ci/test_classify_ci_scope.py new file mode 100644 index 0000000..4389c9d --- /dev/null +++ b/tools/ci/test_classify_ci_scope.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Tests for event-scoped full CI approval.""" + +from __future__ import annotations + +import unittest + +from tools.ci import classify_ci_scope as scope + + +class ClassifyCIScopeTests(unittest.TestCase): + def test_docs_only_pr_does_not_require_full_ci(self) -> None: + full_required = scope.classify( + event_name="pull_request", + files_payload=[[{"filename": "docs/human/repository-settings.md"}]], + expected_count=1, + ) + self.assertFalse(full_required) + + def test_unknown_or_executable_path_requires_full_ci(self) -> None: + self.assertTrue(scope.requires_full_ci([".github/workflows/ci.yml"])) + self.assertTrue(scope.requires_full_ci(["new-root-file.txt"])) + + def test_rename_from_executable_path_requires_full_ci(self) -> None: + filenames = scope.extract_filenames( + [[{"filename": "docs/old-ci.yml", "previous_filename": ".github/workflows/ci.yml"}]] + ) + self.assertTrue(scope.requires_full_ci(filenames)) + + def test_code_pull_request_requires_full_ci(self) -> None: + payload = [[{"filename": "core/rust/Cargo.toml"}]] + self.assertTrue( + scope.classify( + event_name="pull_request", + files_payload=payload, + expected_count=1, + ) + ) + + def test_empty_file_response_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "CI_SCOPE_ERR_NO_CHANGED_FILES"): + scope.extract_filenames([]) + + def test_truncated_file_response_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "CI_SCOPE_ERR_FILE_COUNT_MISMATCH"): + scope.extract_filenames([[{"filename": "docs/one.md"}]], expected_count=3001) + + def test_main_and_manual_runs_are_always_full(self) -> None: + for event_name in ("push", "workflow_dispatch"): + self.assertTrue( + scope.classify( + event_name=event_name, + files_payload=None, + expected_count=None, + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_evaluate_ci_gate.py b/tools/ci/test_evaluate_ci_gate.py new file mode 100644 index 0000000..6eac60d --- /dev/null +++ b/tools/ci/test_evaluate_ci_gate.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Tests for the fail-closed final CI gate.""" + +from __future__ import annotations + +import unittest + +from tools.ci import evaluate_ci_gate as gate + + +def job_results(*, full: bool, main: bool = False, approved: bool = False) -> dict[str, str]: + jobs = {name: "success" for name in gate.BASE_JOBS} + jobs.update({name: "success" if full else "skipped" for name in gate.FULL_JOBS}) + jobs.update({name: "success" if main else "skipped" for name in gate.MAIN_JOBS}) + jobs[gate.APPROVAL_JOB] = "success" if approved else "skipped" + return jobs + + +class EvaluateCIGateTests(unittest.TestCase): + def test_docs_only_pr_passes_without_full_jobs(self) -> None: + errors = gate.evaluate( + event_name="pull_request", + full_required=False, + jobs=job_results(full=False), + ) + self.assertEqual([], errors) + + def test_code_pr_fails_closed_without_environment_approval(self) -> None: + errors = gate.evaluate( + event_name="pull_request", + full_required=True, + jobs=job_results(full=False), + ) + self.assertIn( + "CI_GATE_ERR_JOB_RESULT: full-ci-approval expected=success actual=skipped", + errors, + ) + + def test_approved_full_pr_requires_every_full_job(self) -> None: + jobs = job_results(full=True, approved=True) + jobs["sdk-platform"] = "failure" + errors = gate.evaluate( + event_name="pull_request", + full_required=True, + jobs=jobs, + ) + self.assertIn( + "CI_GATE_ERR_JOB_RESULT: sdk-platform expected=success actual=failure", + errors, + ) + + def test_approved_full_pr_passes_with_main_only_jobs_skipped(self) -> None: + errors = gate.evaluate( + event_name="pull_request", + full_required=True, + jobs=job_results(full=True, approved=True), + ) + self.assertEqual([], errors) + + def test_main_requires_full_and_smoke_jobs(self) -> None: + jobs = job_results(full=True, main=True) + jobs["verify-script-smoke"] = "skipped" + errors = gate.evaluate( + event_name="push", + full_required=True, + jobs=jobs, + ) + self.assertIn( + "CI_GATE_ERR_JOB_RESULT: verify-script-smoke expected=success actual=skipped", + errors, + ) + + def test_missing_base_job_fails_closed(self) -> None: + jobs = job_results(full=False) + del jobs["rust-core"] + errors = gate.evaluate( + event_name="pull_request", + full_required=False, + jobs=jobs, + ) + self.assertIn( + "CI_GATE_ERR_JOB_RESULT: rust-core expected=success actual=missing", + errors, + ) + + def test_job_parser_rejects_unknown_results(self) -> None: + with self.assertRaisesRegex(ValueError, "CI_GATE_ERR_INVALID_JOB_RESULT"): + gate.parse_jobs(["rust-core=neutral"]) + + def test_job_parser_rejects_unknown_jobs(self) -> None: + with self.assertRaisesRegex(ValueError, "CI_GATE_ERR_UNKNOWN_JOB"): + gate.parse_jobs(["future-unreviewed-job=success"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/github/apply_branch_protection.sh b/tools/github/apply_branch_protection.sh index b3ecbbe..84f98b8 100755 --- a/tools/github/apply_branch_protection.sh +++ b/tools/github/apply_branch_protection.sh @@ -78,10 +78,7 @@ cat >"${payload_file}" < [reviewer-login]" >&2 + exit 1 +fi + +REPO="$1" +REVIEWER_LOGIN="${2:-${REPO%%/*}}" +ENVIRONMENT_NAME="${FULL_CI_ENVIRONMENT:-full-ci}" +export GH_TOKEN="${GH_TOKEN:-$(gh auth token)}" + +gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${REPO}/actions/permissions/fork-pr-contributor-approval" \ + -f approval_policy=all_external_contributors >/dev/null + +reviewer_id="$(gh api "users/${REVIEWER_LOGIN}" --jq '.id')" +if [[ -z "${reviewer_id}" || "${reviewer_id}" == "null" ]]; then + echo "FULL_CI_ENV_APPLY_ERR_REVIEWER: unable to resolve ${REVIEWER_LOGIN}" >&2 + exit 2 +fi + +jq -n \ + --argjson reviewer_id "${reviewer_id}" \ + '{ + wait_timer: 0, + prevent_self_review: false, + reviewers: [{type: "User", id: $reviewer_id}], + deployment_branch_policy: null + }' | gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${REPO}/environments/${ENVIRONMENT_NAME}" \ + --input - >/dev/null + +python3 tools/ci/check_full_ci_environment.py \ + --repo "${REPO}" \ + --environment "${ENVIRONMENT_NAME}" \ + --expected-reviewer "${REVIEWER_LOGIN}" \ + --check-owner-only-settings + +echo "Full CI settings applied for ${REPO}: ${ENVIRONMENT_NAME} (${REVIEWER_LOGIN})"