From 0404ebc5c534fa8dfd979ac7c397e140f3ef111f Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 14:53:32 -0300 Subject: [PATCH 01/17] ci(workflows): consolidate ci into a single sequential job Eleven jobs each paid a cold 138s devbox install, so roughly 81% of the CI cost was redundant reinstallation. The lanes now share one devbox install in a single sequential job, gated by dorny/paths-filter so a bump in one ecosystem stops triggering the others. Runners move to GitHub-hosted, which is free and gives 4 vCPU on this public repo. Every check step carries continue-on-error and a final aggregator fails the job, so one broken lane no longer hides the rest. --- .github/workflows/ci.yml | 383 ++++++++++++++++++++------------------- 1 file changed, 193 insertions(+), 190 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7636adc..8ac0e42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,259 +3,262 @@ name: CI on: pull_request: branches: [master] + push: + branches: [master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true permissions: contents: read + # Required by dorny/paths-filter on pull_request events. + pull-requests: read jobs: - quality: - name: commit + docs + secrets - runs-on: blacksmith-2vcpu-ubuntu-2404 + changes: + name: detect changes + runs-on: ubuntu-latest + outputs: + global: ${{ github.event_name == 'push' || steps.filter.outputs.global == 'true' }} + go: ${{ github.event_name == 'push' || steps.filter.outputs.go == 'true' }} + rust: ${{ github.event_name == 'push' || steps.filter.outputs.rust == 'true' }} + node: ${{ github.event_name == 'push' || steps.filter.outputs.node == 'true' }} + python: ${{ github.event_name == 'push' || steps.filter.outputs.python == 'true' }} + dotnet: ${{ github.event_name == 'push' || steps.filter.outputs.dotnet == 'true' }} + tooling: ${{ github.event_name == 'push' || steps.filter.outputs.tooling == 'true' }} + steps: + - name: Filter changed paths + uses: dorny/paths-filter@v4 + id: filter + if: github.event_name == 'pull_request' + with: + filters: | + global: + - 'devbox.json' + - 'devbox.lock' + - '.justfile' + - '.github/workflows/**' + go: + - '*.go' + - '**/*.go' + - 'go.mod' + - 'go.sum' + - '.golangci.yml' + - '.goreleaser.yaml' + # The bindings are members of the Cargo workspace, so their Rust + # sources are inputs to the workspace-wide fmt/clippy/audit/deny. + rust: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'deny.toml' + - '.cargo/**' + - 'bindings/node/Cargo.toml' + - 'bindings/node/build.rs' + - 'bindings/node/src/**' + - 'bindings/python/Cargo.toml' + - 'bindings/python/src/**' + node: + - 'bindings/node/**' + python: + - 'bindings/python/**' + dotnet: + - 'dotnet/**' + # Repo-root tooling (commitlint, husky, lint-staged). Deliberately + # separate from the `node` lane so a lint-staged bump does not + # rebuild the native addon. + tooling: + - 'package.json' + - 'pnpm-lock.yaml' + - 'commitlint.config.cjs' + - '.lintstagedrc.cjs' + + ci: + name: ci + needs: changes + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 + # Restored before devbox: the devbox init_hook runs during installation + # and resolves Go tooling through the module cache. + - name: Cache Go modules and build + uses: actions/cache@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ubuntu-go-${{ hashFiles('**/go.sum') }} + restore-keys: ubuntu-go- + - name: Install devbox uses: jetify-com/devbox-install-action@v0.15.0 with: enable-cache: true devbox-version: 0.17.4 + - name: Cache cargo and target + uses: Swatinem/rust-cache@v2 + + - name: Cache NuGet packages + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: ubuntu-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} + restore-keys: ubuntu-nuget- + - name: Install Node tooling run: devbox run -- pnpm install --frozen-lockfile - name: Lint commit messages + id: commitlint + continue-on-error: true + if: github.event_name == 'pull_request' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: devbox run -- pnpm exec commitlint --config commitlint.config.cjs --from "$BASE_SHA" --to "$HEAD_SHA" - name: Run quality gates + id: quality + continue-on-error: true env: # Lychee falls back to anonymous GitHub requests without this, which # are heavily rate-limited in CI. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: devbox run -- just quality - go-test: - name: go / tidy + vet + test - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache Go modules and build - uses: actions/cache@v6 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ubuntu-go-${{ hashFiles('**/go.sum') }} - restore-keys: ubuntu-go- - - name: Tidy check + - name: go / tidy check + id: go-tidy-check + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just go-tidy-check - - name: Vet + + - name: go / vet + id: go-vet + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just go-vet - - name: Test + + - name: go / golangci-lint + id: go-lint + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just go-lint + + - name: go / gosec + id: go-lint-sec + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just go-lint-sec + + - name: go / govulncheck + id: go-lint-vuln + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just go-lint-vuln + + # Single test invocation covering both the race lane of `just go-ci` and + # the coverage profile. + - name: go / test + id: go-test + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- go test -race -count=1 -coverprofile=coverage.out ./... + + - name: go / build + id: go-build + continue-on-error: true + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just go-build + - name: Upload coverage - if: always() + if: always() && steps.go-test.outcome != 'skipped' + continue-on-error: true uses: actions/upload-artifact@v7 with: name: coverage path: coverage.out retention-days: 7 - go-lint: - name: go / golangci-lint - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Run golangci-lint - run: devbox run -- just go-lint - - go-security: - name: go / gosec + govulncheck - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Static security analysis (gosec) - run: devbox run -- just go-lint-sec - - name: Vulnerability scan (govulncheck) - run: devbox run -- just go-lint-vuln + - name: rust / fmt + clippy + test + audit + id: rust-ci + continue-on-error: true + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just rust-ci - go-build: - name: go / build / ${{ matrix.os }} - runs-on: ${{ matrix.runs-on }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache Go modules and build - uses: actions/cache@v6 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ matrix.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: ${{ matrix.os }}-go- - - name: Build - run: devbox run -- just go-build + # The addon is compiled from the workspace crate, so a Rust change has to + # rebuild it even when bindings/node is untouched. + - name: node / build + test + id: node-ci + continue-on-error: true + if: needs.changes.outputs.node == 'true' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just node-ci - rust: - name: rust / fmt + clippy + test + audit - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache cargo and target - uses: Swatinem/rust-cache@v2 - - name: Format check - run: devbox run -- just rust-fmt-check - - name: Clippy - run: devbox run -- just rust-lint - - name: Test - run: devbox run -- just rust-test - - name: Audit - run: devbox run -- just rust-audit + - name: python / build + test + id: py-ci + continue-on-error: true + if: needs.changes.outputs.python == 'true' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just py-ci - node: - name: node / ${{ matrix.os }} - runs-on: ${{ matrix.runs-on }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache cargo and target - uses: Swatinem/rust-cache@v2 - - name: Build native addon - run: devbox run -- just node-build - - name: Test - run: devbox run -- just node-test + - name: dotnet / build + fmt + test + id: dotnet-ci + continue-on-error: true + if: needs.changes.outputs.dotnet == 'true' || needs.changes.outputs.global == 'true' + run: devbox run -- just dotnet-ci - dotnet: - name: dotnet / build + fmt + test - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache NuGet packages - uses: actions/cache@v6 - with: - path: ~/.nuget/packages - key: ubuntu-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} - restore-keys: ubuntu-nuget- - - name: Build - run: devbox run -- just dotnet-build - - name: Format check - run: devbox run -- just dotnet-fmt-check - - name: Test - run: devbox run -- just dotnet-test - - name: Verify package + - name: dotnet / verify package + id: dotnet-verify-pack + continue-on-error: true + if: needs.changes.outputs.dotnet == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just dotnet-verify-pack + - name: Aggregate check results + if: always() + env: + STEPS: ${{ toJSON(steps) }} + run: | + set -euo pipefail + failed=$(printf '%s' "$STEPS" | jq -r 'to_entries[] | select(.value.outcome == "failure") | .key') + if [ -n "$failed" ]; then + printf 'failed checks:\n%s\n' "$failed" + printf '%s' "$failed" | while read -r s; do echo "::error::check failed: $s"; done + exit 1 + fi + echo "all checks passed" + dotnet-compat: name: dotnet / runtime matrix - runs-on: ${{ matrix.runs-on }} - strategy: - fail-fast: false - matrix: - include: - # net8.0 is the oldest published target; the devbox lane only ships - # SDK 10, so this lane installs both runtimes and runs the suite on - # each TFM. - - os: ubuntu-latest - runs-on: blacksmith-2vcpu-ubuntu-2404 - # The package has no OS restriction; Windows runs the build and the - # platform-independent tests (process tests are Unix-only). - - os: windows-latest - runs-on: windows-latest + needs: changes + if: needs.changes.outputs.dotnet == 'true' || needs.changes.outputs.global == 'true' + # net8.0 is the oldest published target; the devbox lane only ships SDK 10, + # so this lane installs both runtimes and runs the suite on each TFM. The + # package has no OS restriction; Windows runs the build and the + # platform-independent tests (process tests are Unix-only). The Linux leg is + # covered by the `ci` job. + runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v7 + - name: Setup .NET uses: actions/setup-dotnet@v6 with: dotnet-version: | 8.0.x 10.0.x + - name: Cache NuGet packages uses: actions/cache@v6 with: path: ~/.nuget/packages - key: ${{ matrix.os }}-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} - restore-keys: ${{ matrix.os }}-nuget- + key: windows-latest-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} + restore-keys: windows-latest-nuget- + - name: Test (net8.0 and net10.0) run: dotnet test dotnet/Codexcw.slnx -p:TestAllFrameworks=true - - python: - name: python / ${{ matrix.os }} - runs-on: ${{ matrix.runs-on }} - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.15.0 - with: - enable-cache: true - devbox-version: 0.17.4 - - name: Cache cargo and target - uses: Swatinem/rust-cache@v2 - - name: Build extension - run: devbox run -- just py-build - - name: Test - run: devbox run -- just py-test From 4ac36065f422664a289cb0f376304c2f918c3799 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 14:53:41 -0300 Subject: [PATCH 02/17] ci(release): move release workflows to github-hosted runners The build matrices already carried the GitHub runner label in `os`, so `runs-on` now reads from it and the Blacksmith keys are gone. Jobs without a matrix take ubuntu-latest. Only runner labels change; the publish conditions, permissions and OIDC/trusted-publishing inputs are untouched. --- .github/workflows/release-crate.yml | 2 +- .github/workflows/release-go.yml | 2 +- .github/workflows/release-npm.yml | 18 ++++++++---------- .github/workflows/release-pypi.yml | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release-crate.yml b/.github/workflows/release-crate.yml index 60ad543..e615d19 100644 --- a/.github/workflows/release-crate.yml +++ b/.github/workflows/release-crate.yml @@ -17,7 +17,7 @@ permissions: jobs: publish: if: startsWith(github.ref, 'refs/tags/rust-v') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml index 90cb96f..32ec9a0 100644 --- a/.github/workflows/release-go.yml +++ b/.github/workflows/release-go.yml @@ -11,7 +11,7 @@ permissions: jobs: goreleaser: if: startsWith(github.ref, 'refs/tags/v') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 6853035..a999729 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -21,17 +21,17 @@ defaults: jobs: build: name: build / ${{ matrix.target }} - runs-on: ${{ matrix.runs-on }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: x86_64-unknown-linux-gnu } - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: aarch64-unknown-linux-gnu, cross: true } - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: x86_64-unknown-linux-musl, cross: true } - - { os: macos-latest, runs-on: blacksmith-6vcpu-macos-latest, target: x86_64-apple-darwin } - - { os: macos-latest, runs-on: blacksmith-6vcpu-macos-latest, target: aarch64-apple-darwin } - - { os: windows-latest, runs-on: blacksmith-2vcpu-windows-2025, target: x86_64-pc-windows-msvc } + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu } + - { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, cross: true } + - { os: ubuntu-latest, target: x86_64-unknown-linux-musl, cross: true } + - { os: macos-latest, target: x86_64-apple-darwin } + - { os: macos-latest, target: aarch64-apple-darwin } + - { os: windows-latest, target: x86_64-pc-windows-msvc } steps: - name: Checkout uses: actions/checkout@v7 @@ -71,9 +71,7 @@ jobs: name: publish needs: build if: startsWith(github.ref, 'refs/tags/node-v') - # npm provenance is only issued on GitHub-hosted runners; Blacksmith is - # self-hosted. The heavy build matrix stays on Blacksmith; this small - # publish job runs github-hosted (free on public repos) to keep provenance. + # npm provenance is only issued on GitHub-hosted runners. runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index e9ac844..5966308 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -17,18 +17,18 @@ permissions: jobs: wheels: name: wheels / ${{ matrix.os }} / ${{ matrix.target }} - runs-on: ${{ matrix.runs-on }} + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: x86_64, manylinux: auto } - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: aarch64, manylinux: auto } - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: x86_64, manylinux: musllinux_1_1 } - - { os: ubuntu-latest, runs-on: blacksmith-2vcpu-ubuntu-2404, target: aarch64, manylinux: musllinux_1_1 } - - { os: macos-latest, runs-on: blacksmith-6vcpu-macos-latest, target: x86_64, manylinux: auto } - - { os: macos-latest, runs-on: blacksmith-6vcpu-macos-latest, target: aarch64, manylinux: auto } - - { os: windows-latest, runs-on: blacksmith-2vcpu-windows-2025, target: x64, manylinux: auto } + - { os: ubuntu-latest, target: x86_64, manylinux: auto } + - { os: ubuntu-latest, target: aarch64, manylinux: auto } + - { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_1 } + - { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_1 } + - { os: macos-latest, target: x86_64, manylinux: auto } + - { os: macos-latest, target: aarch64, manylinux: auto } + - { os: windows-latest, target: x64, manylinux: auto } steps: - name: Checkout uses: actions/checkout@v7 @@ -50,7 +50,7 @@ jobs: sdist: name: sdist - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 @@ -72,7 +72,7 @@ jobs: name: publish needs: [wheels, sdist] if: startsWith(github.ref, 'refs/tags/py-v') - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Download artifacts uses: actions/download-artifact@v8 From 4dde26a529dba9285d77c5f45cf60bac9150dcff Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 14:53:49 -0300 Subject: [PATCH 03/17] build(devbox): pin govulncheck and gosec Both tools come from nixpkgs at the versions the init_hook was compiling from source on every run, so the shell no longer needs network access or a `go install` to have them on PATH. --- devbox.json | 4 +-- devbox.lock | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/devbox.json b/devbox.json index 5003ec1..c74301f 100644 --- a/devbox.json +++ b/devbox.json @@ -4,6 +4,8 @@ "go": "1.26", "golangci-lint": "2.12", "gofumpt": "0.10", + "govulncheck": "1.6.0", + "gosec": "2.28.0", "goreleaser": "2.15", "syft": "1.18", "rustup": "1.28.2", @@ -35,8 +37,6 @@ "export CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}", "export PATH=$GOBIN:$CARGO_HOME/bin:$PATH", "rustup show active-toolchain >/dev/null 2>&1 || rustup toolchain install 1.90.0 --profile minimal --component rustfmt --component clippy", - "if [ ! -x \"$GOBIN/govulncheck\" ]; then go install golang.org/x/vuln/cmd/govulncheck@latest; fi", - "if [ ! -x \"$GOBIN/gosec\" ]; then go install github.com/securego/gosec/v2/cmd/gosec@latest; fi", "if [ -z \"${CI:-}\" ]; then pnpm install --frozen-lockfile && pnpm exec husky; fi" ] } diff --git a/devbox.lock b/devbox.lock index 0b1e3d4..7e30e28 100644 --- a/devbox.lock +++ b/devbox.lock @@ -404,6 +404,82 @@ } } }, + "gosec@2.28.0": { + "last_modified": "2026-08-01T16:34:20Z", + "resolved": "github:NixOS/nixpkgs/a5cbcfe954791221bfffe2307f7d1a1bf61a871e#gosec", + "source": "devbox-search", + "version": "2.28.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/libarzfjp8ql7zsfgmlx7isgfd0b751x-gosec-2.28.0", + "default": true + } + ], + "store_path": "/nix/store/libarzfjp8ql7zsfgmlx7isgfd0b751x-gosec-2.28.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1gkyi3chzsg9mh889dya8cjn2hgfiasi-gosec-2.28.0", + "default": true + } + ], + "store_path": "/nix/store/1gkyi3chzsg9mh889dya8cjn2hgfiasi-gosec-2.28.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/i2kcnx89xl4airnl35fq8ql9ndf1nbza-gosec-2.28.0", + "default": true + } + ], + "store_path": "/nix/store/i2kcnx89xl4airnl35fq8ql9ndf1nbza-gosec-2.28.0" + } + } + }, + "govulncheck@1.6.0": { + "last_modified": "2026-08-01T16:34:20Z", + "resolved": "github:NixOS/nixpkgs/a5cbcfe954791221bfffe2307f7d1a1bf61a871e#govulncheck", + "source": "devbox-search", + "version": "1.6.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ly0apvkva44w1kycvhdnb1z1sg4isvl6-govulncheck-1.6.0", + "default": true + } + ], + "store_path": "/nix/store/ly0apvkva44w1kycvhdnb1z1sg4isvl6-govulncheck-1.6.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/hlkcpib5bxrc8567cr2rah9xfiqj0c6j-govulncheck-1.6.0", + "default": true + } + ], + "store_path": "/nix/store/hlkcpib5bxrc8567cr2rah9xfiqj0c6j-govulncheck-1.6.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/jal1aq2n1kn41h6akxgghnn9zbsdahml-govulncheck-1.6.0", + "default": true + } + ], + "store_path": "/nix/store/jal1aq2n1kn41h6akxgghnn9zbsdahml-govulncheck-1.6.0" + } + } + }, "just@1.51": { "last_modified": "2026-05-21T08:15:18Z", "resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#just", From f753e54075e5bf7d7f72e0c07f7618be6f5b8592 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 14:53:52 -0300 Subject: [PATCH 04/17] ci(deps): group dependabot minor and patch updates Minor and patch bumps land in one PR per ecosystem; majors stay individual so an incompatible one cannot block the group or complicate bisection. --- .github/dependabot.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1d9c70e..a5e80d7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: cargo directory: / @@ -15,6 +19,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: npm directory: / @@ -23,6 +31,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: npm directory: /bindings/node @@ -31,6 +43,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: pip directory: /bindings/python @@ -39,6 +55,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: nuget directory: /dotnet @@ -47,6 +67,10 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "build(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] - package-ecosystem: github-actions directory: / @@ -55,3 +79,7 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "ci(deps)" + groups: + minor-and-patch: + patterns: ["*"] + update-types: ["minor", "patch"] From 22c3f1214085703e8385ae9d1b9f7bf00f5fa3d0 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:22:09 -0300 Subject: [PATCH 05/17] ci(workflows): restore the linux leg of the dotnet runtime matrix The `ci` job runs `just dotnet-ci` on the devbox SDK 10 only, so folding the Linux leg into it left net8.0 tested nowhere on Unix: the Windows leg skips the 44 Unix-only process tests. The matrix runs both legs again, each installing 8.0.x and 10.0.x through actions/setup-dotnet and running the suite on every TFM. The `dotnet` path filter still gates the job. --- .github/workflows/ci.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac0e42..576e843 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,12 +236,15 @@ jobs: name: dotnet / runtime matrix needs: changes if: needs.changes.outputs.dotnet == 'true' || needs.changes.outputs.global == 'true' - # net8.0 is the oldest published target; the devbox lane only ships SDK 10, - # so this lane installs both runtimes and runs the suite on each TFM. The - # package has no OS restriction; Windows runs the build and the - # platform-independent tests (process tests are Unix-only). The Linux leg is - # covered by the `ci` job. - runs-on: windows-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # net8.0 is the oldest published target and the devbox lane only ships + # SDK 10, so this job installs both runtimes and runs the suite on each + # TFM. Linux is where the Unix-only process tests actually execute; + # Windows covers the platform-independent surface. + os: [ubuntu-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@v7 @@ -257,8 +260,8 @@ jobs: uses: actions/cache@v6 with: path: ~/.nuget/packages - key: windows-latest-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} - restore-keys: windows-latest-nuget- + key: ${{ matrix.os }}-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} + restore-keys: ${{ matrix.os }}-nuget- - name: Test (net8.0 and net10.0) run: dotnet test dotnet/Codexcw.slnx -p:TestAllFrameworks=true From 5c07f77d230b8374a57543492e9e8cb1c18d0c18 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:22:52 -0300 Subject: [PATCH 06/17] ci(workflows): persist language caches when checks fail actions/cache writes in a post step gated on `post-if: success()`, so a red job discarded the Go and NuGet caches even for lanes that had passed. Restore and save are now separate steps: the save runs under `always()` whenever the restore missed an exact key. Swatinem/rust-cache gets `cache-on-failure` for the same reason. The devbox cache was already surviving, since devbox-install-action calls actions/cache/save inline rather than as a post step. --- .github/workflows/ci.yml | 47 +++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 576e843..cfa7ff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,10 +85,11 @@ jobs: with: fetch-depth: 0 - # Restored before devbox: the devbox init_hook runs during installation - # and resolves Go tooling through the module cache. - - name: Cache Go modules and build - uses: actions/cache@v6 + # Restore and save are split so the cache is still written when a check + # fails: the post step of actions/cache only runs on success. + - name: Restore Go cache + id: go-cache + uses: actions/cache/restore@v6 with: path: | ~/go/pkg/mod @@ -104,9 +105,12 @@ jobs: - name: Cache cargo and target uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - - name: Cache NuGet packages - uses: actions/cache@v6 + - name: Restore NuGet cache + id: nuget-cache + uses: actions/cache/restore@v6 with: path: ~/.nuget/packages key: ubuntu-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} @@ -177,6 +181,16 @@ jobs: if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just go-build + - name: Save Go cache + if: always() && steps.go-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ steps.go-cache.outputs.cache-primary-key }} + - name: Upload coverage if: always() && steps.go-test.outcome != 'skipped' continue-on-error: true @@ -218,6 +232,14 @@ jobs: if: needs.changes.outputs.dotnet == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just dotnet-verify-pack + - name: Save NuGet cache + if: always() && steps.nuget-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v6 + with: + path: ~/.nuget/packages + key: ${{ steps.nuget-cache.outputs.cache-primary-key }} + - name: Aggregate check results if: always() env: @@ -256,8 +278,9 @@ jobs: 8.0.x 10.0.x - - name: Cache NuGet packages - uses: actions/cache@v6 + - name: Restore NuGet cache + id: nuget-cache + uses: actions/cache/restore@v6 with: path: ~/.nuget/packages key: ${{ matrix.os }}-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} @@ -265,3 +288,11 @@ jobs: - name: Test (net8.0 and net10.0) run: dotnet test dotnet/Codexcw.slnx -p:TestAllFrameworks=true + + - name: Save NuGet cache + if: always() && steps.nuget-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v6 + with: + path: ~/.nuget/packages + key: ${{ steps.nuget-cache.outputs.cache-primary-key }} From aaa8d91e663bed7a8d0a92d36a913dea00ddab66 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:23:09 -0300 Subject: [PATCH 07/17] ci(workflows): report every failed check in the aggregator `printf '%s'` left the list without a trailing newline, so the final `read` returned non-zero and no `::error::` annotation was ever emitted; the job failed with the names only in the raw log. The jq filter also looked at `outcome` alone, missing cancelled steps and the `outcome: success` / `conclusion: failure` case. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfa7ff9..5bdab28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,10 +246,13 @@ jobs: STEPS: ${{ toJSON(steps) }} run: | set -euo pipefail - failed=$(printf '%s' "$STEPS" | jq -r 'to_entries[] | select(.value.outcome == "failure") | .key') + failed=$(printf '%s' "$STEPS" | jq -r ' + to_entries[] + | select([.value.outcome, .value.conclusion] | any(. == "failure" or . == "cancelled")) + | .key') if [ -n "$failed" ]; then printf 'failed checks:\n%s\n' "$failed" - printf '%s' "$failed" | while read -r s; do echo "::error::check failed: $s"; done + printf '%s\n' "$failed" | while read -r s; do echo "::error::check failed: $s"; done exit 1 fi echo "all checks passed" From 03018b10db80fe20d60c270c78e220b98fa109eb Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:23:20 -0300 Subject: [PATCH 08/17] ci(workflows): fold the coverage upload into the aggregator Steps without an `id` never reach `toJSON(steps)`, so a failure of the artifact service left the CI green and the coverage profile missing. The step now carries an `id` and reports a missing profile as an error. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdab28..1afaaa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,12 +192,14 @@ jobs: key: ${{ steps.go-cache.outputs.cache-primary-key }} - name: Upload coverage + id: upload-coverage if: always() && steps.go-test.outcome != 'skipped' continue-on-error: true uses: actions/upload-artifact@v7 with: name: coverage path: coverage.out + if-no-files-found: error retention-days: 7 - name: rust / fmt + clippy + test + audit From e6aa2bde556ba374291d08bcfbb40b394ea6f04a Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:23:35 -0300 Subject: [PATCH 09/17] ci(workflows): cap cargo test parallelism against an etxtbsy race `reports_claude_usage_process_failures` execs a fake script right after writing it, and fails with `Text file busy` when a sibling test thread still holds the inherited write descriptor. The 4 vCPU runner doubles the parallelism of `cargo test` and made the race frequent enough to cost reruns. RUST_TEST_THREADS is a migration guard; the fixture needs to close the descriptor before the exec. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1afaaa7..5be5e1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,10 +202,16 @@ jobs: if-no-files-found: error retention-days: 7 + # RUST_TEST_THREADS caps the parallelism of `cargo test`. It is a + # migration guard: the 4 vCPU GitHub-hosted runner makes the ETXTBSY race + # in the fake-executable fixtures likely enough to force reruns. The real + # fix belongs in the fixture and is tracked separately. - name: rust / fmt + clippy + test + audit id: rust-ci continue-on-error: true if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.global == 'true' + env: + RUST_TEST_THREADS: 2 run: devbox run -- just rust-ci # The addon is compiled from the workspace crate, so a Rust change has to From d1990f189a094bbd1b2e3ea3ef0e1f2e0154d928 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:24:10 -0300 Subject: [PATCH 10/17] ci(workflows): drop the unused tooling path filter Nothing consumed the output: commitlint and `just quality` run on every event regardless of which paths changed. The reason the repo-root package.json stays out of the `node` lane now lives as a comment on that filter. --- .github/workflows/ci.yml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5be5e1c..5a561bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: node: ${{ github.event_name == 'push' || steps.filter.outputs.node == 'true' }} python: ${{ github.event_name == 'push' || steps.filter.outputs.python == 'true' }} dotnet: ${{ github.event_name == 'push' || steps.filter.outputs.dotnet == 'true' }} - tooling: ${{ github.event_name == 'push' || steps.filter.outputs.tooling == 'true' }} steps: - name: Filter changed paths uses: dorny/paths-filter@v4 @@ -60,20 +59,14 @@ jobs: - 'bindings/node/src/**' - 'bindings/python/Cargo.toml' - 'bindings/python/src/**' + # The repo-root package.json is tooling only (commitlint, husky, + # lint-staged) and is not an input to the native addon. node: - 'bindings/node/**' python: - 'bindings/python/**' dotnet: - 'dotnet/**' - # Repo-root tooling (commitlint, husky, lint-staged). Deliberately - # separate from the `node` lane so a lint-staged bump does not - # rebuild the native addon. - tooling: - - 'package.json' - - 'pnpm-lock.yaml' - - 'commitlint.config.cjs' - - '.lintstagedrc.cjs' ci: name: ci From 0815501ef6fe1531bba24b0cfb555e2e899031cd Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:34:53 -0300 Subject: [PATCH 11/17] ci(workflows): only save a cache once its content was produced The exact primary key is immutable, so publishing under it is a one-shot decision: every later run hits it and skips its own save. Saving under `always()` alone meant that an infra failure or a cancellation before the lanes ran could freeze a prefix-restored or empty cache under the new exact key, and path filters make a skipped lane the common case. Each save now requires a successful restore, a non-cancelled job, and at least one step that actually populates that cache having succeeded. --- .github/workflows/ci.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a561bd..82b59e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,8 +174,15 @@ jobs: if: needs.changes.outputs.go == 'true' || needs.changes.outputs.global == 'true' run: devbox run -- just go-build + # Publishing under the exact primary key is irreversible, and a later run + # that hits it skips its own save. So only save once a step that actually + # populates this cache has completed. - name: Save Go cache - if: always() && steps.go-cache.outputs.cache-hit != 'true' + if: >- + always() && !cancelled() + && steps.go-cache.outcome == 'success' + && steps.go-cache.outputs.cache-hit != 'true' + && (steps.go-tidy-check.outcome == 'success' || steps.go-test.outcome == 'success' || steps.go-build.outcome == 'success') continue-on-error: true uses: actions/cache/save@v6 with: @@ -234,7 +241,11 @@ jobs: run: devbox run -- just dotnet-verify-pack - name: Save NuGet cache - if: always() && steps.nuget-cache.outputs.cache-hit != 'true' + if: >- + always() && !cancelled() + && steps.nuget-cache.outcome == 'success' + && steps.nuget-cache.outputs.cache-hit != 'true' + && (steps.dotnet-ci.outcome == 'success' || steps.dotnet-verify-pack.outcome == 'success') continue-on-error: true uses: actions/cache/save@v6 with: @@ -291,10 +302,15 @@ jobs: restore-keys: ${{ matrix.os }}-nuget- - name: Test (net8.0 and net10.0) + id: dotnet-test run: dotnet test dotnet/Codexcw.slnx -p:TestAllFrameworks=true - name: Save NuGet cache - if: always() && steps.nuget-cache.outputs.cache-hit != 'true' + if: >- + always() && !cancelled() + && steps.nuget-cache.outcome == 'success' + && steps.nuget-cache.outputs.cache-hit != 'true' + && steps.dotnet-test.outcome == 'success' continue-on-error: true uses: actions/cache/save@v6 with: From 274691ce329a91ed9f3fdd18c97340610216ef32 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:35:04 -0300 Subject: [PATCH 12/17] build(devbox): let the pinned packages win over locally installed tools `$GOBIN` came first in PATH, so the untracked `bin/govulncheck` and `bin/gosec` left behind by the old init_hook kept shadowing the pinned packages on developer machines, and never got updated. Appending both `$CARGO_HOME/bin` and `$GOBIN` puts the devbox profile first, which also stops a `just rust-tools` install from shadowing the pinned cargo-deny and cargo-audit. The Rust toolchain is unaffected: the profile's rustup shims resolve the same 1.90.0 toolchain from $RUSTUP_HOME. CI never saw the problem, since `bin/` is gitignored. --- devbox.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devbox.json b/devbox.json index c74301f..4910c6e 100644 --- a/devbox.json +++ b/devbox.json @@ -35,7 +35,7 @@ "init_hook": [ "export GOBIN=$PWD/bin", "export CARGO_HOME=${CARGO_HOME:-$HOME/.cargo}", - "export PATH=$GOBIN:$CARGO_HOME/bin:$PATH", + "export PATH=$PATH:$CARGO_HOME/bin:$GOBIN", "rustup show active-toolchain >/dev/null 2>&1 || rustup toolchain install 1.90.0 --profile minimal --component rustfmt --component clippy", "if [ -z \"${CI:-}\" ]; then pnpm install --frozen-lockfile && pnpm exec husky; fi" ] From 249b6b1d7a43cb1863ff8bb6cf904e599bc1c2cf Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:53:56 -0300 Subject: [PATCH 13/17] ci(workflows): key the go cache on the toolchain that produced it GOTOOLCHAIN is `auto` and GOMODCACHE sits inside the cached paths, so a `go` directive newer than the devbox toolchain gets downloaded into the cache. That download is not recorded in go.sum, so hashing go.sum alone kept hitting the old exact key and re-downloading the toolchain on every run. go.mod and devbox.lock are now part of the key. The NuGet key of the `ci` job gains devbox.lock for the same reason: its SDK comes from devbox. The `dotnet-compat` key does not, since that job takes its SDK from setup-dotnet. --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82b59e5..b1e4006 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,11 @@ jobs: # Restore and save are split so the cache is still written when a check # fails: the post step of actions/cache only runs on success. + # + # Under GOTOOLCHAIN=auto a `go` directive newer than the devbox toolchain + # is downloaded into GOMODCACHE, which lives inside this cache and is not + # recorded in go.sum. go.mod and devbox.lock are part of the key so that + # switch produces a miss instead of a permanent stale hit. - name: Restore Go cache id: go-cache uses: actions/cache/restore@v6 @@ -87,8 +92,8 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ubuntu-go-${{ hashFiles('**/go.sum') }} - restore-keys: ubuntu-go- + key: ${{ runner.os }}-${{ runner.arch }}-go-${{ hashFiles('go.mod', 'go.sum', 'devbox.lock') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-go- - name: Install devbox uses: jetify-com/devbox-install-action@v0.15.0 @@ -101,13 +106,14 @@ jobs: with: cache-on-failure: true + # devbox.lock is in the key because this job restores with the devbox SDK. - name: Restore NuGet cache id: nuget-cache uses: actions/cache/restore@v6 with: path: ~/.nuget/packages - key: ubuntu-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props') }} - restore-keys: ubuntu-nuget- + key: ${{ runner.os }}-${{ runner.arch }}-nuget-${{ hashFiles('dotnet/**/*.csproj', 'dotnet/Directory.Build.props', 'devbox.lock') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-nuget- - name: Install Node tooling run: devbox run -- pnpm install --frozen-lockfile @@ -293,6 +299,8 @@ jobs: 8.0.x 10.0.x + # No devbox.lock in the key: this job takes its SDK from setup-dotnet + # above, not from devbox. - name: Restore NuGet cache id: nuget-cache uses: actions/cache/restore@v6 From 712c004e4ea682fbaab6dce6d2fa91465e64b4b5 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 15:54:47 -0300 Subject: [PATCH 14/17] ci(workflows): restrict the cargo cache to runs that populate it `cache-on-failure` made the post-save fire after a prefix restore or a miss without any Cargo lane having run, freezing an empty target under the exact key that later runs then hit and cannot replace. With path filters, a skipped lane is the common case, so this was the normal path rather than an edge case. `save-if` is evaluated when the action starts and cannot consult the lanes, so the action is gated on the lanes that consume the cache instead: rust, node and python all build from the workspace crate. It also moves below `pnpm install`, the last step that can abort the job, so no fail-fast path reaches the post-save with an empty target. --- .github/workflows/ci.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1e4006..052171f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,11 +101,6 @@ jobs: enable-cache: true devbox-version: 0.17.4 - - name: Cache cargo and target - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - # devbox.lock is in the key because this job restores with the devbox SDK. - name: Restore NuGet cache id: nuget-cache @@ -118,6 +113,19 @@ jobs: - name: Install Node tooling run: devbox run -- pnpm install --frozen-lockfile + # `save-if` is evaluated when this step runs, so it cannot consult the + # lanes that populate the cache. Gating the whole action on those lanes + # gives the same protection as the explicit saves below: when no Cargo + # lane will run, nothing is restored and nothing is saved, so an empty + # target can never be frozen under the exact key. It sits after the last + # step that can abort the job, since every check below is + # continue-on-error and cannot fail-fast past it. + - name: Cache cargo and target + if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.node == 'true' || needs.changes.outputs.python == 'true' || needs.changes.outputs.global == 'true' + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Lint commit messages id: commitlint continue-on-error: true From cf0fcbcccb0a7e5500396ca09d777a5441117e86 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 16:14:10 -0300 Subject: [PATCH 15/17] ci(workflows): require a cargo lane to have compiled before saving The lane gate only proves a Cargo lane was scheduled. A node run that dies in `npm install`, or a python run that dies in `uv venv`, never reaches cargo, and `cache-on-failure` then published an empty target under the exact key that later runs hit and can no longer replace. `save-if` is read by the post step, so it can require one of the lanes that build from the workspace crate to have succeeded. The comment that claimed otherwise was wrong. --- .github/workflows/ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 052171f..5e5589c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,18 +113,19 @@ jobs: - name: Install Node tooling run: devbox run -- pnpm install --frozen-lockfile - # `save-if` is evaluated when this step runs, so it cannot consult the - # lanes that populate the cache. Gating the whole action on those lanes - # gives the same protection as the explicit saves below: when no Cargo - # lane will run, nothing is restored and nothing is saved, so an empty - # target can never be frozen under the exact key. It sits after the last - # step that can abort the job, since every check below is - # continue-on-error and cannot fail-fast past it. + # The gate skips the restore when no Cargo lane is scheduled; `save-if` + # then requires one to have actually reached cargo, since a lane dying + # before it compiles would otherwise freeze an empty target under the + # exact key. `save-if` is read by the post step, so it sees these + # outcomes. `cache-on-failure` stays because the action declares + # `post-if: success() || env.CACHE_ON_FAILURE == 'true'`, and the + # aggregator has already made the job red by then. - name: Cache cargo and target if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.node == 'true' || needs.changes.outputs.python == 'true' || needs.changes.outputs.global == 'true' uses: Swatinem/rust-cache@v2 with: cache-on-failure: true + save-if: ${{ steps.rust-ci.outcome == 'success' || steps.node-ci.outcome == 'success' || steps.py-ci.outcome == 'success' }} - name: Lint commit messages id: commitlint From 6480cddc0b34cf2bf37b8afb312051bb9e7fa507 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 17:22:39 -0300 Subject: [PATCH 16/17] ci(deps): trigger the dotnet lane on root editorconfig changes dotnet/.editorconfig does not set `root = true`, so the repo-root file is inherited by `dotnet format` and by the build analyzers. Adding a C# rule at the root therefore changes what the dotnet lane verifies, while the filter matched only `dotnet/**` and skipped it. Verified that no other checker in this repo reads .editorconfig: rustfmt has no rustfmt.toml and does not consult it, gofumpt and goimports have no such config, and markdownlint-cli2 uses .markdownlint.jsonc. So the file belongs in this lane rather than in `global`. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e5589c..1139c99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,13 @@ jobs: - 'bindings/node/**' python: - 'bindings/python/**' + # `dotnet format` is the only checker here that reads .editorconfig, + # and it inherits the root file because dotnet/.editorconfig does + # not set `root = true`. rustfmt, gofumpt and markdownlint-cli2 all + # ignore it, so it does not belong in their lanes. dotnet: - 'dotnet/**' + - '.editorconfig' ci: name: ci From a8be7bf0201e6d4fc4d1d41734ad93e28b51a307 Mon Sep 17 00:00:00 2001 From: Caian Ertl Date: Tue, 4 Aug 2026 17:38:30 -0300 Subject: [PATCH 17/17] ci(deps): run every lane when gitattributes changes `* text=auto eol=lf` decides how the whole working tree is materialised at checkout, so an added rule such as `*.cs text eol=crlf` can break a lane whose own paths were untouched. The file matched no filter at all, which let such a PR go green with every lane skipped and fail on the next push to master. It belongs in `global`, next to the other repo-wide policy files. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1139c99..e80e16f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - 'devbox.lock' - '.justfile' - '.github/workflows/**' + # Checkout transformations apply to every file, so a change here + # can break any lane, not just the one that owns the paths. + - '.gitattributes' go: - '*.go' - '**/*.go'