Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,46 @@ jobs:
- name: Consumer shapes against a packed tarball
run: node scripts/compat-matrix.mjs --manager ${{ matrix.manager }}

# Most consumers of the runtime guard are BUNDLED — a Worker through wrangler, a Next edge middleware,
# a SvelteKit adapter build — and all of them tree-shake. A guard that has lost the part which screens
# requests still starts, still logs, and still looks installed, so the failure arrives through the
# consumer's build rather than ours and is invisible from here. This bundles a real edge guard and puts
# the published CVE-2017-5941 exploit through the bundled output, which is the only place a
# shaken-away branch can be observed.
bundled-consumer:
name: 🧩 Bundled edge guard blocks the exploit
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm

- name: Install dependencies
run: npm ci

# `sideEffects: false` is deliberately NOT declared in `package.json`; it was measured to buy zero
# bytes on both consumer shapes, so declaring it would be a standing promise to bundlers with
# nothing bought for it.
#
# The self-test is the gating half: it checks the audit still recognises the constructs it is meant
# to, because a tripwire that cannot fire is the same defect as no tripwire.
- name: Check the side-effect audit against its own cases
run: node scripts/side-effect-audit.mjs --selftest

# The audit itself is a REPORT and exits zero on what it finds — a CommonJS bundle executes at module
# scope by construction and a bin is supposed to run, so there is no honest threshold to fail on. It
# is here to be read when someone considers declaring the field.
- name: Report what the emitted artifacts execute at import time
run: npm run audit:side-effects

- name: Bundle an edge guard and attack it
run: npm run test:bundled

# The generated `.cmd` launcher and path handling are Windows-only code paths in npm's shim, not ours,
# and they are exactly what breaks a bin that works everywhere else. One smoke test rather than the whole
# matrix: the question is whether the launcher runs and resolves, not whether four managers agree.
Expand Down Expand Up @@ -203,3 +243,66 @@ jobs:

- name: Audit published dependency tree
run: npm audit --omit=dev --audit-level=moderate

# One status for branch protection to require.
#
# Requiring the jobs above directly means branch protection names a matrix label — `Consumers on npm
# 11.6.0`, `Validate on Node 24.x`. Those names change whenever a version is bumped, and a required
# check whose name no longer exists is not reported as missing: it is simply never satisfied, or worse,
# silently dropped from the set that gates the merge. Requiring this one job instead means the matrix can
# change freely and the gate keeps meaning "everything passed".
#
# `if: always()` is what makes it work. Without it this job would be SKIPPED when a dependency fails,
# and a skipped required check reads as success to branch protection — the gate would pass precisely
# when something was broken.
required:
name: Required CI gate
if: always()
needs:
- capability-contract
- consumers
- bundled-consumer
- windows-smoke
- validate
- production-audit
runs-on: ubuntu-latest

steps:
- name: Every required job must have succeeded
env:
# The whole `needs` context, so this cannot drift out of step with the list above: a job added
# to `needs` is covered without a second edit here.
RESULTS: ${{ toJSON(needs) }}
run: |
set -euo pipefail

# A matrix job contributes ONE result under its job id, so `consumers` covers every manager and
# `validate` covers every Node version, including 24.
# A floor on how many jobs this gate is standing on. Emptying the `needs` list above would
# otherwise leave a green required check that verifies nothing at all — the one failure mode a
# gate must not have, since it is indistinguishable from a working one.
count=$(printf '%s' "$RESULTS" | python3 -c 'import json, sys; print(len(json.load(sys.stdin)))')
if [ "$count" -lt 6 ]; then
echo "::error::This gate is standing on ${count} job(s); it is meant to require 6. A required check that verifies nothing passes exactly when something is broken."
exit 1
fi

