fix(ci): drop invalid workflows permission scope from branch-cleanup - #1420
Conversation
`workflows` is not a GitHub Actions permission scope. The valid keys are
`actions`, `artifact-metadata`, `attestations`, `checks`, `code-quality`,
`contents`, `deployments`, `discussions`, `id-token`, `issues`, `packages`,
`pages`, `pull-requests`, `security-events`, `statuses`, and
`vulnerability-alerts`. Declaring it made `branch-cleanup.yml` unparseable,
so GitHub rejected the file outright.
The result was a run created on every push to every branch, including main,
each one failing instantly. Verified against the workflow's own run history:
1,182 runs, 100% failure, no successes. The signature is that of an
unloadable file rather than a failing job -- runs are named by file path
(`.github/workflows/branch-cleanup.yml`) instead of by the `name:` field
("Branch Cleanup"), `created_at` / `run_started_at` / `updated_at` are
byte-identical so no job ever starts, and runs fire on branches the trigger
filter excludes (`main`, `groupthinking-fix-upstream-error-leakage`, and
others, against `on.push.branches: ["claude/branch-cleanup-*"]`).
Removing the key changes no effective permission -- an invalid key grants
nothing, and the workflow has never executed with it in force. `contents:
write`, which the archive-tag and delete paths actually need, is retained.
The confusion is understandable: `workflow` is a real scope for PATs and
OAuth apps. It is not grantable to GITHUB_TOKEN via `permissions:`. The
header comment and the `AUDIT.md` row that recorded the original change are
corrected to say so, since both would otherwise invite re-adding the key.
AUDIT.md also claimed the note about restoring workflow-containing branches
was incorrect; that note was right, and it is restored -- GITHUB_TOKEN
cannot create or update files under `.github/workflows/` regardless of the
permissions block, so that path needs a PAT with the `workflow` scope.
Closes #1405
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145DzkZ1bsuJe3WktfWfyx8
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
Agent Completion Truth Gate: BLOCKEDReasons: Machine-readable verdict{
"details": {
"collection_errors": [
"incomplete_linked_issue_contract",
"linked_issue_not_agent_task",
"missing_intent_snapshot",
"missing_agent_run_id",
"missing_agent_login"
],
"invalid_fields": [
"policy.agent_login",
"policy.run_id"
]
},
"reasons": [
"invalid_payload"
],
"verdict": "blocked"
} |
|
@coderabbitai full review The skip above was a label race, not a configuration mismatch — auto-label applied Worth flagging what to focus on, since the diff is three lines and the interesting question is not in it: this PR deletes The claim is falsifiable in two directions and I would rather you check it than agree with it:
Generated by Claude Code |
|
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== PR metadata and changed files ==='
gh pr view 1420 --repo groupthinking/EventRelay \
--json number,title,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,files,statusCheckRollup \
--jq '{number,title,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,files:[.files[]|{path,additions,deletions}],checks:[.statusCheckRollup[]|{name,conclusion,status}]}'
echo '=== PR diff ==='
gh pr diff 1420 --repo groupthinking/EventRelay -- .github/workflows/branch-cleanup.yml .github/workflows/AUDIT.md
echo '=== Workflow outline and relevant sections ==='
ast-grep outline .github/workflows/branch-cleanup.yml --items all || true
cat -n .github/workflows/branch-cleanup.yml
echo '=== Audit references ==='
rg -n -C 5 'branch-cleanup|workflows: write|workflow' .github/workflows/AUDIT.md || true
echo '=== Every workflow permissions key ==='
python3 - <<'PY'
from pathlib import Path
import re
for path in sorted(Path(".github/workflows").glob("*.yml")):
text = path.read_text()
m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
if m:
print(f"--- {path}")
print(m.group(0).rstrip())
PY
echo '=== Recent branch-cleanup workflow runs ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml/runs?per_page=30' \
--jq '.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}'
echo '=== Current PR check runs ==='
gh pr checks 1420 --repo groupthinking/EventRelay --json name,state,workflow,link,bucket 2>&1 || trueLength of output: 4743 🤖 web_search executed:
Length of output: 14987 🏁 Script executed: #!/bin/bash
set -u -o pipefail
echo '=== Changed-file patches from GitHub API ==='
gh api 'repos/groupthinking/EventRelay/pulls/1420/files?per_page=100' \
--jq '.[] | select(.filename==".github/workflows/branch-cleanup.yml" or .filename==".github/workflows/AUDIT.md") | {filename,status,patch}'
echo '=== branch-cleanup workflow at PR head ==='
cat -n .github/workflows/branch-cleanup.yml || true
echo '=== Relevant audit entries ==='
rg -n -C 6 'branch-cleanup|workflows: write|workflow' .github/workflows/AUDIT.md || true
echo '=== YAML permissions validation ==='
python3 - <<'PY'
from pathlib import Path
import re
valid = {
"actions", "artifact-metadata", "attestations", "checks", "code-scanning",
"contents", "deployments", "discussions", "id-token", "issues", "models",
"packages", "pages", "pull-requests", "security-events", "statuses",
}
path = Path(".github/workflows/branch-cleanup.yml")
text = path.read_text()
m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
keys = []
if m:
keys = re.findall(r'(?m)^[ \t]+([A-Za-z][\w-]*):', m.group(1))
print({"permissions_keys": keys, "invalid_keys": sorted(set(keys) - valid)})
print({"has_workflows_key": bool(re.search(r'(?m)^[ \t]*workflows\s*:', text))})
print({"has_restore_push": 'refs/tags/archive/$b:refs/heads/$b' in text})
PY
echo '=== All workflow permissions blocks ==='
python3 - <<'PY'
from pathlib import Path
import re
for path in sorted(Path(".github/workflows").glob("*.yml")):
text = path.read_text()
m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
if m:
print(f"--- {path}")
print(m.group(0).rstrip())
PY
echo '=== Latest 30 branch-cleanup runs ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml/runs?per_page=30' \
--jq '.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}'
echo '=== PR check-run details, including failed checks ==='
gh api 'repos/groupthinking/EventRelay/commits/f81e0c19da000f49c6ae33342e26248aa889f03d/check-runs?per_page=100' \
--jq '.check_runs[] | select(.conclusion=="failure" or .status!="completed") | {name,status,conclusion,details_url,output}'Length of output: 132137 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '=== Exact workflow permissions block and YAML parse ==='
python3 - <<'PY'
from pathlib import Path
import yaml
path = Path(".github/workflows/branch-cleanup.yml")
document = yaml.safe_load(path.read_text())
permissions = document["permissions"]
valid = {
"actions", "artifact-metadata", "attestations", "checks", "contents",
"deployments", "discussions", "id-token", "issues", "models", "packages",
"pages", "pull-requests", "security-events", "statuses",
}
print({
"name": document["name"],
"triggers": sorted(document[True].keys()),
"permissions": permissions,
"invalid_permission_keys": sorted(set(permissions) - valid),
"restore_command": document["jobs"]["cleanup"]["steps"][2]["run"].splitlines()[1].strip(),
})
PY
echo '=== Failed truth-gate annotations ==='
gh api 'repos/groupthinking/EventRelay/check-runs/92953636607/annotations?per_page=100' \
--jq '.[] | {path,start_line,end_line,annotation_level,message,title}'
echo '=== Workflow record ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml' \
--jq '{id,name,path,state,created_at,updated_at}'
echo '=== GitHub Docs: workflow syntax permission allowlist ==='
curl -fsSL \
'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' \
| awk '
/^### `permissions`/{show=1}
show {print}
show && /^### `/ && $0 !~ /^### `permissions`/ {exit}
' \
| grep -E '^(###|####)|`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' \
| head -100
echo '=== GitHub Docs: pushing workflow-file changes ==='
curl -fsSL \
'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' \
| grep -in -A8 -B4 'workflow' \
| head -80Length of output: 2392 🏁 Script executed: #!/bin/bash
set -u -o pipefail
echo '=== Exact permissions mapping by YAML indentation ==='
awk '
/^permissions:$/ { in_permissions=1; next }
in_permissions && /^[^[:space:]]/ { in_permissions=0 }
in_permissions && /^ [A-Za-z][A-Za-z0-9-]*:/ { print }
' .github/workflows/branch-cleanup.yml
echo '=== Workflow trigger and restore command lines ==='
grep -nE '^(name:|on:| workflow_dispatch:| push:| branches:|permissions:| contents:| pull-requests:| git push origin)' \
.github/workflows/branch-cleanup.yml
echo '=== Failed truth-gate annotations ==='
gh api 'repos/groupthinking/EventRelay/check-runs/92953636607/annotations?per_page=100' \
--jq '.[] | {path,start_line,end_line,annotation_level,message,title}' || true
echo '=== Workflow record ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml' \
--jq '{id,name,path,state,created_at,updated_at}' || true
echo '=== GitHub Docs: workflow syntax permission allowlist ==='
curl -fsSL \
'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' \
| grep -n -E -A2 -B2 '`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' \
| head -120 || true
echo '=== GitHub Docs: workflow-file changes require workflow authority ==='
curl -fsSL \
'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' \
| grep -in -A10 -B5 'workflow' \
| head -120 || trueLength of output: 2648
I cannot approve yet. 🐇🔎 ✏️ Learnings added
|
Red-team pass on the diffThe interesting risk here is not in the three lines removed. It is that this change makes a branch-deletion workflow executable for the first time in its history. A fix that takes a destructive workflow from "never runs" to "runs" deserves more scrutiny than its diff size suggests, so I went looking for a way it could fire unintentionally. Conclusion: it cannot, and the blast radius today is zero. Three independent gates, each verified against the file rather than the header comment. 1. The push-sentinel path has no trigger surface at all
This was the failure mode I was actually hunting. Because the workflow has always failed instantly, someone could plausibly have pushed a 2. Sentinel matching is explicit, and the default is a no-op
Worth stating plainly since the header comment does not: the sentinel path hard-codes 3.
|
Green gate reached —
|
| Check | Result |
|---|---|
validate |
success |
CI (guards, lint-python, lint-frontend, build, test) |
success |
CodeQL |
success |
gitleaks (working tree) |
success |
dependency-review |
success |
PR Governance |
success |
Canonical issue and evidence |
success |
Conditionally required, and reporting green rather than pending: Coverage success, E2E Pipeline Tests skipped. Also green: Security Scan (python + javascript), gh-aw Validation, Agent completion enforcement, Auto Label. Vercel preview Ready.
Not green: agent-completion/truth-gate, invalid_payload. Not in the required list, unfixable from this branch — full analysis above. It has now re-fired three times on this PR (push, body edit, ready-for-review) with a byte-identical verdict each time, which is itself a small demonstration of the point MERGE_POLICY.md:157 makes about checks that carry no signal.
Review
CodeRabbit ran a full review and returned no code findings, confirming each substantive claim independently — that the permissions mapping no longer declares workflows, that the historical run data supports the unloadable-file diagnosis, that removing the key reduces no available GITHUB_TOKEN permission, and that the restore constraint is a reachable pre-existing limitation rather than a regression from this change. Zero review threads are open.
The fix is already demonstrably working
Worth restating because it is verifiable from this PR's own run history rather than from anything I assert: this branch has produced zero branch-cleanup runs. Every push to every other branch still produces one instantly-failed run. git ls-remote confirms 0 of 342 branches match the claude/branch-cleanup-* trigger, so once this lands the workflow should stop generating runs entirely until someone deliberately creates such a branch or dispatches it.
Staged merge command
gh pr merge 1420 --squash --repo groupthinking/EventRelay
Not merging automatically: this PR carries no automerge label, and main is protected.
Generated by Claude Code
|
@coderabbitai full review Auto-review skipped on this head for want of a qualifying label, so requesting explicitly. Worth flagging that this PR's body asserts a clean CodeRabbit full review with no code findings — as of The diff is two files, +7/−3. The claim to test hardest is the central one, because the whole change rests on it: Two secondary claims also worth a skeptical pass:
Generated by Claude Code |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
repo="groupthinking/EventRelay"
pr="1420"
echo '=== PR metadata, files, and checks ==='
gh pr view "$pr" --repo "$repo" \
--json number,title,headRefOid,headRefName,baseRefName,mergeable,mergeStateStatus,files,statusCheckRollup \
--jq '{
number,title,headRefOid,headRefName,baseRefName,mergeable,mergeStateStatus,
files:[.files[]|{path,additions,deletions}],
checks:[.statusCheckRollup[]|{name,conclusion,status}]
}'
echo '=== PR patches ==='
gh api "repos/$repo/pulls/$pr/files?per_page=100" --jq '
.[] | select(.filename==".github/workflows/branch-cleanup.yml" or .filename==".github/workflows/AUDIT.md")
| {filename,status,patch}'
echo '=== branch-cleanup workflow structure ==='
ast-grep outline .github/workflows/branch-cleanup.yml --items all || true
cat -n .github/workflows/branch-cleanup.yml
echo '=== Relevant AUDIT.md context ==='
rg -n -C 8 'branch-cleanup|workflows: write|workflow permission|permission' .github/workflows/AUDIT.md || true
echo '=== Parse permissions and identify top-level workflow properties ==='
python3 - <<'PY'
from pathlib import Path
import yaml
p = Path(".github/workflows/branch-cleanup.yml")
d = yaml.safe_load(p.read_text())
valid = {
"actions", "artifact-metadata", "attestations", "checks", "contents",
"deployments", "discussions", "id-token", "issues", "models", "packages",
"pages", "pull-requests", "security-events", "statuses",
}
print({
"name": d.get("name"),
"triggers": sorted((d.get(True) or {}).keys()),
"permissions": d.get("permissions"),
"invalid_permission_keys": sorted(set(d.get("permissions", {})) - valid),
})
PY
echo '=== Exact delete and restore controls ==='
rg -n -C 5 '\[run-cleanup:|\[restore-branch:|dry_run|git push origin|archive/' \
.github/workflows/branch-cleanup.yml
echo '=== Recent branch-cleanup runs ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?per_page=30" --jq '
.workflow_runs[] |
{id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path,head_sha}'
echo '=== Current PR branch-cleanup runs ==='
head_sha=$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq .headRefOid)
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?head_sha=$head_sha&per_page=100" --jq '
.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}' || true
echo '=== Official GitHub Docs: supported permissions keys ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' |
sed -n '/^### `permissions`/,/^### /p' |
grep -E '^#### `|^### `permissions`' || true
echo '=== Official GitHub Docs: workflow-file update authentication restriction ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' |
grep -in -A10 -B5 'workflow' | head -100 || trueLength of output: 23910 🏁 Script executed: #!/bin/bash
set -u -o pipefail
repo="groupthinking/EventRelay"
pr="1420"
head_sha="f81e0c19da000f49c6ae33342e26248aa889f03d"
echo '=== Exact permission block ==='
awk '
/^permissions:$/ {active=1; print; next}
active && /^[^[:space:]]/ {active=0}
active {print}
' .github/workflows/branch-cleanup.yml
echo '=== Delete harness interface and safety-related operations ==='
fd -a '^branch-cleanup-delete\.sh$' . |
while IFS= read -r path; do
echo "--- $path"
grep -nE '^(#!/|set -)|DRY_RUN|GITHUB_TOKEN|archive/|git (push|branch)|pull request|open PR|gh pr|delete' "$path" || true
done
echo '=== Recent branch-cleanup workflow runs ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?per_page=30" --jq '
.workflow_runs[] |
{id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path,head_sha}' || true
echo '=== branch-cleanup workflow runs for the PR head ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?head_sha=$head_sha&per_page=100" --jq '
{total_count, runs:[.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}]}' || true
echo '=== Workflow record as recognized by GitHub ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml" --jq \
'{id,name,path,state,created_at,updated_at}' || true
echo '=== Official GitHub Actions workflow-syntax documentation ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' |
awk '
/^### `permissions`/{show=1}
show {print}
show && /^### `/ && $0 != "### `permissions`" {exit}
' |
grep -E '^### |^#### |`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' ||
true
echo '=== Official GitHub REST documentation for workflow-file updates ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' |
grep -in -A14 -B5 'workflows' | head -160 ||
true
echo '=== PR reviews and CodeRabbit statuses ==='
gh pr view "$pr" --repo "$repo" --json reviews,statusCheckRollup --jq '
{
reviews:[.reviews[] | {author:.author.login,state,submittedAt,body}],
coderabbit_statuses:[
.statusCheckRollup[] |
select((.name // "" | test("CodeRabbit"; "i")) or (.context // "" | test("CodeRabbit"; "i"))) |
{name,context,conclusion,status,description,targetUrl}
]
}' || trueLength of output: 16344 🤖 web_search executed: `site:docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions "Available permissions" "contents" "pull-requests" site:docs.github.com "workflow files" "personal access token" "workflow scope" "GitHub App"` Length of output: 13950
You are interacting with an AI system. |
The allowlist, from primary sourceGood question to insist on, and there's a concrete reason your extraction came up empty. Why your So The canonical file is
I confirmed the same list independently against the rendered page at docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, where the reusable is expanded. Both agree, and neither contains Your GitHub Apps finding is right, and it is the source of the confusionYou found that GitHub Apps have a separate Workflows repository permission. That is real, and it is exactly why this bug was written in the first place — the original error message ("refusing to allow a GitHub App to create or update workflow ... without The trap is that the two live in different systems:
The The strongest evidence is not documentaryWorth stating, because it settles the question without depending on how anyone reads a docs page: GitHub's own parser already adjudicated this. An invalid
1,182 runs, 100% failure, zero successes across the workflow's entire history. And the confirming half, from this PR: pushing If Generated by Claude Code |
Correction — my review request above was wrong on its premiseI claimed the body's "CodeRabbit ran a full review and returned no code findings" was unsupported. It is supported. CodeRabbit posted exactly that at 18:04 (comment 5220456048) — "Full review complete. I found no code findings" — with all four substantive claims verified independently. What misled me: it delivered the result as an issue comment rather than a submitted review object, so Two costs worth recording rather than quietly absorbing:
Nothing in the diff changes and no finding is outstanding. The Terminal state unchanged: Generated by Claude Code |
Head moved to
|
| Green | validate, guards, lint-python, lint-frontend, build, PR Governance, Canonical issue and evidence, gitleaks (working tree), dependency-review, validate-gh-aw, bandit, npm-audit, python-safety, Security Scan - python, Vercel Agent Review, Vercel Preview Comments |
| Still running | test, trivy, Generate and Upload Coverage, Security Scan - javascript |
| Skipped | E2E Pipeline Tests, merge, approve |
| Neutral | CodeQL, Trivy (the capitalised duplicate #1410 documents; lowercase trivy is the real job) |
| Failing | none |
The fix survived the merge intact — checked, not assumed
main's own changes touched AUDIT.md in the same commit, so I verified rather than trusting a clean merge exit code:
permissions:resolves to exactly{contents: write, pull-requests: read}— no invalid key.grep 'workflows: write'over the workflow returns nothing.- The
Correction (#1405)row inAUDIT.mdsurvived, and merged alongside main's edits to neighbouring rows rather than clobbering them. - The header comment explaining why the scope is invalid is present.
git diff origin/main for the two files this PR owns is +9/−6 — the original change, nothing more.
Note for whoever merges
Vercel is rebuilding on the new head and will report Ready; it does not gate this change either way, since gate 4 scopes previews to apps/web/** and this diff touches only .github/.
Unchanged from before: the branch-cleanup workflow has produced zero runs on this branch across both heads, which remains the direct evidence that the file now parses and honours its trigger filter.
Generated by Claude Code
Blocker cleared — this PR is now fully greenThe
Checks on
|
|
Canonical issue
Closes #1405
Outcome
.github/workflows/branch-cleanup.ymlloads. It has never loaded before.The
permissions:block declaredworkflows: write. That is not a GitHub Actions permission scope, so GitHub rejected the file outright and created an instantly-failed run on every push to every branch — includingmain. Removing one line makes the file parseable for the first time.Scope
.github/workflows/branch-cleanup.yml— the invalidworkflows: writekey removed; header comment corrected (it asserted the token needs that right)..github/workflows/AUDIT.md— the row that recorded adding the key. Left alone it is a standing instruction to re-add it, which would re-break the file. It also called a prior NOTE "incorrect"; that NOTE was right, and this restores it.The key never granted anything
Valid
permissions:keys areactions,artifact-metadata,attestations,checks,code-quality,contents,deployments,discussions,id-token,issues,packages,pages,pull-requests,security-events,statuses,vulnerability-alerts.workflowsis not among them.The confusion behind it is reasonable.
workflowis a real scope — for PATs and OAuth apps, and it is the one needed to push changes to files under.github/workflows/. It is simply not grantable toGITHUB_TOKENthrough apermissions:block. So the original error message was genuine and the remedy was not available; the key granted nothing and cost the file its validity.contents: write, which the archive-tag and branch-delete paths actually use, is untouched.Verification
Head
b8dc521—f81e0c1plus a merge ofmain(8cd4a10). The diff is unchanged at two files, +7/−3. Every result below read from the live API.The fix is confirmed by the push that opened this PR — this is the direct evidence, not an inference. Pushing
f81e0c1toclaude/clever-heisenberg-e6y7a1created zerobranch-cleanupruns. Before this commit, every push to every branch created one, because an unloadable workflow is reported regardless of its trigger filter. Now the file parses,on.push.branches: ["claude/branch-cleanup-*"]is honoured, and this branch does not match it — so nothing fires.That satisfies acceptance criterion 3 on ci: branch-cleanup.yml is an invalid workflow file (
workflowsis not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405 outright, and it is a stronger test than criterion 1: a run named "Branch Cleanup" would only prove the file loads, whereas no run at all proves the file loads and its filters are being applied.The file parses, re-checked at the merged head.
yaml.safe_loadresolvesname: Branch Cleanup,permissions: {contents: write, pull-requests: read}, triggersworkflow_dispatch+push. Zero keys outside the valid set.The old failure was an unloadable file, not a failing job — three independent signatures, sampled across the 30 most recent runs before this change:
.github/workflows/branch-cleanup.yml— the path, not thename:field. GitHub falls back to the path when it cannot load the workflow.created_at==run_started_at==updated_at, byte-identical. No job ever starts.main,groupthinking-fix-upstream-error-leakage,groupthinking-skill-dispatch-regression-tests— against a filter of onlyclaude/branch-cleanup-*.Scale, and it was still growing. 1,182 total runs, 100%
failure, no successes in the workflow's entire history. Issue ci: branch-cleanup.yml is an invalid workflow file (workflowsis not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405 measured 1,163 two days ago; 19 more accumulated since, across 11 distinct branches in a single 30-run page.Sole source. A survey of every
permissions:block in.github/workflows/*.ymlreturns this key frombranch-cleanup.ymland nowhere else.Blast radius of enabling the workflow is zero.
git ls-remotereturns 0 of 342 branches matchingclaude/branch-cleanup-*, so no latent sentinel commit can fire now that the file parses.workflow_dispatchdefaults to dry-run.Checks — all complete, none failing
mergeable_state: clean.validateCI(guards,lint-python,lint-frontend,build,test)CodeQLgitleaks (working tree)dependency-reviewPR GovernanceCanonical issue and evidenceCoverageSecurity Scan(python + javascript)validate-gh-awtrivy,bandit,python-safety,npm-auditE2E Pipeline TestsVercel preview
Ready;Vercel Agent Reviewsuccess.On the previously-red check. Earlier revisions of this section recorded
agent-completion/truth-gatefailing withinvalid_payload, and named it the sole cause ofmergeable_state: unstable. That check no longer exists — #1431 retired the agent-completion truth gate and merged tomainas8cd4a10, which is merged into this branch. It does not appear in this head's check runs at all, and the merge state is nowclean. The analysis in the comments below is retained as a record of what was true at the time, not as a live caveat.Review: CodeRabbit ran a full review and returned no code findings, independently confirming the permission analysis, the unloadable-file diagnosis, and the restore-constraint framing. Zero review threads are open. It declined to approve for two stated reasons — the truth gate failing, and CI still in progress. Both are now resolved: the gate is retired and every check has completed.
Risk
contents: write, retained.git push origin "refs/tags/archive/$b:refs/heads/$b") recreates a ref whose tree may contain.github/workflows/files.GITHUB_TOKENcannot create or update those, and nopermissions:key lifts that. If restore fails that way it is a pre-existing latent constraint this fix makes reachable for the first time — not a regression. It needs a PAT with theworkflowscope, or a local push. The header comment now says so.Production evidence
Not applicable — CI configuration only, no runtime or deployable surface. The observable effect is on GitHub Actions itself, and it is already measurable: see the first verification item.
Agent handoff
workflowsis not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405workflowsis not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405 has been open since Aug 5 with no PR; the session that filed it could not land the fix because its branch already carried fix(ci): stop Dependabot bumping generated gh-aw lock files #1404 under a different canonical issueclaude/branch-cleanup-*branch; criterion 4 (mergeable_state) is now observable directly:cleanAgent provenance
Agent-authored. No
agent-lock-manifestis filled in. The manifest declared arun_idandagent_loginthat the truth gate treated as evidence expected to be corroborated by append-only result comments; there is no dispatch record behind this change, and fabricating those values would have injected false evidence into that mechanism. That gate has since been retired by #1431, so the field is now inert either way.