From dce29dc28aa87465a056d06b8c5b6872ce1bac7d Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Thu, 30 Jul 2026 13:56:16 -0400 Subject: [PATCH] build(sfw): bump to 1.14.0, retire the SFW_SHIM_ACTIVE_CARGO escape hatch Bumps Socket Firewall to 1.14.0 and removes the SFW_SHIM_ACTIVE_CARGO escape hatch, routing the cargo shim through the normal firewall path. Folds in the soak/pinned-tooling review and adversarial-review fixes made on top, including the decmpfs=0.1.0 exact pin (0.1.2 breaks musl) and the soakBypass version-mismatch guard with its test. --- .claude/skills/soak/SKILL.md | 19 ++- .github/renovate.json | 83 ++++++++-- .github/workflows/ci.yml | 14 +- .github/workflows/soak-autofix.yml | 125 ++++++++++++++ external-tools.json | 76 +++++---- package.json | 1 + scripts/soak/external-tools.mts | 184 ++++++++++++++++++--- scripts/soak/external-tools.test.mts | 138 +++++++++++++++- scripts/soak/paths.mts | 8 +- scripts/soak/remotes.mts | 2 +- scripts/soak/soak.mts | 238 ++++++++++++++++++++++----- scripts/soak/soak.test.mts | 119 +++++++++++++- scripts/soak/update-deps.mts | 71 +++++++- scripts/soak/update-deps.test.mts | 30 +++- tools/pnpm-workspace.yaml | 9 +- vendor/aube/Cargo.toml | 12 +- 16 files changed, 994 insertions(+), 135 deletions(-) create mode 100644 .github/workflows/soak-autofix.yml diff --git a/.claude/skills/soak/SKILL.md b/.claude/skills/soak/SKILL.md index 3888b571d..f5a360dac 100644 --- a/.claude/skills/soak/SKILL.md +++ b/.claude/skills/soak/SKILL.md @@ -21,9 +21,11 @@ One rule: a release must be at least `SOAK_DAYS` old before this repo adopts it. - `pnpm run soak` — parity-check every surface (CI-gated in docs-links) - `pnpm run soak:fix` — rewrite drifted windows, prune expired exclusions - `pnpm run deps:update` — bump npm (taze) + cargo deps through the window -- `pnpm run tools:check` / `tools:install` — validate / install the SRI-pinned external tools (`external-tools.json`) +- `pnpm run tools:check` / `tools:fix` / `tools:install` — validate / prune-expired-bypasses / install the SRI-pinned external tools (`external-tools.json`) - `pnpm run test:scripts` — the scripts' own unit tests +The gates fail closed on invalid states (missing, malformed, or wrong-arithmetic annotations) and WARN on expired ones — stale is not unsafe, and nobody has to watch for it: the scheduled `soak-autofix` workflow runs `soak:fix` + `tools:fix` daily and commits the pruning as a bot PR. + A soak change is done when `pnpm run soak` and `pnpm run test:scripts` both exit 0 — the same gates CI runs. Re-run them after every fix. ## Change the window (one place) @@ -39,11 +41,11 @@ A soak change is done when `pnpm run soak` and `pnpm run test:scripts` both exit Add to `minimumReleaseAgeExclude` in `tools/pnpm-workspace.yaml` with the annotation on the line above (block list only — flow `[..]` is rejected because a comment line can't attach to an inline entry): ```yaml -# published: 2026-07-08 | removable: 2026-07-15 +# published: YYYY-MM-DD | removable: YYYY-MM-DD - 'name@1.2.3' ``` -`removable` = `published + SOAK_DAYS` (this example assumes a 7-day window). `published` must be the real registry publish date. Once `removable` passes, `pnpm run soak` fails until the pin is pruned (`soak:fix` does it). Bare names / `@scope/*` globs are standing trust and need no annotation. External tools use the same shape via a `soakBypass` object in `external-tools.json`. +`removable` = `published + SOAK_DAYS`; `published` must be the real registry publish date (the placeholders above are schematic — copying them verbatim is rejected). Once `removable` passes, `pnpm run soak` warns until the pin is pruned (`soak:fix` or the soak-autofix workflow does it). Bare names / `@scope/*` globs are standing trust and need no annotation. External tools use the same shape via a `soakBypass` object in `external-tools.json`. ## The cargo soak needs nightly — the repo still must not pin one @@ -51,6 +53,17 @@ Add to `minimumReleaseAgeExclude` in `tools/pnpm-workspace.yaml` with the annota The nightly is instead requested per-invocation, at the only step that picks versions: `scripts/soak/update-deps.mts` runs `cargo +nightly update`. Everything else — every CI job, every shipped binary — builds on stable. If you need the cargo soak somewhere new, call `cargo +nightly` there; do not add a toolchain file. +**Keep the nightly current — a merely-old one silently disables the +window.** Cargo treats an `[unstable]` key it does not implement as a +warning and exits 0, so an old nightly resolves with NO window while +looking successful. Measured both sides: nightly 2026-03-21 (cargo +1.96.0-nightly) has no such `-Z` and skips the window silently; nightly +2026-07-27 (cargo 1.99.0-nightly) supports `-Z min-publish-age` and +visibly holds a too-fresh release back (`available: v0.2.189, published +7 days ago`). `deps:update` detects the warning and fails with the fix +(`rustup update nightly`) — if you see it, the lockfile changes it just +made are unsoaked. + ## Maintaining this skill `scripts/soak/` is the law; this file only documents it — when they disagree, fix this file. Keep it concise (goal + constraints, not step enumeration), and keep the window value in `constants.mts` rather than restating it here. diff --git a/.github/renovate.json b/.github/renovate.json index 7bb7bb6a1..52c7126d0 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,17 +1,31 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "dependencyDashboard": true, - "suppressNotifications": ["prEditedNotification"], - "labels": ["dependencies"], - "schedule": ["before 4am on Monday"], + "suppressNotifications": [ + "prEditedNotification" + ], + "labels": [ + "dependencies" + ], + "schedule": [ + "before 4am on Monday" + ], "semanticCommits": "disabled", "separateMajorMinor": true, "minimumReleaseAge": "7 days", "internalChecksFilter": "strict", - "enabledManagers": ["github-actions", "cargo", "npm"], + "enabledManagers": [ + "github-actions", + "cargo", + "npm" + ], "cargo": { "rangeStrategy": "update-lockfile", - "managerFilePatterns": ["/^Cargo\\.toml$/", "/^crates/[^/]+/Cargo\\.toml$/", "/^vendor/"] + "managerFilePatterns": [ + "/^Cargo\\.toml$/", + "/^crates/[^/]+/Cargo\\.toml$/", + "/^vendor/" + ] }, "npm": { "managerFilePatterns": [ @@ -22,41 +36,78 @@ "packageRules": [ { "description": "Pin GitHub Actions to immutable commit SHAs", - "matchDepTypes": ["action"], + "matchDepTypes": [ + "action" + ], "pinDigests": true }, { "description": "Annotate pinned GitHub Actions SHAs with a version comment", - "extends": ["helpers:pinGitHubActionDigests"], + "extends": [ + "helpers:pinGitHubActionDigests" + ], "extractVersion": "^(?v?\\d+\\.\\d+\\.\\d+)$", "versioning": "regex:^v?(?\\d+)(\\.(?\\d+)\\.(?\\d+))?$" }, { "description": "Group upload/download artifact updates (versions are dependent)", "groupName": "artifact GitHub Actions", - "matchManagers": ["github-actions"], - "matchPackageNames": ["actions/upload-artifact", "actions/download-artifact"] + "matchManagers": [ + "github-actions" + ], + "matchPackageNames": [ + "actions/upload-artifact", + "actions/download-artifact" + ] }, { "description": "Disable PRs updating GitHub-hosted runner versions (runs-on: ubuntu-latest, etc.)", - "matchManagers": ["github-actions"], - "matchDatasources": ["github-runners"], + "matchManagers": [ + "github-actions" + ], + "matchDatasources": [ + "github-runners" + ], "enabled": false }, { "description": "Group Cargo minor+patch updates into one PR; majors get individual PRs", "groupName": "Cargo dependencies (minor+patch)", - "matchManagers": ["cargo"], - "matchUpdateTypes": ["minor", "patch", "digest"] + "matchManagers": [ + "cargo" + ], + "matchUpdateTypes": [ + "minor", + "patch", + "digest" + ] }, { "description": "Group npm minor+patch updates; majors get individual PRs", "groupName": "npm dependencies (minor+patch)", - "matchManagers": ["npm"], - "matchUpdateTypes": ["minor", "patch"] + "matchManagers": [ + "npm" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ] + }, + { + "description": "decmpfs 0.1.2 fails to compile for musl targets (FICLONE ioctl request typed c_ulong, which is c_int on musl), and nub builds musl (docker-smoke alpine leg, release musl binaries). Hold at 0.1.0 until upstream fixes it \u2014 mirrors the rule in upstream aube.", + "matchManagers": [ + "cargo" + ], + "matchPackageNames": [ + "decmpfs" + ], + "allowedVersions": "<=0.1.0" } ], "vulnerabilityAlerts": { - "labels": ["security", "dependencies"] + "labels": [ + "security", + "dependencies" + ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6197244..de5d45ce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -796,12 +796,10 @@ jobs: # upgrades itself to enterprise when the secret appears. env: SOCKET_SECURITY_KEY: ${{ secrets.SOCKET_SECURITY_KEY }} - # cargo test spawns nub test binaries that talk to LOCAL registry - # mocks; routing `cargo` through the sfw shim would wrap that traffic - # in the firewall proxy and mangle it (upstream ECONNRESET, 405s on - # registry writes). The sentinel makes the cargo shim exec the real - # cargo directly — pnpm/npm installs stay firewalled. - SFW_SHIM_ACTIVE_CARGO: "1" + # The old SFW_SHIM_ACTIVE_CARGO=1 escape hatch is gone: sfw >= 1.14.0 + # exempts loopback in the child's NO_PROXY (localhost,127.0.0.1,::1), + # so nub test binaries talking to LOCAL registry mocks are never + # proxied — cargo can ride the shim like everything else. strategy: fail-fast: false matrix: ${{ fromJSON(needs.matrix-plan.outputs.test) }} @@ -920,6 +918,10 @@ jobs: shell: bash env: MATRIX_NODE: ${{ matrix.node }} + # download() attaches this to github.com fetches only (release + # assets: pnpm/sfw binaries) — avoids unauthenticated rate + # limits and covers private assets (sfw-enterprise). + GITHUB_TOKEN: ${{ github.token }} run: | # The soak scripts are erasable-TS .mts and need a type-stripping # Node; the compat legs run them with the side-installed 26.5 diff --git a/.github/workflows/soak-autofix.yml b/.github/workflows/soak-autofix.yml new file mode 100644 index 000000000..ae8f13505 --- /dev/null +++ b/.github/workflows/soak-autofix.yml @@ -0,0 +1,125 @@ +name: soak-autofix + +# An expired soakBypass annotation or a cleared minimumReleaseAgeExclude pin +# is STALE, not unsafe: the version has soaked, so the gates warn rather than +# fail. Nobody should have to watch for that warning either — this workflow +# runs the fixers daily and, when they change anything, commits to a bot +# branch and opens (or updates) a PR, so the ledger converges to clean on its +# own. Invalid annotations (missing, malformed, wrong arithmetic) still fail +# the gate; those need a human. + +on: + schedule: + # Daily, shortly after midnight UTC — annotations expire on date + # boundaries, so the fix lands the morning a window clears. + - cron: '23 0 * * *' + workflow_dispatch: + +permissions: {} + +# One run at a time: overlapping runs would race on the bot branch +# force-push and the open-PR check. +concurrency: + group: soak-autofix + cancel-in-progress: false + +jobs: + autofix: + # Guard the dispatch path: run only from main, so a workflow_dispatch + # on a topic branch can't force-push bot/soak-autofix from arbitrary + # HEAD state. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + # persist-credentials stays on: this job pushes its own fix branch. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # zizmor: ignore[artipacked] + with: + # Optional PAT: PRs opened with the default github.token do NOT + # trigger CI on the PR (GitHub drops workflow events from + # token-created refs), so checks stay pending until a human + # closes/reopens the PR. Set the SOAK_AUTOFIX_TOKEN repo secret + # (a fine-grained PAT with contents+pull-requests write) to make + # the autofix PRs run CI like any other PR. + token: ${{ secrets.SOAK_AUTOFIX_TOKEN || github.token }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Run the soak fixers + id: fixers + # Fixers write mechanical repairs first, then exit by post-fix + # check status. Capture that status instead of masking it: the + # commit step below still lands whatever WAS fixable, and the + # final step re-raises the failure so a crashed fixer or a + # human-only finding can never ride a green run. + run: | + status=0 + node scripts/soak/soak.mts --fix || status=$? + node scripts/soak/external-tools.mts --fix || status=$? + echo "status=${status}" >> "$GITHUB_OUTPUT" + - name: Commit + PR when something was fixed + env: + GH_TOKEN: ${{ secrets.SOAK_AUTOFIX_TOKEN || github.token }} + run: | + set -euo pipefail + if git diff --quiet; then + echo "soak surfaces clean — nothing to fix" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="bot/soak-autofix" + # NO force push. The previous fetch-then---force-with-lease shape + # was security theater: `git fetch origin $BRANCH` updates the + # remote-tracking ref to the branch's CURRENT tip (checkout leaves + # the default wildcard refspec in place), so the lease is taken + # against whatever another actor just pushed and the force + # succeeds — silently discarding, for example, a human's review + # fixes committed onto the open autofix PR. + # + # Instead: stash the mechanical fixes, BASE the work on the + # existing bot branch when there is one, and fast-forward push. + # Human commits on the branch are preserved by construction, and + # a genuine conflict fails the run instead of being resolved by + # deletion. + git stash push --include-untracked -m soak-autofix + if git fetch origin "$BRANCH"; then + git checkout -B "$BRANCH" "origin/$BRANCH" + else + echo "no existing $BRANCH on origin — creating it" + git checkout -B "$BRANCH" + fi + if ! git stash pop; then + echo "::error::soak fixes conflict with the existing $BRANCH; resolve that branch (or close its PR) and re-run" + exit 1 + fi + git add -A + # Re-running the fixers on top of an already-fixed branch is a + # no-op; say so and stop rather than pushing an empty commit. + if git diff --cached --quiet; then + echo "$BRANCH already carries these fixes — nothing to push" + exit 0 + fi + git commit -m "chore(soak): prune expired soak annotations (automated) + + Generated by the soak-autofix workflow: soak.mts --fix + + external-tools.mts --fix. Windows that cleared have soaked; + their bypass annotations are dead weight the gates would + otherwise fail on." + git push origin "$BRANCH" + if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')" ]; then + gh pr create --head "$BRANCH" \ + --title "chore(soak): prune expired soak annotations (automated)" \ + --body "Automated by the soak-autofix workflow. The listed soak windows have cleared, so their bypass annotations are stale. Diff is the full review: only annotation/window lines are touched." + fi + - name: Re-raise fixer findings + # After the mechanical repairs are committed, a nonzero fixer + # status means findings remain that need a human (or the fixer + # itself crashed) — fail the run so it can't read as clean. + if: steps.fixers.outputs.status != '0' + run: | + echo "soak fixers exited nonzero — human-actionable findings remain (see fixer step log)" + exit 1 diff --git a/external-tools.json b/external-tools.json index edbfd655c..357d6f8d7 100644 --- a/external-tools.json +++ b/external-tools.json @@ -2,7 +2,7 @@ "tools": { "pnpm": { "description": "pnpm — the fleet's package manager.", - "version": "11.8.0", + "version": "11.15.1", "packageManager": "pnpm", "repository": "github:pnpm/pnpm", "release": "asset", @@ -13,35 +13,35 @@ "platforms": { "darwin-arm64": { "asset": "pnpm-darwin-arm64.tar.gz", - "integrity": "sha512-kgYcLu653+Gx7l7qEtM2zMNLBvb96ABcRSkzOrbfvkndb+veMt6lHJK5r3wTfuRYlDb8Lnk/h0wUD6sP3taJ9g==" + "integrity": "sha512-lHYcdK/uTX+hHyXqGrkYZSB9g1RcPMo02rk1d0val5dEGAp8C5omJg45gtiKE72rFtQU8xVGRaBD+9HCnWNrTQ==" }, "darwin-x64": { - "asset": "pnpm-11.8.0.tgz", - "integrity": "sha512-wfXnxMskHI8XS3Q4UdgvQrgCMkr8iw8Ra5atsVqgZmSUjd42lgo7oQebpbSyndAUATW5S1tfUmNZIknWjlVfJg==" + "asset": "pnpm-11.15.1.tgz", + "integrity": "sha512-gTULB+U8lTigLx8jA7QpD6LXvgTlbiqXDEzEtBfcdh3hlu2r1J1Vx9yVgNuBAHxEFD5OPX5GKzAA0jwlUSLQZQ==" }, "linux-arm64": { "asset": "pnpm-linux-arm64.tar.gz", - "integrity": "sha512-p1IcVUlwYf3OJQYmdHGFr08d1BOSatHEmLJSBSdoNPGqOqfslFHjSqiuZ/3yuQxxypqp8OfnM3ci3Jh7ZEBlSw==" + "integrity": "sha512-zgPT+13ucXlb/YPP15xI5lsDpFohQY9zzUzr+KIipApmBbvzNBif01a5GXrqEuXHvbUhEAcB+eM4ZuvJ9+LfZg==" }, "linux-arm64-musl": { "asset": "pnpm-linux-arm64-musl.tar.gz", - "integrity": "sha512-u5Do3diwK7FL5vk+i/x2I4q4ujdZ5gSoLNgmlt1w2C7NOjAZOpDK0CJYM4gDa5BetEwzdNpuN/gaMqzJk6I5NA==" + "integrity": "sha512-5ttsFqCHnwPMjfbP7wcD+luX1fH13YeY3Oru/mwVeaQRIgeXbS9+pe6XXzM8R8LFo5RcWjkV/FzCVfLL3iZ3ew==" }, "linux-x64": { "asset": "pnpm-linux-x64.tar.gz", - "integrity": "sha512-Sn7hG4Xsq6pmi8TE8lpIkwRzAYzf5qtFk8zSsZWkSJFcmv5FlsrO8UP3lLRI+ppcRNYS10FMCTwXe3Nt66A0Pg==" + "integrity": "sha512-N6t/VxORcB/i0XTJBGaRjaw986N8c4SswtJejFfLEOgi2lbTSJCRyk7bNbZ1+FPVRVzBE8Wt4470m1Y+LIOACg==" }, "linux-x64-musl": { "asset": "pnpm-linux-x64-musl.tar.gz", - "integrity": "sha512-5WXlo2yCDmoBIue5iHRK3zNSPVAmzci+5RhuxwUA4hnH9W3TFjDnQHyAzDVnkY878mfTXE35THZeEc1OKYPmag==" + "integrity": "sha512-MQ2S67HyPgzj+rk2kvTiC9M62XUxuT5IGQiNsbI8fW+RI9haFjaA/cy+kL84TXi+g/MDok5+plxNUa80Tayjrw==" }, "win-arm64": { "asset": "pnpm-win32-arm64.zip", - "integrity": "sha512-iv1hJEj9FUiVFae2cmCYjbRK0Xn27Uh+kpglT2LWvQyl4WeIOgs1ouKrptVxk3X3t7DB5vuYKbsmE+9BnoQ4FA==" + "integrity": "sha512-PiVRWhoYiNcdBXjPPT9I2XQQN+/BZmrgCTVuX5EuV4/1pcpEQlbHxvaU+XMf1nO+Bt9scV0Qi7kk09eBFfqlAA==" }, "win-x64": { "asset": "pnpm-win32-x64.zip", - "integrity": "sha512-jyqMgedndbck/xJjXPem5Lw7V0YtDfiUJIB81e/hmnrg3yLKPoyCKO5ILegYkLPIr945IJRcVoJZwjtog8FCqg==" + "integrity": "sha512-U1SFdMLlhEgZ6zQpZU+Y0itOBqF7PPb42WScXJ0VY4aPDGmVjYI4r4g3EsOEkcVwMhPI0ISsACgZ9S2ixBHAMw==" } } }, @@ -52,78 +52,90 @@ ], "description": "npm — pinned, SRI-verified registry tarball; installed without self-update", "repository": "npm:npm", - "version": "12.0.0", - "integrity": "sha512-qzvPQfNSY7louiM6rv7dL0hi5esBGLn1lLwxbdyL5XOIssWzoYMwn8xqvWhYcZL6onTkenYSxrtKxsFrFbUFyw==" + "version": "12.0.1", + "integrity": "sha512-L5T9i/YAQWQWqTS/xZxJkei/9zcu99hCeE4qi41IyBVV7mRQad3qc2JfuOktwmH+qwGI/V2rbCL+/UYxb1+RQA==" }, "sfw-free": { "description": "Socket Firewall (free tier) — malware gate on dep installs.", - "version": "1.13.1", + "version": "1.14.0", "repository": "github:SocketDev/sfw-free", "release": "asset", "binaryName": "sfw", "notes": [ "Used when SOCKET_SECURITY_KEY is not set", - "Shims npm/yarn/pnpm so every install call passes through the firewall" + "Shims npm/yarn/pnpm so every install call passes through the firewall", + "1.14.0 adopted early via soakBypass: sfw diagnostics moved to stderr + NO_PROXY loopback exemption (kills the SFW_SHIM_ACTIVE_CARGO CI escape hatch)" ], "platforms": { "darwin-arm64": { "asset": "sfw-free-macos-arm64", - "integrity": "sha512-T6wBOJGdRVSI8577lGqRNzNd6Q+1vqKyaqGgOA8G4M5MU2vcsUnXuJTgP2MMZjUqROSXUlFL0mHguuxXT2QadQ==" + "integrity": "sha512-vcD8n2RmW5MpoEzls8rCe2Wk7/Kj5E4xFXOtwPJaezAqCUrBtt3j0X/Hcnd4a3eBJfit7AiqvnWpnECB5Z56vg==" }, "darwin-x64": { "asset": "sfw-free-macos-x86_64", - "integrity": "sha512-4G/AIY5UGU81wcepDKErY5u0nY85D8UM9nXTEPv8CR2rOV/s4IcmrkxywwZ3ipejHVQB7QmCVt0/SsqWglGikw==" + "integrity": "sha512-OACpfFptj5BF+uHAnmL15safe9jj9ErywqIN57A7uaRftnD/Vw+wy8c+OWLNOAkrE4NfAbfa+YG0+cOJem5yUA==" }, "linux-arm64": { "asset": "sfw-free-linux-arm64", - "integrity": "sha512-FYRYR52SL+KKFldW4ogYOUnTH5OSqvtXwzGFeWi0W2x+75KZcPiGzWBbhMmh0f5QtgYLV+4qdREgmKCBEayNtA==" + "integrity": "sha512-CYp5C4KeyghXa0SUtT6Wr+X851hXIX03cjTFK3qAyU/x+HQkW7ZanAfvWhO+zCmv8DBrXHVWo4Nlv6VnP6vXXQ==" }, "linux-x64": { "asset": "sfw-free-linux-x86_64", - "integrity": "sha512-waLrsPG2a7EOv0XuvXDQZGgCZ4MTtOfZh8TmGbM6gn2B6Nh6HI+15jaoKdAS9wgdTyIqTuqU+O+NtVYd+kuFaA==" + "integrity": "sha512-hbEIhCOBuUjFDpNYDYFJ1j/IwIAiaxKHsGaQ4USOiwBK1y1poupGlIRiqtDkVj4P9x+9I0Gki3xoVq6C2nyBbw==" }, "win-x64": { "asset": "sfw-free-windows-x86_64.exe", - "integrity": "sha512-YYnfwR6M/PHo72LSyKtpY3bAUG4F4ckToJqGx5Fkz4rwg1+48hkxuBaF3hdxHUdHPkfO5grDyoNgXGe7FojGcg==" + "integrity": "sha512-JDpo1DAN+YUYSQ8VlVNPWxbOLoxPjnKSFfyAX+BsnHh3UdURWhCj+vy/aPuA3qg46CZQMpWIlzxBw+l86y/zRw==" } + }, + "soakBypass": { + "version": "1.14.0", + "published": "2026-07-23", + "removable": "2026-07-30" } }, "sfw-enterprise": { "description": "Socket Firewall (enterprise tier) — selected when SOCKET_SECURITY_KEY is set.", - "version": "1.13.1", + "version": "1.14.0", "repository": "github:SocketDev/firewall-release", "release": "asset", "binaryName": "sfw", "notes": [ "Used when SOCKET_SECURITY_KEY is set (the one env var every Socket product reads)", - "Same shims as sfw-free, broader ecosystem support (Ruby, .NET, Go on Linux)" + "Same shims as sfw-free, broader ecosystem support (Ruby, .NET, Go on Linux)", + "1.14.0 adopted early via soakBypass: sfw diagnostics moved to stderr + NO_PROXY loopback exemption (kills the SFW_SHIM_ACTIVE_CARGO CI escape hatch)" ], "platforms": { "darwin-arm64": { "asset": "sfw-macos-arm64", - "integrity": "sha512-ZDy2C6leKyTHZFvcZZpG2eQqVzs7buk+Hs92fkaMYME829QzyxdGQVVgwEVaGJpedGdUvhksKKcvT9IynI1kxg==" + "integrity": "sha512-MKU4aNBU0EGiSIJiwTnLDJQl6WKO4JPUD1j7c9FihrtizPCtokQxDc+/QAVjc9RFfUIyGZ9OYoVQbTXiysgVJw==" }, "darwin-x64": { "asset": "sfw-macos-x86_64", - "integrity": "sha512-cm76we0sn7kqPOya/ZGQpPyhjRDyFT5lHigeT5Qso+QaPL6Cmwi0FVs2L7l63j+WR/9eYPU1WjjGOto5NbWsEQ==" + "integrity": "sha512-MWkEGX+v04H9Bpy5iC+zHthw9sl9PDAy6vAcoQsJZuy4OGCxWhwJ408egKq19KtEaQxHgwtL/6SoV2j3IHvxmg==" }, "linux-arm64": { "asset": "sfw-linux-arm64", - "integrity": "sha512-9qPi3mobBfyq1k+pD2GDG0tZkhy16f7FXE9oGiwmwPvy5PXwnlzqEXnYld3qGsKIVwNhunev/If26oROTHbrHA==" + "integrity": "sha512-a3FZCp18qNiOPj5tVqlyFtv/N8i1RZ0VcWEqMrgEFfJ4HSMTOraNc4Jh/DVzC1MhRXSUvVcHkM+1bK6lbPE5Gg==" }, "linux-x64": { "asset": "sfw-linux-x86_64", - "integrity": "sha512-lu9h8UzDZt34gdCEVHBGW6goE1Ayykq413EovV5B4nG7jBK27mI0GQstzVbWXA3wWaweT39PehXGtVpdqIDGSA==" + "integrity": "sha512-9SbMTScglJUzlBrrAkJrQlPvSW35LmjZz/VRRIz0VOYCzWuV9tjAxSiXXHTXmnewrDa5ym/pxobYfc1CrnLX0w==" }, "win-x64": { "asset": "sfw-windows-x86_64.exe", - "integrity": "sha512-URZXauIsdUT12E2KTc4sfsxRmJm7nRJzAgM+IYGX4Xq+X0cl/eAbH5SpYIJKpsnW9csSztW9ceyPhlM+f3neIQ==" + "integrity": "sha512-1SnvHJ04sQrRgMAVtGYri5rBysXFXc8PFPXT1kWBF79X6Xa+4uaJb3epJ6HW9HdRvnquDDgP/ZVj7wb6rglADQ==" } + }, + "soakBypass": { + "version": "1.14.0", + "published": "2026-07-23", + "removable": "2026-07-30" } }, "zizmor": { "description": "GitHub Actions security linter — audits .github/ for workflow-injection / credential-leak patterns.", - "version": "1.26.1", + "version": "1.28.0", "repository": "github:zizmorcore/zizmor", "release": "asset", "notes": [ @@ -133,23 +145,23 @@ "platforms": { "darwin-arm64": { "asset": "zizmor-aarch64-apple-darwin.tar.gz", - "integrity": "sha512-UfLPPdYejR8fvrFwr9Tos7LKFvqr7YcAHWZlAmeo7imYI/fIucYnCY6HeDyk2cbFUvDpQTdps6WQS1QpqCtTfQ==" + "integrity": "sha512-qErEbGBrRH6hwuJDTPio0VUKpV3gKT39eNFwLel7J1RqaucMC5yTilyoo0UdIa9ilxM5P5EocN+wuH8Gw90c7Q==" }, "darwin-x64": { "asset": "zizmor-x86_64-apple-darwin.tar.gz", - "integrity": "sha512-SCbcEzF/zy2qNuNaocLPIUC4Wuq5GnHt0iUFve3qx6bJyFdzU6pDFZkF64dojGC7M5gAcK/4acXGPx3YnMDy/g==" + "integrity": "sha512-hXXbUc6nELKsjUznvTq94dAv4qjeH74Gyj3/1ji+LJf2DWNbk82zc3qM5Y0ik8MkPwrQPC537ETtznTtPKNtQQ==" }, "linux-arm64": { "asset": "zizmor-aarch64-unknown-linux-gnu.tar.gz", - "integrity": "sha512-TkGvwt0zYdmiJ7LZmy6Bz9CdkqcuEpFKhXUK9m0MhSPxBx2gYBsrw6OxGSttMEQ3bvxdvyG6rqbnMgjyDZB1zA==" + "integrity": "sha512-OtTfOoCgBvs05J5fTuo2Kva0y+3j8f7DqlOpbugbd/k/EJRPJjXQpDcEClXn3ClDNXq597kqG3yOAekfidMYUQ==" }, "linux-x64": { "asset": "zizmor-x86_64-unknown-linux-gnu.tar.gz", - "integrity": "sha512-zTMERMDd3JfaRX12klj2fhZGDyrXeLkVUY1QJkCv8RRmAa9uVuH88gHOnv4CQtC5hXS7FPRJzvCVOnw38gV83g==" + "integrity": "sha512-j+SaxjjzZpol8oGl+5yoXPijSLnLaZQd/hjQQfQmxAJpoRJsRITzo52iYOd5E7Gf8YmEt455QjzjPO5eVaC6YQ==" }, "win-x64": { "asset": "zizmor-x86_64-pc-windows-msvc.zip", - "integrity": "sha512-Pijh/CrrOAkZzLiTr2LTHdI8d6+5Ql6B+suY6fXVmL8UVa+4Q36hHL5K67iRFz03v/V/UcrY6+dfhnmot/xfww==" + "integrity": "sha512-YS/j35KOSOO32ZZSCeRgNFQmthYuGBb0hQ2puPimy9c0kSi3eYeINasLBuqEPzfqcoabxePZCG1byBMT4+dDbQ==" } } }, diff --git a/package.json b/package.json index 242839220..119fee7ae 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "soak:fix": "node scripts/soak/soak.mts --fix", "deps:update": "node scripts/soak/update-deps.mts", "tools:check": "node scripts/soak/external-tools.mts --check", + "tools:fix": "node scripts/soak/external-tools.mts --fix", "tools:install": "node scripts/soak/external-tools.mts --install-all --shims", "remotes:check": "node scripts/soak/remotes.mts --check", "remotes:fix": "node scripts/soak/remotes.mts --fix", diff --git a/scripts/soak/external-tools.mts b/scripts/soak/external-tools.mts index a54954641..165e842d2 100644 --- a/scripts/soak/external-tools.mts +++ b/scripts/soak/external-tools.mts @@ -7,6 +7,10 @@ * * - `--check` validate every pin (shape, SRI prefix, soak * annotations on any soakBypass) — CI gate, no network + * - `--fix` prune soakBypass annotations whose window has + * cleared, then run the same checks (the scheduled + * soak-autofix workflow commits the result so the + * gate never sits red waiting for a human) * - `--install ` download + SRI-verify + install into the local * tool rack (see paths.mts RACK_DIR) with a PATH * handle in BIN_DIR @@ -77,7 +81,14 @@ function platformKey(): string { if (!osKey || !archKey) { throw new Error(`unsupported platform ${process.platform}-${process.arch}`) } - return `${osKey}-${archKey}` + // musl vs glibc via the loader-presence heuristic. Without this a musl + // host silently resolved the glibc pin (the `-musl` pnpm entries were + // dead keys) and installed a binary that can't run; tools with no -musl + // pin now fail loud with "no pinned asset" instead. + const musl = + process.platform === 'linux' && + (existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1')) + return `${osKey}-${archKey}${musl ? '-musl' : ''}` } function sriSha512(buf: Buffer): string { @@ -104,6 +115,16 @@ export function checkPins(tools: Record): string[] { } if (pin.soakBypass) { const { published, removable } = pin.soakBypass + // A bypass names the version it was granted for. Bump the pin and + // leave the annotation behind and it now vouches for a version that + // is no longer installed — the ledger says "1.13.1 was adopted early" + // while 1.14.0 ships unreviewed. Mismatch is a hard finding, not a + // stale-annotation warning. + if (pin.soakBypass.version !== pin.version) { + out.push( + `${name}: soakBypass is for ${pin.soakBypass.version} but the pin is ${pin.version} — re-date the annotation for the version actually pinned, or drop it`, + ) + } if (!isValidIsoDate(published) || !isValidIsoDate(removable)) { out.push(`${name}: soakBypass dates are not real YYYY-MM-DD calendar dates`) continue @@ -112,17 +133,77 @@ export function checkPins(tools: Record): string[] { if (removable !== expected) { out.push(`${name}: soakBypass removable ${removable}, wanted ${expected} (published + ${SOAK_DAYS}d)`) } - // A bypass whose window has passed is dead weight: the version has - // soaked, so the annotation must come off (same rule the workspace - // yaml excludes live under). - if (removable < todayIso()) { - out.push(`${name}: soakBypass expired (removable ${removable}) — the pin has soaked, remove the annotation`) - } } } return out } +/** + * Expired soakBypass annotations are STALE, not unsafe: the version has + * soaked, the bypass no longer bypasses anything, and the pin stays + * SRI-verified. They are reported as WARNINGS (exit 0), never failures — + * a date boundary must not redden CI overnight with zero code change. + * The soak-autofix workflow prunes them daily via --fix, so the ledger + * still converges to clean. Missing/malformed/wrong-arithmetic + * annotations stay hard checkPins failures: those are unauditable, + * which IS unsafe. + */ +export function staleBypasses(tools: Record): string[] { + const out: string[] = [] + const today = todayIso() + for (const [name, pin] of Object.entries(tools)) { + const bypass = pin.soakBypass + if (!bypass) { + continue + } + if (!isValidIsoDate(bypass.published) || !isValidIsoDate(bypass.removable)) { + continue + } + // Wrong-arithmetic annotations are a hard checkPins failure, never + // stale/prunable — a too-early removable must not read as "cleared". + if (bypass.removable !== addDaysIso(bypass.published, SOAK_DAYS)) { + continue + } + if (bypass.removable < today) { + out.push(name) + } + } + return out +} + +/** + * Prune expired soakBypass annotations in place and return the pruned tool + * names. Once `removable` is in the past the version has soaked and the + * annotation is dead weight — staleBypasses warns about it until this + * prunes it. Only valid, expired dates are pruned; malformed annotations + * stay findings for a human (never silently rewritten). + */ +export function pruneExpiredSoakBypasses(doc: { + tools: Record +}): string[] { + const pruned: string[] = [] + const today = todayIso() + for (const [name, pin] of Object.entries(doc.tools)) { + const bypass = pin.soakBypass + if (!bypass) { + continue + } + if (!isValidIsoDate(bypass.published) || !isValidIsoDate(bypass.removable)) { + continue + } + // Wrong-arithmetic annotations are a hard checkPins failure, never + // stale/prunable — a too-early removable must not read as "cleared". + if (bypass.removable !== addDaysIso(bypass.published, SOAK_DAYS)) { + continue + } + if (bypass.removable < today) { + delete pin.soakBypass + pruned.push(name) + } + } + return pruned +} + function sriToHex(sri: string): string { return Buffer.from(sri.slice('sha512-'.length), 'base64').toString('hex') } @@ -145,7 +226,16 @@ export function checkDockerPrebake( if (shimList && shimList.join(' ') !== SFW_ECOSYSTEMS.join(' ')) { out.push(`docker prebake: shim list [${shimList.join(' ')}] != SFW_ECOSYSTEMS [${SFW_ECOSYSTEMS.join(' ')}]`) } - if (rustVersion && !dockerBody.includes(`toolchain install ${rustVersion}`)) { + // Parse the install line's full argument list rather than substring- + // matching: `rustup toolchain install 1.91.0 1.93.0` must satisfy an + // msrv of 1.93 even though "toolchain install 1.93" never appears. + const installedToolchains = [...dockerBody.matchAll(/toolchain install ([^\\\n]+)/g)] + .flatMap(m => m[1]!.trim().split(/\s+/)) + .filter(a => /^\d/.test(a)) + if ( + rustVersion && + !installedToolchains.some(t => t === rustVersion || t.startsWith(`${rustVersion}.`)) + ) { out.push(`docker prebake: image does not pre-install the ${rustVersion} msrv toolchain`) } const sfw = tools['sfw-free'] @@ -183,20 +273,39 @@ export function checkDockerPrebake( } export async function download(url: string, expectedSri: string): Promise { - const headers: Record = {} // Only GitHub gets the token (private release assets); sending it to any // other host (e.g. the npm registry for purl tools) would leak the // credential. Cross-origin redirects strip the header automatically. - if (process.env.GITHUB_TOKEN && new URL(url).hostname === 'github.com') { - headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}` - } + const token = + process.env.GITHUB_TOKEN && new URL(url).hostname === 'github.com' + ? process.env.GITHUB_TOKEN + : '' // Fail fast on a stalled release/registry response instead of hanging // CI; 120s is generous for the largest pinned binary on a slow runner. - const res = await fetch(url, { - headers, - redirect: 'follow', - signal: AbortSignal.timeout(120_000), - }) + const attempt = (withAuth: boolean) => + fetch(url, { + headers: withAuth && token ? { authorization: `Bearer ${token}` } : {}, + redirect: 'follow', + signal: AbortSignal.timeout(120_000), + }) + let res = await attempt(Boolean(token)) + // Retry semantics, split by what the status actually means: + // 401/403/404 with a token — the credential is the problem (a PUBLIC + // cross-repo asset endpoint rejecting an Actions token). Retry + // WITHOUT it; public assets need none. + // >=500 — transient. Retry with the SAME auth: dropping it here made a + // private asset (sfw-enterprise) 404 on the retry, reporting a bogus + // "download failed 404" and guaranteeing the retry could never + // succeed. + // The URL is fixed and the SRI is verified below either way, so no retry + // can substitute a different artifact. + if (!res.ok && token && [401, 403, 404].includes(res.status)) { + res = await attempt(false) + } + if (!res.ok && res.status >= 500) { + await new Promise(r => setTimeout(r, 2_000)) + res = await attempt(Boolean(token)) + } if (!res.ok) { throw new Error(`download failed ${res.status} ${url}`) } @@ -266,9 +375,19 @@ export function linkHandle(target: string, name: string): void { } async function installAssetTool(name: string, pin: ToolPin): Promise { - const plat = pin.platforms?.[platformKey()] + const key = platformKey() + const plat = pin.platforms?.[key] if (!plat) { - throw new Error(`${name}: no pinned asset for ${platformKey()}`) + const available = Object.keys(pin.platforms ?? {}).join(', ') || '(none)' + // Name the musl case explicitly: several upstreams (sfw today) ship no + // musl asset, and the failure is otherwise a puzzle on an alpine + // runner. Failing loud beats installing a glibc binary that cannot + // run, but the message has to say what to do about it. + const muslHint = key.endsWith('-musl') + ? `\n ${name} publishes no musl asset. Either run this on a glibc host, ` + + `or add a ${key} entry to external-tools.json once upstream ships one.` + : '' + throw new Error(`${name}: no pinned asset for ${key} (pinned: ${available})${muslHint}`) } // A platform pinned to a registry .tgz (pnpm has no darwin-x64 SEA // upstream) routes through the npm-tarball path instead. @@ -481,10 +600,22 @@ REAL=$(PATH="$CLEAN_PATH" command -v '${cmd}' || true)` set -euo pipefail ${resolveReal} if [ -n "\${${sentinel}:-}" ] || [ -z "$REAL" ] || ! command -v sfw >/dev/null 2>&1; then + # Fail-open must not be SILENT-open: say so once on stderr when the + # firewall is missing (never on the sentinel re-entry path, where sfw + # itself is the caller). + if [ -z "\${${sentinel}:-}" ] && [ -n "$REAL" ]; then + echo "[sfw-shim] sfw not on PATH — running ${cmd} unfirewalled" >&2 + fi [ -n "$REAL" ] && exec "$REAL" "$@" echo "${cmd}: not found" >&2; exit 127 fi export ${sentinel}=1 +# Enterprise sfw defaults to BLOCK for non-registry hosts +# (SFW_UNKNOWN_HOST_ACTION, parsed by the enterprise config), which +# breaks ordinary dev flows the day a Socket key lands. Only the +# enterprise build reads the var — it is inert for the free tier — so +# setting it unconditionally is safe. +export SFW_UNKNOWN_HOST_ACTION=ignore exec sfw '${cmd}' "$@" ` // Remove the handle before writing: writeFileSync FOLLOWS a symlink, so @@ -503,8 +634,16 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise 0) { + writeFileSync(EXTERNAL_TOOLS_JSON, `${JSON.stringify(doc, null, 2)}\n`) + console.log(`[external-tools] pruned expired soakBypass: ${pruned.join(', ')}`) + } + } const tools = loadTools() - if (argv.includes('--check') || argv.length === 0) { + if (argv.includes('--check') || argv.includes('--fix') || argv.length === 0) { const problems = checkPins(tools) if (DOCKER_PREBAKE) { const dockerAbs = path.join(REPO_ROOT, DOCKER_PREBAKE) @@ -525,6 +664,11 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { +test('checkPins validates soakBypass dates and arithmetic; expiry is a warning, not a failure', () => { const pub = addDaysIso(todayIso(), -1) const good = { a: { @@ -42,15 +44,65 @@ test('checkPins validates soakBypass dates, arithmetic, and expiry', () => { }, } assert.deepEqual(checkPins(good), []) + assert.deepEqual(staleBypasses(good), []) const wrongMath = structuredClone(good) wrongMath.a.soakBypass.removable = addDaysIso(pub, 3) assert.match(checkPins(wrongMath)[0]!, /removable/) + // A bypass vouching for a version other than the one pinned is a hard + // finding: the annotation would say an unshipped version was reviewed. + const versionMismatch = structuredClone(good) + versionMismatch.a.soakBypass.version = '0.9.0' + assert.match(checkPins(versionMismatch)[0]!, /soakBypass is for/) + // Expired-but-valid is STALE, not unsafe: checkPins exits clean, the + // stale list reports it, and --fix / soak-autofix prunes it. const expired = structuredClone(good) expired.a.soakBypass = { version: '1.0.0', published: '2020-01-01', removable: '2020-01-08' } - assert.match(checkPins(expired)[0]!, /expired/) + assert.deepEqual(checkPins(expired), []) + assert.deepEqual(staleBypasses(expired), ['a']) const impossible = structuredClone(good) impossible.a.soakBypass = { version: '1.0.0', published: '2026-13-45', removable: '2026-13-52' } assert.match(checkPins(impossible)[0]!, /calendar/) + assert.deepEqual(staleBypasses(impossible), []) +}) + +test('pruneExpiredSoakBypasses prunes only valid, expired annotations', () => { + const pub = addDaysIso(todayIso(), -1) + const doc = { + tools: { + fresh: { + version: '1.0.0', + integrity: GOOD_SRI, + soakBypass: { version: '1.0.0', published: pub, removable: addDaysIso(pub, SOAK_DAYS) }, + }, + expired: { + version: '1.0.0', + integrity: GOOD_SRI, + soakBypass: { version: '1.0.0', published: '2020-01-01', removable: '2020-01-08' }, + }, + // Malformed dates stay findings for a human — never silently pruned. + malformed: { + version: '1.0.0', + integrity: GOOD_SRI, + soakBypass: { version: '1.0.0', published: '2026-13-45', removable: '2026-13-52' }, + }, + unannotated: { version: '1.0.0', integrity: GOOD_SRI }, + }, + } + // Wrong-arithmetic + already-past removable: hard failure territory, + // never pruned or stale. + ;(doc.tools as Record)['wrongmath'] = { + version: '1.0.0', + integrity: GOOD_SRI, + soakBypass: { version: '1.0.0', published: todayIso(), removable: '2020-01-02' }, + } + assert.deepEqual(pruneExpiredSoakBypasses(doc), ['expired']) + assert.ok('soakBypass' in (doc.tools as Record)['wrongmath']!) + assert.deepEqual(staleBypasses(doc.tools), []) + assert.ok(doc.tools.fresh.soakBypass) + assert.ok(!('soakBypass' in doc.tools.expired)) + assert.ok(doc.tools.malformed.soakBypass) + // Idempotent: a second pass finds nothing left to prune. + assert.deepEqual(pruneExpiredSoakBypasses(doc), []) }) test('the repo Dockerfile prebake (when present) matches the tracked pins', t => { @@ -124,6 +176,49 @@ test('checkDockerPrebake flags every drift class (synthetic image)', () => { ) }) +// The reason the arg-list parse replaced a substring match: a multi-arg +// install line must SATISFY an msrv it contains. Only the negative case was +// covered before, so the fix itself was untested. +test('checkDockerPrebake accepts an msrv satisfied by a later install arg', () => { + const tools = { + 'sfw-free': { + version: '1.0.0', + platforms: { 'linux-arm64': { asset: 'sfw-linux-arm64', integrity: GOOD_SRI } }, + }, + } + const body = [ + 'for cmd in npm yarn pnpm pip pip3 uv cargo; do make_shim "$cmd"; done', + 'RUN rustup toolchain install 1.91.0 1.93.0 --profile minimal', + 'RUN curl -o /x https://github.com/SocketDev/sfw-free/releases/download/v1.0.0/sfw-linux-arm64', + 'COPY rack/sfw-free/1.0.0/sfw /usr/local/bin/sfw', + `RUN asset=sfw-linux-arm64; sha=${'0'.repeat(128)} verify`, + ].join('\n') + // 1.93 is the SECOND argument — a substring match for + // "toolchain install 1.93" would miss it and false-fail. + const problems = checkDockerPrebake(body, tools, '', '1.93') + assert.equal(problems.some(p => /msrv toolchain/.test(p)), false) + // And a version the line does NOT install is still reported. + assert.ok(checkDockerPrebake(body, tools, '', '1.99').some(p => /msrv toolchain/.test(p))) +}) + +// The 5xx branch is the one this retry logic is named for; the auth +// fallback test above does not reach it. +test('download retries a 5xx and succeeds on the second attempt', async t => { + const payload = Buffer.from('after-5xx') + let calls = 0 + t.mock.method(globalThis, 'fetch', async () => { + calls += 1 + return calls === 1 ? new Response('boom', { status: 503 }) : new Response(payload) + }) + await withEnv('GITHUB_TOKEN', undefined, async () => { + assert.deepEqual( + await download('https://example.com/a', sriOf(payload)), + payload, + ) + }) + assert.equal(calls, 2) +}) + test('download sends the GitHub token to github.com only', async t => { const payload = Buffer.from('pinned-bytes') const seen: Array<{ host: string; auth: string | undefined }> = [] @@ -142,6 +237,45 @@ test('download sends the GitHub token to github.com only', async t => { assert.equal(seen[1]!.auth, undefined) }) +test('download keeps auth across a 5xx retry, drops it only on 401/403/404', async t => { + const payload = Buffer.from('private-bytes') + const seen: Array = [] + let calls = 0 + t.mock.method(globalThis, 'fetch', async (_url: string | URL, init?: RequestInit) => { + seen.push((init?.headers as Record | undefined)?.authorization) + calls += 1 + // First attempt: transient 500. Retry must still carry the token, or a + // private asset would 404 and never recover. + return calls === 1 ? new Response('boom', { status: 500 }) : new Response(payload) + }) + await withEnv('GITHUB_TOKEN', 'ghs_test_token', async () => { + const got = await download('https://github.com/o/r/releases/download/v1/a', sriOf(payload)) + assert.deepEqual(got, payload) + }) + assert.equal(seen.length, 2) + assert.ok(seen[0], 'first attempt is authed') + assert.ok(seen[1], 'the 5xx retry stays authed') +}) + +test('download falls back to unauthenticated when the authed fetch fails', async t => { + const payload = Buffer.from('public-bytes') + const seen: Array = [] + t.mock.method(globalThis, 'fetch', async (_url: string | URL, init?: RequestInit) => { + const auth = (init?.headers as Record | undefined)?.authorization + seen.push(auth) + // Authed fetch is rejected 404 (as a public cross-repo asset endpoint + // can when handed an Actions token); the unauthenticated retry wins. + return auth ? new Response('nope', { status: 404 }) : new Response(payload) + }) + await withEnv('GITHUB_TOKEN', 'ghs_test_token', async () => { + const got = await download('https://github.com/o/r/releases/download/v1/a', sriOf(payload)) + assert.deepEqual(got, payload) + }) + assert.equal(seen.length, 2) + assert.ok(seen[0]) + assert.equal(seen[1], undefined) +}) + test('download rejects http errors and integrity mismatches', async t => { const payload = Buffer.from('served-bytes') let status = 503 diff --git a/scripts/soak/paths.mts b/scripts/soak/paths.mts index e0336dada..0b698fa6d 100644 --- a/scripts/soak/paths.mts +++ b/scripts/soak/paths.mts @@ -38,7 +38,13 @@ export const NPM_INSTALLERS: string[][] = [['pnpm', 'install']] // rustup's cargo shim — the only cargo that understands `+nightly`, and so // the only one whose `cargo update` can honor the [unstable] // min-publish-age soak (see .cargo/config.toml). -export const RUSTUP_CARGO = path.join(os.homedir(), '.cargo/bin/cargo') +// CARGO_HOME-aware: rustup installs its shims under $CARGO_HOME/bin. +const CARGO_HOME = process.env.CARGO_HOME || path.join(os.homedir(), '.cargo') +export const RUSTUP_CARGO = path.join( + CARGO_HOME, + 'bin', + process.platform === 'win32' ? 'cargo.exe' : 'cargo', +) // Pinned external tool manifest + the local tool rack it installs into: // exact versions under rack///, flat PATH handles in bin/. diff --git a/scripts/soak/remotes.mts b/scripts/soak/remotes.mts index 4f4651891..225d58ba6 100644 --- a/scripts/soak/remotes.mts +++ b/scripts/soak/remotes.mts @@ -41,7 +41,7 @@ export const EXPECTED_REMOTES: Record = { */ export function normalizeGitHubRepo(url: string): string | null { const m = - /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim()) + /^(?:https:\/\/(?:[^@/\s]+@)?|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim()) return m ? m[1]! : null } diff --git a/scripts/soak/soak.mts b/scripts/soak/soak.mts index 1dc91ad34..f62c4e16e 100644 --- a/scripts/soak/soak.mts +++ b/scripts/soak/soak.mts @@ -68,20 +68,79 @@ export function checkCargoConfig(body: string, file: string): Finding[] { return out } +/** + * npm has its OWN exclude surface — `min-release-age-exclude[]=` + * (npm >= 11.17) — parallel to pnpm's `minimumReleaseAgeExclude` block. + * The same rule applies: a bare name or `@scope/*` glob expresses standing + * trust, but a VERSION-PINNED entry is a dated bypass and needs the + * `# published: | removable:` annotation on the line above. Without this + * check, `min-release-age-exclude[]=lodash@1.2.3` was an unvalidated, + * never-expiring hole in exactly the gate the yaml side closes (found by + * auditing a fleet repo that uses this syntax heavily for trusted scopes). + */ +export function checkNpmrcExcludes(body: string, file: string): Finding[] { + const out: Finding[] = [] + const lines = body.split('\n') + for (let i = 0; i < lines.length; i++) { + const m = /^min-release-age-exclude\[\]\s*=\s*(\S+)\s*$/.exec(lines[i]!) + if (!m) { + continue + } + const spec = m[1]! + if (!VERSION_PIN_RE.test(spec)) { + // Bare name / scope glob: standing trust, no annotation needed. + continue + } + const ann = ANNOTATION_RE.exec(lines[i - 1]?.trim() ?? '') + if (!ann) { + out.push({ + file, + what: `npm soak exclude '${spec}' annotation`, + saw: '(no annotation on the line above)', + wanted: `# published: YYYY-MM-DD | removable: `, + fix: 'annotate the pin with its real registry publish date, or exclude the bare name for standing trust', + }) + continue + } + const [, published, removable] = ann as unknown as [string, string, string] + if (!isValidIsoDate(published) || !isValidIsoDate(removable)) { + out.push({ + file, + what: `npm soak exclude '${spec}' annotation dates`, + saw: `${published} | ${removable}`, + wanted: 'real YYYY-MM-DD calendar dates', + fix: 'correct the annotation to the real registry publish date', + }) + continue + } + const expected = addDaysIso(published, SOAK_DAYS) + if (removable !== expected) { + out.push({ + file, + what: `npm soak exclude '${spec}' removable date`, + saw: removable, + wanted: `${expected} (published ${published} + ${SOAK_DAYS} days)`, + fix: 'correct the removable date', + }) + } + } + return out +} + export function checkNpmrc(body: string, file: string): Finding[] { + const out: Finding[] = [] const days = /^min-release-age=(\d+)\s*$/m.exec(body)?.[1] - if (Number(days) === SOAK_DAYS) { - return [] - } - return [ - { + if (Number(days) !== SOAK_DAYS) { + out.push({ file, what: 'npm min-release-age window', saw: days ?? '(missing)', wanted: String(SOAK_DAYS), fix: `set min-release-age=${SOAK_DAYS} (or run --fix)`, - }, - ] + }) + } + out.push(...checkNpmrcExcludes(body, file)) + return out } export function checkWorkspaceYaml(body: string, file: string): Finding[] { @@ -103,13 +162,13 @@ export function checkWorkspaceYaml(body: string, file: string): Finding[] { /** * Every version-pinned `minimumReleaseAgeExclude` entry must carry, on the * line directly above, `# published: YYYY-MM-DD | removable: YYYY-MM-DD` - * with `removable = published + SOAK_DAYS`, and must be pruned once - * `removable` is strictly in the past. Bare names and `@scope/*` globs are - * standing trust, not dated bypasses — no annotation required. + * with `removable = published + SOAK_DAYS`. Bare names and `@scope/*` + * globs are standing trust, not dated bypasses — no annotation required. + * EXPIRED entries are not findings (see staleExcludes): stale is not + * unsafe, and a date boundary must not redden CI with zero code change. */ export function checkExcludeAnnotations(body: string, file: string): Finding[] { const out: Finding[] = [] - const today = todayIso() // Flow style would be invisible to the block parser below — an // unvalidated, never-expiring bypass. One canonical shape only. if (/^minimumReleaseAgeExclude:\s*\[/m.test(body)) { @@ -157,19 +216,37 @@ export function checkExcludeAnnotations(body: string, file: string): Finding[] { fix: 'correct the removable date', }) } - if (removable < today) { - out.push({ - file, - what: `soak exclude '${entry.name}' expired`, - saw: `removable ${removable} < today ${today}`, - wanted: 'entry pruned once its window has passed', - fix: 'delete the pin + its annotation (or run --fix)', - }) - } } return out } +/** + * Version-pinned excludes whose window has cleared. Stale, not unsafe — + * the soak would admit the version anyway — so main() WARNS about these + * (exit 0) instead of failing; `--fix` (and the daily soak-autofix + * workflow) prunes them together with their annotation lines. Only valid, + * correctly-annotated entries qualify: anything malformed stays a + * checkExcludeAnnotations failure. + */ +export function staleExcludes(body: string): string[] { + const today = todayIso() + return parseExcludeEntries(body) + .filter( + e => + VERSION_PIN_RE.test(e.name) && + e.annotation && + isValidIsoDate(e.annotation.published) && + isValidIsoDate(e.annotation.removable) && + // Wrong-arithmetic annotations are NOT stale — they are a hard + // checkExcludeAnnotations failure a human must correct. Treating + // a too-early removable as "cleared" would prune a bypass whose + // real window may still be open. + e.annotation.removable === addDaysIso(e.annotation.published, SOAK_DAYS) && + e.annotation.removable < today, + ) + .map(e => e.name) +} + interface ExcludeEntry { name: string line: number @@ -183,7 +260,10 @@ export function parseExcludeEntries(body: string): ExcludeEntry[] { let blockIndent = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i]! - if (/^minimumReleaseAgeExclude:\s*$/.test(line)) { + // Tolerate a trailing comment on the key line — without it, a stray + // `minimumReleaseAgeExclude: # note` never opened the block and every + // entry beneath silently escaped validation. + if (/^minimumReleaseAgeExclude:\s*(?:#.*)?$/.test(line)) { inBlock = true blockIndent = -1 continue @@ -233,7 +313,22 @@ export function checkCatalogParity( for (const m of block.matchAll(/^[ \t]+['"]?([^'":\s]+)['"]?:\s*['"]?([^'"\s]+)['"]?\s*$/gm)) { catalog[m[1]!] = m[2]! } - const pkg = JSON.parse(pkgJson) + let pkg: { dependencies?: Record; devDependencies?: Record } + try { + pkg = JSON.parse(pkgJson) + } catch { + // A broken package.json is a finding, not a stack trace — the gate + // must report every surface, not die on the first bad parse. + return [ + { + file: yamlFile, + what: 'catalog package.json parse', + saw: '(invalid JSON in the package.json beside the workspace yaml)', + wanted: 'parseable JSON so catalog lockstep is checkable', + fix: 'repair the package.json, then re-run', + }, + ] + } const declared: Record = { ...pkg.dependencies, ...pkg.devDependencies, @@ -301,35 +396,75 @@ export function checkRenovateConfig(body: string, file: string): Finding[] { }, ] } + const out: Finding[] = [] const wanted = `${SOAK_DAYS} days` const saw = config['minimumReleaseAge'] - if (SOAK_DAYS === 0 ? saw === undefined : saw === wanted) { - return [] - } - return [ - { + if (!(SOAK_DAYS === 0 ? saw === undefined : saw === wanted)) { + out.push({ file, what: 'renovate minimumReleaseAge window', saw: saw === undefined ? '(missing — an extends: preset does not count)' : String(saw), wanted: SOAK_DAYS === 0 ? '(absent — soak disabled)' : wanted, fix: `set "minimumReleaseAge": "${wanted}" at the top level (or run --fix)`, - }, - ] + }) + } + // Without strict, renovate's default "flexible" internal-checks mode + // will raise an update that has NOT cleared minimumReleaseAge when no + // other update is pending — i.e. the window silently stops biting. + if (SOAK_DAYS !== 0 && config['internalChecksFilter'] !== 'strict') { + out.push({ + file, + what: 'renovate internalChecksFilter', + saw: config['internalChecksFilter'] === undefined + ? '(missing — defaults to "flexible")' + : String(config['internalChecksFilter']), + wanted: '"strict" so minimumReleaseAge is never bypassed', + fix: 'set "internalChecksFilter": "strict" at the top level', + }) + } + return out } +/** + * Rewrite ONLY the minimumReleaseAge line, textually. A JSON.parse + + * re-stringify round-trip reformats the whole file (collapsing the + * hand-written single-line arrays other rules use) — 20 lines of churn + * for a 1-line change, and a standing fight with whatever formatter the + * repo runs. The window is the only key this fixer owns; every other + * byte, including packageRules like the decmpfs musl hold, is left + * untouched. + */ export function fixRenovateConfig(body: string): string { - let config: Record + // Bail on unparseable input: never rewrite blind. try { - config = JSON.parse(body) + JSON.parse(body) } catch { return body } + // [ \t] on the trailing match, not \s: under /m, `\s*$` consumes the + // NEWLINES after the value, so a replace here silently deletes the blank + // line that follows — the same defect fixed in fixNpmrc/fixWorkspaceYaml, + // which this fixer had kept. + const existing = /^([ \t]*)"minimumReleaseAge"\s*:\s*"[^"]*"(,?)[ \t]*$/m if (SOAK_DAYS === 0) { - delete config['minimumReleaseAge'] - } else { - config['minimumReleaseAge'] = `${SOAK_DAYS} days` + // Drop the line (and its newline) when the soak is disabled. + return body.replace(new RegExp(`${existing.source}\n`, 'm'), '') } - return `${JSON.stringify(config, null, 2)}\n` + if (existing.test(body)) { + return body.replace(existing, `$1"minimumReleaseAge": "${SOAK_DAYS} days"$2`) + } + // Absent: insert as the last top-level key, matching the file's own + // indentation, without touching anything else. + const lastBrace = body.lastIndexOf('}') + if (lastBrace === -1) { + return body + } + const head = body.slice(0, lastBrace).replace(/\s*$/, '') + const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? ' ' + // No separator after `{` (an empty object) or an existing trailing + // comma — otherwise `{}` would become the invalid `{,\n...}`. + const comma = head.endsWith('{') || head.endsWith(',') ? '' : ',' + return `${head}${comma}\n${indent}"minimumReleaseAge": "${SOAK_DAYS} days"\n${body.slice(lastBrace)}` } export function fixCargoConfig(body: string): string { @@ -340,15 +475,19 @@ export function fixCargoConfig(body: string): string { } export function fixNpmrc(body: string): string { - if (/^min-release-age=\d+\s*$/m.test(body)) { - return body.replace(/^min-release-age=\d+\s*$/m, `min-release-age=${SOAK_DAYS}`) + // [ \t] not \s: `\s` matches newlines, so `\s*$` under /m swallowed the + // blank lines that follow the key (silent reformatting of the file). + if (/^min-release-age=\d+[ \t]*$/m.test(body)) { + return body.replace(/^min-release-age=\d+[ \t]*$/m, `min-release-age=${SOAK_DAYS}`) } return `${body.trimEnd()}\nmin-release-age=${SOAK_DAYS}\n` } export function fixWorkspaceYaml(body: string): string { + // [ \t] not \s on the trailing match: `\s*$` under /m consumes the + // newlines after the value, deleting following blank lines. let out = body.replace( - /^(minimumReleaseAge:\s*)\d+\s*$/m, + /^(minimumReleaseAge:[ \t]*)\d+[ \t]*$/m, `$1${SOAK_MINUTES}`, ) // Prune expired pins together with their annotation line. @@ -356,7 +495,21 @@ export function fixWorkspaceYaml(body: string): string { const lines = out.split('\n') const drop = new Set() for (const entry of parseExcludeEntries(out)) { - if (entry.annotation && entry.annotation.removable < today) { + // Prune only WELL-FORMED cleared annotations (same rule as + // staleExcludes): a wrong-arithmetic removable already in the past + // must surface as a check failure, not vanish silently. + // VERSION_PIN_RE too: the prune set must EQUAL the warn set + // (staleExcludes). Without it a bare-name / `@scope/*` standing-trust + // entry that merely sits under an expired annotation line was deleted + // by --fix — silently re-arming the soak for a whole scope, in a bot + // commit whose review story is "only annotation lines are touched". + if ( + entry.annotation && + VERSION_PIN_RE.test(entry.name) && + isValidIsoDate(entry.annotation.published) && + entry.annotation.removable === addDaysIso(entry.annotation.published, SOAK_DAYS) && + entry.annotation.removable < today + ) { drop.add(entry.line - 1) if (ANNOTATION_RE.test(lines[entry.line - 2]?.trim() ?? '')) { drop.add(entry.line - 2) @@ -425,6 +578,13 @@ export function main(argv: string[] = process.argv.slice(2)): number { // Catalog <-> package.json lockstep for the package next to the yaml. const yamlAbs = path.join(REPO_ROOT, SURFACES.workspaceYaml) const pkgAbs = path.join(path.dirname(yamlAbs), 'package.json') + if (existsSync(yamlAbs)) { + for (const name of staleExcludes(readFileSync(yamlAbs, 'utf8'))) { + console.warn( + `[soak] warn: exclude '${name}' has soaked — stale pin, pruned by --fix / the soak-autofix workflow`, + ) + } + } if (existsSync(yamlAbs) && existsSync(pkgAbs)) { findings.push( ...checkCatalogParity( diff --git a/scripts/soak/soak.test.mts b/scripts/soak/soak.test.mts index b52a1f0c0..fb06c6fd3 100644 --- a/scripts/soak/soak.test.mts +++ b/scripts/soak/soak.test.mts @@ -9,6 +9,7 @@ import { checkCatalogParity, checkExcludeAnnotations, checkNpmrc, + checkNpmrcExcludes, checkRenovateConfig, checkTazeConfig, checkWorkspaceYaml, @@ -18,6 +19,7 @@ import { fixWorkspaceYaml, main, parseExcludeEntries, + staleExcludes, } from './soak.mts' // A pin published yesterday is inside its window; one published long ago @@ -50,6 +52,30 @@ test('npmrc: window must match SOAK_DAYS and fix writes it', () => { assert.match(fixNpmrc('min-release-age=3\n'), /min-release-age=7/) }) +test('npmrc excludes: version pins need dated annotations, globs do not', () => { + // The shape a fleet repo actually uses: trusted scopes and bare names + // are standing trust and need no annotation. + const trusted = [ + 'min-release-age=7', + 'min-release-age-exclude[]=@socketsecurity/*', + 'min-release-age-exclude[]=sfw', + ].join('\n') + assert.deepEqual(checkNpmrcExcludes(trusted, 'n'), []) + + // A VERSION-PINNED exclude is a dated bypass — unannotated is a finding. + const unannotated = 'min-release-age-exclude[]=lodash@4.17.21\n' + assert.match(checkNpmrcExcludes(unannotated, 'n')[0]!.what, /lodash@4\.17\.21/) + + // Correctly annotated passes; wrong arithmetic is a finding. + const pub = addDaysIso(todayIso(), -1) + const ok = `# published: ${pub} | removable: ${addDaysIso(pub, SOAK_DAYS)}\nmin-release-age-exclude[]=lodash@4.17.21\n` + assert.deepEqual(checkNpmrcExcludes(ok, 'n'), []) + const wrongMath = `# published: ${pub} | removable: ${addDaysIso(pub, 3)}\nmin-release-age-exclude[]=lodash@4.17.21\n` + assert.match(checkNpmrcExcludes(wrongMath, 'n')[0]!.what, /removable date/) + const badDates = `# published: 2026-13-45 | removable: 2026-13-52\nmin-release-age-exclude[]=lodash@4.17.21\n` + assert.match(checkNpmrcExcludes(badDates, 'n')[0]!.what, /annotation dates/) +}) + test('workspace yaml: clean fixture passes', () => { assert.deepEqual(checkWorkspaceYaml(CLEAN_YAML, 'y'), []) }) @@ -73,11 +99,16 @@ test('excludes: unannotated version pin is a finding, bare/glob are not', () => assert.match(findings[0]!.what, /lodash@4\.17\.21/) }) -test('excludes: wrong removable date and expiry are findings', () => { +test('excludes: wrong removable date is a finding; expiry is a warning, not a finding', () => { const wrong = `minimumReleaseAgeExclude:\n # published: ${FRESH_PUB} | removable: ${addDaysIso(FRESH_PUB, 3)}\n - 'a@1.0.0'\n` assert.match(checkExcludeAnnotations(wrong, 'y')[0]!.what, /removable date/) + // Expired-but-valid is STALE, not unsafe: check exits clean, the stale + // list reports it, and --fix / the soak-autofix workflow prunes it. const expired = `minimumReleaseAgeExclude:\n # published: 2020-01-01 | removable: 2020-01-08\n - 'b@1.0.0'\n` - assert.match(checkExcludeAnnotations(expired, 'y')[0]!.what, /expired/) + assert.deepEqual(checkExcludeAnnotations(expired, 'y'), []) + assert.deepEqual(staleExcludes(expired), ['b@1.0.0']) + const malformed = `minimumReleaseAgeExclude:\n # published: 2026-13-45 | removable: 2026-13-52\n - 'c@1.0.0'\n` + assert.deepEqual(staleExcludes(malformed), []) }) test('excludes: impossible calendar dates are findings, not crashes', () => { @@ -93,6 +124,16 @@ test('excludes: entries with trailing comments still parse', () => { assert.equal(checkExcludeAnnotations(yaml, 'y').length, 0) }) +test('fix and stale-list skip a wrong-arithmetic expired annotation', () => { + // published + SOAK_DAYS != removable and removable is already past: + // this must stay a check failure for a human, not silently prune — + // the real window may still be open. + const yaml = `minimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n # published: ${todayIso()} | removable: 2020-01-02\n - 'wrongmath@1.0.0'\n` + assert.deepEqual(staleExcludes(yaml), []) + assert.ok(fixWorkspaceYaml(yaml).includes('wrongmath@1.0.0')) + assert.ok(checkExcludeAnnotations(yaml, 'y').length >= 1) +}) + test('fix prunes expired pins together with their annotations', () => { const yaml = `minimumReleaseAge: 10080\nminimumReleaseAgeExclude:\n # published: 2020-01-01 | removable: 2020-01-08\n - 'old@1.0.0'\n # published: ${FRESH_PUB} | removable: ${FRESH_REM}\n - 'fresh@1.0.0'\n` const fixed = fixWorkspaceYaml(yaml) @@ -122,6 +163,20 @@ test('taze config: window must be imported, not hand-copied', () => { assert.equal(checkTazeConfig('export default {}\n', 't').length, 2) }) +test('parser: a trailing comment on the key line still opens the block', () => { + // Without comment tolerance, every entry under a commented key line + // silently escaped validation — a blind spot in the bypass gate. + const yaml = 'minimumReleaseAgeExclude: # temporary bypasses\n - lodash@4.17.21\n' + assert.deepEqual(parseExcludeEntries(yaml).map(e => e.name), ['lodash@4.17.21']) + assert.equal(checkExcludeAnnotations(yaml, 'y').length, 1) +}) + +test('catalog parity: malformed package.json is a finding, not a crash', () => { + const findings = checkCatalogParity('catalog:\n taze: 19.14.1\n', 'not json', 'y') + assert.equal(findings.length, 1) + assert.match(findings[0]!.what, /parse/) +}) + test('parser: a column-0 line ends the exclude block', () => { const yaml = 'minimumReleaseAgeExclude:\n - react\nonlyBuiltDependencies:\n - esbuild\n' assert.deepEqual(parseExcludeEntries(yaml).map(e => e.name), ['react']) @@ -139,16 +194,68 @@ test('fix rewrites a drifted cargo window and leaves a clean one alone', () => { }) test('renovate: window must be explicit in-repo; preset inheritance is drift', () => { - const good = `{ "extends": ["some>preset"], "minimumReleaseAge": "${SOAK_DAYS} days" }` + const good = `{ "extends": ["some>preset"], "minimumReleaseAge": "${SOAK_DAYS} days", "internalChecksFilter": "strict" }` assert.equal(checkRenovateConfig(good, 'r').length, 0) + // internalChecksFilter is load-bearing: renovate's default "flexible" + // mode raises updates that have NOT cleared minimumReleaseAge. + const noStrict = `{ "minimumReleaseAge": "${SOAK_DAYS} days" }` + assert.match(checkRenovateConfig(noStrict, 'r')[0]!.what, /internalChecksFilter/) + const flexible = `{ "minimumReleaseAge": "${SOAK_DAYS} days", "internalChecksFilter": "flexible" }` + assert.match(checkRenovateConfig(flexible, 'r')[0]!.what, /internalChecksFilter/) // Missing key = inherited-at-best: the preset can change without a // commit here, so the gate demands the explicit value. - assert.equal(checkRenovateConfig('{ "extends": ["some>preset"] }', 'r').length, 1) - assert.equal(checkRenovateConfig('{ "minimumReleaseAge": "3 days" }', 'r').length, 1) - assert.equal(checkRenovateConfig('{ "minimumReleaseAge": 7 }', 'r').length, 1) + // These fixtures each miss BOTH the window and the strict filter, so + // both findings fire; assert on the window one specifically. + for (const bad of [ + '{ "extends": ["some>preset"] }', + '{ "minimumReleaseAge": "3 days" }', + '{ "minimumReleaseAge": 7 }', + ]) { + const findings = checkRenovateConfig(bad, 'r') + assert.ok(findings.some(f => /minimumReleaseAge window/.test(f.what)), bad) + } + // Unparseable input is a single parse finding, not a pile of key checks. assert.equal(checkRenovateConfig('not json', 'r').length, 1) }) +test('renovate fix touches ONLY the window line — no reformatting churn', () => { + // A JSON round-trip would collapse these hand-written single-line + // arrays and rewrite unrelated rules (e.g. the decmpfs musl hold). + const original = [ + '{', + ' "extends": ["local>preset"],', + ' "packageRules": [', + ' {', + ' "matchPackageNames": ["decmpfs"],', + ' "allowedVersions": "<=0.1.0"', + ' }', + ' ],', + ` "minimumReleaseAge": "3 days"`, + '}', + '', + ].join('\n') + const fixed = fixRenovateConfig(original) + assert.equal(JSON.parse(fixed).minimumReleaseAge, `${SOAK_DAYS} days`) + // Every other line is byte-identical. + const changed = original + .split('\n') + .map((line, i) => [line, fixed.split('\n')[i]]) + .filter(([a, b]) => a !== b) + assert.equal(changed.length, 1) + assert.match(changed[0]![1]!, /minimumReleaseAge/) + // Rules survive verbatim, arrays stay inline. + assert.ok(fixed.includes('"matchPackageNames": ["decmpfs"]')) + assert.ok(fixed.includes('"allowedVersions": "<=0.1.0"')) + assert.ok(fixed.includes('"extends": ["local>preset"]')) +}) + +test('renovate fix inserts the window into a minimal object without breaking JSON', () => { + // Regression: the naive insert produced the invalid `{,\n ... }`. + const fixed = fixRenovateConfig('{}') + assert.equal(JSON.parse(fixed).minimumReleaseAge, `${SOAK_DAYS} days`) + assert.equal(fixRenovateConfig(fixed), fixed) +}) + test('renovate fix sets the window, preserves other keys, and is idempotent', () => { const fixed = fixRenovateConfig('{\n "labels": ["dependencies"],\n "minimumReleaseAge": "3 days"\n}\n') const parsed = JSON.parse(fixed) diff --git a/scripts/soak/update-deps.mts b/scripts/soak/update-deps.mts index aaafd05c0..7f92aec74 100644 --- a/scripts/soak/update-deps.mts +++ b/scripts/soak/update-deps.mts @@ -69,7 +69,76 @@ function updateCargo(dryRun: boolean): number { return 1 } const args = dryRun ? ['+nightly', 'update', '--dry-run'] : ['+nightly', 'update'] - return run(RUSTUP_CARGO, args, REPO_ROOT) + // VERIFY the soak actually applied. `[unstable] min-publish-age` is a + // warning-only unused key on any cargo that does not implement it, so a + // merely-OLD nightly updates every crate with no window at all, + // silently, and the run still exits 0. Measured both sides: + // - nightly 2026-03-21 (cargo 1.96.0-nightly): no such -Z, key unused + // - nightly 2026-07-27 (cargo 1.99.0-nightly): -Z min-publish-age + // present, and resolution visibly holds a too-fresh release back + // ("available: v0.2.189, published 7 days ago") + // Capture stderr and treat the warning as a failure: claiming a + // protection we did not apply is worse than no protection. + console.log(`[update-deps] ${RUSTUP_CARGO} ${args.join(' ')} (in .)`) + const res = spawnSync(RUSTUP_CARGO, args, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['inherit', 'inherit', 'pipe'], + }) + const stderr = res.stderr ?? '' + process.stderr.write(stderr) + if (res.error) { + console.error(`[update-deps] ${RUSTUP_CARGO}: ${res.error.message}`) + return 1 + } + // The window can make re-resolution IMPOSSIBLE rather than merely + // holding a version back: if a requirement's only matching release is + // younger than the window (e.g. `pkg = "^4"` when 4.0.0 shipped 3 days + // ago), cargo fails the whole update. That is the soak doing its job, + // but cargo's own help line advertises + // CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow — a blanket env-var + // bypass this design deliberately does not have. Say so before someone + // copy-pastes it out of a red terminal. + if (isBlockedByPublishAge(stderr)) { + console.error( + '[update-deps] the cargo soak BLOCKED this re-resolution: a requirement can\n' + + ' only be satisfied by a release younger than the window (see the error above).\n' + + ' This is the window working, not a bug. Options, in order of preference:\n' + + ' 1. wait out the remaining days and re-run;\n' + + ' 2. relax/repin the requirement so an already-soaked version satisfies it;\n' + + ' 3. if the fresh release is genuinely required, adopt it as a deliberate,\n' + + ' reviewable commit — NOT via CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE,\n' + + ' which silently disables the window for every crate in the graph.', + ) + return res.status ?? 1 + } + if (isMinPublishAgeUnsupported(stderr)) { + console.error( + '[update-deps] cargo ignored [unstable] min-publish-age — this nightly does not\n' + + ' implement it, so the update ran with NO soak window. Update the nightly\n' + + ' (`rustup update nightly`) and re-run; the lockfile changes are unsoaked.', + ) + return 1 + } + return res.status ?? 1 +} + +/** + * cargo emits `unused config key ...` (a warning, exit 0) for an + * `[unstable]` key it does not implement, so the ONLY signal that the soak + * silently did not apply is this line on stderr. Exported for the tests. + */ +export function isMinPublishAgeUnsupported(stderr: string): boolean { + return /unused config key `unstable\.min-publish-age`/.test(stderr) +} + +/** + * cargo's resolver failure when a requirement's only candidate is inside + * the window: `version X is too new (published N days ago, minimum age M + * days)`. Exported for the tests; pins cargo's wording. + */ +export function isBlockedByPublishAge(stderr: string): boolean { + return /is too new \(published .*minimum age/.test(stderr) } // No flag = both; naming both explicitly also means both — a naive diff --git a/scripts/soak/update-deps.test.mts b/scripts/soak/update-deps.test.mts index bc159c7b9..834cba56d 100644 --- a/scripts/soak/update-deps.test.mts +++ b/scripts/soak/update-deps.test.mts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { selectEcosystems } from './update-deps.mts' +import { + isBlockedByPublishAge, + isMinPublishAgeUnsupported, + selectEcosystems, +} from './update-deps.mts' test('no ecosystem flag updates both', () => { assert.deepEqual(selectEcosystems([]), { npm: true, cargo: true }) @@ -16,3 +20,27 @@ test('a single flag selects only that ecosystem', () => { test('naming both explicitly means both, not neither (regression)', () => { assert.deepEqual(selectEcosystems(['--npm', '--cargo']), { npm: true, cargo: true }) }) + +// The cargo soak is a warning-only unused key on any cargo that does not +// implement it, so this string is the ONLY evidence it silently did not +// apply. Pin the exact wording cargo emits. +test('detects the unused-config-key warning that means the cargo soak did not apply', () => { + const real = + "warning: unused config key `unstable.min-publish-age` in `/repo/.cargo/config.toml`\n" + assert.equal(isMinPublishAgeUnsupported(real), true) + assert.equal(isMinPublishAgeUnsupported('warning: unused config key `unstable.other`\n'), false) + assert.equal(isMinPublishAgeUnsupported(''), false) + assert.equal(isMinPublishAgeUnsupported(' Updating crates.io index\n'), false) +}) + +// The other half of the cargo-window contract: the resolver can fail +// outright when a requirement's only candidate is too fresh. Pin cargo's +// wording (captured from a real run) so the guidance keeps firing. +test('detects the resolver failure that means the window blocked re-resolution', () => { + const real = + 'error: failed to select a version for the requirement `clap_usage = "^4"`\n' + + ' version 4.0.0 is too new (published 3 days ago, minimum age 7 days)\n' + assert.equal(isBlockedByPublishAge(real), true) + assert.equal(isBlockedByPublishAge('error: failed to select a version\n'), false) + assert.equal(isBlockedByPublishAge(''), false) +}) diff --git a/tools/pnpm-workspace.yaml b/tools/pnpm-workspace.yaml index a60d05e2b..6d73c0187 100644 --- a/tools/pnpm-workspace.yaml +++ b/tools/pnpm-workspace.yaml @@ -17,8 +17,11 @@ minimumReleaseAge: 10080 # Exclusions: bare names / scope globs express standing trust; version pins # are dated soak bypasses and REQUIRE, on the line above: # # published: YYYY-MM-DD | removable: YYYY-MM-DD (removable = published + 7d) -# `pnpm run soak` rejects unannotated or expired pins (and flow-style [..] -# lists, which the gate can't validate); `soak:fix` prunes expired ones. +# `pnpm run soak` rejects unannotated pins (and flow-style [..] lists, +# which the gate can't validate); expired ones warn until `soak:fix` / +# the soak-autofix workflow prunes them. +# Schematic example (use the REAL registry publish date, never these +# placeholders): # minimumReleaseAgeExclude: -# # published: 2026-01-01 | removable: 2026-01-08 +# # published: YYYY-MM-DD | removable: YYYY-MM-DD # - 'example@1.2.3' diff --git a/vendor/aube/Cargo.toml b/vendor/aube/Cargo.toml index 678fb591f..886d6f74a 100644 --- a/vendor/aube/Cargo.toml +++ b/vendor/aube/Cargo.toml @@ -106,10 +106,14 @@ reflink-copy = "0.1" # applied to native addons as they land in the CAS. The `addon` feature # unwraps a napi `--compress` hybrid back to the raw `.node` before the # kernel re-compresses it transparently. -# Pinned to 0.1.0: 0.1.2's FICLONE reflink path types the ioctl request as -# `libc::c_ulong`, which is `libc::Ioctl` on glibc but `c_int` on musl, so it -# fails to compile for *-unknown-linux-musl. Unpin once -# https://github.com/SocketDev/decmpfs releases a fix. +# Pinned EXACT, matching upstream aube: 0.1.2's FICLONE reflink path types +# the ioctl request as `libc::c_ulong`, which is `libc::Ioctl` on glibc but +# `c_int` on musl, so it fails to compile for *-unknown-linux-musl — which +# nub builds (docker-smoke's node:22-alpine leg, release.yml's musl +# binaries). A caret range here was held at 0.1.0 only by the cargo soak +# window, and 0.1.2 published 2026-07-24 — i.e. the range would have +# adopted the musl-breaking release on 2026-07-31 the first time anything +# re-resolved. Unpin once https://github.com/SocketDev/decmpfs ships a fix. decmpfs = { version = "=0.1.0", features = ["addon"] } glob = "0.3" # BurntSushi's gitignore matcher (from ripgrep). Used by `aube pack` /