diff --git a/.github/scripts/check_cooldown.sh b/.github/scripts/check_cooldown.sh new file mode 100755 index 00000000..174d1051 --- /dev/null +++ b/.github/scripts/check_cooldown.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Prove the supply-chain cooldown in bunfig.toml actually rejects a fresh release. +# +# Requested by Dima Ryskin (Wix secplatform) as the review for #597: "i think the +# best 'review' would be a test. Can we stack another PR on top of that and check +# if installing a fresh-package is rejected? I used +# npmjs.com/package/electron-nightly for always 'fresh' versions". +# +# This MUST run without the Wix embargo gateway. With the gateway in front, +# embargo would refuse the fresh version itself and the run would prove nothing +# about Bun's guardrail — which is what the publish workflows actually rely on. +# +# Three cases, because "it errored" is not the only pass and not the only failure: +# control a long-stable package still installs, so a red result means the +# cooldown fired rather than the probe being broken +# floating `bun add ` must not land a version inside the cooldown — +# either refused, or silently resolved to an older one +# exact pin `bun add @` must be refused. This is the +# bypass path that matters: a PR pinning an exact fresh version. +# +# Linux/GNU only (runs on ubuntu-latest). Age arithmetic is done in Node to avoid +# date(1) portability problems. +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FRESH_PKG="electron-nightly" # publishes nightly, so its latest is always fresh +CONTROL_PKG="lodash" # unchanged for years; must install + +# Read the policy from bunfig.toml rather than hardcoding it, so this test cannot +# drift away from the setting it is supposed to be verifying. +COOLDOWN_SECONDS="$(sed -nE 's/^[[:space:]]*minimumReleaseAge[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p' "$REPO_ROOT/bunfig.toml" | head -1)" +if [ -z "$COOLDOWN_SECONDS" ]; then + echo "FAIL: no minimumReleaseAge found in bunfig.toml — nothing to verify" + exit 1 +fi +echo "Cooldown under test: ${COOLDOWN_SECONDS}s ($((COOLDOWN_SECONDS / 86400)) days)" +echo "Bun: $(bun --version)" +echo + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cp "$REPO_ROOT/bunfig.toml" "$WORK/bunfig.toml" +cd "$WORK" +printf '{ "name": "cooldown-probe", "private": true, "version": "0.0.0" }\n' >package.json + +failures=0 + +# Age in seconds of a specific published version, per the registry's own metadata. +published_age_seconds() { + curl -sS "https://registry.npmjs.org/$1" | node -e " + let raw = ''; + process.stdin.on('data', (d) => (raw += d)).on('end', () => { + const when = JSON.parse(raw).time?.['$2']; + if (!when) { console.error('no publish time for $1@$2'); process.exit(1); } + console.log(Math.floor((Date.now() - Date.parse(when)) / 1000)); + }); + " +} + +echo "── control: bun add $CONTROL_PKG ──────────────────────────────" +if bun add "$CONTROL_PKG" >control.log 2>&1; then + echo "PASS control package installed, so the probe environment works" +else + echo "FAIL control package could not install — the probe is broken, not the cooldown" + sed 's/^/ /' control.log + failures=$((failures + 1)) +fi +echo + +echo "── floating: bun add $FRESH_PKG ───────────────────────────────" +if bun add "$FRESH_PKG" >floating.log 2>&1; then + resolved="$(node -p "require('$WORK/node_modules/$FRESH_PKG/package.json').version")" + age="$(published_age_seconds "$FRESH_PKG" "$resolved")" || age="" + if [ -z "$age" ]; then + echo "FAIL installed $resolved but could not determine its publish time" + failures=$((failures + 1)) + elif [ "$age" -ge "$COOLDOWN_SECONDS" ]; then + echo "PASS resolved $resolved, published ${age}s ago (>= cooldown)" + echo " fresh versions were filtered out of resolution" + else + echo "FAIL installed $resolved, published only ${age}s ago — inside the cooldown" + failures=$((failures + 1)) + fi +else + echo "PASS bun refused to install $FRESH_PKG" + sed 's/^/ /' floating.log | tail -5 +fi +echo + +echo "── exact pin: bun add $FRESH_PKG@ ─────────────────────" +newest="$(curl -sS "https://registry.npmjs.org/$FRESH_PKG" | node -e " + let raw = ''; + process.stdin.on('data', (d) => (raw += d)).on('end', () => { + const doc = JSON.parse(raw); + console.log(doc['dist-tags'].nightly ?? doc['dist-tags'].latest); + }); +")" +newest_age="$(published_age_seconds "$FRESH_PKG" "$newest")" || newest_age="" +echo "Newest published: $newest (${newest_age:-unknown}s old)" + +if [ -n "$newest_age" ] && [ "$newest_age" -ge "$COOLDOWN_SECONDS" ]; then + echo "SKIP newest version is already older than the cooldown; nothing fresh to reject" + echo " (unexpected for $FRESH_PKG — check it is still publishing nightly)" +elif bun add "$FRESH_PKG@$newest" >pinned.log 2>&1; then + echo "FAIL an exact pin bypassed the cooldown and installed $newest" + echo " a PR pinning a fresh version would defeat the guardrail" + failures=$((failures + 1)) +else + echo "PASS bun refused the exact fresh pin $newest" + sed 's/^/ /' pinned.log | tail -5 +fi +echo + +if [ "$failures" -gt 0 ]; then + echo "RESULT: $failures check(s) failed — the cooldown does not hold" + exit 1 +fi +echo "RESULT: cooldown holds — fresh releases cannot enter the dependency tree" diff --git a/.github/scripts/check_wix_proxy_steps.py b/.github/scripts/check_wix_proxy_steps.py index 8110a74d..d07b6534 100644 --- a/.github/scripts/check_wix_proxy_steps.py +++ b/.github/scripts/check_wix_proxy_steps.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 """Fail if any GitHub Actions job skips the mandatory Wix gateway proxy action. -There is no opt-out marker by design. A job that genuinely cannot run the proxy -(a non-ubuntu runner, say) changes this script in the same PR, so the exception -gets reviewed in the open. +There is still no per-job opt-out marker by design. A job that genuinely cannot +run the proxy changes this script in the same PR, so the exception gets reviewed +in the open — which is exactly how GATEWAY_EXEMPT_WORKFLOWS below came to exist. + +The rule is bidirectional: jobs must run the proxy, and jobs in an exempt +workflow must not. """ from __future__ import annotations @@ -19,6 +22,30 @@ # so a sparse checkout has to materialize both directories. REQUIRED_PATHS = (".github/actions/wix-gateway-proxy", ".github/certs") +# Workflows that must NOT route npm through the embargo gateway. +# +# secplatform's interim policy for OSS repos (Dima Ryskin): use embargo for +# non-publish tasks, and protect publish tasks with an enforced lockfile +# (`bun install --frozen-lockfile`) plus a package manager that honors a +# minimal-age directive (`minimumReleaseAge` in bunfig.toml) instead. Those jobs +# resolve nothing, so dropping the gateway does not widen what they can pull. +# The gateway cannot carry a publish today — `npm publish` sends +# `PUT /`, which misses its `^~ /-/` passthrough block, and it sets no +# client_max_body_size so nginx's 1 MB default rejects a packument carrying the +# base64 tarball. +# +# Keyed on exact filename, each with the reason it abstains, and every exemption +# is printed on success — so this cannot quietly grow. Revisit once the embargo +# publish bug is fixed: the publish workflows should go back to being gatewayed +# like everything else. +GATEWAY_EXEMPT_WORKFLOWS = { + ".github/workflows/manual-publish.yml": "publishes; installs are frozen-lockfile only", + ".github/workflows/preview-publish.yml": "publishes; installs are frozen-lockfile only", + # The gateway would reject the fresh release itself, so a gatewayed run could + # not tell us whether Bun's cooldown works. This job exists to prove it does. + ".github/workflows/cooldown-check.yml": "must reach the registry ungatewayed to test the cooldown", +} + FIX_HINT = """Every job must run the Wix gateway proxy immediately after a checkout that puts it on disk, or that job's npm installs bypass the Wix embargo gateway. @@ -118,6 +145,19 @@ def job_problem(job: dict, workflows: frozenset[str]) -> str | None: return _checkout_problem(steps[0]) +def exempt_job_problem(job: dict, reason: str) -> str | None: + """Describe why this exempt job wrongly runs the proxy, or None if it abstains.""" + if "uses" in job: + return None + steps = job.get("steps") or [] + if any(_uses(step) == PROXY_ACTION for step in steps): + return ( + "runs the Wix gateway proxy, but this workflow is exempt and must not " + f"({reason})" + ) + return None + + def _job_lines(text: str) -> dict[str, int]: document = yaml.compose(text) if document is None: @@ -133,16 +173,23 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: paths = sorted(p for p in workflows_dir.iterdir() if p.suffix in (".yml", ".yaml")) workflows = frozenset(p.relative_to(repo_root).as_posix() for p in paths) problems = [] - jobs = calls = 0 + jobs = calls = exempt = 0 + exempt_paths = set() for path in paths: + rel = path.relative_to(repo_root).as_posix() text = path.read_text(encoding="utf-8") lines = _job_lines(text) for job_id, job in ((yaml.safe_load(text) or {}).get("jobs") or {}).items(): job = job or {} - jobs += 1 - calls += "uses" in job - problem = job_problem(job, workflows) + if rel in GATEWAY_EXEMPT_WORKFLOWS: + exempt += 1 + exempt_paths.add(rel) + problem = exempt_job_problem(job, GATEWAY_EXEMPT_WORKFLOWS[rel]) + else: + jobs += 1 + calls += "uses" in job + problem = job_problem(job, workflows) if problem: problems.append((path.relative_to(repo_root), lines[job_id], job_id, problem)) @@ -156,9 +203,13 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: print( f"Wix gateway proxy: verified {jobs - calls} of {jobs} jobs across " - f"{len(paths)} workflows ({calls} reusable-workflow calls delegate to " - f"the workflow they call)." + f"{len(paths) - len(exempt_paths)} workflows ({calls} reusable-workflow calls " + f"delegate to the workflow they call)." ) + if exempt: + print(f"Exempt by policy, and verified to abstain ({exempt} job(s)):") + for rel in sorted(exempt_paths): + print(f" {rel} — {GATEWAY_EXEMPT_WORKFLOWS[rel]}") return 0 diff --git a/.github/scripts/test_check_wix_proxy_steps.py b/.github/scripts/test_check_wix_proxy_steps.py index a407a0f4..856cb232 100644 --- a/.github/scripts/test_check_wix_proxy_steps.py +++ b/.github/scripts/test_check_wix_proxy_steps.py @@ -331,6 +331,79 @@ def test_job_with_no_body_is_reported_as_missing_the_proxy(self): self.assertIn('Job "build" does not run the Wix gateway proxy.', output) +class GatewayExemptionTests(unittest.TestCase): + """Exempt workflows must abstain from the proxy; everything else must run it.""" + + PUBLISH_WITHOUT_PROXY = textwrap.dedent("""\ + name: Manual Package Publish + on: + workflow_dispatch: + + jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci + - run: npm publish + """) + + PUBLISH_WITH_PROXY = textwrap.dedent("""\ + name: Package Preview Publish + on: + pull_request: + + jobs: + publish-preview: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - run: npm publish + """) + + def test_publish_workflow_may_omit_the_proxy(self): + with fixture_repo(**{"manual-publish": self.PUBLISH_WITHOUT_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("Exempt by policy", output) + self.assertIn(".github/workflows/manual-publish.yml", output) + + def test_each_exemption_prints_its_reason(self): + # An exemption is only reviewable if the run says why it exists. + with fixture_repo(**{"cooldown-check": self.PUBLISH_WITHOUT_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + expected = checker.GATEWAY_EXEMPT_WORKFLOWS[".github/workflows/cooldown-check.yml"] + self.assertIn(expected, output) + + def test_exempt_jobs_are_not_counted_as_verified(self): + with fixture_repo( + **{"manual-publish": self.PUBLISH_WITHOUT_PROXY, "good": COMPLIANT_WORKFLOW} + ) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("verified 1 of 1 jobs across 1 workflows", output) + + def test_publish_workflow_running_the_proxy_is_rejected(self): + with fixture_repo(**{"preview-publish": self.PUBLISH_WITH_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 1) + self.assertIn("this workflow is exempt and must not", output) + + def test_a_non_publish_workflow_still_needs_the_proxy(self): + with fixture_repo(**{"some-publish-helper": self.PUBLISH_WITHOUT_PROXY}) as root: + code, output = run_main(root) + + self.assertEqual(code, 1) + self.assertIn("does not run the Wix gateway proxy", output) + + class RepositoryTests(unittest.TestCase): def test_every_job_in_this_repository_runs_the_proxy(self): self.assertEqual(checker.main(), 0) diff --git a/.github/workflows/cooldown-check.yml b/.github/workflows/cooldown-check.yml new file mode 100644 index 00000000..bf43e9c5 --- /dev/null +++ b/.github/workflows/cooldown-check.yml @@ -0,0 +1,43 @@ +name: Supply-chain Cooldown Check + +# Proves the `minimumReleaseAge` cooldown in bunfig.toml actually rejects a +# freshly published release — the test Dima Ryskin asked for as the review of the +# interim policy, using electron-nightly for reliably "fresh" versions. +# +# Deliberately does NOT run the Wix gateway proxy: with embargo in front, the +# gateway would refuse the fresh version and this would prove nothing about Bun's +# guardrail, which is what the publish workflows rely on. check-wix-proxy.yml +# knows about this exemption and verifies the job abstains. +# +# Uses no third-party actions. `oven-sh/setup-bun` is not on the org allowlist +# (github_owned_allowed only), and SHA pinning is now required — so Bun is +# installed from a run step and actions/checkout is pinned by SHA. + +on: + workflow_dispatch: + pull_request: + paths: + - "bunfig.toml" + - ".github/scripts/check_cooldown.sh" + - ".github/workflows/cooldown-check.yml" + +jobs: + cooldown: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # actions/checkout v7.0.1, SHA-pinned per the org policy. + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Install Bun + # Not oven-sh/setup-bun: third-party actions are not allowlisted for this + # org, and the cooldown does not apply to Bun's own installer anyway. + run: | + curl -fsSL https://bun.sh/install | bash + echo "$HOME/.bun/bin" >>"$GITHUB_PATH" + + - name: Verify the cooldown rejects a fresh release + run: bash .github/scripts/check_cooldown.sh diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 89234c24..f5fc9160 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -27,19 +27,33 @@ on: env: CLI_PACKAGE_DIR: packages/cli +# This workflow deliberately does NOT run the Wix gateway proxy. +# +# secplatform's interim policy for OSS repos (Dima Ryskin): use embargo for +# non-publish tasks, and protect publish tasks with an enforced lockfile plus a +# package manager honoring a minimal-age directive — here `minimumReleaseAge` in +# bunfig.toml. The gateway cannot carry a publish today: `npm publish` sends +# `PUT /`, which matches neither its `^~ /-/` passthrough block nor +# `~ \.tgz$` and so lands in `location /` (proxy_metadata, a read path with +# caching); it also sets no client_max_body_size, so nginx's 1 MB default rejects +# a packument carrying the base64 tarball. This workflow used to pin the registry +# and then strip the pin from /etc/hosts just before publishing; that hack is gone. +# +# `bun install --frozen-lockfile` below installs bun.lock verbatim and resolves +# nothing, so removing the gateway does not widen what this job can pull. +# +# check-wix-proxy.yml enforces the split: the proxy is mandatory everywhere +# except the two publish workflows, where it is forbidden. jobs: publish: runs-on: ubuntu-latest + permissions: + # contents: write for the release commit, tag, and GitHub Release. + # id-token: write for npm trusted publishing (OIDC). + contents: write + id-token: write steps: - - name: Checkout for wix gateway proxy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - sparse-checkout: .github - - - name: Wix gateway proxy (mandatory) - uses: ./.github/actions/wix-gateway-proxy - - name: Generate a token id: generate-token uses: actions/create-github-app-token@v2 @@ -60,8 +74,9 @@ jobs: node-version-file: ".node-version" registry-url: "https://registry.npmjs.org" - - name: Update npm - run: npm install -g npm@latest + # No `npm install -g npm@latest`. Trusted publishing needs npm >= 11.5.1 and + # setup-node resolves `.node-version` (24) to the newest 24.x, which bundles + # npm >= 11.17.0. The upgrade was also a needless registry fetch. - name: Setup Bun id: setup-bun @@ -82,13 +97,13 @@ jobs: - name: Set version working-directory: ${{ env.CLI_PACKAGE_DIR }} + # `npm version` takes both a keyword (patch/minor/major) and an explicit + # version, so it replaces the old branch. It also replaces `bunx json-bump`: + # json-bump is declared in no manifest, so bunx fetched it from the registry + # at run time — outside bun.lock, and outside the cooldown, since bunx + # accepts --minimum-release-age without enforcing it (oven-sh/bun#30748). run: | - VERSION_INPUT="${{ github.event.inputs.version }}" - if [[ "$VERSION_INPUT" =~ ^(patch|minor|major)$ ]]; then - bunx json-bump package.json --$VERSION_INPUT - else - bunx json-bump package.json --replace="$VERSION_INPUT" - fi + npm version "${{ github.event.inputs.version }}" --no-git-tag-version --no-workspaces echo "NEW_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV - name: Build package @@ -120,11 +135,13 @@ jobs: echo "NPM tag: ${{ github.event.inputs.npm_tag }}" echo "Dry run: ${{ github.event.inputs.dry_run }}" - - name: Unpin npm registry for first-party publish - # The embargo would refuse the just-built version; installs above stayed gatewayed. - run: sudo sed -i '/registry\.npmjs\.org/d' /etc/hosts - - name: Publish to NPM + # Authenticates via npm trusted publishing (OIDC) using `id-token: write` + # above — no NODE_AUTH_TOKEN/NPM_TOKEN, so no npm credential reaches the + # build. Requires a trusted publisher for `base44` registered on npmjs.com + # against this repo AND this workflow filename (the registry keys on the + # filename, so each publish workflow needs its own entry). Provenance is + # then generated automatically. working-directory: ${{ env.CLI_PACKAGE_DIR }} run: | # Remove devDependencies before publish (everything is bundled) @@ -190,9 +207,3 @@ jobs: "release_url": "${{ env.RELEASE_URL }}", "release_name": "Release v${{ env.NEW_VERSION }}" } - -permissions: - contents: write - packages: write - pull-requests: read - id-token: write diff --git a/.github/workflows/preview-publish.yml b/.github/workflows/preview-publish.yml index 225235f9..49f9bf7f 100644 --- a/.github/workflows/preview-publish.yml +++ b/.github/workflows/preview-publish.yml @@ -4,9 +4,32 @@ on: pull_request: types: [opened, synchronize, reopened] +# This workflow deliberately does NOT run the Wix gateway proxy. +# +# secplatform's interim policy for OSS repos (Dima Ryskin): use embargo for +# non-publish tasks, and protect publish tasks with an enforced lockfile plus a +# package manager honoring a minimal-age directive — here `minimumReleaseAge` in +# bunfig.toml. The gateway cannot carry a publish today: `npm publish` sends +# `PUT /`, which matches neither its `^~ /-/` passthrough block nor +# `~ \.tgz$` and so lands in `location /` (proxy_metadata, a read path with +# caching); it also sets no client_max_body_size, so nginx's 1 MB default rejects +# a packument carrying the base64 tarball. This workflow used to pin the registry +# and then strip the pin from /etc/hosts just before publishing; that hack is gone. +# +# `bun install --frozen-lockfile` below installs bun.lock verbatim and resolves +# nothing, so removing the gateway does not widen what this job can pull. +# +# check-wix-proxy.yml enforces the split: the proxy is mandatory everywhere +# except the two publish workflows, where it is forbidden. jobs: publish-preview: runs-on: ubuntu-latest + permissions: + # id-token: write for npm trusted publishing (OIDC). + # pull-requests: write for the install-instructions comment. + contents: read + id-token: write + pull-requests: write defaults: run: working-directory: packages/cli @@ -15,19 +38,15 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Wix gateway proxy (mandatory) - uses: ./.github/actions/wix-gateway-proxy - + # No `npm install -g npm@latest`. Trusted publishing needs npm >= 11.5.1 and + # setup-node resolves `.node-version` (24) to the newest 24.x, which bundles + # npm >= 11.17.0. The upgrade was also a needless registry fetch. - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: ".node-version" registry-url: "https://registry.npmjs.org" - - name: Update npm - run: npm install -g npm@latest - working-directory: . - - name: Setup Bun id: setup-bun uses: oven-sh/setup-bun@v2 @@ -90,14 +109,16 @@ jobs: exit 1 fi - # Update name with error handling - if ! bunx json-bump package.json --entry=name --replace="$PREVIEW_PACKAGE"; then + # `npm pkg set` replaces `bunx json-bump`: json-bump is declared in no + # manifest, so bunx fetched it from the registry at run time — outside + # bun.lock, and outside the cooldown, since bunx accepts + # --minimum-release-age without enforcing it (oven-sh/bun#30748). + if ! npm pkg set name="$PREVIEW_PACKAGE"; then echo "❌ ERROR: Failed to set package name to $PREVIEW_PACKAGE" exit 1 fi - # Update version with error handling - if ! bunx json-bump package.json --replace="${{ steps.preview_info.outputs.version }}"; then + if ! npm pkg set version="${{ steps.preview_info.outputs.version }}"; then echo "❌ ERROR: Failed to set package version to ${{ steps.preview_info.outputs.version }}" exit 1 fi @@ -120,13 +141,13 @@ jobs: echo "✅ Safety check passed. Package name is safe to publish." - - name: Unpin npm registry for first-party publish - # The embargo would refuse the just-built version; installs above stayed gatewayed. - run: sudo sed -i '/registry\.npmjs\.org/d' /etc/hosts - - name: Publish preview package - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Authenticates via npm trusted publishing (OIDC) using `id-token: write` + # above — NODE_AUTH_TOKEN/secrets.NPM_TOKEN removed so no npm credential + # reaches the build. Requires a trusted publisher for `@base44-preview/cli` + # registered on npmjs.com against this repo AND this workflow filename (the + # registry keys on the filename, so this workflow needs its own entry, + # separate from manual-publish.yml). run: | # Remove devDependencies before publish (everything is bundled) jq 'del(.devDependencies)' package.json > package.json.tmp && mv package.json.tmp package.json @@ -231,8 +252,3 @@ jobs: await createOrUpdateComment(context.issue.number); } } - -permissions: - contents: read - pull-requests: write - id-token: write diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..baa3a344 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,24 @@ +[install] +# Supply-chain cooldown — Wix secplatform interim policy for OSS repos. +# +# Bun resolves only package versions published at least this long ago, so a +# compromised release cannot enter bun.lock inside the window. This is what lets +# the publish workflows run without the embargo gateway, which cannot carry +# `npm publish` today (see .github/workflows/manual-publish.yml). +# +# Units are SECONDS, unlike npm's `min-release-age`, which takes days. +# 604800 = 7 days. +# +# Applies at resolution time: `bun install` without a lockfile entry, `bun add`, +# `bun update`. `bun install --frozen-lockfile`, which is all CI runs, installs +# bun.lock verbatim and never resolves, so it is unaffected by design. +# +# Known gaps, accepted knowingly rather than discovered later: +# - `bunx` accepts --minimum-release-age but does not enforce it +# (oven-sh/bun#30748). Do not fetch tooling with bunx in a release path; +# the publish workflows use `npm version` / `npm pkg set` instead. +# - `bun update --latest` skips the cooldown for transitive deps +# (oven-sh/bun#25305). +# - No bypass when an update fixes a known vulnerability +# (oven-sh/bun#26065) — use minimumReleaseAgeExcludes for that case. +minimumReleaseAge = 604800 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 453cff78..99f98c5f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -61,7 +61,7 @@ bun run lint:fix # Biome - auto-fix These apply to every task. See topic guides below for domain-specific rules. -1. **Bun for everything** - Use `bun` commands for install, test, build, run +1. **Bun for everything** - Use `bun` commands for install, test, build, run. `bunfig.toml` sets `minimumReleaseAge` (7 days, in **seconds**), a supply-chain cooldown: Bun will not resolve a version published more recently, so a compromised release cannot enter `bun.lock`. It applies to `bun add`/`bun update`, never to `bun install --frozen-lockfile` (which resolves nothing). Two traps: **never fetch tooling with `bunx` in a release path** — it accepts `--minimum-release-age` without enforcing it ([oven-sh/bun#30748](https://github.com/oven-sh/bun/issues/30748)) — and `bun update --latest` skips the cooldown for transitive deps ([#25305](https://github.com/oven-sh/bun/issues/25305)). Use `minimumReleaseAgeExcludes` when a security fix must land inside the window 2. **Zod validation** - Required for all external data (API responses, config files) 3. **@clack/prompts only** - For all user interaction (prompts, spinners, logs). No `console.log`. Under the global `--json` flag the lifecycle runs **silent** (prompts and spinners suppressed, logs routed to stderr) — never assume a TTY 4. **ES Modules** - Use `.js` extensions in all imports