Skip to content

Remove ineffective Dependabot auto-merge workflow #675

Remove ineffective Dependabot auto-merge workflow

Remove ineffective Dependabot auto-merge workflow #675

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@v4
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@v4
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@v4
- uses: actions/setup-node@v4
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@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 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@v4
- uses: actions/setup-node@v4
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
strategy:
fail-fast: false
matrix:
node-version:
# The floor `engines` claims, through to the current release line. 18 and 20 are past their
# upstream support, and are kept deliberately: this package exists to protect apps on whatever
# runtime a builder platform happens to give them, so the oldest runtime it claims to support is
# the one where a regression matters most. That is not theoretical — the Node 18 job is what
# caught a detection field arriving empty there because the runtime exposes no global `crypto`.
- 18.x
- 20.x
- 22.x
- 24.x
- 26.x
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
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'
- 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@v4
- name: Setup Node
uses: actions/setup-node@v4
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
# 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.'