From 931e30804db9f49d125a1cec096fb7fd2650596f Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 09:36:43 -0700 Subject: [PATCH 1/6] build: bump just to 1.58.0 The weekly toolchain-currency report is the only thing watching the mise pins, and it can only signal by failing. Clearing a known-benign lag keeps the next genuine one visible rather than lost in a run that is already red. --- mise.lock | 44 ++++++++++++++++++++++---------------------- mise.toml | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/mise.lock b/mise.lock index ca1d58e..2ae6fc6 100644 --- a/mise.lock +++ b/mise.lock @@ -125,43 +125,43 @@ url_api = "https://api.github.com/repos/goreleaser/goreleaser/releases/assets/49 provenance = "github-attestations" [[tools.just]] -version = "1.57.0" +version = "1.58.0" backend = "aqua:casey/just" [tools.just."platforms.linux-arm64"] -checksum = "sha256:f225044a81adea6e0b3a8b9370aaf374e6af76c8735ae263ac993df55fd137ec" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976912" +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510099" [tools.just."platforms.linux-arm64-musl"] -checksum = "sha256:f225044a81adea6e0b3a8b9370aaf374e6af76c8735ae263ac993df55fd137ec" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976912" +checksum = "sha256:748237128c4c40cbdabc65e841d05ceba13cc23a91eaba395495894c1d9764df" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510099" [tools.just."platforms.linux-x64"] -checksum = "sha256:45b548094283cb9739af8f13273b8cddeee869f5b4ef2bb631b1f311cb566155" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976790" +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509978" [tools.just."platforms.linux-x64-musl"] -checksum = "sha256:45b548094283cb9739af8f13273b8cddeee869f5b4ef2bb631b1f311cb566155" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976790" +checksum = "sha256:4a5cc2f53e6f0f8c59092a6cc38291eb729d46a7dd95d3ae582008881b84931d" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509978" [tools.just."platforms.macos-arm64"] -checksum = "sha256:0381db216c2f97ce31d838a1562c1064dfbfa73f5a8a81581338a2cd9217df47" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976803" +checksum = "sha256:50ae3e996c974a0bf32ea7d10f495070df33f1b43e0616b2769e3d4821ed8f48" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500509965" [tools.just."platforms.macos-x64"] -checksum = "sha256:5e6ade3698095576274b2b32cc9e5d467185e8e40b04949004c04cc3d7e962dc" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-x86_64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481976824" +checksum = "sha256:9a09cfef66aaa79da58203970103a0684307716caaabd3e9844cacc4dc0f4023" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500510084" [tools.just."platforms.windows-x64"] -checksum = "sha256:4c7391d17cb1d17b758b52004ee6411372b8a13ff37c3c9b9031625cb6026e09" -url = "https://github.com/casey/just/releases/download/1.57.0/just-1.57.0-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/casey/just/releases/assets/481977734" +checksum = "sha256:759f16fb7aa17c5c8b9594b6d4a8c1a6630dfd042cf2b3ff84841454d3d188dc" +url = "https://github.com/casey/just/releases/download/1.58.0/just-1.58.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/casey/just/releases/assets/500511374" [[tools.lefthook]] version = "2.1.10" diff --git a/mise.toml b/mise.toml index 41c1995..3521042 100644 --- a/mise.toml +++ b/mise.toml @@ -10,7 +10,7 @@ go = "1.26.5" # golangci-lint-action `version:` — treat them as one atomic value so local and # CI lint run the same binary against the same config schema. golangci-lint = "2.12.2" -just = "1.57.0" +just = "1.58.0" lefthook = "2.1.10" # goreleaser backs `just release-check`. It is pinned here rather than assumed # present because the release path is reachable locally, not only from the tag From b12177a95db246404cfd3593b76e2d464e00d7e4 Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 09:40:08 -0700 Subject: [PATCH 2/6] ci: split the toolchain currency report out of the vulnerability scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan reports an advisory; the currency report reports that a pin moved upstream, and it can only surface at all by failing. Sharing one workflow left a red result meaning either thing, and a failure notification carries the workflow name rather than the job name — so the ambiguity landed hardest on the scheduled run, the one case where neither has a push-driven backstop. Two claims did not survive scrutiny while moving them, and both were published in SECURITY.md rather than confined to a comment: GitHub notifies on every completed run by default, with failed-only an opt-in account setting, and the 60-day scheduled-workflow disable runs on one repository-level clock rather than one per workflow. The new workflow keeps a pull_request trigger with the job guarded off it, so the file is still parsed on any PR that edits it — a schedule-only workflow has no event that would catch a malformed edit, and its recurring editor is a bot merging on green checks. Its mise pin moves here too, since Dependabot bumps the action SHA but never the version input. --- .claude/rules/toolchain-ci-parity.md | 5 +- .github/workflows/toolchain.yml | 85 ++++++++++++++++++++++++++++ .github/workflows/vuln.yml | 43 -------------- CONTRIBUTING.md | 2 +- SECURITY.md | 5 +- 5 files changed, 92 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/toolchain.yml diff --git a/.claude/rules/toolchain-ci-parity.md b/.claude/rules/toolchain-ci-parity.md index 3ab7c4a..e45f034 100644 --- a/.claude/rules/toolchain-ci-parity.md +++ b/.claude/rules/toolchain-ci-parity.md @@ -8,6 +8,7 @@ paths: - ".goreleaser.yaml" - ".github/workflows/ci.yml" - ".github/workflows/vuln.yml" + - ".github/workflows/toolchain.yml" - ".github/workflows/release.yml" - "justfile" --- @@ -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 @@ -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.) diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml new file mode 100644 index 0000000..f3398bf --- /dev/null +++ b/.github/workflows/toolchain.yml @@ -0,0 +1,85 @@ +name: Toolchain currency + +# Separate from the vulnerability scan on purpose. This workflow reports that a +# pin trails upstream, which is routine; that one reports an advisory, which is +# not. A failure notification carries the workflow name and not the job name, so +# running both under one name would leave a red result meaning either thing — +# and the scheduled run, where neither has a push-driven backstop, is exactly +# where that ambiguity costs the most. +on: + schedule: + # Weekly. GitHub disables scheduled workflows after 60 days without + # *repository* activity — one clock covering every scheduled workflow here, + # not one per workflow — so a long-quiet repository stops reporting + # silently. Read a gap in these runs as a symptom, not as a clean report. + - cron: "17 6 * * 1" + workflow_dispatch: + # Not to run the report on pull requests — the guard below skips it — but so + # the file is parsed on any PR that edits it. A workflow reachable only by + # schedule and dispatch has no event that exercises it during development, so + # a malformed edit would surface as no run at all, which is indistinguishable + # from a quiet week. Dependabot edits this file's action pin on a green-check + # basis, which is the case worth catching. + pull_request: + branches: [main] + +permissions: + contents: read + +# No cancel-in-progress: a manual dispatch and the weekly cron share main'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: + # Keeps the report off pull requests while leaving the file above reachable + # by the pull_request trigger. Reporting on pins gates nothing a PR could + # act on, so a red result here would be noise on someone else's change. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + 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@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 + 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 is behind — a step that always + # succeeds is a report nobody is told about. How much that failure buys + # depends on a setting this repository does not control: GitHub's default + # notifies on every completed run, and only the opt-in "Only notify for + # failed workflows" account option makes a red run the sole notification + # (see GitHub's "Notifications for workflow runs" docs). With that option + # off a green run notifies too, which makes the exit 1 redundant rather + # than wrong. Clear a red run 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 diff --git a/.github/workflows/vuln.yml b/.github/workflows/vuln.yml index b1d76a9..b69463b 100644 --- a/.github/workflows/vuln.yml +++ b/.github/workflows/vuln.yml @@ -43,46 +43,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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f55fea2..96a63bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. A separate weekly workflow — the toolchain currency report, kept apart from the vulnerability scan so a red result names which of the two fired — 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. 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. diff --git a/SECURITY.md b/SECURITY.md index 6dc55d2..d4067e9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 a separate weekly workflow checks the pins against upstream 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 loudly it surfaces depends on a setting this repository does not control, because GitHub notifies on every completed run by default and "Only notify for failed workflows" is an opt-in account option. That report is kept in its own workflow rather than alongside the scan above, so a red result names which of the two fired — a failure notification carries the workflow name, not the job name. It blocks nothing (neither workflow is a required check). `just toolchain-outdated` runs the same check locally. 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 silently in a quiet repository.** GitHub disables scheduled workflows after 60 days without repository activity. That is one repository-level clock rather than one per workflow, so the vulnerability scan and the toolchain report stop together — read a gap in either one's weekly runs as a symptom, not as a clean result. Re-enabling a disabled scheduled workflow also rebinds its notifications to whoever re-enabled it. 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. From c2c55c6bdd71a29e227183ed892fc77a4ee6631a Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 09:42:12 -0700 Subject: [PATCH 3/6] chore(deps): bump jdx/mise-action from 4.2.3 to 4.2.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taken over from the open Dependabot PR, which targets this action on a line in vuln.yml that the preceding commit deletes — so landing it there would conflict rather than apply. Bumping it on the action's new home instead lets the bot's PR close on its own once it sees the pin at the target version. --- .github/workflows/toolchain.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml index f3398bf..a022a18 100644 --- a/.github/workflows/toolchain.yml +++ b/.github/workflows/toolchain.yml @@ -46,7 +46,7 @@ jobs: # 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@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 + - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 with: version: 2026.8.3 # Report on the pinned versions; do not install them. From 1dba428258713e245f2ec00a74292d0fac6dc736 Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 10:41:30 -0700 Subject: [PATCH 4/6] ci: name the cause correctly when the toolchain report goes red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report exists so a red run says which concern fired, and it had two ways to say the wrong thing. A failed or silent mise yielded an empty command substitution, which compared against {} raised the pin-lag error — a broken reporter reported as stale pins. Capturing once and branching on emptiness separates them, and testing inside `if` keeps the errexit shell from exiting there with no annotation at all. The pull_request trigger went the other way: it claimed to catch a bad action pin, which the job it guarded cannot reach, because a skipped job resolves no uses:. Drop it rather than keep a mechanism that does not do what it says; what remained was parse reachability resting on a premise GitHub does not document. State the notification behavior only at the strength GitHub documents — runs you triggered, failed-only an option, and a scheduled run notifying its creator — and keep the 60-day condition in SECURITY.md alone, with both workflows pointing at it instead of carrying their own copies. --- .github/workflows/toolchain.yml | 72 +++++++++++++++------------------ .github/workflows/vuln.yml | 7 ++-- CONTRIBUTING.md | 2 +- SECURITY.md | 4 +- 4 files changed, 40 insertions(+), 45 deletions(-) diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml index a022a18..4fa6877 100644 --- a/.github/workflows/toolchain.yml +++ b/.github/workflows/toolchain.yml @@ -1,51 +1,38 @@ name: Toolchain currency -# Separate from the vulnerability scan on purpose. This workflow reports that a -# pin trails upstream, which is routine; that one reports an advisory, which is -# not. A failure notification carries the workflow name and not the job name, so -# running both under one name would leave a red result meaning either thing — -# and the scheduled run, where neither has a push-driven backstop, is exactly -# where that ambiguity costs the most. +# 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. GitHub disables scheduled workflows after 60 days without - # *repository* activity — one clock covering every scheduled workflow here, - # not one per workflow — so a long-quiet repository stops reporting - # silently. Read a gap in these runs as a symptom, not as a clean report. + # 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: - # Not to run the report on pull requests — the guard below skips it — but so - # the file is parsed on any PR that edits it. A workflow reachable only by - # schedule and dispatch has no event that exercises it during development, so - # a malformed edit would surface as no run at all, which is indistinguishable - # from a quiet week. Dependabot edits this file's action pin on a green-check - # basis, which is the case worth catching. - pull_request: - branches: [main] permissions: contents: read -# No cancel-in-progress: a manual dispatch and the weekly cron share main's ref, -# and cancelling the scheduled run because someone probed the report would drop -# the run that exists to fire when nothing else does. +# 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: - # Keeps the report off pull requests while leaving the file above reachable - # by the pull_request trigger. Reporting on pins gates nothing a PR could - # act on, so a red result here would be noise on someone else's change. - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' 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. + # 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 @@ -63,23 +50,30 @@ jobs: # 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 is behind — a step that always - # succeeds is a report nobody is told about. How much that failure buys - # depends on a setting this repository does not control: GitHub's default - # notifies on every completed run, and only the opt-in "Only notify for - # failed workflows" account option makes a red run the sole notification - # (see GitHub's "Notifications for workflow runs" docs). With that option - # off a green run notifies too, which makes the exit 1 redundant rather - # than wrong. Clear a red run 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. + # 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` 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 + + # Captured once, and the reporter's own failure 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 — the one thing this workflow exists to + # avoid. Testing it inside `if` is also what keeps `set -e` 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 diff --git a/.github/workflows/vuln.yml b/.github/workflows/vuln.yml index b69463b..c6370d5 100644 --- a/.github/workflows/vuln.yml +++ b/.github/workflows/vuln.yml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96a63bc..29c4038 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. A separate weekly workflow — the toolchain currency report, kept apart from the vulnerability scan so a red result names which of the two fired — 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. diff --git a/SECURITY.md b/SECURITY.md index d4067e9..2812990 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,8 +43,8 @@ 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 a separate weekly workflow checks the pins against upstream 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 loudly it surfaces depends on a setting this repository does not control, because GitHub notifies on every completed run by default and "Only notify for failed workflows" is an opt-in account option. That report is kept in its own workflow rather than alongside the scan above, so a red result names which of the two fired — a failure notification carries the workflow name, not the job name. It blocks nothing (neither workflow is a required check). `just toolchain-outdated` runs the same check locally. 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 silently in a quiet repository.** GitHub disables scheduled workflows after 60 days without repository activity. That is one repository-level clock rather than one per workflow, so the vulnerability scan and the toolchain report stop together — read a gap in either one's weekly runs as a symptom, not as a clean result. Re-enabling a disabled scheduled workflow also rebinds its notifications to whoever re-enabled 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 a disabled scheduled workflow also rebinds its notifications to whoever re-enabled it. 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. From 3d60462df2b3640d3d32080a3d922d2a1e7d16b9 Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 10:41:59 -0700 Subject: [PATCH 5/6] docs: drop a duplicated notification-routing sentence Two adjacent supply-chain bullets stated who a re-enabled scheduled workflow notifies. The first now covers scheduled-run routing in full, so the second points at it rather than restating a fact that would then have to stay true in two places. --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 2812990..97bac45 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ field-docket holds no credentials of its own. It authenticates to nothing, reads - **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. -- **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 a disabled scheduled workflow also rebinds its notifications to whoever re-enabled 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. From b4789f63d3209dd4194a43c1f1268d5a6c9b0f17 Mon Sep 17 00:00:00 2001 From: Jacob Wan Date: Mon, 10 Aug 2026 11:06:16 -0700 Subject: [PATCH 6/6] ci: annotate a broken reporter in the summary pipeline too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under errexit the summary pipeline exited the step before either annotation could run, so a failing mise produced the one red result this workflow cannot explain — precisely the gap it exists to close. The summary is cosmetic, so its status no longer decides the step; the authoritative JSON call below is guarded and still names the cause. --- .github/workflows/toolchain.yml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/toolchain.yml b/.github/workflows/toolchain.yml index 4fa6877..ec068d3 100644 --- a/.github/workflows/toolchain.yml +++ b/.github/workflows/toolchain.yml @@ -56,18 +56,24 @@ jobs: # 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` adds pipefail, without which a mise failure in the - # reporting pipeline would be masked by tee's exit status. + # `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: | - mise outdated --bump --local | tee -a "$GITHUB_STEP_SUMMARY" + # 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 - # Captured once, and the reporter's own failure 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 — the one thing this workflow exists to - # avoid. Testing it inside `if` is also what keeps `set -e` from - # exiting here with no annotation at all. + # 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