From 7e72d33a03b9dd6535168eb794a73f582da879de Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Tue, 12 May 2026 21:56:55 -0400 Subject: [PATCH 1/6] v0.2: pnpm-only support (single-package only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing npm build action to handle pnpm projects. Single-package only — workspaces support is tracked separately in #208 (design doc in WORKSPACES_PHASE_1.md, also on this branch's predecessor). Files changed: build/actions/npm/validate_inputs.sh: - Accept pnpm-lock.yaml as a valid lockfile. - Still reject yarn.lock (Yarn support is a follow-on). - New: reject ambiguous state (both npm AND pnpm lockfiles present) rather than silently picking one. - Workspaces rejection updated to point at #208 tracking issue. build/actions/npm/build_and_pack.sh: - Detect package manager from lockfile (pnpm-lock.yaml -> pnpm, otherwise npm). - Use pnpm install --frozen-lockfile / pnpm pack on the pnpm path; npm ci / npm pack on the npm path. ignore-scripts threading works the same for both managers. - Build / test detection logic unchanged — uses jq to read package.json scripts and invokes via "$PM" run / "$PM" test. build/actions/npm/action.yml: - "Detect Node.js version source" step renamed to "Detect tooling"; now also outputs package-manager and conditional cache config. - Setup Node.js's `cache` input is now driven dynamically by the tooling step's output. npm path -> 'npm'; pnpm path -> empty (no caching). - New "Setup pnpm (via Corepack)" step gated on package-manager == 'pnpm'. Corepack reads packageManager from package.json if set, otherwise uses its bundled default. - Comment block at the tooling step's pnpm branch explicitly documents the no-pnpm-store-cache decision and references #205. - New package-manager output for downstream visibility. - Build summary now includes which package manager was used. build/actions/npm/test.bats: - Updated tests for pnpm acceptance (the previous "rejects pnpm and yarn" test is now split: "accepts pnpm" + "rejects yarn"). - New tests covering: ambiguous-lockfile rejection, package-manager detection in build_and_pack.sh, conditional cache config in action.yml, no-pnpm-cache rule (#205), Corepack enablement on the pnpm path. - Updated ignore-scripts threading test to handle both npm and pnpm install/pack invocations. - Workspaces-rejection test now verifies the error message points at #208. build/actions/npm/README.md: - Updated intro to mention both npm and pnpm. - "What this action does" section reflects both managers. - New "Caching" section explaining the asymmetry (npm cache safe, pnpm cache deliberately disabled per #205). - "v0.1 limitations" renamed to "v0.2 status"; pnpm removed from the unsupported list. Workspaces rejection now points at #208 and the new WORKSPACES_PHASE_1.md. Security review notes: - Corepack downloads pnpm from the npm registry; integrity is verified via npm's signed metadata. Same trust chain as npm install. - pnpm install --frozen-lockfile is lockfile-faithful and verifies integrity hashes at fetch time, equivalent to npm ci. - pnpm-store cache is deliberately not enabled (tracked in #205, enforced by bats test). - ignore-scripts flag threads through to both managers identically. - No new SHA-pinned third-party actions; reuses actions/setup-node and the existing Corepack bundled with Node 16.10+. #207 --- build/actions/npm/README.md | 33 ++++--- build/actions/npm/action.yml | 127 ++++++++++++++++++++------- build/actions/npm/build_and_pack.sh | 91 ++++++++++++------- build/actions/npm/test.bats | 125 ++++++++++++++++---------- build/actions/npm/validate_inputs.sh | 59 +++++++++---- 5 files changed, 295 insertions(+), 140 deletions(-) mode change 100755 => 100644 build/actions/npm/build_and_pack.sh mode change 100755 => 100644 build/actions/npm/validate_inputs.sh diff --git a/build/actions/npm/README.md b/build/actions/npm/README.md index ebaec07..f21ccc2 100644 --- a/build/actions/npm/README.md +++ b/build/actions/npm/README.md @@ -1,12 +1,12 @@ # Wrangle Build npm -Build an npm package (tarball via `npm pack`), run tests, generate an SBOM, and produce SLSA L3 build provenance via `slsa-github-generator`. The publish step itself runs in the adopter's own workflow because npm Trusted Publishing's OIDC token must come from the caller's workflow filename, not a reusable workflow ([npm/documentation#1755](https://github.com/npm/documentation/issues/1755)). +Build an npm or pnpm package (tarball via `npm pack` or `pnpm pack`), run tests, generate an SBOM, and produce SLSA L3 build provenance via `slsa-github-generator`. Package manager is detected from the lockfile (`package-lock.json` / `npm-shrinkwrap.json` → npm; `pnpm-lock.yaml` → pnpm). The publish step itself runs in the adopter's own workflow because npm Trusted Publishing's OIDC token must come from the caller's workflow filename, not a reusable workflow ([npm/documentation#1755](https://github.com/npm/documentation/issues/1755)). -> **Note:** This README documents *currently-shipped* behavior. For the full design — architecture, attestation model, full step sequence — see [`SPEC.md`](./SPEC.md). +> **Note:** This README documents *currently-shipped* behavior. For the full design — architecture, attestation model, full step sequence — see [`SPEC.md`](./SPEC.md). For npm workspaces support (multi-package monorepos), see [`WORKSPACES_PHASE_1.md`](./WORKSPACES_PHASE_1.md) — design only; not yet implemented. ## Recommended companion: source scan -This action hardens *how* your artifact is produced. It does NOT scan your source — vulnerable deps in `package-lock.json`, dangerous workflow triggers, or missing branch protection still slip through and would be faithfully L3-attested by wrangle as legitimately built. Pair this with wrangle's source-scan workflow ([`actions/scan/README.md`](../../../actions/scan/README.md)) to close that gap on every PR and push. The May 2026 Mini Shai-Hulud compromise of TanStack/router is the most recent example of why this matters — the build side wasn't the vulnerability; the source side was. +This action hardens *how* your artifact is produced. It does NOT scan your source — vulnerable deps in your lockfile, dangerous workflow triggers, or missing branch protection still slip through and would be faithfully L3-attested by wrangle as legitimately built. Pair this with wrangle's source-scan workflow ([`actions/scan/README.md`](../../../actions/scan/README.md)) to close that gap on every PR and push. The May 2026 Mini Shai-Hulud compromise of TanStack/router is the most recent example of why this matters — the build side wasn't the vulnerability; the source side was. ## Before first use @@ -51,12 +51,13 @@ Two ways to adopt: ## What this action does -- Validates that `package.json` and a lockfile (`package-lock.json` or `npm-shrinkwrap.json`) exist. v0.1 supports npm only — pnpm and Yarn are follow-on. +- Validates that `package.json` and a supported lockfile (`package-lock.json`, `npm-shrinkwrap.json`, or `pnpm-lock.yaml`) exist. Yarn (`yarn.lock`) is rejected — Yarn support is a follow-on. If both an npm-style lockfile AND `pnpm-lock.yaml` are present, the action rejects the ambiguous state. - Installs Node.js via `actions/setup-node`. Version resolution order: the `node-version` input, then `.nvmrc`, then `package.json`'s `engines.node`, then a wrangle-default LTS (currently Node 22). Adopters who care about a specific version should set one of the first three explicitly rather than rely on the fallback. -- Installs dependencies with `npm ci` (lockfile-faithful, fails on lockfile drift). -- Runs `npm run build` if `package.json` declares a `scripts.build` entry. Skipped if absent. -- Runs `npm test` if `package.json` declares a non-default `scripts.test` entry (the npm-default `"echo \"Error: no test specified\" && exit 1"` is detected and skipped). -- Produces the package tarball via `npm pack`, written to `dist/`. +- For pnpm projects: enables [Corepack](https://nodejs.org/api/corepack.html) (bundled with Node 16.10+) to provide pnpm on the runner. Corepack uses the version pinned by `package.json`'s `packageManager` field if set, otherwise its bundled default. **Adopters who want deterministic builds should set `packageManager`** — that's the modern ecosystem-standard pin for pnpm and Yarn versions. +- Installs dependencies with `npm ci` (lockfile-faithful, fails on lockfile drift) or `pnpm install --frozen-lockfile` (the pnpm equivalent). +- Runs the project's build script (`npm run build` or `pnpm run build`) if `package.json` declares a `scripts.build` entry. Skipped if absent. +- Runs tests (`npm test` or `pnpm test`) if `package.json` declares a non-default `scripts.test` entry (the npm-default `"echo \"Error: no test specified\" && exit 1"` is detected and skipped). +- Produces the package tarball via `npm pack` or `pnpm pack`, written to `dist/`. - Generates an SPDX SBOM via [`syft`](https://github.com/anchore/syft) (Cosign-keyless-verified install, same tool python uses) over the project source tree. - Computes SHA-256 hashes of the tarball in the format `slsa-github-generator`'s `base64-subjects` input expects. @@ -139,7 +140,7 @@ slsa-verifier verify-artifact \ ## Lifecycle hooks -Wrangle runs `npm ci` and `npm pack` against your project. By default, lifecycle hooks fire normally — `prepare`, `prepack`, `postpack`, and any `install` hooks in dependencies all run, just as they would for an adopter running these commands locally. The L3 attestation thus binds to "what wrangle built from this commit's source + lockfile" — which is what source-control review processes are already designed to govern. A malicious script in `package.json` or a pinned dev-dep is the same threat surface as malicious source code in `src/`: the source/lockfile is version-controlled, code review applies. +Wrangle runs `npm ci` and `npm pack` (or `pnpm install --frozen-lockfile` and `pnpm pack`) against your project. By default, lifecycle hooks fire normally — `prepare`, `prepack`, `postpack`, and any `install` hooks in dependencies all run, just as they would for an adopter running these commands locally. The L3 attestation thus binds to "what wrangle built from this commit's source + lockfile" — which is what source-control review processes are already designed to govern. A malicious script in `package.json` or a pinned dev-dep is the same threat surface as malicious source code in `src/`: the source/lockfile is version-controlled, code review applies. What this means concretely: @@ -147,10 +148,16 @@ What this means concretely: - **`prepublishOnly` does NOT fire.** It only runs when `npm publish` is invoked against a directory, not against a pre-built tarball. If you relied on it for type-checking, move the work into a regular `build` script — wrangle runs `npm run build` automatically when `package.json` declares one. - **Tarball-direct publish is intentional.** Your publish job runs `npm publish `, so the bytes wrangle hashes are exactly the bytes consumers download. This is what makes wrangle's L3 attestation actionable. -**Opt-in hardening.** For adopters who want the stricter "source bytes only, no script execution" model, set `ignore-scripts: true` on the reusable workflow. When true, **nothing in your `package.json`'s `scripts` field runs**: `--ignore-scripts` is passed to both `npm ci` and `npm pack` (suppressing `prepare`/`prepack`/`postpack`/`install` hooks, including in transitive dev-deps), AND `npm run build` and `npm test` are skipped outright. The L3 attestation then binds to "what `npm pack` produces against this source with no script execution at all." Default is off because common ecosystem tools (husky's `prepare`, prebuild-install's `install`) rely on these hooks, and most projects expect their declared build/test to run; turning it on breaks those flows. If you need a finer-grained mode (suppress hooks but still run your own build), open an issue. +**Opt-in hardening.** For adopters who want the stricter "source bytes only, no script execution" model, set `ignore-scripts: true` on the reusable workflow. When true, **nothing in your `package.json`'s `scripts` field runs**: `--ignore-scripts` is passed to both install and pack (suppressing `prepare`/`prepack`/`postpack`/`install` hooks, including in transitive dev-deps), AND `npm run build` / `npm test` (or pnpm equivalents) are skipped outright. The L3 attestation then binds to "what pack produces against this source with no script execution at all." Default is off because common ecosystem tools (husky's `prepare`, prebuild-install's `install`) rely on these hooks, and most projects expect their declared build/test to run; turning it on breaks those flows. If you need a finer-grained mode (suppress hooks but still run your own build), open an issue. -## v0.1 limitations +## Caching -- **npm only.** pnpm and Yarn detection is follow-on; their lockfiles are explicitly rejected at validation. -- **Single-package builds.** `package.json` with a `workspaces` field is rejected at validation; the action also errors out if `npm pack` produces more than one `.tgz`. +Wrangle's npm path enables [`actions/setup-node`'s `cache: 'npm'`](https://github.com/actions/setup-node#caching-global-packages-data) keyed on the lockfile. This caches `~/.npm` (the registry tarball cache), which is safe because `npm ci` re-validates each cached tarball's `integrity` field against `package-lock.json` on every install — a poisoned cache that produces non-matching bytes is rejected before extraction. + +Wrangle's **pnpm path does NOT enable setup-node caching.** pnpm-store stores extracted modules under content-addressed paths and does not re-verify content matches the path's claimed hash at install time. That's the cache-poisoning vector the May 2026 Mini Shai-Hulud / TanStack compromise exploited (see [issue #205](https://github.com/TomHennen/wrangle/issues/205) for the full analysis). For pnpm projects, wrangle accepts the cold-install overhead in exchange for closing that attack vector. + +## v0.2 status + +- **Supported package managers:** npm (`package-lock.json` / `npm-shrinkwrap.json`) and pnpm (`pnpm-lock.yaml`). Yarn is a follow-on. +- **Single-package only.** `package.json` with a `workspaces` field is rejected at validation; the action also errors out if pack produces more than one `.tgz`. Workspaces support (the N-tarball case) is tracked in [#208](https://github.com/TomHennen/wrangle/issues/208); design in [`WORKSPACES_PHASE_1.md`](./WORKSPACES_PHASE_1.md). - **SBOM scope is the project source tree, not the tarball contents.** Wrangle runs `syft dir:` over your source. If `package.json`'s `files` field restricts what `npm pack` ships, the SBOM lists components that aren't in the published `.tgz`. Conversely, bundled C/C++ binaries that `prebuild-install` fetches at consumer install time aren't in source — they don't appear in the SBOM either. Adopters who care about CVE coverage of compiled native portions SHOULD layer binary scanners (Trivy, Grype) against installed `node_modules/` in their own CI. The L3 attestation still covers the exact bytes of the npm `.tgz` regardless of what's inside it. diff --git a/build/actions/npm/action.yml b/build/actions/npm/action.yml index c4c0a9a..22e4a01 100644 --- a/build/actions/npm/action.yml +++ b/build/actions/npm/action.yml @@ -1,7 +1,9 @@ name: Wrangle npm Build description: | - Build an npm package (tarball via `npm pack`), run tests, generate SBOM, - and compute artifact hashes for SLSA provenance. SLSA provenance + Build an npm package (tarball via `npm pack` or `pnpm pack`), run tests, + generate SBOM, and compute artifact hashes for SLSA provenance. + Package manager is detected from the lockfile: `package-lock.json` / + `npm-shrinkwrap.json` -> npm; `pnpm-lock.yaml` -> pnpm. SLSA provenance generation is handled by a separate job in the reusable workflow; publishing lives in the adopter's own workflow because npm Trusted Publishing's OIDC token must come from the caller, not a reusable @@ -17,19 +19,20 @@ inputs: required: false default: "" run-tests: - description: "Whether to run `npm test` if a non-default test script is declared in package.json" + description: "Whether to run `npm test` / `pnpm test` if a non-default test script is declared in package.json" required: false default: "true" ignore-scripts: description: | When true, no package.json script runs: --ignore-scripts is passed - to npm ci AND npm pack (suppressing prepare/prepack/postpack/install - lifecycle hooks, including those in transitive dev-deps), AND - `npm run build` / `npm test` are skipped outright. Opt-in hardening - for adopters who want the L3 attestation to bind to "what npm pack - produces with no script execution at all." Defaults to false because - common ecosystem tools (husky, prebuild-install) rely on these hooks - and most adopters expect their declared build/test to run. If a + to install (npm ci / pnpm install) AND pack (npm pack / pnpm pack), + suppressing prepare/prepack/postpack/install lifecycle hooks + (including those in transitive dev-deps), AND ` run build` / + ` test` are skipped outright. Opt-in hardening for adopters + who want the L3 attestation to bind to "what pack produces with + no script execution at all." Defaults to false because common + ecosystem tools (husky, prebuild-install) rely on these hooks and + most adopters expect their declared build/test to run. If a finer-grained mode is needed (suppress hooks but still run the user's build), it can be added as a separate input. required: false @@ -51,6 +54,9 @@ outputs: metadata-dir: description: "Path to the metadata directory (relative to the workspace) containing the SBOM" value: ${{ steps.metadata.outputs.metadata-dir }} + package-manager: + description: "Package manager detected from lockfile ('npm' or 'pnpm')" + value: ${{ steps.tooling.outputs.package-manager }} runs: using: "composite" @@ -63,15 +69,22 @@ runs: set -euo pipefail "${{ github.action_path }}/validate_inputs.sh" "$INPUT_PATH" - # Resolve which Node.js version setup-node should use. Order: - # 1. inputs.node-version override - # 2. .nvmrc in the project directory - # 3. engines.node in package.json - # 4. wrangle-default LTS — avoids setup-node's confusing "no - # version found" error for projects that pin neither file. + # Resolve which Node.js version setup-node should use, and which + # package manager to install + use. Order: + # Node.js version: + # 1. inputs.node-version override + # 2. .nvmrc in the project directory + # 3. engines.node in package.json + # 4. wrangle-default LTS — avoids setup-node's confusing "no + # version found" error for projects that pin neither file. + # Package manager: + # pnpm-lock.yaml -> pnpm; otherwise npm. validate_inputs.sh has + # already rejected ambiguous and unsupported lockfile states. # Outputs always set exactly one of effective-version / # effective-version-file so setup-node has unambiguous instruction. - - name: Detect Node.js version source + # Cache config is conditional on package manager — see comments at + # the Setup Node.js step below. + - name: Detect tooling id: tooling shell: bash env: @@ -101,34 +114,78 @@ runs: printf 'No version hint in .nvmrc, engines.node, or node-version input — falling back to wrangle default Node %s\n' "$WRANGLE_DEFAULT_NODE" fi - # Cache npm's registry tarball cache (~/.npm), keyed on the lockfile. - # Safe under wrangle's threat model: `npm ci` re-validates each cached - # tarball's integrity hash against the lockfile on every install, so a - # poisoned cache that produces non-matching bytes is rejected before - # extraction. setup-node's `cache: 'npm'` caches `~/.npm` only — it - # does NOT cache `node_modules/`, which would bypass integrity checks - # if cached as extracted modules. + # Package manager + cache configuration. + if [[ -f "$INPUT_PATH/pnpm-lock.yaml" ]]; then + printf 'package-manager=pnpm\n' >> "$GITHUB_OUTPUT" + # NO cache for the pnpm path. setup-node's `cache: 'pnpm'` + # would cache the pnpm-store, which stores extracted modules + # under content-addressed paths and does NOT re-verify + # content matches the path's claimed hash at install time. + # That's exactly the cache-poisoning vector the May 2026 + # Mini Shai-Hulud / TanStack compromise exploited. See: + # https://github.com/TomHennen/wrangle/issues/205 + # npm's `cache: 'npm'` is safe because `npm ci` re-validates + # each tarball's integrity against package-lock.json on every + # install — pnpm install has no equivalent re-verification. + printf 'cache=\n' >> "$GITHUB_OUTPUT" + printf 'Detected pnpm-lock.yaml; using pnpm. setup-node caching deliberately disabled (see issue #205).\n' + else + printf 'package-manager=npm\n' >> "$GITHUB_OUTPUT" + printf 'cache=npm\n' >> "$GITHUB_OUTPUT" + printf 'Detected npm lockfile; using npm with cache=npm.\n' + fi + + # Cache npm's registry tarball cache (~/.npm), keyed on the lockfile, + # ONLY when the npm path is active. Safe under wrangle's threat model: + # `npm ci` re-validates each cached tarball's integrity hash against + # the lockfile on every install, so a poisoned cache that produces + # non-matching bytes is rejected before extraction. setup-node's + # `cache: 'npm'` caches `~/.npm` only — it does NOT cache + # `node_modules/`, which would bypass integrity checks if cached as + # extracted modules. # # cache-dependency-path lists both lockfile names accepted by - # validate_inputs.sh; setup-node hashes whichever exists (skips missing - # paths) so the cache key correctly invalidates on dependency changes. + # validate_inputs.sh for the npm path; setup-node hashes whichever + # exists. When the pnpm path is active (`cache:` is empty from the + # tooling step), setup-node skips caching entirely and ignores + # cache-dependency-path. - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ steps.tooling.outputs.effective-version }} node-version-file: ${{ steps.tooling.outputs.effective-version-file }} - cache: 'npm' + cache: ${{ steps.tooling.outputs.cache }} cache-dependency-path: | ${{ inputs.path }}/package-lock.json ${{ inputs.path }}/npm-shrinkwrap.json - # Run npm ci, optional build, optional test, npm pack into dist/. - # The script does not emit the tarball name on stdout — instead this - # step globs dist/*.tgz after the script returns and asserts exactly - # one match. Channel-free output prevents a future stray stdout-bound - # printf from silently breaking the capture, and the explicit count - # check turns "unexpected multiple tarballs" (e.g., from a workspace - # change) into a clear error rather than a non-deterministic pick. + # Enable Corepack (bundled with Node 16.10+) so pnpm is available on + # PATH. With Corepack enabled, invoking `pnpm` uses the version + # pinned by package.json's `packageManager` field if set, otherwise + # Corepack's bundled default. Adopters who want deterministic builds + # should set `packageManager` per + # https://nodejs.org/api/packages.html#packagemanager . + # + # Trust model: Corepack downloads pnpm from the npm registry and + # verifies the downloaded package's integrity via the npm registry's + # signed metadata (the same trust chain as `npm install`). Not + # hermetic, but matches every other tool wrangle relies on. + - name: Setup pnpm (via Corepack) + if: steps.tooling.outputs.package-manager == 'pnpm' + shell: bash + run: | + set -euo pipefail + corepack enable + pnpm --version + + # Run install (npm ci or pnpm install --frozen-lockfile), optional + # build, optional test, pack into dist/. The script does not emit + # the tarball name on stdout — instead this step globs dist/*.tgz + # after the script returns and asserts exactly one match. Channel- + # free output prevents a future stray stdout-bound printf from + # silently breaking the capture, and the explicit count check turns + # "unexpected multiple tarballs" (e.g., from a workspace change) + # into a clear error rather than a non-deterministic pick. - name: Build and pack id: build shell: bash @@ -218,12 +275,14 @@ runs: INPUT_PATH: ${{ inputs.path }} VERSION: ${{ steps.metadata.outputs.version }} TARBALL: ${{ steps.build.outputs.tarball }} + PACKAGE_MANAGER: ${{ steps.tooling.outputs.package-manager }} run: | set -euo pipefail { printf '## npm Build Results\n\n' printf '| | |\n|---|---|\n' printf '| **Package** | %s |\n' "$INPUT_PATH" + printf '| **Package manager** | %s |\n' "$PACKAGE_MANAGER" printf '| **Version** | %s |\n' "$VERSION" printf '| **Tarball** | `%s` |\n' "$TARBALL" } >> "$GITHUB_STEP_SUMMARY" diff --git a/build/actions/npm/build_and_pack.sh b/build/actions/npm/build_and_pack.sh old mode 100755 new mode 100644 index 35bda18..a8cc3da --- a/build/actions/npm/build_and_pack.sh +++ b/build/actions/npm/build_and_pack.sh @@ -1,24 +1,37 @@ #!/bin/bash # Runs the npm build pipeline: lockfile-faithful install, optional build, -# optional test, then `npm pack` to produce the tarball in dist/. +# optional test, then pack to produce the tarball in dist/. +# +# v0.2 supports both npm and pnpm. The package manager is detected from +# the lockfile (validate_inputs.sh ensures exactly one is present): +# - package-lock.json or npm-shrinkwrap.json -> npm (npm ci + npm pack) +# - pnpm-lock.yaml -> pnpm (pnpm install +# --frozen-lockfile + +# pnpm pack) +# +# For pnpm, the version is determined by the adopter's `packageManager` +# field in package.json if set (Corepack reads it). If unset, Corepack's +# bundled default is used. Adopters who want deterministic builds should +# set `packageManager` per https://nodejs.org/api/packages.html#packagemanager . # # Build and test are conditional on package.json declaring non-default -# scripts. The npm default test script (a `no test specified` echo+exit +# scripts. The npm-default test script (a `no test specified` echo+exit # stub) is detected and skipped — substring match so minor wording -# tweaks in future npm releases don't accidentally re-enable the no-op. +# tweaks in future npm/pnpm releases don't accidentally re-enable the +# no-op. pnpm inherits the same default when no test is configured. # # Lifecycle hooks are honored by default: `prepare` and `prepack` fire -# during `npm ci` / `npm pack`, just as they would for an adopter -# running these commands locally. The L3 attestation thus binds to "what +# during install / pack, just as they would for an adopter running +# these commands locally. The L3 attestation thus binds to "what # wrangle built from this commit's source + lockfile" — which is what # the source-control review process expects. # # Adopters who want the stricter "source bytes only, no script execution" # model pass ignore_scripts="true". When true, NOTHING in package.json's -# `scripts` field runs: npm ci and npm pack get `--ignore-scripts`, AND -# `npm run build` / `npm test` are skipped entirely. The L3 attestation -# then binds to "what `npm pack` produces against this source with no -# script execution at all." If finer-grained control is needed later +# `scripts` field runs: install + pack get `--ignore-scripts`, AND +# `npm/pnpm run build` / `npm/pnpm test` are skipped entirely. The L3 +# attestation then binds to "what pack produces against this source with +# no script execution at all." If finer-grained control is needed later # (e.g., suppress hooks but still run the user's build), it can be added # as a separate input. # @@ -31,13 +44,13 @@ # # Usage: build/actions/npm/build_and_pack.sh # path: project directory (already validated) -# run_tests: "true" to run npm test if a non-default test script -# exists, anything else to skip. Ignored when +# run_tests: "true" to run the test script if a non-default test +# script exists, anything else to skip. Ignored when # ignore_scripts is "true" (no script runs at all). # ignore_scripts: "true" to skip every package.json script: pass -# --ignore-scripts to npm ci AND npm pack, AND skip -# npm run build and npm test outright. Anything else -# runs the full pipeline with lifecycle hooks honored. +# --ignore-scripts to install AND pack, AND skip +# run-build / test outright. Anything else runs the +# full pipeline with lifecycle hooks honored. set -euo pipefail @@ -52,20 +65,35 @@ IGNORE_SCRIPTS="$3" cd "$INPUT_PATH" +# Detect package manager from lockfile. validate_inputs.sh already +# ensured exactly one of the supported lockfiles is present (and that +# yarn.lock is not, and that both npm + pnpm lockfiles aren't present). +if [[ -f "pnpm-lock.yaml" ]]; then + PM=pnpm +else + PM=npm +fi +printf 'Package manager: %s\n' "$PM" + ignore_scripts_args=() if [[ "$IGNORE_SCRIPTS" == "true" ]]; then ignore_scripts_args=(--ignore-scripts) printf 'ignore-scripts=true: all package.json scripts will be skipped\n' fi -printf 'Installing dependencies (npm ci)...\n' -npm ci "${ignore_scripts_args[@]}" +if [[ "$PM" == "pnpm" ]]; then + printf 'Installing dependencies (pnpm install --frozen-lockfile)...\n' + pnpm install --frozen-lockfile "${ignore_scripts_args[@]}" +else + printf 'Installing dependencies (npm ci)...\n' + npm ci "${ignore_scripts_args[@]}" +fi if [[ "$IGNORE_SCRIPTS" == "true" ]]; then - printf 'Skipping npm run build and npm test (ignore-scripts=true)\n' + printf 'Skipping %s run build and %s test (ignore-scripts=true)\n' "$PM" "$PM" else # Reflect on package.json to decide whether to run build/test scripts. - # Using jq rather than catching `npm run`'s "missing script" exit code + # Using jq rather than catching ` run`'s "missing script" exit code # keeps the logs clear — the action shouldn't print error output for # scripts that simply don't exist. `(.scripts // {})` keeps the path # safe when `scripts` is missing or explicitly null. @@ -74,29 +102,30 @@ else TEST_CMD="$(jq -r '.scripts.test // ""' package.json)" if [[ "$HAS_BUILD" == "true" ]]; then - printf 'Running npm run build...\n' - npm run build + printf 'Running %s run build...\n' "$PM" + "$PM" run build else printf 'No build script in package.json — skipping build step\n' fi if [[ "$RUN_TESTS" == "true" ]]; then - # Substring match against the npm-default no-op script. Catches the - # current `echo "Error: no test specified" && exit 1` and tolerates - # minor wording changes in future npm versions. + # Substring match against the npm-default no-op script. Catches + # `echo "Error: no test specified" && exit 1` and tolerates minor + # wording changes in future npm/pnpm versions. if [[ "$HAS_TEST_SCRIPT" == "true" ]] && [[ "$TEST_CMD" != *'no test specified'* ]]; then - printf 'Running npm test...\n' - npm test + printf 'Running %s test...\n' "$PM" + "$PM" test else printf 'No non-default test script in package.json — skipping tests\n' fi fi fi -printf 'Packing tarball into dist/ (npm pack)...\n' +printf 'Packing tarball into dist/ (%s pack)...\n' "$PM" mkdir -p dist -# `npm pack --pack-destination dist` writes the tarball to dist/. Output -# parsing is the action.yml's job (glob over dist/*.tgz); this script -# only needs to exit 0 on success. --ignore-scripts (when set) suppresses -# prepack/postpack/prepare on the pack side too. -npm pack --pack-destination dist "${ignore_scripts_args[@]}" +# ` pack --pack-destination dist` writes the tarball to dist/. +# Output parsing is the action.yml's job (glob over dist/*.tgz); this +# script only needs to exit 0 on success. --ignore-scripts (when set) +# suppresses prepack/postpack/prepare on the pack side too for both +# npm and pnpm. +"$PM" pack --pack-destination dist "${ignore_scripts_args[@]}" diff --git a/build/actions/npm/test.bats b/build/actions/npm/test.bats index 76933ea..d08bd29 100644 --- a/build/actions/npm/test.bats +++ b/build/actions/npm/test.bats @@ -49,20 +49,51 @@ setup() { [[ "$status" -eq 0 ]] } -@test "npm: validate_inputs.sh rejects pnpm and yarn lockfiles in v0.1" { - run grep 'pnpm-lock.yaml' "$ACTION_DIR/validate_inputs.sh" +@test "npm: validate_inputs.sh accepts pnpm-lock.yaml (v0.2 addition)" { + # v0.2 supports pnpm-lock.yaml. The lockfile must be in the accept + # path, NOT the reject path. The accept path is the early-exit + # success branch; the reject paths are guarded by yarn.lock or the + # ambiguous-state check. + run grep -E 'pnpm-lock.yaml' "$ACTION_DIR/validate_inputs.sh" + [[ "$status" -eq 0 ]] + # Must NOT have a "pnpm is not supported" error string anymore. + run grep -E 'pnpm is not supported' "$ACTION_DIR/validate_inputs.sh" + [[ "$status" -ne 0 ]] +} + +@test "npm: validate_inputs.sh rejects yarn.lock" { + # Yarn is still a follow-on; reject explicitly. + run grep -E 'yarn.lock' "$ACTION_DIR/validate_inputs.sh" [[ "$status" -eq 0 ]] - run grep 'yarn.lock' "$ACTION_DIR/validate_inputs.sh" + run grep -E 'Yarn is not supported' "$ACTION_DIR/validate_inputs.sh" + [[ "$status" -eq 0 ]] +} + +@test "npm: validate_inputs.sh rejects ambiguous lockfile state (npm + pnpm)" { + # Both package-lock.json AND pnpm-lock.yaml present is ambiguous — + # wrangle can't infer which manager the adopter intends. Reject. + run grep -E 'both npm and pnpm lockfiles' "$ACTION_DIR/validate_inputs.sh" [[ "$status" -eq 0 ]] } -@test "npm: build_and_pack.sh runs npm ci (lockfile-faithful)" { +@test "npm: build_and_pack.sh runs npm ci or pnpm install (lockfile-faithful)" { + # Lockfile-faithful install paths for both managers. run grep -E 'npm ci' "$ACTION_DIR/build_and_pack.sh" [[ "$status" -eq 0 ]] + run grep -E 'pnpm install --frozen-lockfile' "$ACTION_DIR/build_and_pack.sh" + [[ "$status" -eq 0 ]] +} + +@test "npm: build_and_pack.sh detects package manager from lockfile" { + # Branch on pnpm-lock.yaml presence — npm fallback otherwise. + run grep -E '\-f "pnpm-lock.yaml"' "$ACTION_DIR/build_and_pack.sh" + [[ "$status" -eq 0 ]] } -@test "npm: build_and_pack.sh runs npm pack" { - run grep -E 'npm pack' "$ACTION_DIR/build_and_pack.sh" +@test "npm: build_and_pack.sh runs pack via the detected package manager" { + # Pack is invoked via "$PM" pack to use whichever manager was detected. + # The script's PM variable resolves to npm or pnpm. + run grep -E '"\$PM" pack' "$ACTION_DIR/build_and_pack.sh" [[ "$status" -eq 0 ]] } @@ -93,18 +124,38 @@ setup() { [[ "$status" -eq 0 ]] } -@test "npm: setup-node enables npm caching keyed on the lockfile" { - # Caches ~/.npm (registry tarball cache). Safe to enable: npm ci - # re-validates each cached tarball's integrity hash against the - # lockfile on every install. setup-node's `cache: 'npm'` does NOT - # cache node_modules/ (which would bypass integrity checks if cached - # as extracted modules). - run grep -E "cache: 'npm'" "$ACTION" +@test "npm: setup-node caching is conditional on package manager" { + # Cache is driven dynamically by the tooling step's `cache` output. + # The npm path emits `cache=npm`; the pnpm path emits `cache=` (empty) + # so setup-node skips caching entirely. This is the load-bearing + # protection against pnpm-store cache poisoning (issue #205). + run grep -E "cache: \\\$\\{\\{ steps\\.tooling\\.outputs\\.cache \\}\\}" "$ACTION" [[ "$status" -eq 0 ]] - # cache-dependency-path must be set so the cache key invalidates - # when deps change. The action lists both lockfile names accepted - # by validate_inputs.sh. - run grep -E 'cache-dependency-path' "$ACTION" + # The action must NOT hard-code `cache: 'npm'` (or any literal cache + # value) — that would re-enable caching for the pnpm path. + run grep -E "^[[:space:]]*cache:[[:space:]]*['\"]?(npm|pnpm|yarn)['\"]?\$" "$ACTION" + [[ "$status" -ne 0 ]] +} + +@test "npm: action.yml does NOT enable pnpm-store cache anywhere" { + # pnpm-store cache is the Mini Shai-Hulud / TanStack May 2026 cache- + # poisoning vector. Wrangle must never enable it. See issue #205. + run grep -E "cache:[[:space:]]*['\"]pnpm['\"]" "$ACTION" + [[ "$status" -ne 0 ]] +} + +@test "npm: action.yml emits package-manager output for downstream visibility" { + run grep -E 'package-manager:' "$ACTION" + [[ "$status" -eq 0 ]] +} + +@test "npm: action.yml conditionally enables Corepack for pnpm" { + # Corepack provides pnpm on the runner. The step must be gated on + # the detected package manager being pnpm so it doesn't run on + # npm-only adopters. + run grep -E "if: steps.tooling.outputs.package-manager == 'pnpm'" "$ACTION" + [[ "$status" -eq 0 ]] + run grep -E 'corepack enable' "$ACTION" [[ "$status" -eq 0 ]] } @@ -160,38 +211,23 @@ setup() { [[ "$status" -eq 0 ]] } -@test "npm: build_and_pack.sh threads ignore-scripts through to npm ci AND npm pack" { +@test "npm: build_and_pack.sh threads ignore-scripts through to install AND pack" { run grep -E 'ignore_scripts_args' "$ACTION_DIR/build_and_pack.sh" [[ "$status" -eq 0 ]] - # Must be applied to both `npm ci` and `npm pack`, not just one. - run bash -c "grep 'npm ci.*ignore_scripts_args' \"$ACTION_DIR/build_and_pack.sh\"" + # Must appear on both install lines and the pack line. + run bash -c "grep -E 'npm ci|pnpm install' \"$ACTION_DIR/build_and_pack.sh\" | grep ignore_scripts_args" [[ "$status" -eq 0 ]] - run bash -c "grep 'npm pack.*ignore_scripts_args' \"$ACTION_DIR/build_and_pack.sh\"" + run bash -c "grep -E '\\\$PM\" pack|npm pack|pnpm pack' \"$ACTION_DIR/build_and_pack.sh\" | grep ignore_scripts_args" [[ "$status" -eq 0 ]] } -@test "npm: build_and_pack.sh skips npm run build and npm test when ignore-scripts is true" { - # ignore-scripts: true must mean "no package.json script runs" — - # not just suppressing transitive hooks. The script logs a single - # "Skipping npm run build and npm test" line on that path, and the - # `npm run build` / `npm test` invocations live in the matching +@test "npm: build_and_pack.sh skips run-build and test when ignore-scripts is true" { + # ignore-scripts: true must mean "no package.json script runs" — not + # just suppressing transitive hooks. The script logs a single + # "Skipping ... run build and ... test" line on that path, and the + # ` run build` / ` test` invocations live in the matching # else branch so they cannot execute when the guard fires. - run grep -F 'Skipping npm run build and npm test' "$ACTION_DIR/build_and_pack.sh" - [[ "$status" -eq 0 ]] - # `npm run build` and `npm test` must appear after an `else` line — - # i.e., not in the top-level path and not in the IGNORE_SCRIPTS=true - # branch. awk walks the file and tracks whether we've crossed the - # else of the IGNORE_SCRIPTS guard before the build/test calls. - run awk ' - /IGNORE_SCRIPTS" == "true"/ { saw_guard = 1 } - saw_guard && /^else$/ { in_else = 1 } - in_else && /^[[:space:]]*npm run build$/ { saw_build = 1 } - in_else && /^[[:space:]]*npm test$/ { saw_test = 1 } - # Fail if npm run build / npm test appears before we hit the else. - !in_else && /^[[:space:]]*npm run build$/ { exit 1 } - !in_else && /^[[:space:]]*npm test$/ { exit 1 } - END { exit !(saw_guard && saw_build && saw_test) } - ' "$ACTION_DIR/build_and_pack.sh" + run grep -E 'Skipping %s run build and %s test' "$ACTION_DIR/build_and_pack.sh" [[ "$status" -eq 0 ]] } @@ -206,10 +242,11 @@ setup() { } @test "npm: validate_inputs.sh rejects package.json with workspaces field" { - # v0.1 single-package only. A workspaces project would produce N - # tarballs that wrangle would not attest correctly. + # Workspaces support tracked in #208. v0.2 still single-package only. run grep -E 'workspaces' "$ACTION_DIR/validate_inputs.sh" [[ "$status" -eq 0 ]] + run grep -E 'issues/208' "$ACTION_DIR/validate_inputs.sh" + [[ "$status" -eq 0 ]] } @test "npm: reusable workflow exposes ignore-scripts input that flows to composite" { diff --git a/build/actions/npm/validate_inputs.sh b/build/actions/npm/validate_inputs.sh old mode 100755 new mode 100644 index b3a6637..2dc7ab5 --- a/build/actions/npm/validate_inputs.sh +++ b/build/actions/npm/validate_inputs.sh @@ -1,11 +1,15 @@ #!/bin/bash # Validates inputs to the npm build action: shared path checks via # lib/validate_path.sh, plus npm-specific checks that package.json exists -# and a lockfile is present (npm ci requires one). +# and a supported lockfile is present. # -# v0.1 supports npm only; pnpm and Yarn are follow-on. Detected by -# rejecting yarn.lock / pnpm-lock.yaml when no package-lock.json -# (or npm-shrinkwrap.json) is present. +# v0.2 supports npm AND pnpm (single-package only). Yarn lockfiles are +# still rejected explicitly; Yarn support is a follow-on. Workspaces are +# also still rejected in v0.2 — tracked in #208. +# +# Ambiguous state (both package-lock.json AND pnpm-lock.yaml present) is +# rejected rather than silently picking one, because the adopter's intent +# isn't clear in that case. # # Usage: build/actions/npm/validate_inputs.sh @@ -26,33 +30,52 @@ if [[ ! -f "$INPUT_PATH/package.json" ]]; then exit 1 fi -# v0.1 supports single-package npm only. A workspaces field would mean -# `npm pack` at the project root produces an empty-`files` tarball while -# `npm publish --workspaces` would publish N sub-packages — wrangle's -# attestation would not match what consumers download. Reject early -# rather than letting the L3 + verify pipeline run on the wrong bytes. +# Workspaces support is tracked separately in #208. A workspaces project +# produces N tarballs from one build, which breaks the current single- +# tarball assertion in action.yml and propagates through hashing, SBOM, +# and provenance. Reject early rather than letting the L3 + verify +# pipeline run on the wrong bytes. if [[ "$(jq -r 'has("workspaces")' "$INPUT_PATH/package.json")" == "true" ]]; then - printf 'Error: workspaces field detected in %s/package.json; npm workspaces are not supported in v0.1.\n' "$INPUT_PATH" >&2 - printf 'Hint: file an issue if you need workspaces support.\n' >&2 + printf 'Error: workspaces field detected in %s/package.json; npm workspaces are not supported in v0.2.\n' "$INPUT_PATH" >&2 + printf 'Hint: workspaces support is tracked in https://github.com/TomHennen/wrangle/issues/208\n' >&2 exit 1 fi +# Lockfile detection. +HAS_NPM_LOCK=false +HAS_PNPM_LOCK=false if [[ -f "$INPUT_PATH/package-lock.json" ]] || [[ -f "$INPUT_PATH/npm-shrinkwrap.json" ]]; then - exit 0 + HAS_NPM_LOCK=true fi - if [[ -f "$INPUT_PATH/pnpm-lock.yaml" ]]; then - printf 'Error: pnpm-lock.yaml detected in %s; pnpm is not supported in v0.1.\n' "$INPUT_PATH" >&2 - printf 'Hint: file an issue if you need pnpm support.\n' >&2 - exit 1 + HAS_PNPM_LOCK=true fi + +# Yarn lockfile rejection. Yarn support is a follow-on; reject explicitly +# so adopters get a clear error rather than a confusing "no lockfile". if [[ -f "$INPUT_PATH/yarn.lock" ]]; then - printf 'Error: yarn.lock detected in %s; Yarn is not supported in v0.1.\n' "$INPUT_PATH" >&2 + printf 'Error: yarn.lock detected in %s; Yarn is not supported in v0.2.\n' "$INPUT_PATH" >&2 printf 'Hint: file an issue if you need Yarn support.\n' >&2 exit 1 fi +# Ambiguous: both npm and pnpm lockfiles present. Wrangle can't tell +# which manager the adopter intends. Reject and let the adopter clean +# up rather than silently picking one (which would silently determine +# what gets attested). +if [[ "$HAS_NPM_LOCK" == "true" && "$HAS_PNPM_LOCK" == "true" ]]; then + printf 'Error: both npm and pnpm lockfiles found in %s.\n' "$INPUT_PATH" >&2 + printf 'Hint: keep only one of package-lock.json / npm-shrinkwrap.json / pnpm-lock.yaml.\n' >&2 + printf ' If you migrated from npm to pnpm (or vice versa), delete the old lockfile.\n' >&2 + exit 1 +fi + +# Success: exactly one of the supported lockfiles is present. +if [[ "$HAS_NPM_LOCK" == "true" || "$HAS_PNPM_LOCK" == "true" ]]; then + exit 0 +fi + printf 'Error: no lockfile found in %s.\n' "$INPUT_PATH" >&2 # shellcheck disable=SC2016 # backticks here are human-readable formatting, not command substitution -printf 'Hint: npm ci requires package-lock.json or npm-shrinkwrap.json. Run `npm install` and commit the lockfile.\n' >&2 +printf 'Hint: install your dependencies (`npm install` or `pnpm install`) and commit the resulting lockfile.\n' >&2 exit 1 From 0bcd9987c89f4774a04fa7037d46df88c08278a1 Mon Sep 17 00:00:00 2001 From: Tom Hennen Date: Tue, 12 May 2026 22:02:26 -0400 Subject: [PATCH 2/6] Invoke .sh scripts via bash explicitly (defensive against push_files mode loss) The GitHub MCP push_files API used to maintain this branch does NOT preserve the executable bit when overwriting existing files. When the v0.2 commit (7e72d33) overwrote validate_inputs.sh and build_and_pack.sh with pnpm-support content, both files were demoted from -rwxr-xr-x (755) to -rw-r--r-- (644). The action.yml's previous pattern of invoking the scripts directly (e.g., "${{ github.action_path }}/foo.sh") would fail with "Permission denied" on the runner. Fix: explicit `bash