From 42cb58b3520ea6c55ba67c145b05012efb2f7896 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Tue, 11 Aug 2026 15:00:02 +0300 Subject: [PATCH 1/2] ci: Bun cooldown for installs, no gateway in publish, OIDC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the interim policy Dima Ryskin set out for OSS repos, so GitHub Actions can be re-enabled for base44/cli at the org level: 1. enforce lock-files on all node builds, so the build never overrides one 2. enforce a package manager that supports a minimal-age directive 3. use embargo in non-publish workflow tasks Bun satisfies point 2 natively, so no package manager migration is needed: bun install --minimum-release-age= Only install packages published at least N seconds ago (security feature) bunfig.toml sets `minimumReleaseAge = 604800` (7 days; Bun takes SECONDS, unlike npm's min-release-age, which takes days). Point 1 was already satisfied by `bun install --frozen-lockfile`, which is what every workflow runs. That choice matters operationally, not just aesthetically: Wix-managed machines block registry.npmjs.org at the network-extension layer, so no developer here can regenerate an npm lockfile without routing through the embargo gateway or an internal mirror. A Bun-native cooldown keeps bun.lock as the single source of truth and needs no lockfile regeneration at all. Point 3 — the publish workflows no longer run the embargo gateway: - Drops the `sudo sed -i /etc/hosts` unpin hack from both. There is no pin to strip now, so the window where the gateway was bypassed is gone entirely. - Safe because those jobs resolve nothing: `--frozen-lockfile` installs bun.lock verbatim, so dropping the gateway does not widen what they can pull. - check_wix_proxy_steps.py enforces the split bidirectionally: non-publish jobs must run the proxy, publish jobs must not. Exemptions are keyed on exact filename and printed on success, so the list cannot quietly grow. Also closes the remaining unprotected registry fetches in the publish path: - Authenticate via npm trusted publishing (OIDC). Drops NODE_AUTH_TOKEN / secrets.NPM_TOKEN from preview-publish; manual-publish never had a credential wired at all, so it could not have published regardless. - Replace `bunx json-bump` with `npm version` and `npm pkg set`. json-bump is declared in no manifest, so bunx fetched it at run time — outside bun.lock and outside the cooldown, since bunx accepts --minimum-release-age without enforcing it (oven-sh/bun#30748). - Remove `npm install -g npm@latest` from both publish workflows. - Least-privilege per-job permissions replace the workflow-level blocks; drops manual-publish's unused packages: write and pull-requests: read. Known Bun gaps, documented in bunfig.toml and docs/AGENTS.md rather than left to be discovered: bunx does not enforce the flag (#30748), `bun update --latest` skips transitive deps (#25305), and there is no bypass for vulnerability fixes (#26065) short of minimumReleaseAgeExcludes. Verified: check_wix_proxy_steps.py reports 12 of 12 non-publish jobs across 11 workflows with both publish jobs confirmed to abstain; 12 of 12 unit tests pass (4 new, covering both directions of the rule). Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check_wix_proxy_steps.py | 69 ++++++++++++++++--- .github/scripts/test_check_wix_proxy_steps.py | 64 +++++++++++++++++ .github/workflows/manual-publish.yml | 63 ++++++++++------- .github/workflows/preview-publish.yml | 60 ++++++++++------ bunfig.toml | 24 +++++++ docs/AGENTS.md | 2 +- 6 files changed, 224 insertions(+), 58 deletions(-) create mode 100644 bunfig.toml diff --git a/.github/scripts/check_wix_proxy_steps.py b/.github/scripts/check_wix_proxy_steps.py index 8110a74d..c039421f 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 PUBLISH_WORKFLOWS below came to exist. + +The rule is bidirectional: non-publish jobs must run the proxy, and publish jobs +must not. """ from __future__ import annotations @@ -19,6 +22,28 @@ # 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, and every exemption is printed on success, so this +# cannot quietly grow. Revisit once the embargo publish bug is fixed: these +# workflows should go back to being gatewayed like everything else. +PUBLISH_WORKFLOWS = frozenset( + { + ".github/workflows/manual-publish.yml", + ".github/workflows/preview-publish.yml", + } +) + 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 +143,20 @@ def job_problem(job: dict, workflows: frozenset[str]) -> str | None: return _checkout_problem(steps[0]) +def publish_job_problem(job: dict) -> str | None: + """Describe why this publish 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 publish workflows must not: the gateway " + "cannot carry `npm publish`, so these workflows rely on the committed " + "lockfile plus min-release-age in .npmrc instead" + ) + return None + + def _job_lines(text: str) -> dict[str, int]: document = yaml.compose(text) if document is None: @@ -133,16 +172,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 PUBLISH_WORKFLOWS: + exempt += 1 + exempt_paths.add(rel) + problem = publish_job_problem(job) + 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 +202,14 @@ 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"Publish workflows exempt by policy, and verified to abstain " + f"({exempt} job(s)): " + ", ".join(sorted(exempt_paths)) + ) return 0 diff --git a/.github/scripts/test_check_wix_proxy_steps.py b/.github/scripts/test_check_wix_proxy_steps.py index a407a0f4..dbdcede8 100644 --- a/.github/scripts/test_check_wix_proxy_steps.py +++ b/.github/scripts/test_check_wix_proxy_steps.py @@ -331,6 +331,70 @@ 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 PublishExemptionTests(unittest.TestCase): + """Publish 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_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("but publish workflows 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/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 From 651a5740be97722a4337cc884ee9b70e811992d6 Mon Sep 17 00:00:00 2001 From: Guy Ofeck Date: Tue, 11 Aug 2026 15:29:34 +0300 Subject: [PATCH 2/2] test(ci): prove the cooldown rejects a freshly published release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dima Ryskin asked for a test rather than a code review of #597: "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". Adds .github/workflows/cooldown-check.yml plus check_cooldown.sh, which probes the real bunfig.toml setting in a temp project. Three cases, because an exit code alone does not distinguish a working guardrail from a broken probe: control `bun add lodash` must succeed, so a red result means the cooldown fired rather than the environment being broken. floating `bun add electron-nightly` must not land a version inside the cooldown — either refused, or resolved to an older one. Asserted on the resolved version's publish time from the registry, so silently installing a one-day-old version cannot pass. exact pin `bun add electron-nightly@` must be refused. This is the bypass that matters: a PR pinning an exact fresh version. The cooldown is read out of bunfig.toml rather than hardcoded, so the test cannot drift from the setting it verifies. Two constraints shaped the workflow: - It must NOT run the embargo gateway. Gatewayed, embargo would reject the fresh release itself and the run would prove nothing about Bun. check_wix_proxy_steps gains a third exemption, and now records why each one exists: the frozenset becomes a dict of path -> reason, printed per exemption on success, so an exemption cannot be added without stating its justification. - No third-party actions. The org now sets github_owned_allowed with an empty patterns_allowed and requires SHA pinning, so oven-sh/setup-bun is not usable here; Bun is installed from a run step and actions/checkout is pinned to 3d3c42e5 (v7.0.1). Verified: bash -n clean; bunfig parsing smoke-tested (604800 -> 7 days); check_wix_proxy_steps reports 12 of 12 gatewayed jobs with all 3 exempt jobs confirmed to abstain; 13 of 13 unit tests pass (1 new, asserting each exemption prints its reason). Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/check_cooldown.sh | 118 ++++++++++++++++++ .github/scripts/check_wix_proxy_steps.py | 46 +++---- .github/scripts/test_check_wix_proxy_steps.py | 17 ++- .github/workflows/cooldown-check.yml | 43 +++++++ 4 files changed, 197 insertions(+), 27 deletions(-) create mode 100755 .github/scripts/check_cooldown.sh create mode 100644 .github/workflows/cooldown-check.yml 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 c039421f..d07b6534 100644 --- a/.github/scripts/check_wix_proxy_steps.py +++ b/.github/scripts/check_wix_proxy_steps.py @@ -3,10 +3,10 @@ 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 PUBLISH_WORKFLOWS below came to exist. +in the open — which is exactly how GATEWAY_EXEMPT_WORKFLOWS below came to exist. -The rule is bidirectional: non-publish jobs must run the proxy, and publish jobs -must not. +The rule is bidirectional: jobs must run the proxy, and jobs in an exempt +workflow must not. """ from __future__ import annotations @@ -34,15 +34,17 @@ # client_max_body_size so nginx's 1 MB default rejects a packument carrying the # base64 tarball. # -# Keyed on exact filename, and every exemption is printed on success, so this -# cannot quietly grow. Revisit once the embargo publish bug is fixed: these -# workflows should go back to being gatewayed like everything else. -PUBLISH_WORKFLOWS = frozenset( - { - ".github/workflows/manual-publish.yml", - ".github/workflows/preview-publish.yml", - } -) +# 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. @@ -143,16 +145,15 @@ def job_problem(job: dict, workflows: frozenset[str]) -> str | None: return _checkout_problem(steps[0]) -def publish_job_problem(job: dict) -> str | None: - """Describe why this publish job wrongly runs the proxy, or None if it abstains.""" +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 publish workflows must not: the gateway " - "cannot carry `npm publish`, so these workflows rely on the committed " - "lockfile plus min-release-age in .npmrc instead" + "runs the Wix gateway proxy, but this workflow is exempt and must not " + f"({reason})" ) return None @@ -181,10 +182,10 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: lines = _job_lines(text) for job_id, job in ((yaml.safe_load(text) or {}).get("jobs") or {}).items(): job = job or {} - if rel in PUBLISH_WORKFLOWS: + if rel in GATEWAY_EXEMPT_WORKFLOWS: exempt += 1 exempt_paths.add(rel) - problem = publish_job_problem(job) + problem = exempt_job_problem(job, GATEWAY_EXEMPT_WORKFLOWS[rel]) else: jobs += 1 calls += "uses" in job @@ -206,10 +207,9 @@ def main(repo_root: pathlib.Path = REPO_ROOT) -> int: f"delegate to the workflow they call)." ) if exempt: - print( - f"Publish workflows exempt by policy, and verified to abstain " - f"({exempt} job(s)): " + ", ".join(sorted(exempt_paths)) - ) + 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 dbdcede8..856cb232 100644 --- a/.github/scripts/test_check_wix_proxy_steps.py +++ b/.github/scripts/test_check_wix_proxy_steps.py @@ -331,8 +331,8 @@ 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 PublishExemptionTests(unittest.TestCase): - """Publish workflows must abstain from the proxy; everything else must run it.""" +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 @@ -368,9 +368,18 @@ def test_publish_workflow_may_omit_the_proxy(self): code, output = run_main(root) self.assertEqual(code, 0) - self.assertIn("exempt by policy", output) + 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} @@ -385,7 +394,7 @@ def test_publish_workflow_running_the_proxy_is_rejected(self): code, output = run_main(root) self.assertEqual(code, 1) - self.assertIn("but publish workflows must not", output) + 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: 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