failed=$(printf '%s' "$RESULTS" | python3 -c "
import json, sys
needs = json.load(sys.stdin)
bad = [name for name, job in needs.items() if job.get('result') != 'success']
print(' '.join(sorted(bad)))
")

printf '%s' "$RESULTS" | python3 -c "
import json, sys
for name, job in sorted(json.load(sys.stdin).items()):
print(f\"{job.get('result', 'unknown'):>9} {name}\")
"

if [ -n "$failed" ]; then
echo "::error::Not every required job succeeded: ${failed}"
exit 1
fi

echo 'All required jobs succeeded.'
39 changes: 39 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ reads exactly like passing.
is reachable. Nothing else can see those: they are decided by the tarball's metadata rather than by the
code, so the source suite passes while a consumer cannot import the package at all. CI runs it across
npm, pnpm, Yarn and Bun, plus a Windows smoke test.
- `npm run test:bundled` if you touched the runtime guard, the build config or `exports`. It bundles an
edge guard with esbuild through the `workerd` condition and then puts the real CVE-2017-5941 exploit
through the bundled output. Most consumers of the guard are bundled and every bundler tree-shakes, and a
guard that has lost the part which screens requests still starts, still logs and still looks installed —
so this is the only check that can see it. It also enforces a size ceiling on each shape.
- Read your own diff, commit messages and pull-request description against the boundary above. The
pull-request template asks you to confirm this; it is the only control that covers prose, because secret
scanning finds credentials and nothing finds a paragraph of architecture.
Expand All @@ -63,6 +68,40 @@ Changing onboarding, the install prompt or the setup guide? Read `MAINTAINING.md
files are load-bearing — and note that the install prompt is a tested artifact with its own gate in
`field-test/`.

## Two packaging decisions, and why they are what they are

**`sideEffects: false` is not declared, deliberately.** It was measured, not assumed: `npm run
test:bundled` builds both consumer shapes with and without the field, and it changes the bundle by zero
bytes in each. The root entry already shakes down to 0.8 kB for a single-symbol import. So the field would
buy nothing while standing as a permanent promise to every bundler that no module in the package ever
needs evaluating; the day someone adds a top-level registration, consumers lose it silently in their own
build. If you want to add the field, add the evidence first: a shape where it demonstrably saves bytes.

`npm run audit:side-effects` supports that decision but does not settle it, and it is a **report rather
than a check** — it exits zero on what it finds. It discovers every emitted `.js` and `.cjs` under `dist/`
itself, walks the part of each that runs at import time, and names anything that can execute, deliberately
over-reporting rather than recognising shapes it believes are safe. Pass file paths to narrow it.

The exit code covers only what is unambiguously wrong — a missing file, an empty build directory, or
`--selftest` failing — because there is no honest threshold to fail on: a CommonJS bundle executes at module
scope by construction, `dist/cli.js` ends in `main().then(…)` because a bin is supposed to run, and a benign
lazy-init call in a library entry is indistinguishable here from a harmful one.

`--selftest` is the part with teeth, and it is what CI gates on: 31 cases. `export default init()`,
`class C extends init()`, a bare `import './x.js'`, a top-level `throw`, `new Set(imported)` and
`{ ...imported }` all look like plain declarations to an allowlist, and every one of them runs code. The
list has three outcomes rather than two, because an import with bindings is neither a finding nor safe: it
evaluates another module, and this script cannot see what that does.

The real evidence that the package survives a bundler is `npm run test:bundled`, which attacks the bundled
guard rather than reading it.

**`module` is kept even though nothing this repository tests reads it.** The bundled test shows removing
it changes nothing, but that only means a bundler which understands `exports` ignores it — which is what
`exports` is for. Webpack 4 and older rollup setups predate `exports` and fall back to `module`. The test
cannot see those, so a zero delta is not evidence that dropping it is safe, and one line is a cheap thing
to be wrong about in the safe direction.

## Generated artifacts that must stay in step

Each of these is generated from a source of truth and committed. Changing the source without regenerating
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"dev": "tsup --watch",
"test": "vitest run",
"test:consumers": "node scripts/compat-matrix.mjs",
"test:bundled": "node scripts/bundled-consumer.mjs",
"audit:side-effects": "npm run build && node scripts/side-effect-audit.mjs --selftest && node scripts/side-effect-audit.mjs",
"test:manifest": "bun scripts/test-manifest.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit && node scripts/typecheck-templates.mjs",
Expand Down
5 changes: 5 additions & 0 deletions scripts/build-edge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const result = await esbuild.build({
platform: 'browser', // WinterCG: no Node globals assumed
target: 'es2022',
sourcemap: true,
// The map resolves a stack frame to file and line without carrying the source text, which is published
// in this repository anyway. `tsup.config.ts` sets the same thing for the other three artifacts, and
// `tests/source-maps.test.ts` checks every one of them — the setting living in two places is the reason
// that test exists.
sourcesContent: false,
// Keep the stub inline so the artifact is a single self-contained file (an edge bundler should not
// have to chase a chunk that only ever throws).
splitting: false,
Expand Down
Loading
Loading