From e03dc87106168b185a0ae8092c13a34f465dc566 Mon Sep 17 00:00:00 2001 From: nolte Date: Mon, 17 Aug 2026 22:37:31 +0200 Subject: [PATCH 1/2] feat(ci): mint an App token for the presentation-branch refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing `main` under GITHUB_TOKEN cannot work. GitHub rejects a push from a GitHub App identity that creates or updates any file under .github/workflows/ unless the installation holds the `workflows` permission; GITHUB_TOKEN is such an identity, and the workflow permissions: block exposes no `workflows` scope to grant it. The refresh resets `main` to the release tag, and that diff carries workflow files whenever `main` has fallen behind — seven of them at the time of writing. Observed in run 32065009956, dispatched right after #43 fixed the empty `from_branch`: ! [remote rejected] 57450b5 -> main (refusing to allow a GitHub App to create or update workflow `.github/workflows/release-cd-refresh-master.yml` without `workflows` permission) `main` carries no branch protection at all, so this is not a protection problem and no permissions: change can lift it. Forward vars.PORTFOLIO_APP_ID and secrets.PORTFOLIO_APP_PRIVATE_KEY so the reusable mints a short-lived installation token for the push. This reverses the reasoning removed in b16fc1d, which called the App wiring an audit-trail improvement deferred until gh-plumbing#330 Phase 2. That was wrong for this repository: the wiring is the only path that works today. PREREQUISITE, NOT YET VERIFIED: the installation behind PORTFOLIO_APP_ID must hold both `contents: write` and `workflows: write` on this repository. The reusable's mint step is continue-on-error, which only covers a failed mint — a successful mint with insufficient permissions has no fallback to secrets.token and fails the push. Confirm the installation permissions before merging; this PR stays draft until then. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A4KA8swD1ZzDqknDV5TUxx --- .../workflows/release-cd-refresh-master.yml | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-cd-refresh-master.yml b/.github/workflows/release-cd-refresh-master.yml index 85d3164..b43b8f8 100644 --- a/.github/workflows/release-cd-refresh-master.yml +++ b/.github/workflows/release-cd-refresh-master.yml @@ -21,21 +21,28 @@ jobs: uses: nolte/gh-plumbing/.github/workflows/reusable-release-cd-refresh-master.yml@d51e51ec3ec17ceea09fe9eb40ac00857b6fa1be # v2.0.0 secrets: token: ${{ secrets.GITHUB_TOKEN }} + app-private-key: ${{ secrets.PORTFOLIO_APP_PRIVATE_KEY }} permissions: contents: write with: target_branch: main # `inputs.tag` on the manual path, the release payload on the automatic # one. Mirrors the reference wiring in nolte/gh-plumbing's own caller. - # - # The reusable also accepts `app-id` / `app-private-key` to mint an App - # installation token instead of pushing as GITHUB_TOKEN. Deliberately not - # wired here yet: `vars.PORTFOLIO_APP_ID` exists, but whether that - # installation holds `contents: write` on this repository is unverified, - # and the reusable's `continue-on-error` on the mint step only covers a - # failed mint — a successful mint without push rights would break the - # today-green automatic path. Wire it in a separate change once the - # installation permissions are confirmed; it becomes mandatory when - # Phase 2 of gh-plumbing#330 lands `restrictions.apps` on the - # presentation branch. from_branch: ${{ inputs.tag || github.event.release.tag_name }} + # The App token is a hard requirement here, not an audit-trail nicety. + # GitHub rejects a push from a GitHub App identity that creates or updates + # any file under .github/workflows/ unless the installation holds the + # `workflows` permission — and GITHUB_TOKEN is such an identity, with no + # `workflows` scope available in the permissions: block above to grant it. + # Refreshing `main` resets it to the release tag, and that diff carries + # workflow files whenever `main` has fallen behind, so the push is + # rejected regardless of branch protection (`main` is unprotected here and + # it was rejected anyway). Observed in run 32065009956; recorded as a + # platform constraint in spec/project/workflow-health/. + # + # The installation therefore needs BOTH `contents: write` and + # `workflows: write`. Note the reusable's mint step is + # `continue-on-error`, which only covers a failed mint: if the mint + # succeeds but the installation lacks a permission, there is no fallback + # to secrets.token and the push fails outright. + app-id: ${{ vars.PORTFOLIO_APP_ID }} From 339ab5a660e38b95e53ab7baa9bb5012647ee1b4 Mon Sep 17 00:00:00 2001 From: nolte Date: Mon, 17 Aug 2026 23:43:09 +0200 Subject: [PATCH 2/2] fix(ci): fail loudly on half-configured App credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the App wiring degrades silently. The reusable's mint step is `continue-on-error`, so if `PORTFOLIO_APP_ID` is set but `PORTFOLIO_APP_PRIVATE_KEY` is missing or stale — a key rotated in the App settings without updating the repo secret — the mint fails, the error is swallowed, `outputs.token` is empty, and the push falls back to GITHUB_TOKEN. It then dies with exactly the `refusing to allow a GitHub App to create or update workflow ...` rejection this PR wires the App token in to avoid, while the real cause appears only as an annotation on a step that didn't fail. Check both values in the existing validate_ref job, which already runs on both trigger paths. Both set or both empty pass; any mismatch fails with a message naming which half is missing. Verified by extracting the step's script and running the four combinations: true/true and false/false pass, true/false and false/true fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A4KA8swD1ZzDqknDV5TUxx --- .../workflows/release-cd-refresh-master.yml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/release-cd-refresh-master.yml b/.github/workflows/release-cd-refresh-master.yml index c144bf8..66acd4b 100644 --- a/.github/workflows/release-cd-refresh-master.yml +++ b/.github/workflows/release-cd-refresh-master.yml @@ -32,6 +32,34 @@ jobs: runs-on: ubuntu-latest permissions: {} steps: + # The reusable's App-token mint step is `continue-on-error`, so a + # half-configured pair degrades silently: a rotated private key that + # wasn't copied into the repo secret makes the mint fail, the error is + # swallowed, and the push falls back to GITHUB_TOKEN — dying with the + # exact `refusing to allow a GitHub App ...` rejection the App token was + # wired in to avoid, with the real cause visible only as an annotation on + # a step that didn't fail. Fail here instead, where the message is plain. + - name: Require both App credentials or neither + env: + HAS_APP_ID: ${{ vars.PORTFOLIO_APP_ID != '' }} + HAS_APP_KEY: ${{ secrets.PORTFOLIO_APP_PRIVATE_KEY != '' }} + run: | + set -euo pipefail + + if [ "${HAS_APP_ID}" != "${HAS_APP_KEY}" ]; then + echo "::error::App credentials are half-configured (PORTFOLIO_APP_ID set: ${HAS_APP_ID}," \ + "PORTFOLIO_APP_PRIVATE_KEY set: ${HAS_APP_KEY}). The reusable would swallow the failed" \ + "mint and push as GITHUB_TOKEN, which cannot write under .github/workflows/." \ + "Set both, or unset both to fall back deliberately." >&2 + exit 1 + fi + + if [ "${HAS_APP_ID}" = "true" ]; then + echo "App credentials present; the refresh will push with an installation token." + else + echo "No App credentials configured; the refresh will push with GITHUB_TOKEN." + fi + - name: Reject a ref that isn't a release tag env: REQUESTED_TAG: ${{ inputs.tag }}