Keep the Why: Board

Release tags: workflows push with a GitHub App token, protected by environments + tag ruleset

tsk-b6vjaj6rhx03 done KTW Coder 001 (ktw-coder-001)

Oliver chose option B (inbox req-w36jm0bf57f6, 2026-11-05). He is setting up these repo settings himself (inbox items 1–4): a GitHub App keep-the-why-release-tags (Contents: read & write, installed on this repo only); environment pypi with him as required reviewer, deployments from main only; a new environment release with him as required reviewer, deployments from main and tags v*.*.*. Both environments get variable RELEASE_APP_ID and secret RELEASE_APP_PRIVATE_KEY. After this PR merges, a tag ruleset release-tags protects refs/tags/v*, refs/tags/latest, refs/tags/lint-v*, refs/tags/lint-latest and refs/tags/dashboard-v* (restrict creation/update/deletion, block force pushes). Only Repository admin and the App can bypass it.

Why: GITHUB_TOKEN cannot be a ruleset bypass actor. Today publish-lint.yml, publish-dashboard.yml and release.yml push lint-v*, lint-latest, dashboard-v* and latest with it, so the ruleset would break them after the PyPI upload. The App key is stored only in environments that need Oliver's approval. So even a tampered workflow cannot move a tag without his click.

Change (one PR):

  1. publish-lint.yml / publish-dashboard.yml, publish job (already environment: pypi): mint a token with actions/create-github-app-token (pinned to a full SHA with a version comment, like every other action), using app-id: ${{ vars.RELEASE_APP_ID }} and private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}. Push the tags with that token, not with GITHUB_TOKEN, for example via actions/checkout token:, or a push URL built from an env var and never ${{ }} in run:. Drop contents: write from the job if nothing else needs it.
  2. release.yml: put the release job under environment: release. Use the App token for the latest move. gh release create may keep github.token (it creates no tag, because the tag already exists). Keep the ref: pinning and the version gates as they are. The npm job is unchanged; npm staging plus 2FA already gates it.
  3. Git identity for the App pushes: the App's bot user (<slug>[bot] and its noreply address), or keep github-actions[bot]. Pick one and say why in the PR.
  4. context/release-and-distribution.md: supersede da26d6e0 ("no required reviewer / no tag ruleset"). Its revisit condition has been met: more agents with write access now run on the VM. Write a new decision entry for the App + environments + ruleset with the rejected alternatives (A: ruleset only on v*.*.*; C: moving tags by hand; a PAT or deploy key as a plain repo secret). Commit it on its own.
  5. CONTRIBUTING.md release checklist: the maintainer pushes the vX.Y.Z tag himself (agents have write but no bypass). Every publish-lint/publish-dashboard/release run waits for his approval in Actions. Steps 9, 11 and the dashboard paragraph change. docs/security.md: describe the protection (area only, no secret names).
  6. CHANGELOG: one line.

Not in scope: the ruleset itself (Oliver clicks it after the merge), no other workflows. The first real test is the next release (dashboard 0.11.43). Note in the PR what to watch in that run.

Order: show me the design note and the diff before the first push. This card is independent of #760, so you can start now. Reviews by all three plus CI as usual.

Notes

  1. KTW Coder 001 (ktw-coder-001) ·

    Design: release tags through the App token

    Token. actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with permission-contents: write; revoked at job end.

    Finding, needs a ruling: in v3, app-id is deprecated ("Use 'client-id' instead"). I propose client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} instead of app-id: ${{ vars.RELEASE_APP_ID }}, otherwise every run carries a deprecation warning. That changes Oliver's settings item: the variable then holds the App's Client ID (Iv23…, on the App's settings page), not the numeric App ID. If he has already set RELEASE_APP_ID, I use app-id and note the switch as a follow-up. Which one?

    publish-lint / publish-dashboard (publish job):

    • Order: download artifact, PyPI publish, mint token, actions/checkout with token:, tag, push.
    • Today's checkout comes first and persists GITHUB_TOKEN into .git/config while the third-party PyPI action runs. After the change, no write credential is in the job while a third-party action other than checkout or app-token runs, and the token only exists once the upload has succeeded.
    • No token in a push URL and no ${{ }} in run:: checkout's credential helper carries it.
    • permissions: contents: read + id-token: write. contents: write goes, because nothing else in the job writes.

    release.yml:

    • release job under environment: release. Token minted as the first step, checkout with token: and the same ref:.
    • The steps in between are this repository's own scripts in the same approved job, so the longer window buys an attacker nothing they couldn't get by editing those steps. The latest push uses that checkout.
    • gh release create keeps github.token, and contents: write stays for it. Version gates, ref: pinning and the npm job are unchanged.

    Git identity: the App's bot user, keep-the-why-release-tags[bot] with <id>+keep-the-why-release-tags[bot]@users.noreply.github.com. The id is read at runtime with gh api /users/<slug>[bot], using the app-slug output, nothing hardcoded.

    • Why: lint-v* and dashboard-v* are annotated tags (-m), and they carry a tagger. With github-actions[bot], the tagger would name an actor that can no longer push those tags, so the tagger and the audit log would disagree.
    • latest and lint-latest are lightweight, so no identity is needed. release.yml sets no git config.

    Docs:

    • context/release-and-distribution.md: da26d6e0 becomes superseded, with the revisit trigger met (more agents with write access on the VM). A new decision entry covers App + two environments + ruleset. Rejected alternatives:
    • A, ruleset only on v*.*.*: lint, dashboard and latest stay movable by every write account;
    • C, moving tags by hand: the traceability "same run, never by hand" is lost;
    • PAT or deploy key as a plain repo secret: readable by any workflow on any branch, so no approval gate;
    • GITHUB_TOKEN cannot be a bypass actor.
    • Its own commit.
    • CONTRIBUTING.md:
    • steps 9 and 11: each run waits for Oliver's approval; the vX.Y.Z tag is pushed by him;
    • the dashboard paragraph;
    • the closing paragraph: an agent may dispatch, but it neither approves nor pushes v*.
    • docs/security.md: a bullet under "How this repository is protected", area only, no secret names. CHANGELOG: one line.

    What to watch in the first run (dashboard 0.11.43):

    • the approval prompt for pypi;
    • the token step finds the variable and the secret in the environment;
    • dashboard-v0.11.43 is created by keep-the-why-release-tags[bot];
    • the ruleset is already active, so the run is the bypass test.
    • Failure after the upload (the tag push is refused): the version is on PyPI without a tag, and a re-run fails at PyPI. Recovery: Oliver pushes the tag by hand as admin, on the commit of the run. This goes into the PR description.

    Side note: the publish-* header comments say "lightweight tag", but -m makes it annotated. Fix the word along the way?