Repository navigation
Refine managed file updates (#258) #789
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| capability-contract: | |
| name: Capability contract | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| # The version check compares the manifest against the base branch, so it needs history. | |
| fetch-depth: 0 | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22.x | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Manifest is up to date with its source | |
| run: node scripts/emit-capabilities.mjs --check | |
| - name: Vocabulary changes carry a version bump | |
| # A member added or removed without moving CAPABILITY_VERSION leaves every vendoring consumer | |
| # unable to tell it is behind — the same silent drift the manifest exists to remove. | |
| run: node scripts/check-capability-version.mjs --base "origin/${{ github.base_ref || github.event.repository.default_branch }}" | |
| # Does the PUBLISHED package work when a real consumer installs it? | |
| # | |
| # Everything in `validate` tests the source, or `dist/` from inside the repository. Neither can see the | |
| # questions a consumer actually hits, because the tarball's METADATA decides them: which file an `exports` | |
| # condition resolves to, which declarations TypeScript reads beside it, whether `files` left something | |
| # out. A defect there leaves the source suite green while nothing can import the package. | |
| # | |
| # Managers resolve differently, so npm's answer does not generalise — that is the whole reason to run more | |
| # than one. Yarn Classic and Berry differ enough that a pass under one is not a pass under the other, so | |
| # both are here. | |
| # | |
| # Every version is EXACT. A floating major silently changes what was exercised, and a run that passes for | |
| # a reason nobody chose is not evidence. The script also prints the version it resolved, so the label and | |
| # the artifact cannot disagree. | |
| # | |
| # Berry does not come from the `yarn` package on npm — that line stops at 2.x — so it is installed from | |
| # `@yarnpkg/cli-dist`, which is the distribution that carries the `yarn` binary. | |
| consumers: | |
| name: 📦 Consumers on ${{ matrix.label }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { label: npm 11.6.0, manager: npm, setup: 'npm@11.6.0' } | |
| - { label: pnpm 9.15.4, manager: pnpm, setup: 'pnpm@9.15.4' } | |
| - { label: Yarn Classic 1.22.22, manager: yarn, setup: 'yarn@1.22.22' } | |
| # The layout is in the label: this exercises Berry resolving the export map through | |
| # `node_modules`, which is what the fixture pins. Plug'n'Play is a separate shape and is not | |
| # covered, so the check must not read as "Berry works". | |
| - { label: Yarn Berry 4.18.0 (node-modules), manager: yarn, setup: '@yarnpkg/cli-dist@4.18.0' } | |
| - { label: Bun 1.2.21, manager: bun, setup: '' } | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22.x | |
| cache: npm | |
| - if: matrix.manager == 'bun' | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.2.21 | |
| - name: Install the package manager at an exact version | |
| if: matrix.manager != 'bun' | |
| run: | | |
| npm install --global "${{ matrix.setup }}" | |
| # The version actually on PATH, before any fixture runs. An install that resolved to something | |
| # else — or to nothing — must be visible here rather than inferred from a later failure. | |
| ${{ matrix.manager }} --version | |
| - name: Install dependencies | |
| run: npm ci | |
| - 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@v7 | |
| - uses: actions/setup-node@v7 | |
| 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 demos are the artifact shown to somebody to establish the product does what it claims, so a | |
| # demo that cannot start is a claim with nothing behind it. Each must exit zero, print no failed | |
| # step, reach its own verdict line, AND print the proof it exists to print. Installing the | |
| # on-demand demo target is part of the run. | |
| - name: Every demo runs and proves what it claims | |
| run: npm run test:demos | |
| # 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. | |
| windows-smoke: | |
| name: 🪟 Windows bin and consumer smoke | |
| runs-on: windows-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-node@v7 | |
| with: | |
| node-version: 22.x | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Consumer shapes against a packed tarball | |
| run: node scripts/compat-matrix.mjs --manager npm | |
| validate: | |
| name: Validate on Node ${{ matrix.node-version }} | |
| runs-on: ubuntu-latest | |
| env: | |
| # The maintainer runtime range is a claim like any other, and npm treats an `engines` mismatch as | |
| # a warning unless told otherwise — so a tool that raises its floor would keep this job green | |
| # until it happened to reach an API the runtime lacks. Strict, so the install is the check. | |
| NPM_CONFIG_ENGINE_STRICT: 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| node-version: | |
| # Run with the maintainer toolchain, whose runtime range is `^20.19.0 || >=22.12.0` — the | |
| # Vite/Rolldown the test runner brings. That is a union, not a floor: Node 21, and 22.0 | |
| # through 22.11, are outside it. So both exact ends are pinned, because a floating `20.x` or | |
| # `22.x` would stay green after something starts requiring a version released after the one | |
| # documented, and the floating lines are kept beside them for the current releases. | |
| # | |
| # The floor `engines` claims for CONSUMERS is a different number and is tested by | |
| # `declared-floor`, which installs none of this. | |
| # | |
| # 20 is past its upstream support and is kept deliberately: this package exists to protect | |
| # apps on whatever runtime a builder platform gives them, so the oldest line it claims is | |
| # where a regression matters most. | |
| - 20.19.0 | |
| - 20.x | |
| - 22.12.0 | |
| - 22.x | |
| - 24.x | |
| - 26.x | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Typecheck | |
| run: npm run typecheck | |
| # Before the tests. Several of them only run once `dist/` exists — the edge export-resolution and | |
| # edge-safe checks among them — and in the other order they skip, which is indistinguishable from | |
| # passing. Two dist-dependent checks are rerun explicitly below with a flag that refuses to skip; | |
| # the rest rely on this ordering, so it is the ordering that has to be right. | |
| - name: Build | |
| run: npm run build | |
| - name: Test | |
| run: npm test | |
| # After the build, because it drives the built bin. npm installs a bin as a SYMLINK, so this is the | |
| # only invocation shape that catches an entry point which runs when called directly and does nothing | |
| # when installed — silently, with exit 0. | |
| - name: Packaged bin runs when invoked through a symlink | |
| run: npx vitest run tests/bin-invocation.test.ts | |
| env: | |
| PS_REQUIRE_BIN_CHECK: '1' | |
| # After the build, because it loads `dist/protect.js` — what an application actually loads. Testing | |
| # the source would prove the source blocks the exploit and say nothing about the bundle, and the | |
| # bundler is a real failure surface: an export dropped, a branch shaken out, an edge build diverging. | |
| # | |
| # `PS_REQUIRE_CANARY` turns a skipped run into a failure. A canary that skips reads exactly like one | |
| # that passed, and this is the check that proves a generated rule blocks a real exploit. | |
| - name: Canary — the built engine blocks the exploit and allows the control | |
| run: npx vitest run tests/protect/canary-engine-proof.test.ts | |
| env: | |
| PS_REQUIRE_CANARY: '1' | |
| # After the build, because it drives the built CLI against a project that links the built package: | |
| # the listener reporter is COPIED into `dist/` rather than bundled, so a rename or a missed copy | |
| # step is invisible to every source test and shows up only here. | |
| # | |
| # `PS_REQUIRE_RUNTIME_CHECK` turns a skipped run into a failure, for the same reason as the canary: | |
| # a verification check that skips reads exactly like one that passed. | |
| - name: The runtime traversal check works through the built CLI | |
| run: npx vitest run tests/protect/runtime-check-built.test.ts | |
| env: | |
| PS_REQUIRE_RUNTIME_CHECK: '1' | |
| - name: Verify package contents | |
| run: npm pack --dry-run | |
| production-audit: | |
| name: Production dependency audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 20.x | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Audit published dependency tree | |
| run: npm audit --omit=dev --audit-level=moderate | |
| # The tarball, built once on the version releases are built with, for the floor job below to consume. | |
| # | |
| # Separate because packing runs `prepare`, which needs the repository's development dependencies — | |
| # and those are not installable on every runtime a consumer may be on. A floor job that installed them | |
| # would be testing the maintainer toolchain at that version, which is the opposite of the question. | |
| pack: | |
| name: 📦 Pack the artifact | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: .node-version | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Pack | |
| # The directory first: `--pack-destination` does not create one, and npm's failure for a missing | |
| # destination is an ENOENT on the tarball it was about to write. | |
| run: | | |
| mkdir -p packed | |
| npm pack --pack-destination ./packed | |
| - name: Upload | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: packed-tarball | |
| path: packed/*.tgz | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # The floor `engines.node` claims, tested with the published artifact and without this repository's | |
| # devDependencies. | |
| # | |
| # `--self-contained` runs six explicitly named package/runtime shapes whose fixtures install nothing | |
| # but the tarball, including guard screening through both published module formats. The compiler probes | |
| # install `typescript` and `@types/node` at floating versions, and this job is strict, so a floor either | |
| # of them raises later would turn it red over something that is not this package. The ordinary consumer | |
| # matrix runs all nine shapes on Node 22; only this declared-floor job narrows the set. | |
| # | |
| # `engines` is a CONSUMER contract: npm checks it when someone installs this package. The maintainer | |
| # toolchain is a different question with a different answer — the test runner's Vite/Rolldown need | |
| # 20.19 — and letting that decide `engines` would understate what the artifact supports. So the claim | |
| # is tested the way it is made: install the tarball on the lowest version it names, and put a request | |
| # through the guard. No root `npm ci` here, by design. | |
| # | |
| # The version is DERIVED from the manifest, not written here: a hard-coded one keeps testing the old | |
| # floor when the claim moves, and tests above the new floor when it drops. And `engine-strict` makes | |
| # `engines` refuse rather than warn, so a runtime the package does not admit fails this job instead of | |
| # passing it with a warning. | |
| # | |
| # One manager, not the five above: the question here is the runtime, and whether managers agree is | |
| # already answered by that matrix. | |
| declared-floor: | |
| name: 📦 Consumers on the declared Node floor | |
| needs: pack | |
| runs-on: ubuntu-latest | |
| env: | |
| NPM_CONFIG_ENGINE_STRICT: 'true' | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: The floor `engines.node` claims | |
| id: floor | |
| run: | | |
| floor="$(node scripts/engines-floor.mjs)" | |
| # An empty value would reach `setup-node` as "no version asked for", which resolves to | |
| # whatever the runner already has — a green job on a runtime nobody named. | |
| if [ -z "${floor}" ]; then | |
| echo "::error::Could not read a floor out of engines.node." | |
| exit 1 | |
| fi | |
| echo "engines.node admits ${floor} as its lowest version" | |
| echo "version=${floor}" >> "$GITHUB_OUTPUT" | |
| - name: Setup Node | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ steps.floor.outputs.version }} | |
| - name: Download the packed artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: packed-tarball | |
| path: packed | |
| - name: Install the tarball and exercise the guard | |
| run: | | |
| tarball="$(ls packed/*.tgz)" | |
| echo "consuming ${tarball} on $(node -v), engine-strict on" | |
| node scripts/compat-matrix.mjs --manager npm --self-contained --tarball "${tarball}" | |
| # 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 | |
| - pack | |
| - declared-floor | |
| - 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 8 ]; then | |
| echo "::error::This gate is standing on ${count} job(s); it is meant to require 8. 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.' |