diff --git a/.github/tests/test-auto-merge-dry-run.sh b/.github/tests/test-auto-merge-dry-run.sh index cf784b79..078ba3d7 100644 --- a/.github/tests/test-auto-merge-dry-run.sh +++ b/.github/tests/test-auto-merge-dry-run.sh @@ -18,6 +18,10 @@ guard() { $w.on.workflow_call.inputs["dry-run"].default != false or $w.on.workflow_call.secrets.APP_PRIVATE_KEY.required != false then error("dry-run must be default-off and secret-free") + elif $w.jobs.eligibility.if != "!inputs.dry-run" or + $w.jobs.eligibility.permissions != {} or $w.jobs.eligibility.needs != null or + ($w.jobs.eligibility["continue-on-error"] // false) != false + then error("offline eligibility must skip only the independent fixture; live/default reporting must remain") elif ($w.jobs["auto-merge"].if | gsub("\\s";"")) != "!inputs.dry-run&&needs.eligibility.outputs.eligible=='\''true'\''&&(github.event_name=='\''pull_request'\''||inputs.enforce-review-gates||vars.ENFORCE_MERGE_GATES=='\''true'\'')" or $w.jobs["disarm-untrusted-update"].if != "!inputs.dry-run && needs.eligibility.outputs.disarm == '\''true'\''" then error("dry-run must exclude every privileged job") @@ -77,6 +81,12 @@ while IFS=$'\t' read -r label mutation diagnostic; do done <<'CASES' required secret .workflow.on.workflow_call.secrets.APP_PRIVATE_KEY.required=true secret-free live path changed .workflow.on.workflow_call.inputs["dry-run"].default=true default-off +redundant eligibility runner .workflow.jobs.eligibility.if=null offline eligibility +reversed eligibility .workflow.jobs.eligibility.if="inputs.dry-run" offline eligibility +event-gated live reporting .workflow.jobs.eligibility.if="!inputs.dry-run && github.event_name == 'pull_request'" offline eligibility +actor-gated live reporting .workflow.jobs.eligibility.if="!inputs.dry-run && github.actor == 'dependabot[bot]'" offline eligibility +blocked eligibility .workflow.jobs.eligibility.needs="dry-run" offline eligibility +eligibility write token .workflow.jobs.eligibility.permissions={contents:"write"} offline eligibility approval boundary lost .workflow.jobs["auto-merge"].if |= sub("!inputs.dry-run && ";"") privileged job disarm boundary lost .workflow.jobs["disarm-untrusted-update"].if |= sub("!inputs.dry-run && ";"") privileged job shared concurrency lane .workflow.concurrency.group |= sub("\\$\\{\\{ inputs.dry-run.*$";"") isolate its concurrency lane @@ -106,3 +116,13 @@ boundary test skipped .ci.jobs["test-enable-auto-merge-author-gate"].steps |= ma behavior test lost .ci.jobs["test-enable-auto-merge-author-gate"].steps |= map(select(.run != "bash .github/tests/test-auto-merge-fixture.sh")) regressions must execute CASES echo "PASS: auto-merge dry-run boundary rejects $count independent regressions" + +# Exercise the real no-op reporter; the public revision binds hosted empty-input +# proof to the changed workflow rather than a ruleset still reading older main. +yq -r '.jobs.eligibility.steps[] | select(.id == "ineligible") | .run' \ + "${1:-$root/.github/workflows/enable-auto-merge.yaml}" > "$work/complete.sh" +WORKFLOW_SOURCE_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + bash -euo pipefail "$work/complete.sh" > "$work/receipt" +grep -qxF 'Eligibility workflow source SHA: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' "$work/receipt" +grep -qxF '::notice::This event is not eligible for privileged bot auto-merge; completing the required workflow as a safe no-op.' "$work/receipt" +echo 'PASS: native ineligible reporter preserves successful completion and exposes its public source revision' diff --git a/.github/tests/test-catalogue-credentials-controls.sh b/.github/tests/test-catalogue-credentials-controls.sh index 662cc6ce..3980210c 100644 --- a/.github/tests/test-catalogue-credentials-controls.sh +++ b/.github/tests/test-catalogue-credentials-controls.sh @@ -18,6 +18,7 @@ export CATALOGUE_CREDENTIALS_OFFLINE=1 "$tmp/guard" "$tmp/catalogue" controls=0 # Each expected diagnostic is an authority class; no credential value is printed. +# Restoring a writer behind a skipped dependency also requires a status function. while IFS=$'\t' read -r label path mutation expected_status diagnostic; do [[ -n "$label" ]] || continue yq -o=json '.' "$root/$path" > "$tmp/source.json" @@ -55,7 +56,7 @@ restored template writer .github/workflows/template-sync.yaml del(.jobs["templat restored skills writer .github/workflows/update-agent-skills.yaml del(.jobs["update-agent-skills"].if) 1 write authority removed skipped dependency .github/workflows/update-agent-skills.yaml del(.jobs["open-skill-pull-requests"].needs) 1 write authority always restores skipped writer .github/workflows/update-agent-skills.yaml .jobs["open-skill-pull-requests"].if="${{ always() }}" 1 write authority -restored auto merge .github/workflows/enable-auto-merge.yaml .jobs["disarm-untrusted-update"].if="true" 1 write authority +restored auto merge .github/workflows/enable-auto-merge.yaml .jobs["disarm-untrusted-update"].if="${{ always() }}" 1 write authority restored pages writer .github/workflows/deploy-github-pages.yaml del(.jobs.build.if) 1 write authority restored package writer .github/workflows/publish-dotnet-library.yaml del(.jobs.publish.if) 1 write authority restored application writer .github/workflows/publish-app.yaml del(.jobs.publish.if) 1 write authority diff --git a/.github/tests/test-enable-auto-merge-author-gate.sh b/.github/tests/test-enable-auto-merge-author-gate.sh index 4cf813f7..e113e333 100644 --- a/.github/tests/test-enable-auto-merge-author-gate.sh +++ b/.github/tests/test-enable-auto-merge-author-gate.sh @@ -48,11 +48,12 @@ if [[ "$enabled_test_uses" != "./.github/workflows/enable-auto-merge.yaml" || fi # A required workflow must complete successfully for ineligible events rather -# than making its only job SKIPPED. Keep classification in an unconditional, -# zero-permission job and gate the privileged job on its output. +# than making its only job SKIPPED. Keep classification independent of event, +# author and draft state; only the separate offline fixture job replaces it. +# Gate the privileged jobs on its outputs and keep its token unprivileged. eligibility_job_condition="$(yq -r '.jobs.eligibility.if // ""' "$workflow")" -if [[ -n "$eligibility_job_condition" ]]; then - echo "::error file=$workflow::eligibility job must be unconditional so required workflows complete for ineligible events" +if [[ "$eligibility_job_condition" != '!inputs.dry-run' ]]; then + echo "::error file=$workflow::eligibility must execute for every live/default invocation and skip only the independent offline fixture" status=1 fi @@ -93,6 +94,11 @@ if [[ "$ineligible_condition" != *"steps.classify.outputs.eligible != 'true'"* ] echo "::error file=$workflow::eligibility job needs an explicit successful ineligible-event step" status=1 fi +ineligible_source="$(yq -r '.jobs.eligibility.steps[] | select(.id == "ineligible") | .env.WORKFLOW_SOURCE_SHA // ""' "$workflow")" +if [[ "$ineligible_source" != "\${{ job.workflow_sha }}" ]]; then + echo "::error file=$workflow::ineligible reporting must bind its public source revision to the executed workflow" + status=1 +fi configuration_condition="$(yq -r ' [(.jobs.eligibility.steps // [])[] | select(.id == "legacy-concurrency-key") diff --git a/.github/workflows/enable-auto-merge.yaml b/.github/workflows/enable-auto-merge.yaml index 32ada001..19e93141 100644 --- a/.github/workflows/enable-auto-merge.yaml +++ b/.github/workflows/enable-auto-merge.yaml @@ -223,11 +223,12 @@ jobs: - name: 🧪 Verify durable lifecycle ordering run: bash .github/tests/test-is-current-pull-request-lifecycle.sh - # Required-workflow rules treat a workflow whose only job is SKIPPED as - # unsatisfied. Classify every trigger in an unconditional, unprivileged job - # so human, draft, external-author, and merge-group runs complete green as a - # deliberate no-op while the privileged path remains bot-only. + # Classify every live/default trigger in an unprivileged job so human, draft, + # external-author and merge-group runs explicitly complete successfully. + # Only offline mode omits this runner: its independent fixture job already + # reports the result, and both privileged jobs exclude offline mode. eligibility: + if: "!inputs.dry-run" permissions: {} runs-on: ubuntu-latest env: @@ -376,9 +377,11 @@ jobs: - name: ✅ Complete ineligible required-workflow run id: ineligible if: steps.classify.outputs.eligible != 'true' - run: >- - echo "::notice::This event is not eligible for privileged bot auto-merge; - completing the required workflow as a safe no-op." + env: + WORKFLOW_SOURCE_SHA: ${{ job.workflow_sha }} + run: | + echo "::notice::This event is not eligible for privileged bot auto-merge; completing the required workflow as a safe no-op." + printf 'Eligibility workflow source SHA: %s\n' "$WORKFLOW_SOURCE_SHA" # A trusted-bot PR may already be armed when an untrusted actor changes its # lifecycle state or removes trusted review evidence. Revoke that stale state diff --git a/README.md b/README.md index 8f8abeda..2c6f2656 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,9 @@ Use `dry-run: true` to exercise the workflow without changing a pull request. This path runs offline decision fixtures with `contents: read`, forwards no App secret, and has a separate concurrency lane so it cannot cancel live evaluations. The catalogue tests use it for default, actor-trust and pending-queue calls. +Offline mode starts only the fixture runner and skips live eligibility classification. +Omitted or false inputs retain the unprivileged classifier and explicit successful +reporting for ineligible live and required-workflow events. Caller permissions still declare the production workflow's static write ceiling, which GitHub validates even when those jobs are skipped; executed dry-run jobs receive only read access. `dry-run` defaults to `false`, and live mutations require