Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/rules/toolchain-ci-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ paths:
- ".goreleaser.yaml"
- ".github/workflows/ci.yml"
- ".github/workflows/vuln.yml"
- ".github/workflows/toolchain.yml"
- ".github/workflows/release.yml"
- "justfile"
---
Expand All @@ -25,7 +26,7 @@ Some tools are pinned twice — in `mise.toml` (the local binary) and as an acti

The two spellings differ and that is not drift: mise pins a bare patch, both workflow inputs carry the same patch with a leading `v`. Only one of the two actions actually requires it — `golangci-lint-action` validates its input against a `v`-anchored pattern and throws on a bare patch, while `goreleaser-action` accepts either and prepends the `v` itself (read from each action's source at the SHA pinned here; if either changed, the symptom would be a loud failure at install rather than a wrong binary). Matching the two keeps one convention rather than two.

**Nothing detects a mismatch between a `mise.toml` pin and its workflow input.** Dependabot bumps an action's `uses:` SHA, never its inputs, and the weekly `toolchain-report` job reads `mise.toml` alone — so bumping the mise pin and forgetting the workflow leaves that report green the following week while the drift stands. The report prompts the bump; nothing prompts the other half of it. Review is the only guard, which is why this is written down rather than left to a gate.
**Nothing detects a mismatch between a `mise.toml` pin and its workflow input.** Dependabot bumps an action's `uses:` SHA, never its inputs, and the weekly `toolchain-report` job in `.github/workflows/toolchain.yml` reads `mise.toml` alone — so bumping the mise pin and forgetting the workflow leaves that report green the following week while the drift stands. The report prompts the bump; nothing prompts the other half of it. Review is the only guard, which is why this is written down rather than left to a gate.

## go.mod `go` directive tracks the mise Go pin

Expand All @@ -47,7 +48,7 @@ Nothing type-checks that string. The Go linker ignores an `-X` naming a symbol t

`mise.toml` sets `lockfile = true`, and `mise.lock` records each tool's resolved URL and checksum per platform. It is committed, and the two files must move together: run `mise lock` and commit the result in the same commit as any `mise.toml` version change.

**Nothing in CI enforces this.** No workflow job installs through mise — the only `jdx/mise-action` call, in `vuln.yml`'s toolchain-report job, sets `install: false`, and CI takes Go from `go.mod` and its other tools from action inputs. So a stale `mise.lock` is caught by review or not at all, which is why it is written here as a rule rather than left to a gate.
**Nothing in CI enforces this.** No workflow job installs through mise — the only `jdx/mise-action` call, in `toolchain.yml`'s toolchain-report job, sets `install: false`, and CI takes Go from `go.mod` and its other tools from action inputs. So a stale `mise.lock` is caught by review or not at all, which is why it is written here as a rule rather than left to a gate.

Locally, a stale lock fails silently in an unhelpful direction: `mise install` updates an existing lockfile in place, so a bump followed by an install quietly rewrites `mise.lock` and hands you a diff you did not ask for. Review that diff rather than assuming your install could not have touched it. (`mise lock` is what *creates* the lockfile; `mise install` only maintains one that exists.)

Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/toolchain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Toolchain currency

# Separate from the vulnerability scan on purpose. That workflow reports an
# advisory; this one reports that a pin trails upstream, which is routine — and
# GitHub's Actions run list identifies a run by its workflow, so one workflow
# carrying both would leave a red result meaning either thing. If a failure
# notification turns out to name the job as well, the split still buys a
# legible run history and costs nothing.
on:
schedule:
# Weekly. Scheduled workflows here stop firing in a quiet repository —
# SECURITY.md § Build and CI supply chain carries the condition, and what a
# gap in these runs does and does not mean.
- cron: "17 6 * * 1"
workflow_dispatch:

permissions:
contents: read

# No cancel-in-progress: a manual dispatch and the weekly cron share the default
# branch's ref, and cancelling the scheduled run because someone probed the
# report would drop the run that exists to fire when nothing else does.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

jobs:
toolchain-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Dependabot bumps the action's uses: SHA but never its version: input, so
# the mise release pinned here moves only when a maintainer moves it — and
# it is the binary that computes the report below, which makes it the one
# pin whose drift the report cannot surface.
- uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4
with:
version: 2026.8.3
# Report on the pinned versions; do not install them.
install: false

# The mise-managed toolchain is under no update bot (no ecosystem covers
# mise.toml), so this run is the mechanism behind the manual review
# posture recorded in SECURITY.md.
#
# --bump is load-bearing. Every pin in mise.toml is an exact version, and
# plain `mise outdated` compares against the latest release matching the
# requested spec — for an exact pin, itself. Without --bump this step
# reports "All tools are up to date" every week regardless of upstream,
# which is worse than no report at all.
#
# The step fails when there is something to report, because `mise
# outdated` exits 0 whether or not a pin has moved — a step that always
# succeeds is a report nobody is told about. How far that failure actually
# reaches depends on a notification setting outside this repository's
# control; SECURITY.md § Build and CI supply chain records the dependency.
# Nothing is blocked either way: this workflow is not a required check.
- name: Report outdated toolchain pins
# `shell: bash` fixes the flag set (-eo pipefail) in this file rather
# than inheriting the platform default, so the failure semantics the
# guards below are written against are stated rather than assumed.
shell: bash
run: |
# Cosmetic: renders the report onto the run page, and deliberately not
# allowed to decide the step. Under errexit a mise failure here would
# exit before either annotation below, leaving the reporter's own
# breakage as the one failure this workflow cannot explain — the exact
# gap it exists to close. The authoritative call is the guarded one
# below, so a real breakage still surfaces, and surfaces named.
mise outdated --bump --local | tee -a "$GITHUB_STEP_SUMMARY" || true

# 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.
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
echo "::error::Toolchain pins are behind upstream — review per SECURITY.md"
exit 1
fi
50 changes: 4 additions & 46 deletions .github/workflows/vuln.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ on:
push:
branches: [main]
schedule:
# Weekly. GitHub disables scheduled workflows after 60 days without
# repository activity, so a long-quiet repository stops scanning silently —
# read a gap in these runs as a symptom, not as an absence of findings.
# Weekly. Scheduled workflows here stop firing in a quiet repository, on one
# repository-level clock rather than one per workflow — SECURITY.md § Build
# and CI supply chain carries the condition, and why a gap in these runs is
# a symptom rather than an absence of findings.
- cron: "17 6 * * 1"
workflow_dispatch:

Expand Down Expand Up @@ -43,46 +44,3 @@ jobs:
# scan's stdlib baseline is the same version the binary is built with.
- name: Scan dependencies and standard library
run: go tool govulncheck ./...

# Toolchain currency is its own job and runs on schedule/dispatch only: it
# reports on pins rather than gating a change, so there is nothing for it to
# tell a pull request that the pull request could act on.
toolchain-report:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3
with:
version: 2026.7.7
# Report on the pinned versions; do not install them.
install: false

# The mise-managed toolchain is under no update bot (no ecosystem covers
# mise.toml), so this run is the mechanism behind the manual review
# posture recorded in SECURITY.md.
#
# --bump is load-bearing. Every pin in mise.toml is an exact version, and
# plain `mise outdated` compares against the latest release matching the
# requested spec — for an exact pin, itself. Without --bump this step
# reports "All tools are up to date" every week regardless of upstream,
# which is worse than no report at all.
#
# The step fails when there is something to report, and that is the whole
# notification mechanism: `mise outdated` exits 0 either way, and GitHub
# notifies on failed scheduled runs and stays silent on successful ones —
# so a step that always succeeds is a report nobody is told about. A red
# weekly run is the review trigger; clear it by bumping the pins (and
# regenerating mise.lock), or by deciding not to. Nothing is blocked
# either way: this workflow is not a required check.
- name: Report outdated toolchain pins
# `shell: bash` adds pipefail, without which a mise failure in the
# reporting pipeline would be masked by tee's exit status.
shell: bash
run: |
mise outdated --bump --local | tee -a "$GITHUB_STEP_SUMMARY"
if [ "$(mise outdated --bump --local --json)" != "{}" ]; then
echo "::error::Toolchain pins are behind upstream — review per SECURITY.md"
exit 1
fi
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ just install # Install the binary to ~/.local/bin

### Dependencies and the toolchain

Go modules are watched by Dependabot and scanned by `govulncheck` in CI (on every change, and weekly). The mise-managed toolchain has no update bot — no ecosystem covers `mise.toml` — so it is reviewed by hand. The weekly scheduled run fails when a pin is behind upstream, and that failure is the prompt; `just toolchain-outdated` runs the same check locally. It gates nothing — that workflow is not a required check.
Go modules are watched by Dependabot and scanned by `govulncheck` in CI (on every change, and weekly). The mise-managed toolchain has no update bot — no ecosystem covers `mise.toml` — so it is reviewed by hand. The **Toolchain currency** workflow — kept apart from **Vulnerability scan** so a red result names which of the two fired — fails weekly when a pin is behind upstream, and that failure is the prompt; `just toolchain-outdated` runs the same query locally, against whatever mise is on your `PATH`. It gates nothing — that workflow is not a required check.

Several pins move in pairs; `.claude/rules/toolchain-ci-parity.md` records which and why. `SECURITY.md` describes the full supply-chain posture, including what the scanning does and does not guarantee.

Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ field-docket holds no credentials of its own. It authenticates to nothing, reads

## Build and CI supply chain

- **Dependencies and the standard library are scanned for known vulnerabilities.** [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) runs on every pull request, on pushes to `main`, and weekly on a schedule. It reports advisories whose vulnerable symbols are reachable from this code. The scan is **advisory, not blocking** — it is not a required status check, so it informs review rather than gating merges. Note also that GitHub disables scheduled workflows after 60 days without repository activity: a gap in the weekly runs means the schedule stopped, not that nothing was found.
- **Dependencies and the standard library are scanned for known vulnerabilities.** [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) runs on every pull request, on pushes to `main`, and weekly on a schedule. It reports advisories whose vulnerable symbols are reachable from this code. The scan is **advisory, not blocking** — it is not a required status check, so it informs review rather than gating merges.
- **Build-time dependencies are checksum-verified.** Go modules are verified against the checksum database and `go.sum`, re-hashed in CI by `go mod verify`, and held tidy by `go mod tidy -diff` so entries no longer required cannot linger. `govulncheck` itself is a `go.mod` tool dependency, so the scanner is covered by the same verification as everything else.
- **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 weekly run also checks the pins against upstream and **fails when any is behind** — a red scheduled run is the review trigger, since that is what GitHub actually notifies on. It blocks nothing (the workflow is not a required check). `just toolchain-outdated` runs the same check locally. The pinned version of mise itself lives in the workflows 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. 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.
- **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.

Expand Down
Loading