diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml new file mode 100644 index 0000000..d6db1ae --- /dev/null +++ b/.github/workflows/npm-changesets.yml @@ -0,0 +1,255 @@ +# The Changesets-shaped npm flow, for repositories that publish MULTIPLE +# interdependent packages from one workspace. +# +# The tag-derived flow (npm-main.yml / npm-release.yml) owns the single-package +# case and cannot be stretched to this one: it reads the bump from all commits +# since the last release, with no way to attribute a commit to a package, so +# with two packages every change stream bumps both. Changesets scopes each +# change to the packages it names and cascades bumps through dependents — +# that is the one thing it does that cannot be recreated with tags, and the +# reason both flows exist. One published package: use the tag flow. Two or +# more interdependent ones: use this. +# +# Versioning is Changesets' own: merges accumulate changeset files, the flow +# maintains a version pull request, and merging that pull request is the +# release act — this same flow then publishes the new versions to npm. The +# publish authenticates with GitHub OIDC trusted publishing; there is no +# token anywhere, and a guard fails the run if one appears. Provenance is +# requested only when the source repository is public, because the registry +# refuses it from private repositories (E422) rather than degrading. +# +# What a caller provides: +# - permissions: contents: write, pull-requests: write (the version pull +# request), id-token: write (trusted publishing) +# - the trusted publisher on npmjs.com for EVERY published package pointing +# at the CALLER's workflow file; OIDC identifies the top-level workflow, +# and npm allows one workflow per package, so all publishes must run from +# that one file +# - version-command / publish-command when its scripts differ from the +# defaults; both run from the repository root +# +# Serialization is enforced here — the release job carries a per-repository, +# non-cancelling concurrency group — so a caller needs no concurrency of its +# own, though adding one is harmless. +# +# Recovery contract for a publish that fails partway: `changeset publish` +# is resumable. It skips versions that already exist on the registry, so a +# failed run's next attempt — a re-run, or simply the next push — publishes +# only what is missing and then pushes the tags. The pre-publish Build exists +# so that build-class failures never reach the sequential publish at all; +# what a partial publish can still mean is a registry-side failure, and the +# fix is always "run it again", never manual registry surgery. +# +# Known, accepted exposure: changesets/action hands its environment — +# GITHUB_TOKEN included — to the version and publish commands, which run +# consumer code. That is inherent to the Changesets model (the changelog +# writers need the token to read pull-request metadata) and identical to the +# bespoke changesets workflows this replaces; the quality gates, the +# lockfile, and npm 12's install-time script denial are the compensating +# controls. The tag-derived flow does not carry the token into consumer +# commands, which is one more reason single-package repositories belong +# there. +# +# The same acceptance covers OIDC: permissions are job-scoped, so consumer +# install, build, and prepublishOnly code runs in the job that holds +# id-token: write — here and in npm-publish.yml alike. Splitting the build +# into a tokenless job would not close this: `changeset publish` still +# executes each package's prepublishOnly under OIDC, and a dependency able +# to exfiltrate the token could as easily poison the artifact a split job +# would hand over. The boundary that actually holds is the lockfile and the +# gates in front of it — and the token itself mints only this repository's +# own publish identity. + +name: Burnt npm Changesets + +on: + workflow_call: + inputs: + quality-policy-path: + description: Repository-relative quality policy JSONC path + required: false + default: .github/quality-policy.jsonc + type: string + version-command: + description: Command Changesets runs to apply pending changesets + required: false + default: npm run version:packages + type: string + publish-command: + description: Command Changesets runs to publish the packages + required: false + default: npm run publish:packages + type: string + pr-title: + description: Title of the version pull request + required: false + default: "chore(release): version packages 🦋" + type: string + pr-commit: + description: Commit message of the version pull request + required: false + default: "chore: update versions" + type: string + +permissions: + contents: read + +jobs: + quality: + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@d169eff38ec93b6405f15a2b2dd86b4bcda21bcb # v1.4.0 + with: + quality-policy-path: ${{ inputs.quality-policy-path }} + + release: + name: Version or publish + needs: quality + runs-on: ubicloud-standard-4 + concurrency: + # Enforced here rather than trusted to the caller: two concurrent runs + # both force-update Changesets' changeset-release branch, and NOT + # cancel-in-progress because a run cancelled between publishing and + # tagging leaves versions on the registry with nothing behind them. + group: npm-changesets-${{ github.repository }} + cancel-in-progress: false + permissions: + # The version pull request, and the tags and releases Changesets + # creates after publishing. + contents: write + pull-requests: write + # npm trusted publishing (OIDC). + id-token: write + steps: + # Everything in this job runs at the repository root, because that is + # where changesets/action resolves the workspace, the changeset files, + # and the CLI. A nested quality workingDirectory would make the quality + # gates and this job silently disagree about what repository they are + # releasing, so it is rejected rather than accommodated. + - name: Validate invocation + if: fromJSON(needs.quality.outputs.quality-policy).workingDirectory != '.' + run: | + echo "::error::npm-changesets.yml requires the Changesets workspace — and the quality policy's workingDirectory — at the repository root." + exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Changesets reads history and tags to decide what is unpublished. + fetch-depth: 0 + # Do not leave the job's token in `.git/config`. The install and + # build run package code before anything publishes, so a persisted + # credential is reachable by the dependency tree. `commitMode: + # github-api` below authenticates through GITHUB_TOKEN instead, and + # API commits are signed by GitHub, which branch protection tends + # to want. + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + - run: corepack enable + # Before ANY npm invocation — the global npm pin included, since npm + # reads the generated user config and would expand a present credential + # while downloading itself — and before Install, so no consumer + # lifecycle or build code ever has a rejected credential in scope. + # + # This checks what npm will actually authenticate with, NOT whether a + # token exists somewhere in secrets — reading org-level secrets into + # the environment to assert they are empty reports the organization's + # configuration, not this job's, and fails repositories that never + # handed npm anything. + - name: Verify trusted-publishing credentials + run: | + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::No OIDC token available. The calling workflow must grant id-token: write." + exit 1 + fi + if [ -n "${NODE_AUTH_TOKEN:-}" ] || [ -n "${NPM_TOKEN:-}" ]; then + echo "::error::An npm token is present. This flow publishes with trusted publishing and must not carry one." + exit 1 + fi + # Both the generated user config and the repository's own .npmrc, + # and every credential key npm accepts — `_auth` and `_password` + # authenticate a registry just as `_authToken` does. setup-node + # writes an `_authToken` line holding the LITERAL text of the + # interpolation placeholder — the action does not expand it, npm + # does, at read time (actions/setup-node, src/authutil.ts) — so the + # placeholder is expected content and must not be read as a + # credential. npm parses ini-style and trims whitespace around `=`, + # so the patterns allow it too: `_authToken = ` + # authenticates just as well. + # Both the repository root — where the action runs the version and + # publish commands — and this step's own working directory, which a + # policy may point elsewhere. Scanning a file twice is harmless. + # No -q on the second grep: under the default shell's pipefail, -q + # exiting at the first hit can SIGPIPE the producer and turn the + # pipeline's status into 141, which reads as "no credential". + # Consuming the full stream keeps the exit codes honest. + for npmrc in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${GITHUB_WORKSPACE:-.}/.npmrc" .npmrc; do + [ -f "$npmrc" ] || continue + if grep -E '(_authToken|_auth|_password|tokenHelper|certfile|keyfile)[[:space:]]*=' "$npmrc" | + grep -vE '_authToken[[:space:]]*=[[:space:]]*(\$\{NODE_AUTH_TOKEN\})?[[:space:]]*$' > /dev/null; then + echo "::error::$npmrc carries a registry credential. This flow publishes with trusted publishing and must not carry one." + exit 1 + fi + done + - name: Pin the npm CLI + # See "npm install-time security" in AGENTS.md. npm 12 is the floor + # for both halves of this flow: it enforces the install-time + # defaults, and it is well past the 11.5.1 that trusted publishing + # needs — Changesets shells out to `npm publish`, whichever package + # manager installed the workspace. + run: | + npm install --global npm@12 + if [ "$(npm --version | cut -d. -f1)" != "12" ]; then + echo "::error::Expected npm 12 on PATH, found $(npm --version). Something is shadowing the pinned CLI." + exit 1 + fi + - name: Install + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.install }} + # Build before publishing rather than leaning on per-package + # `prepublishOnly`, so a broken build fails the job with its own error + # instead of surfacing as a publish failure halfway through a + # multi-package publish. + - name: Build + run: ${{ fromJSON(needs.quality.outputs.quality-policy).commands.build }} + # The concurrency group serializes runs but does not order them: an + # older push whose quality job finished late can enter the group after + # a newer run and force-update the version pull request backwards, + # dropping the newer commit's changesets. A run that is no longer the + # branch head therefore stands down — the run for the newer commit + # covers everything this one would have done. + - name: Verify this run is still the branch head + id: freshness + # The ref name reaches the script through the environment, never + # interpolated into its source: a branch name is caller-controlled + # text, and expanding it inline would let shell syntax in the name + # execute. + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} + RUN_SHA: ${{ github.sha }} + run: | + head="$(gh api "repos/${{ github.repository }}/git/ref/heads/$REF_NAME" --jq .object.sha)" + if [ "$head" = "$RUN_SHA" ]; then + echo "stale=false" >> "$GITHUB_OUTPUT" + else + echo "stale=true" >> "$GITHUB_OUTPUT" + echo "::notice::$REF_NAME has moved to $head; standing down in favor of that commit's run." + fi + - name: Create release pull request or publish to npm + if: steps.freshness.outputs.stale == 'false' + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + title: ${{ inputs.pr-title }} + commit: ${{ inputs.pr-commit }} + version: ${{ inputs.version-command }} + publish: ${{ inputs.publish-command }} + # Commit and tag over the API rather than the git CLI, which has no + # credentials now that checkout does not persist them. + commitMode: github-api + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The registry refuses provenance from private source repositories + # (E422) rather than publishing without the attestation, so the + # flag follows visibility: a private repository publishes through + # the same OIDC path without attesting, and starts attesting the + # moment it goes public, with no workflow change. + NPM_CONFIG_PROVENANCE: ${{ github.event.repository.private == false }} diff --git a/AGENTS.md b/AGENTS.md index e1c0a9b..1f4d6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,63 @@ This repository holds the organization's required quality workflow and the reusable deployment and publishing workflows. Consumer repositories contribute policy files and thin trigger workflows; everything else lives here. +## The shape + +Two layers, hard boundary between them. + +**This repository is the platform layer.** It owns all logic, all +conditionals, and all credential handling. Nothing outside it touches a +secret, an OIDC token, or a publish/deploy decision. It is operated with +batched releases, Dependabot advancing consumer pins, and a capped policy +schema. + +**A consumer repository is a declaration layer.** Its `.github` contains +exactly two kinds of things, and no logic in either: + +- **Policy files** — every repository-specific fact, commented JSONC, + schema-validated here. Configuration goes in policy, never inline in a + workflow. +- **Trigger files** — thin callers of roughly ten lines with no `if:` + conditions. Each file exists only because GitHub attaches something + per-file that the flows need separated: trigger filters, a permissions + grant, a required-check identity, or a concurrency namespace. A file that + does not carry one of those should not exist; a single multiplexed "ci.yml" + is rejected because it must union every path's permissions onto every + event and replaces declarative `on:` filters with runs that no-op. + Repo-shaped odd jobs (a contract conformance check, a monitor) stay in the + consumer repository — they are not platform material. + +Routing rules, applied in order: + +1. A repository publishing **one** npm package uses the tag-derived flow + (`npm-main.yml` / `npm-release.yml`). **Two or more** interdependent + packages use `npm-changesets.yml`. See "Two npm flow shapes" below for + why the line is hard. +2. The npm caller's filename is load-bearing: npmjs.com binds each package's + trusted publisher to one workflow file, so every publish must run from + that file. Under the tag flow that forces one file with two triggers; the + Changesets flow needs only a push trigger. +3. A repository the standard cannot serve yet runs **sanctioned bespoke** + workflows: an in-repo flow whose header documents exactly which gaps keep + it off the standard, revisited when the standard grows. Silent divergence + is the failure mode; the gap list is what distinguishes an outlier from + drift. +4. The policy schema grows only for needs two or more repositories share. + A knob wanted by exactly one repository means that repository stays + bespoke for that piece instead. + ## Invariants These are not preferences. Changes that break them will be rejected. - Keep every commit signed. -- Workflows must never create commits or push branches. Version numbers are - derived from release tags, never written back to the repository. +- Workflows must never run `git commit` or `git push`, and the tag-derived + flows never write versions back to a repository. The one authorized + exception to write-back: `npm-changesets.yml` maintains its version pull + request and release tags through the GitHub API (`commitMode: github-api`) + — that write-back is Changesets' entire contract and the reason the flow + exists, and API commits are signed by GitHub, which branch protection + wants. Nothing may extend this exception to the git CLI. - Reusable deployment jobs must use the caller repository's actual target environment. Do not introduce `preview` or `preview-*` environments. - Candidate and release are semantic roles, mapped by repository policy. @@ -32,7 +82,7 @@ These are not preferences. Changes that break them will be rejected. ## The flows -Ten workflows. Eight are entry points; two are internal. +Eleven workflows. Nine are entry points; two are internal. | Workflow | Called by | Purpose | | ------------------------ | --------------------------- | ---------------------------------------------- | @@ -43,6 +93,7 @@ Ten workflows. Eight are entry points; two are internal. | `npm-pr.yml` | consumer, on `pull_request` | Quality, package dry run | | `npm-main.yml` | consumer, on push to main | Publish the candidate dist-tag, release drafts | | `npm-release.yml` | consumer, on `release` | Publish the release dist-tag | +| `npm-changesets.yml` | consumer, on push to main | Changesets version PR, multi-package publish | | `phala-deploy.yml` | consumer | Build and deploy a Phala CVM target | | `cloudflare-version.yml` | internal | One `wrangler versions upload` or `deploy` | | `npm-publish.yml` | internal | One `npm publish` via OIDC trusted publishing | @@ -198,6 +249,26 @@ same build to the same Worker twice. Note that this puts pull-request previews on that GitHub Environment, inheriting its secrets and protection rules; that is the cost of modelling one environment honestly. +### Two npm flow shapes + +The npm flows come in two shapes, chosen by how many packages a repository +publishes: + +- **One package** — `npm-main.yml` / `npm-release.yml`. Versions derive from + release tags and Conventional Commits; nothing is committed back. The caller + carries both triggers in one file with event routing, because npm allows one + trusted-publisher workflow per package and both the candidate and the + promoted publish must run from it. +- **Multiple interdependent packages** — `npm-changesets.yml`. The tag flow + cannot attribute a commit to a package, so with two packages every change + stream would bump both; Changesets scopes each change to the packages it + names and cascades bumps through dependents. The caller is a single + push-to-main trigger with no routing: merging the version pull request is + the release act, and the same run publishes. + +Both shapes share the publishing posture below — OIDC trusted publishing, no +tokens, provenance only from public repositories. + ### npm-policy.jsonc ```jsonc diff --git a/README.md b/README.md index 7f329ab..7738324 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,20 @@ SHA with a trailing version comment, so a compromised upstream tag cannot reach the jobs that hold deployment credentials. Dependabot advances the pins weekly and a test rejects any reference that is not a 40-character SHA. -Workflows in this repository never create commits or push branches. +Workflows in this repository never run `git commit` or `git push`, and the +tag-derived flows never write versions back to a repository. The one +write-back exception is the Changesets flow's version pull request, +maintained through the GitHub API — see the invariant in AGENTS.md. ## npm -The npm workflow family performs a package dry run on pull requests, publishes +Repositories publishing multiple interdependent packages use +`npm-changesets.yml`, which wraps Changesets — version pull request on merge, +publish when it lands — in the same trusted-publishing posture as the rest of +the family. The tag-derived flows below are for repositories publishing one +package. + +The tag-derived pair performs a package dry run on pull requests, publishes `v-rc.` with the `next` dist-tag from main, and publishes the stable version with `latest` after manual or automatic promotion. Publishing uses npm trusted publishing through GitHub OIDC, with provenance when the diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 1cba172..1ec41e5 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -486,15 +486,21 @@ test("no workflow accepts an npm token", () => { // npm is retiring 2FA-bypass granular access tokens: they stop skipping 2FA // for account operations in August 2026 and lose publishing entirely around // January 2027. Publishing here is OIDC trusted publishing and nothing else, - // so the only mention of a token name allowed anywhere is npm-publish.yml - // refusing to run when one is present. + // so a token name may appear only where a guard refuses to run when one is + // present: the emptiness check, the refusal message, the grep pattern that + // exempts setup-node's literal interpolation placeholder, and comments + // explaining those. for (const name of fs.readdirSync(directory)) { if (!name.endsWith(".yml")) continue; const source = fs.readFileSync(`${directory}/${name}`, "utf8"); for (const [line] of source.matchAll( /^.*(NPM_TOKEN|NODE_AUTH_TOKEN).*$/gm, )) { - assert.match(line, /-n "\$\{|must not carry one/, `${name}: ${line}`); + assert.match( + line, + /-n "\$\{|must not carry one|^\s*#|NODE_AUTH_TOKEN\\\}/, + `${name}: ${line}`, + ); } } }); @@ -759,3 +765,174 @@ test("npm publish runs package-scoped steps in the package directory", () => { ); } }); + +test("npm changesets flow publishes with OIDC and API commits only", () => { + const source = fs.readFileSync(`${directory}/npm-changesets.yml`, "utf8"); + const workflow = parse(source); + const release = workflow.jobs.release; + assert.equal(release.permissions["id-token"], "write"); + // Serialization is enforced here, not trusted to the caller: concurrent + // runs force-update the changeset-release branch, and a cancel between + // publish and tagging leaves registry versions with nothing behind them. + assert.match(release.concurrency.group, /github\.repository/); + assert.equal(release.concurrency["cancel-in-progress"], false); + const checkout = release.steps.find((step) => + step.uses?.startsWith("actions/checkout@"), + ); + assert.equal(checkout.with["fetch-depth"], 0); + assert.equal(checkout.with["persist-credentials"], false); + // The guard runs before any npm invocation at all — the global pin + // downloads npm through the generated user config — and so before any + // consumer code has a rejected credential in scope. + const stepNames = release.steps.map((step) => step.name); + assert.ok( + stepNames.indexOf("Verify trusted-publishing credentials") < + stepNames.indexOf("Pin the npm CLI"), + ); + assert.ok( + stepNames.indexOf("Pin the npm CLI") < stepNames.indexOf("Install"), + ); + // The action resolves the workspace at the repository root; a nested + // quality workingDirectory is rejected rather than accommodated. + const validate = release.steps.find( + (step) => step.name === "Validate invocation", + ); + assert.match(validate.if, /workingDirectory != '\.'/); + assert.match(validate.run, /repository root/); + const publish = release.steps.at(-1); + assert.equal(publish.with.commitMode, "github-api"); + // A stale run — one whose branch has already moved on — must stand down + // rather than force-update the version pull request backwards. + assert.equal(publish.if, "steps.freshness.outputs.stale == 'false'"); + assert.ok(release.steps.find((step) => step.id === "freshness")); + // Provenance follows source visibility; the registry refuses it from + // private repositories rather than degrading. + assert.match( + publish.env.NPM_CONFIG_PROVENANCE, + /repository\.private == false/, + ); + assert.match(source, /ACTIONS_ID_TOKEN_REQUEST_URL/); +}); + +test("npm changesets guard rejects real credentials, allows the placeholder", (t) => { + const workflow = parse( + fs.readFileSync(`${directory}/npm-changesets.yml`, "utf8"), + ); + const guard = workflow.jobs.release.steps.find( + (step) => step.name === "Verify trusted-publishing credentials", + ); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "npmrc-guard-")); + t.after(() => fs.rmSync(root, { recursive: true })); + const cases = [ + // [label, user npmrc, project npmrc, env overrides, expected status] + ["no oidc", null, null, { ACTIONS_ID_TOKEN_REQUEST_URL: "" }, 1], + ["env NODE_AUTH_TOKEN", null, null, { NODE_AUTH_TOKEN: "npm_x" }, 1], + ["env NPM_TOKEN", null, null, { NPM_TOKEN: "npm_x" }, 1], + ["no npmrc anywhere", null, null, {}, 0], + [ + "placeholder", + "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n", + null, + {}, + 0, + ], + ["empty value", "//registry.npmjs.org/:_authToken=\n", null, {}, 0], + [ + "literal credential", + "//registry.npmjs.org/:_authToken=npm_realtoken\n", + null, + {}, + 1, + ], + // npm's ini parser trims whitespace around `=`, so this authenticates — + // the guard has to see through the spacing (caught in review of the + // guard's first incarnation). + [ + "literal credential with spaces", + "//registry.npmjs.org/:_authToken = npm_realtoken\n", + null, + {}, + 1, + ], + [ + "placeholder with spaces", + "//registry.npmjs.org/:_authToken =${NODE_AUTH_TOKEN}\n", + null, + {}, + 0, + ], + // The other credential keys npm accepts authenticate a registry just as + // _authToken does, and a project .npmrc is read by npm like the user one. + ["basic auth", "//registry.npmjs.org/:_auth=dXNlcjpwYXNz\n", null, {}, 1], + ["password", "//registry.npmjs.org/:_password=cGFzcw==\n", null, {}, 1], + [ + "token helper", + "//registry.npmjs.org/:tokenHelper=/usr/local/bin/npm-token\n", + null, + {}, + 1, + ], + [ + "client certificate", + "//registry.npmjs.org/:certfile=/etc/ssl/npm.crt\n//registry.npmjs.org/:keyfile=/etc/ssl/npm.key\n", + null, + {}, + 1, + ], + [ + "project npmrc credential", + null, + "//registry.npmjs.org/:_authToken=npm_realtoken\n", + {}, + 1, + ], + ["project npmrc benign", null, "save-exact=true\n", {}, 0], + // The action runs version/publish from the repository root, which can + // differ from this step's working directory under a non-root quality + // policy — the workspace root .npmrc must be scanned from anywhere. + [ + "workspace root credential from elsewhere", + null, + "workspace://registry.npmjs.org/:_authToken=npm_realtoken\n", + {}, + 1, + ], + ]; + for (const [ + index, + [label, userRc, projectRc, extra, expected], + ] of cases.entries()) { + const cwd = fs.mkdtempSync(path.join(root, `cwd-${index}-`)); + const env = { + ...process.env, + ACTIONS_ID_TOKEN_REQUEST_URL: "https://oidc", + NPM_CONFIG_USERCONFIG: path.join(cwd, "absent-user-npmrc"), + ...extra, + }; + delete env.NODE_AUTH_TOKEN; + delete env.NPM_TOKEN; + Object.assign(env, extra); + if (userRc !== null) { + const file = path.join(cwd, "user-npmrc"); + fs.writeFileSync(file, userRc); + env.NPM_CONFIG_USERCONFIG = file; + } + const workspace = fs.mkdtempSync(path.join(root, `ws-${index}-`)); + env.GITHUB_WORKSPACE = workspace; + if (projectRc !== null) { + if (projectRc.startsWith("workspace:")) { + fs.writeFileSync( + path.join(workspace, ".npmrc"), + projectRc.slice("workspace:".length), + ); + } else { + fs.writeFileSync(path.join(cwd, ".npmrc"), projectRc); + } + } + const result = spawnSync("bash", ["-euo", "pipefail", "-c", guard.run], { + cwd, + env, + }); + assert.equal(result.status, expected, `${label}: ${result.stderr}`); + } +});