Repository navigation
Remove ineffective Dependabot auto-merge workflow #675
Workflow file for this run
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@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.' |