Skip to content

Refine managed file updates (#258) #789

Refine managed file updates (#258)

Refine managed file updates (#258) #789

Workflow file for this run

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.'