diff --git a/.github/workflows/pr-auto-rebase.yml b/.github/workflows/pr-auto-rebase.yml index 4992224926..45b0d7f7ae 100644 --- a/.github/workflows/pr-auto-rebase.yml +++ b/.github/workflows/pr-auto-rebase.yml @@ -46,9 +46,9 @@ on: default: "" type: string canonical_ref: - description: Ref of ContextualWisdomLab/.github to use for scheduler code + description: Deprecated compatibility input; accepted and ignored because privileged source is bound to the called workflow SHA required: false - default: "main" + default: "" type: string repository_dispatch: types: [pr-auto-rebase] @@ -84,7 +84,6 @@ jobs: MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '100' }} AUTO_REBASE_MAX_PER_RUN: ${{ github.event.client_payload.max_per_run || inputs.max_per_run || vars.AUTO_REBASE_MAX_PER_RUN || '10' }} AUTO_REBASE_HUMAN_WINDOW_MINUTES: ${{ github.event.client_payload.human_window_minutes || inputs.human_window_minutes || vars.AUTO_REBASE_HUMAN_WINDOW_MINUTES || '30' }} - CANONICAL_REF: main steps: - name: Exchange OpenCode app token for cross-repo git writes id: scheduler_app_token @@ -152,14 +151,67 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" - - name: Checkout canonical scheduler + - name: Resolve immutable called-workflow source + id: trusted_source + env: + # github.workflow_sha is the commit GitHub loaded for this workflow. + # Same-repository callers share that commit. A caller-supplied ref + # must not select it. + CALLER_REPOSITORY: ${{ github.repository }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + expected_repository="ContextualWisdomLab/.github" + expected_file=".github/workflows/pr-auto-rebase.yml" + + if [ "$CALLER_REPOSITORY" != "$expected_repository" ]; then + printf '::error::Called workflow was invoked from an untrusted repository: %s (expected %s).\n' \ + "${CALLER_REPOSITORY:-}" "$expected_repository" + exit 1 + fi + + if ! [[ "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Called workflow SHA is missing or malformed: %s.\n' \ + "${WORKFLOW_SHA:-}" + exit 1 + fi + + { + printf 'repository=%s\n' "$expected_repository" + printf 'sha=%s\n' "$WORKFLOW_SHA" + printf 'workflow_file_path=%s\n' "$expected_file" + } >>"$GITHUB_OUTPUT" + printf 'Resolved immutable called-workflow source repository=%s file=%s sha=%s.\n' \ + "$expected_repository" "$expected_file" "$WORKFLOW_SHA" + + - name: Checkout immutable called-workflow source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - repository: ContextualWisdomLab/.github - ref: ${{ env.CANONICAL_REF }} + repository: ${{ steps.trusted_source.outputs.repository }} + ref: ${{ steps.trusted_source.outputs.sha }} fetch-depth: 1 persist-credentials: false + - name: Verify immutable called-workflow checkout + env: + EXPECTED_SHA: ${{ steps.trusted_source.outputs.sha }} + EXPECTED_FILE: ${{ steps.trusted_source.outputs.workflow_file_path }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + if [ "$actual_sha" != "$EXPECTED_SHA" ]; then + printf '::error::Checked-out scheduler SHA %s does not match called-workflow SHA %s.\n' \ + "$actual_sha" "$EXPECTED_SHA" + exit 1 + fi + if [ ! -f "$EXPECTED_FILE" ] || [ -L "$EXPECTED_FILE" ]; then + printf '::error::Called workflow source file is missing or symlinked: %s.\n' \ + "$EXPECTED_FILE" + exit 1 + fi + printf 'Verified immutable scheduler checkout at %s (%s).\n' \ + "$actual_sha" "$EXPECTED_FILE" + - name: Self-test auto-rebase scheduler contract run: python3 scripts/ci/pr_auto_rebase.py --self-test diff --git a/tests/test_pr_auto_rebase_source_pin.py b/tests/test_pr_auto_rebase_source_pin.py new file mode 100644 index 0000000000..48dc036ead --- /dev/null +++ b/tests/test_pr_auto_rebase_source_pin.py @@ -0,0 +1,66 @@ +"""Supply-chain contract for the PR auto-rebase scheduler source checkout.""" + +from __future__ import annotations + +from pathlib import Path + + +_WORKFLOW = ( + Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "pr-auto-rebase.yml" +) + + +def _workflow_text() -> str: + """Read the auto-rebase workflow as UTF-8 text.""" + return _WORKFLOW.read_text(encoding="utf-8") + + +def test_auto_rebase_validates_called_workflow_sha_before_checkout() -> None: + """A floating branch tip must not be checked out before SHA validation.""" + workflow = _workflow_text() + guard = workflow.index("Resolve immutable called-workflow source") + checkout = workflow.index("Checkout immutable called-workflow source") + + assert guard < checkout + assert "CALLER_REPOSITORY: ${{ github.repository }}" in workflow + assert "WORKFLOW_SHA: ${{ github.workflow_sha }}" in workflow + assert '[ "$CALLER_REPOSITORY" != "$expected_repository" ]' in workflow + assert 'expected_repository="ContextualWisdomLab/.github"' in workflow + assert 'expected_file=".github/workflows/pr-auto-rebase.yml"' in workflow + assert '[[ "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]]' in workflow + assert "repository: ${{ steps.trusted_source.outputs.repository }}" in workflow + assert "ref: ${{ steps.trusted_source.outputs.sha }}" in workflow + assert "ref: main" not in workflow + assert "CANONICAL_REF: main" not in workflow + assert "ref: ${{ env.CANONICAL_REF }}" not in workflow + assert "ref: ${{ github.sha }}" not in workflow + + +def test_auto_rebase_verifies_checked_out_sha_matches_runtime_parameter() -> None: + """The commit that executes must equal the validated 40-character SHA.""" + workflow = _workflow_text() + verification = workflow.index("Verify immutable called-workflow checkout") + self_test = workflow.index("Self-test auto-rebase scheduler contract") + + assert verification < self_test + assert 'actual_sha="$(git rev-parse HEAD)"' in workflow + assert '[ "$actual_sha" != "$EXPECTED_SHA" ]' in workflow + assert '[ ! -f "$EXPECTED_FILE" ] || [ -L "$EXPECTED_FILE" ]' in workflow + + +def test_auto_rebase_canonical_ref_input_cannot_select_source() -> None: + """Callers may still send canonical_ref, but it never selects the checkout.""" + workflow = _workflow_text() + declaration = workflow.split("canonical_ref:", 1)[1].split( + "repository_dispatch:", 1 + )[0] + + assert "Deprecated compatibility input" in declaration + assert "ignored" in declaration + assert 'default: ""' in declaration + assert workflow.count("canonical_ref") == 1 + assert "inputs.canonical_ref" not in workflow + assert "github.event.client_payload.canonical_ref" not in workflow