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..ec068d3 --- /dev/null +++ b/.github/workflows/toolchain.yml @@ -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 diff --git a/.github/workflows/vuln.yml b/.github/workflows/vuln.yml index b1d76a9..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: @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f55fea2..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. 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. diff --git a/SECURITY.md b/SECURITY.md index 6dc55d2..97bac45 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 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. 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