Notice if the release job loses the permission that lets it publish #109

Merged
JMR-dev merged 2 commits from test/release-permission-guard into main 2026-08-25 20:59:04 +00:00
JMR-dev commented 2026-08-25 20:38:16 +00:00 (Migrated from github.com)

Closes #107.

build.yml's release job declares contents: write, and nothing checked it. With the declaration removed:

check result
actionlint (pinned digest) clean
CodeQL missing-workflow-permissions silent — a narrower permission isn't an alert
every gating status check passes

The job is if: startsWith(github.ref, 'refs/tags/v'), so no PR and no merge can exercise it. The first thing that notices is a release failing to publish.

It's also one deletion away that looks like tidying: #106 just put a top-level permissions: contents: read directly above it, so a reader could reasonably take the job-level block for a duplicate. It's an override — and a comment saying so isn't a check.

BackupExclusionsTest is the precedent: configuration rather than code, load-bearing, unguarded because nothing compiles it.

The part worth reading twice

The test passed. Then the mutation that's supposed to redden it didn't:

BUILD SUCCESSFUL in 614ms

Gradle can't infer that a test depends on a file outside the source set, so the task stayed UP-TO-DATE and the test never ran. Under --rerun-tasks the same mutation failed it properly — which is the tell: the assertion was right and the wiring was not.

A guard that doesn't re-run when its subject changes is not a guard. It's a test that will be green on the day it matters, which is worse than no test because it reads as cover.

Fixed by declaring the workflow as a task input, then verified the whole way round without --rerun-tasks: mutate → task re-runs and fails; restore → task re-runs and passes.

What it pins, and what it doesn't

It asserts the declaration exists in the release job's block. It cannot assert a release actually publishes — that needs a tag push, which is the thing no PR can do. A tripwire against silent removal, not proof the path works. The KDoc says so.

Closes #107. `build.yml`'s `release` job declares `contents: write`, and **nothing checked it**. With the declaration removed: | check | result | |---|---| | actionlint (pinned digest) | clean | | CodeQL `missing-workflow-permissions` | silent — a *narrower* permission isn't an alert | | every gating status check | passes | The job is `if: startsWith(github.ref, 'refs/tags/v')`, so no PR and no merge can exercise it. **The first thing that notices is a release failing to publish.** It's also one deletion away that *looks like tidying*: #106 just put a top-level `permissions: contents: read` directly above it, so a reader could reasonably take the job-level block for a duplicate. It's an override — and a comment saying so isn't a check. `BackupExclusionsTest` is the precedent: configuration rather than code, load-bearing, unguarded because nothing compiles it. ## The part worth reading twice The test passed. Then the mutation that's supposed to redden it **didn't**: ``` BUILD SUCCESSFUL in 614ms ``` Gradle can't infer that a test depends on a file outside the source set, so the task stayed `UP-TO-DATE` and the test never ran. Under `--rerun-tasks` the same mutation failed it properly — which is the tell: **the assertion was right and the wiring was not.** A guard that doesn't re-run when its subject changes is not a guard. It's a test that will be green on the day it matters, which is worse than no test because it reads as cover. Fixed by declaring the workflow as a task input, then verified the whole way round **without** `--rerun-tasks`: mutate → task re-runs and fails; restore → task re-runs and passes. ## What it pins, and what it doesn't It asserts the declaration exists in the `release` job's block. It **cannot** assert a release actually publishes — that needs a tag push, which is the thing no PR can do. A tripwire against silent removal, not proof the path works. The KDoc says so.
Sign in to join this conversation.