diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml index ec068d3..370dc3f 100644 --- a/.github/workflows/toolchain.yml +++ b/.github/workflows/toolchain.yml @@ -71,15 +71,51 @@ jobs: # Decisive, and captured once so the reporter's own failure is told # apart from a stale pin: a failed or silent mise yields an empty - # substitution, which compared straight against {} would raise the - # pin-lag error and so name the wrong cause. Testing it inside `if` is - # what keeps errexit from exiting here with no annotation at all. + # substitution, and an empty result must never be read as a verdict + # about the pins. Testing it inside `if` is what keeps errexit from + # exiting here with no annotation at all. if ! outdated="$(mise outdated --bump --local --json)" || [ -z "$outdated" ]; then echo "::error::mise could not report pin status — the reporter failed, not the pins" exit 1 fi - if [ "$outdated" != "{}" ]; then + # Every jq call is guarded, and each names its own failure. Unguarded, + # jq would exit under errexit with no annotation; sharing one message + # would name the wrong cause. Both are the bare and misattributed + # exits this step exists to never produce. + if ! count="$(jq -r 'length' <<<"$outdated")"; then + echo "::error::jq could not process mise's output — the reporter failed, not the pins" + exit 1 + fi + + # A verdict is only readable from a payload that describes the pins. + # Reporting without installing means mise emits an entry per pinned + # tool, so nothing to count is the reporter having seen nothing — an + # unreachable or renamed mise.toml, say — rather than a clean bill. + # This is the direction that must fail loudly: a green run notifies + # no one, so a report gone blind would stay that way indefinitely. + if [ "$count" -eq 0 ]; then + echo "::error::mise reported no pinned tools — the reporter failed, not the pins" + exit 1 + fi + + # Keyed on bump rather than on the payload being empty, because the + # shape of --json depends on installation state: reporting without + # installing yields an entry per tool carrying a null bump, which + # makes an emptiness test true on every run. bump is non-null exactly + # when a pin trails upstream, in either state — verified against the + # mise release pinned above, which is where to re-check it. The count + # guard does not cover a renamed or dropped bump: entries would still + # be present, jq would match none of them, and the report would go + # quiet. So a run that stops failing across a mise upgrade is the + # symptom to distrust. + if ! behind="$(jq -r 'to_entries[] | select(.value.bump != null) | "\(.key) \(.value.requested) -> \(.value.bump)"' <<<"$outdated")"; then + echo "::error::jq could not process mise's output — the reporter failed, not the pins" + exit 1 + fi + + if [ -n "$behind" ]; then echo "::error::Toolchain pins are behind upstream — review per SECURITY.md" + printf '%s\n' "$behind" exit 1 fi diff --git a/SECURITY.md b/SECURITY.md index 97bac45..e3a42a3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,7 +43,7 @@ field-docket holds no credentials of its own. It authenticates to nothing, reads - **CI pins its own toolchain; `mise.lock` covers local development.** Tool versions live in `mise.toml` with their resolved download URLs and checksums in the committed `mise.lock`, and `mise install` verifies against it — but no CI job installs through mise, so the lockfile governs a contributor's machine rather than the build. CI pins independently: Go comes from `go.mod` via `actions/setup-go`, golangci-lint and GoReleaser from their actions' explicit `version:` inputs. Read the lockfile as reproducibility for local work, not as a control on the published artifacts. - **Actions are pinned by commit digest.** Every `uses:` in every workflow names a full commit SHA with the version as a trailing comment — a tag can be repointed, a commit SHA cannot. - **Update automation covers Go modules and GitHub Actions.** Dependabot watches both weekly, and bumps the action SHAs along with their version comments. -- **The mise toolchain is reviewed manually.** No update bot covers `mise.toml`, so the **Toolchain currency** workflow checks the pins against upstream weekly and **fails when any is behind**. The deliberate failure is what surfaces the report at all, since `mise outdated` exits 0 whether or not a pin has moved. How far that failure reaches is not a property of this repository: GitHub notifies you about workflow runs *you* triggered, on every completed run unless you select the "only notify for failed workflows" option, and for a scheduled run the person notified is the workflow's creator — or whoever last changed its cron or re-enabled it. Keeping this report in its own workflow rather than alongside the scan above is what lets a red result name which of the two fired, since the run list identifies a run by its workflow. It blocks nothing (neither workflow is a required check). `just toolchain-outdated` runs the same query locally, against whatever mise is on your `PATH` rather than the pinned one, and it reports without failing. The pinned version of mise itself lives in that workflow rather than in `mise.toml`, so nothing reports it; it moves only when a maintainer moves it. +- **The mise toolchain is reviewed manually.** No update bot covers `mise.toml`, so the **Toolchain currency** workflow checks the pins against upstream weekly and **fails when any is behind**. The deliberate failure is what surfaces the report at all, since `mise outdated` exits 0 whether or not a pin has moved. A red run therefore means one of three things, and its annotation says which: a pin is behind and is named, or mise could not report, or its output could not be processed. Read the last two as the reporter having failed rather than as a verdict about the pins — and note that the report is not able to detect a change in mise's own output schema, which would make it fall quiet rather than loud, so a run that stops failing across a mise upgrade is worth distrusting. GitHub notifies you about workflow runs *you* triggered, on every completed run unless you select the "only notify for failed workflows" option, and for a scheduled run the person notified is the workflow's creator — or whoever last changed its cron or re-enabled it. Keeping this report in its own workflow rather than alongside the scan above is what lets a red result name which of the two fired, since the run list identifies a run by its workflow. It blocks nothing (neither workflow is a required check). `just toolchain-outdated` runs the same query locally, against whatever mise is on your `PATH` rather than the pinned one, and it reports without failing. The pinned version of mise itself lives in that workflow rather than in `mise.toml`, so nothing reports it; it moves only when a maintainer moves it. - **Scheduled scanning stops firing in a quiet repository.** In a public repository GitHub disables scheduled workflows after 60 days without repository activity. That is one repository-level clock rather than one per workflow, so **Vulnerability scan** and **Toolchain currency** stop together — read a gap in either one's weekly runs as a symptom, not as a clean result. Re-enabling one moves its notifications, per the bullet above. This describes a detector paired with a human response, not a project that is continuously free of known advisories. Go patch releases routinely fix reachable standard-library symbols, so the scan going red is expected periodically and is resolved by a maintainer bumping the pinned toolchain.