bug(_release.yml): cut-path manifest push to main blocked by branch protection pre-receive hook (workflow token is not admin) #78

Closed
opened 2026-06-25 22:17:55 +02:00 by quartermaster · 0 comments

Symptom

The v0.4.0 cut-path workflow completed most of its work but failed at the final manifest-update push to main:

remote: 
To http://forgejo:3000/frankenbit/release-toolkit
 ! [remote rejected] HEAD -> main (pre-receive hook declined)
error: failed to push some refs to 'http://forgejo:3000/frankenbit/release-toolkit'
exitcode '1': failure

Surfaced live during the v0.4.0 cut (actions/runs/6238 — decide + act task 13107).

What completed before the failure

The cut-path's act-step got these substantive actions done:

  1. Layer 1 + 2 + 3 cut-safeguards all passed → mode=cut
  2. draft-release.sh --version 0.4.0 succeeded → Forgejo draft release #121 created at target 5f4812d
  3. Manifest jq-updated locally + git config set (#73 fix worked)
  4. git commit succeeded → [main 0b20424] chore(manifest): update for v0.4.0
  5. git push origin HEAD:main rejected by branch protection pre-receive hook

So the user-facing artifact (draft release) IS created, but the substrate-state (manifest) update never landed on main.

Root cause (verified at source)

main branch protection in this repo (/api/v1/repos/frankenbit/release-toolkit/branch_protections/main):

{
  "branch_name": "main",
  "enable_push": true,
  "enable_push_whitelist": false,
  "block_on_outdated_branch": true,
  "apply_to_admins": false,
  ...
}

The apply_to_admins: false setting means admin pushes bypass the protection but non-admin pushes (including the workflow's GITHUB_TOKEN-authed push) hit the pre-receive hook. The default GITHUB_TOKEN in Forgejo Actions does NOT have admin scope — so the cut-path push is blocked.

This is the same class as #70 + #73 — slice 1b's _release.yml reusable assumes the workflow can push to main, which works when the consumer's repo has no branch protection but fails when the consumer enables it.

Why slice 2's first-fire didn't catch this

Slice 2's first fire was mode=noop — the act-step short-circuits before the push. The first content-having attempt silently dry-ran (#70). The second got past dry-run but failed at git commit (#73). Only THIS attempt (post-#70-fix + post-#73-fix) actually reached the push.

Recovery (already done for v0.4.0 cut)

Manifest pushed manually as quartermaster (admin; bypasses apply_to_admins: false protection) at commit 595c10f. v0.4.0 draft release remains valid; operator's Gate 3 Publish click is the final step.

The next workflow run (triggered by the manual manifest push) walks from 5f4812d..HEAD → finds only the manifest commit → chore(manifest): is not release-relevant per cc → mode=noop. No loop.

Proposed fix options for v0.4.1+

Three candidate surfaces:

Option Shape Tradeoff
(A) Consumer config: release_token: <PAT> Consumer wires an admin-scope PAT as secrets.RELEASE_TOOLKIT_TOKEN; _release.yml uses it for the push if set, falls back to GITHUB_TOKEN if not Consumer-controlled; documented; doesn't require API/protection changes; requires consumers to provision the PAT
(B) Open the manifest update as a PR The act-step's cut-path opens a follow-up PR for the manifest update instead of direct-pushing to main; merge fires another workflow which detects mode=noop More automation-PRs; consumer needs to auto-merge or accept the PR; introduces a brief window where manifest is out-of-sync with releases
(C) Document the branch-protection requirement docs/integration.md adds a "Branch protection compatibility" section: consumer must either disable apply_to_admins:false for main, OR exempt forgejo-actions from protection, OR use option (A) Lowest engineering cost; pushes the operational complexity to the consumer; might fail on consumers who don't read the docs

I lean (A) + (C): support a consumer-provided PAT for the substrate-mutation actions, document the branch protection compatibility matrix, and clearly explain when (A) is needed.

Sibling silent-failure-class lessons — same shape as #73 / #70

This is the THIRD instance of "slice 1b's _release.yml reusable didn't carry forward an implicit assumption from v0.3.x." Promotes the carry-forward class n=2 → n=3:

  • #41: pull_request.closed expression-engine fail-opens silently
  • #56: manifest-vs-tag-vs-history silent desync
  • #66: prerelease-tag-walk silent window-shift
  • #70: DRY_RUN_INPUT='false' silent dry-run promotion
  • #73: missing git identity on update path
  • #78 (this): cut-path manifest push blocked by branch protection (workflow token is not admin)

Per feedback_reusable_redesign_carry_forward_enumeration.md (banked at n=2), this is now n=3 for the carry-forward class. Empirical promotion-bar met. Surveyor / operator can decide if this warrants a named project pattern + ADR-class capture, or stays as the implementer pre-flight checklist memory entry.

Refs

  • Blocks: v0.4.0 substrate-state completion via workflow alone (manually unblocked at 595c10f)
  • Surfacing run: actions/runs/6238 (decide + act task 13107)
  • Carry-forward class memory: feedback_reusable_redesign_carry_forward_enumeration.md — promotes to n=3 with this instance
  • Sibling silent-failure-class: #41, #56, #66, #70, #73
  • v0.4.0 arc: #52

Filed: 2026-06-25 from live cut-path catch (third dogfood surface in the v0.4.0 arc).

## Symptom The v0.4.0 cut-path workflow completed most of its work but failed at the final manifest-update push to main: ``` remote: To http://forgejo:3000/frankenbit/release-toolkit ! [remote rejected] HEAD -> main (pre-receive hook declined) error: failed to push some refs to 'http://forgejo:3000/frankenbit/release-toolkit' exitcode '1': failure ``` Surfaced live during the v0.4.0 cut (actions/runs/6238 — `decide + act` task 13107). ## What completed before the failure The cut-path's act-step got these substantive actions done: 1. ✅ Layer 1 + 2 + 3 cut-safeguards all passed → `mode=cut` 2. ✅ `draft-release.sh --version 0.4.0` succeeded → Forgejo draft release #121 created at target `5f4812d` 3. ✅ Manifest jq-updated locally + git config set (#73 fix worked) 4. ✅ `git commit` succeeded → `[main 0b20424] chore(manifest): update for v0.4.0` 5. ❌ `git push origin HEAD:main` **rejected** by branch protection pre-receive hook So the user-facing artifact (draft release) IS created, but the substrate-state (manifest) update never landed on main. ## Root cause (verified at source) `main` branch protection in this repo (`/api/v1/repos/frankenbit/release-toolkit/branch_protections/main`): ```json { "branch_name": "main", "enable_push": true, "enable_push_whitelist": false, "block_on_outdated_branch": true, "apply_to_admins": false, ... } ``` The `apply_to_admins: false` setting means **admin pushes bypass the protection** but non-admin pushes (including the workflow's `GITHUB_TOKEN`-authed push) hit the pre-receive hook. The default `GITHUB_TOKEN` in Forgejo Actions does NOT have admin scope — so the cut-path push is blocked. This is the same class as #70 + #73 — slice 1b's `_release.yml` reusable assumes the workflow can push to main, which works when the consumer's repo has no branch protection but fails when the consumer enables it. ## Why slice 2's first-fire didn't catch this Slice 2's first fire was `mode=noop` — the act-step short-circuits before the push. The first content-having attempt silently dry-ran (#70). The second got past dry-run but failed at git commit (#73). Only THIS attempt (post-#70-fix + post-#73-fix) actually reached the push. ## Recovery (already done for v0.4.0 cut) Manifest pushed manually as `quartermaster` (admin; bypasses `apply_to_admins: false` protection) at commit `595c10f`. v0.4.0 draft release remains valid; operator's Gate 3 Publish click is the final step. The next workflow run (triggered by the manual manifest push) walks from `5f4812d..HEAD` → finds only the manifest commit → `chore(manifest):` is not release-relevant per cc → mode=noop. No loop. ## Proposed fix options for v0.4.1+ Three candidate surfaces: | Option | Shape | Tradeoff | |---|---|---| | **(A) Consumer config: `release_token: <PAT>`** | Consumer wires an admin-scope PAT as `secrets.RELEASE_TOOLKIT_TOKEN`; `_release.yml` uses it for the push if set, falls back to `GITHUB_TOKEN` if not | Consumer-controlled; documented; doesn't require API/protection changes; requires consumers to provision the PAT | | **(B) Open the manifest update as a PR** | The act-step's cut-path opens a follow-up PR for the manifest update instead of direct-pushing to main; merge fires another workflow which detects mode=noop | More automation-PRs; consumer needs to auto-merge or accept the PR; introduces a brief window where manifest is out-of-sync with releases | | **(C) Document the branch-protection requirement** | docs/integration.md adds a "Branch protection compatibility" section: consumer must either disable `apply_to_admins:false` for main, OR exempt `forgejo-actions` from protection, OR use option (A) | Lowest engineering cost; pushes the operational complexity to the consumer; might fail on consumers who don't read the docs | I lean **(A) + (C)**: support a consumer-provided PAT for the substrate-mutation actions, document the branch protection compatibility matrix, and clearly explain when (A) is needed. ## Sibling silent-failure-class lessons — same shape as #73 / #70 This is the THIRD instance of "slice 1b's `_release.yml` reusable didn't carry forward an implicit assumption from v0.3.x." Promotes the carry-forward class n=2 → n=3: - **#41**: pull_request.closed expression-engine fail-opens silently - **#56**: manifest-vs-tag-vs-history silent desync - **#66**: prerelease-tag-walk silent window-shift - **#70**: DRY_RUN_INPUT='false' silent dry-run promotion - **#73**: missing git identity on update path - **#78 (this)**: cut-path manifest push blocked by branch protection (workflow token is not admin) Per `feedback_reusable_redesign_carry_forward_enumeration.md` (banked at n=2), this is now **n=3** for the carry-forward class. Empirical promotion-bar met. Surveyor / operator can decide if this warrants a named project pattern + ADR-class capture, or stays as the implementer pre-flight checklist memory entry. ## Refs - **Blocks**: v0.4.0 substrate-state completion via workflow alone (manually unblocked at 595c10f) - **Surfacing run**: actions/runs/6238 (decide + act task 13107) - **Carry-forward class memory**: `feedback_reusable_redesign_carry_forward_enumeration.md` — promotes to n=3 with this instance - **Sibling silent-failure-class**: [#41](https://git.frankenbit.de/frankenbit/release-toolkit/issues/41), [#56](https://git.frankenbit.de/frankenbit/release-toolkit/issues/56), [#66](https://git.frankenbit.de/frankenbit/release-toolkit/issues/66), [#70](https://git.frankenbit.de/frankenbit/release-toolkit/issues/70), [#73](https://git.frankenbit.de/frankenbit/release-toolkit/issues/73) - **v0.4.0 arc**: [#52](https://git.frankenbit.de/frankenbit/release-toolkit/issues/52) Filed: 2026-06-25 from live cut-path catch (third dogfood surface in the v0.4.0 arc).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#78
No description provided.