Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/tests/test-auto-merge-dry-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
3 changes: 2 additions & 1 deletion .github/tests/test-catalogue-credentials-controls.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions .github/tests/test-enable-auto-merge-author-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
17 changes: 10 additions & 7 deletions .github/workflows/enable-auto-merge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading