diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e3e702..eb5b032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: push: branches: - main diff --git a/.github/workflows/maintenance-implementation.yml b/.github/workflows/maintenance-implementation.yml index a85d6a9..324824f 100644 --- a/.github/workflows/maintenance-implementation.yml +++ b/.github/workflows/maintenance-implementation.yml @@ -308,7 +308,7 @@ jobs: contents: write pull-requests: write checks: read - actions: read + actions: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -345,8 +345,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh pr checks "${{ steps.pr.outputs.number }}" --watch --fail-fast --interval 10 - gh pr checks "${{ steps.pr.outputs.number }}" --json name,bucket,link > maintenance-run/pr-checks.json + ./scripts/dispatch-pr-checks \ + --pr "${{ steps.pr.outputs.number }}" \ + --check "Script checks" \ + --output maintenance-run/pr-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-run/pr-checks.json - name: Re-verify exact SHA, sealed tree, and preconditions env: @@ -447,8 +449,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh pr checks "${{ steps.readiness.outputs.number }}" --watch --fail-fast --interval 10 - gh pr checks "${{ steps.readiness.outputs.number }}" --json name,bucket,link > maintenance-run/readiness-checks.json + ./scripts/dispatch-pr-checks \ + --pr "${{ steps.readiness.outputs.number }}" \ + --check "Script checks" \ + --output maintenance-run/readiness-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-run/readiness-checks.json actual="$(gh pr view "${{ steps.readiness.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "${{ steps.readiness.outputs.head_sha }}" diff --git a/.github/workflows/maintenance-release.yml b/.github/workflows/maintenance-release.yml index b755356..050cd82 100644 --- a/.github/workflows/maintenance-release.yml +++ b/.github/workflows/maintenance-release.yml @@ -23,7 +23,8 @@ on: permissions: contents: write issues: write - actions: read + actions: write + checks: read pull-requests: write concurrency: @@ -330,8 +331,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh pr checks "${{ steps.event_pr.outputs.number }}" --watch --fail-fast --interval 10 - gh pr checks "${{ steps.event_pr.outputs.number }}" --json name,bucket,link > release-run/event-checks.json + ./scripts/dispatch-pr-checks \ + --pr "${{ steps.event_pr.outputs.number }}" \ + --check "Script checks" \ + --output release-run/event-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' release-run/event-checks.json actual="$(gh pr view "${{ steps.event_pr.outputs.number }}" --json headRefOid --jq .headRefOid)" test "$actual" = "${{ steps.event_pr.outputs.head_sha }}" diff --git a/.github/workflows/maintenance-watch.yml b/.github/workflows/maintenance-watch.yml index 3acb160..376d265 100644 --- a/.github/workflows/maintenance-watch.yml +++ b/.github/workflows/maintenance-watch.yml @@ -146,6 +146,7 @@ jobs: actions: write artifact-metadata: write attestations: write + checks: read contents: write id-token: write pull-requests: write @@ -267,8 +268,10 @@ jobs: --body "Opaque evidence state for a reviewed no-change result.")" number="${url##*/}" fi - gh pr checks "$number" --required --watch --fail-fast --interval 10 - gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/no-change-checks.json + ./scripts/dispatch-pr-checks \ + --pr "$number" \ + --check "Script checks" \ + --output maintenance-plan-download/no-change-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/no-change-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" @@ -348,8 +351,10 @@ jobs: url="$(gh pr create --base main --head "$branch" --title "chore: complete $action_key" \ --body "Deterministic EOL completion bound to exact cross-repository readiness.")" number="${url##*/}" - gh pr checks "$number" --required --watch --fail-fast --interval 10 - gh pr checks "$number" --required --json name,bucket,link > maintenance-plan-download/eol-checks.json + ./scripts/dispatch-pr-checks \ + --pr "$number" \ + --check "Script checks" \ + --output maintenance-plan-download/eol-checks.json jq -e '[.[] | select(.name=="Script checks") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json jq -e '[.[] | select(.name=="Protected controls") | .bucket] == ["pass"]' maintenance-plan-download/eol-checks.json test "$(gh pr view "$number" --json headRefOid --jq .headRefOid)" = "$head" diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index 8d4b3cb..7d03fcf 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -2,6 +2,12 @@ name: Protected controls on: pull_request_target: + workflow_dispatch: + inputs: + pr_number: + description: Pull request to validate at the dispatched branch head + required: true + type: string permissions: attestations: read @@ -14,20 +20,43 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Resolve exact pull request state + id: pr + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] + gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" > "$RUNNER_TEMP/pr.json" + test "$(jq -r .state "$RUNNER_TEMP/pr.json")" = "open" + test "$(jq -r .base.ref "$RUNNER_TEMP/pr.json")" = "main" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + test "$(jq -r .head.sha "$RUNNER_TEMP/pr.json")" = "${{ github.sha }}" + test "refs/heads/$(jq -r .head.ref "$RUNNER_TEMP/pr.json")" = "${{ github.ref }}" + fi + { + echo "number=$PR_NUMBER" + echo "head_sha=$(jq -r .head.sha "$RUNNER_TEMP/pr.json")" + echo "base_sha=$(jq -r .base.sha "$RUNNER_TEMP/pr.json")" + echo "head_ref=$(jq -r .head.ref "$RUNNER_TEMP/pr.json")" + echo "head_repository=$(jq -r .head.repo.full_name "$RUNNER_TEMP/pr.json")" + echo "author=$(jq -r .user.login "$RUNNER_TEMP/pr.json")" + } >> "$GITHUB_OUTPUT" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ steps.pr.outputs.base_sha }} persist-credentials: false - name: Require exact-head owner approval for protected paths env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_NUMBER: ${{ steps.pr.outputs.number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + HEAD_REPOSITORY: ${{ steps.pr.outputs.head_repository }} + PR_AUTHOR: ${{ steps.pr.outputs.author }} PROTECTED_REVIEWER: ${{ vars.MAINTENANCE_OWNER }} run: | python3 - <<'PY' diff --git a/docs/repository-settings.md b/docs/repository-settings.md index 75a0ec9..ca97e70 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -34,6 +34,10 @@ Required repository state: - Enable the organization setting that permits Actions to create pull requests; runtime workflows do not submit approving reviews. Protected-control approval remains owner-only except for the deterministic evidence-state proof above. +- Because GitHub suppresses ordinary PR events created by `GITHUB_TOKEN`, each + deterministic PR coordinator explicitly dispatches `ci.yml` and + `protected-controls.yml` at the exact PR branch, then accepts only newly + created successful check runs for that head SHA. - Allow GitHub-owned Actions plus only `openai/codex-action` and `jdx/mise-action`, and require every Action reference to use a full commit SHA. diff --git a/maintenance/protected-paths.json b/maintenance/protected-paths.json index f3ebbcd..2b7056f 100644 --- a/maintenance/protected-paths.json +++ b/maintenance/protected-paths.json @@ -12,6 +12,7 @@ "scripts/admit-maintenance-plan", "scripts/capture-maintenance-evidence", "scripts/configure-github-maintenance", + "scripts/dispatch-pr-checks", "scripts/maintenance-event", "scripts/notify-maintenance", "scripts/prepare-agent-task", diff --git a/scripts/dispatch-pr-checks b/scripts/dispatch-pr-checks new file mode 100755 index 0000000..70960de --- /dev/null +++ b/scripts/dispatch-pr-checks @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 --pr --check --output " >&2 + exit 2 +} + +pr_number="" +primary_check="" +output="" + +while (($#)); do + case "$1" in + --pr) + pr_number="${2:-}" + shift 2 + ;; + --check) + primary_check="${2:-}" + shift 2 + ;; + --output) + output="${2:-}" + shift 2 + ;; + *) + usage + ;; + esac +done + +[[ "$pr_number" =~ ^[1-9][0-9]*$ ]] || usage +[[ -n "$primary_check" && -n "$output" ]] || usage +repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +pr="$(gh api "repos/$repository/pulls/$pr_number")" +state="$(jq -r .state <<<"$pr")" +base_ref="$(jq -r .base.ref <<<"$pr")" +head_ref="$(jq -r .head.ref <<<"$pr")" +head_sha="$(jq -r .head.sha <<<"$pr")" +head_repository="$(jq -r .head.repo.full_name <<<"$pr")" + +[[ "$state" == "open" ]] +[[ "$base_ref" == "main" ]] +[[ "$head_repository" == "$repository" ]] +[[ "$head_ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] +[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] + +checks_url="repos/$repository/commits/$head_sha/check-runs?per_page=100&filter=latest" +before="$(gh api -H "Accept: application/vnd.github+json" "$checks_url")" +primary_before="$(jq --arg name "$primary_check" '[.check_runs[] | select(.name==$name) | .id] | max // 0' <<<"$before")" +protected_before="$(jq '[.check_runs[] | select(.name=="Protected controls") | .id] | max // 0' <<<"$before")" + +# Events created with GITHUB_TOKEN do not recursively start pull_request +# workflows. workflow_dispatch is the supported exception, so run the two +# base-reviewed validators explicitly at the exact PR branch. +gh workflow run ci.yml --repo "$repository" --ref "$head_ref" +gh workflow run protected-controls.yml --repo "$repository" --ref "$head_ref" \ + -f "pr_number=$pr_number" + +deadline=$((SECONDS + 900)) +latest="" +while ((SECONDS < deadline)); do + latest="$(gh api -H "Accept: application/vnd.github+json" "$checks_url")" + primary="$(jq -c --arg name "$primary_check" --argjson floor "$primary_before" \ + '[.check_runs[] | select(.name==$name and .id>$floor)] | max_by(.id) // empty' <<<"$latest")" + protected="$(jq -c --argjson floor "$protected_before" \ + '[.check_runs[] | select(.name=="Protected controls" and .id>$floor)] | max_by(.id) // empty' <<<"$latest")" + + if [[ -n "$primary" && -n "$protected" ]]; then + failed="$(jq -r -s '[.[] | select(.status=="completed" and .conclusion!="success") | .details_url] | join("\n")' \ + <<<"$primary"$'\n'"$protected")" + if [[ -n "$failed" ]]; then + echo "A dispatched required check failed:" >&2 + echo "$failed" >&2 + exit 1 + fi + if jq -e -s 'all(.[]; .status=="completed" and .conclusion=="success")' \ + <<<"$primary"$'\n'"$protected" >/dev/null; then + mkdir -p "$(dirname "$output")" + jq -s --arg head "$head_sha" \ + 'map({name, bucket:"pass", link:.details_url, headSha:$head, checkRunId:.id})' \ + <<<"$primary"$'\n'"$protected" > "$output" + exit 0 + fi + fi + sleep 5 +done + +echo "Timed out waiting for newly dispatched required checks at $head_sha." >&2 +exit 1 diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 4b910e9..375a3e6 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -271,10 +271,30 @@ def test_invariants_and_durable_state_are_protected(self): self.assertTrue(path_is_protected(".github/codex-action-contract.json")) self.assertTrue(path_is_protected("maintenance/policy-invariants.json")) self.assertTrue(path_is_protected("scripts/validate-codex-action-inputs")) + self.assertTrue(path_is_protected("scripts/dispatch-pr-checks")) self.assertTrue(path_is_protected("maintenance-events/new-branch.json")) self.assertTrue(path_is_protected("maintenance-state/last-evidence.json")) self.assertFalse(path_is_protected("support-policy.json")) + def test_token_created_prs_explicitly_dispatch_required_checks(self): + root = pathlib.Path(__file__).resolve().parents[1] + ci = (root / ".github/workflows/ci.yml").read_text() + protected = (root / ".github/workflows/protected-controls.yml").read_text() + dispatcher = (root / "scripts/dispatch-pr-checks").read_text() + self.assertIn("workflow_dispatch:", ci) + self.assertIn("workflow_dispatch:", protected) + self.assertIn("pr_number:", protected) + self.assertIn("gh workflow run ci.yml", dispatcher) + self.assertIn("gh workflow run protected-controls.yml", dispatcher) + for workflow in ( + "maintenance-watch.yml", + "maintenance-implementation.yml", + "maintenance-release.yml", + ): + body = (root / ".github/workflows" / workflow).read_text() + self.assertIn("./scripts/dispatch-pr-checks", body) + self.assertNotIn("gh pr checks", body) + def test_malformed_contract_shapes_fail_closed(self): contract = self._contract() contract["allowedAuthority"] = [[]]