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):
publish-lint.yml/publish-dashboard.yml, publish job (alreadyenvironment: pypi): mint a token withactions/create-github-app-token(pinned to a full SHA with a version comment, like every other action), usingapp-id: ${{ vars.RELEASE_APP_ID }}andprivate-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}. Push the tags with that token, not withGITHUB_TOKEN, for example viaactions/checkouttoken:, or a push URL built from an env var and never${{ }}inrun:. Dropcontents: writefrom the job if nothing else needs it.release.yml: put thereleasejob underenvironment: release. Use the App token for thelatestmove.gh release createmay keepgithub.token(it creates no tag, because the tag already exists). Keep theref:pinning and the version gates as they are. The npm job is unchanged; npm staging plus 2FA already gates it.- Git identity for the App pushes: the App's bot user (
<slug>[bot]and its noreply address), or keepgithub-actions[bot]. Pick one and say why in the PR. 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 onv*.*.*; C: moving tags by hand; a PAT or deploy key as a plain repo secret). Commit it on its own.CONTRIBUTING.mdrelease checklist: the maintainer pushes thevX.Y.Ztag himself (agents have write but no bypass). Everypublish-lint/publish-dashboard/releaserun 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).- 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
KTW Coder 001 (ktw-coder-001) ·
Design: release tags through the App token
Token.
actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0withpermission-contents: write; revoked at job end.Finding, needs a ruling: in v3,
app-idis deprecated ("Use 'client-id' instead"). I proposeclient-id: ${{ vars.RELEASE_APP_CLIENT_ID }}instead ofapp-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 setRELEASE_APP_ID, I useapp-idand note the switch as a follow-up. Which one?publish-lint / publish-dashboard (publish job):
- Order: download artifact, PyPI publish, mint token,
actions/checkoutwithtoken:, tag, push. - Today's checkout comes first and persists
GITHUB_TOKENinto.git/configwhile 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
${{ }}inrun:: checkout's credential helper carries it. permissions: contents: read+id-token: write.contents: writegoes, because nothing else in the job writes.
release.yml:
releasejob underenvironment: release. Token minted as the first step, checkout withtoken:and the sameref:.- 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
latestpush uses that checkout. gh release createkeepsgithub.token, andcontents: writestays 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 withgh api /users/<slug>[bot], using theapp-slugoutput, nothing hardcoded.- Why:
lint-v*anddashboard-v*are annotated tags (-m), and they carry a tagger. Withgithub-actions[bot], the tagger would name an actor that can no longer push those tags, so the tagger and the audit log would disagree. latestandlint-latestare lightweight, so no identity is needed. release.yml sets no git config.
Docs:
context/release-and-distribution.md: da26d6e0 becomessuperseded, 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_TOKENcannot be a bypass actor.- Its own commit.
CONTRIBUTING.md:- steps 9 and 11: each run waits for Oliver's approval; the
vX.Y.Ztag 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.43is created bykeep-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
-mmakes it annotated. Fix the word along the way?- Order: download artifact, PyPI publish, mint token,