diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d091181..e303b97 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -457,3 +457,83 @@ jobs: echo "$out" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+' || { echo "::error::extracted binary produced unexpected output"; exit 1; } + + # Proves the --config pass-through actually carries pkg 6.21 build hooks, and + # that a standalone config (no package.json entry) invokes cleanly — that + # combination hard-errored before the entry is supplied from package.json bin. + build-hooks: + name: build hooks (preBuild + postBuild + transform) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Scaffold a fixture with a standalone pkg config + id: fix + shell: bash + run: | + set -euo pipefail + fix="${RUNNER_TEMP}/hooks-fixture" + rm -rf "$fix"; mkdir -p "$fix" + cd "$fix" + cat > package.json <<'JSON' + { "name": "hooks-app", "version": "1.0.0", "bin": "index.js" } + JSON + # __STAMP__ is rewritten in-flight by the transform hook — the packed + # binary prints the rewritten value while this file keeps the literal. + cat > index.js <<'JS' + console.log('stamp=__STAMP__'); + JS + # preBuild is a plain shell string (no nested quoting) so a failure + # here means the hook did not run, not that the fixture was malformed. + cat > pkg.config.mjs <<'MJS' + import { writeFileSync } from 'node:fs'; + + export default { + preBuild: 'touch prebuild-ran.txt', + postBuild: (output) => writeFileSync('postbuild.txt', output), + transform: (file, body) => + file.endsWith('index.js') + ? body.toString().replaceAll('__STAMP__', () => 'rewritten-by-transform') + : undefined, + }; + MJS + echo "fixture=$fix" >> "$GITHUB_OUTPUT" + + - name: Build with hooks + id: build + uses: ./ + with: + config: ${{ steps.fix.outputs.fixture }}/pkg.config.mjs + targets: node22-linux-x64 + checksum: sha256 + + - name: Assert every hook fired + shell: bash + run: | + set -euo pipefail + fix='${{ steps.fix.outputs.fixture }}' + + [ -f "$fix/prebuild-ran.txt" ] || { echo "::error::preBuild hook did not run"; exit 1; } + + # The on-disk source keeps the placeholder — transform must not mutate it. + grep -q '__STAMP__' "$fix/index.js" || { + echo "::error::transform wrote back to disk; it should only affect packed bytes"; exit 1; + } + + # postBuild wrote the output path it was handed. + [ -f "$fix/postbuild.txt" ] || { echo "::error::postBuild hook did not run"; exit 1; } + echo "postBuild recorded: $(cat "$fix/postbuild.txt")" + + # transform rewrote the packed source, so the built binary prints the + # rewritten stamp rather than the literal placeholder. + bin=$(echo '${{ steps.build.outputs.binaries }}' | jq -r '.[0]') + [ -f "$bin" ] || { echo "::error::binary missing: $bin"; exit 1; } + chmod +x "$bin" + out=$("$bin") + echo "binary output: $out" + [ "$out" = 'stamp=rewritten-by-transform' ] || { + echo "::error::transform hook did not rewrite the packed source (got: $out)"; exit 1; + } diff --git a/README.md b/README.md index 8f46bf4..4c7be40 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,16 @@ Tracking issue: [yao-pkg/pkg#248](https://github.com/yao-pkg/pkg/issues/248). The action does **not** mirror pkg's CLI flags as inputs. Pkg-specific knobs — SEA mode, bundled Node compression, `public` / `publicPackages`, V8 `options`, `noBytecode`, `noDict`, `debug`, bytecode-fabricator fallback — -live in your pkg config file (`.pkgrc.json`, `pkg.config.{js,ts,json}`, or -the `pkg` field of `package.json`). See +live in your pkg config file (`.pkgrc`, `.pkgrc.json`, `pkg.config.{js,cjs,mjs}`, +or the `pkg` field of `package.json`). See [yao-pkg/pkg's README](https://github.com/yao-pkg/pkg#config) for the full schema. +pkg 6.21 also runs `preBuild` / `postBuild` / `transform` hooks from that file. +They execute arbitrary code with the runner's environment — see +[docs/inputs.md](docs/inputs.md#build-hooks) before enabling them on a workflow +that builds untrusted refs. + Example — SEA mode with Brotli-compressed bundle + fallback, saved as `.pkgrc.json`: ```json diff --git a/STATUS.yaml b/STATUS.yaml index d1bf55b..a208d9b 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -67,11 +67,37 @@ input-surface-slim: - no-dict - debug - extra-args - migration: 'Move each value into .pkgrc / pkg.config.{js,ts,json} or the `pkg` field of package.json. `buildPkgArgs` forwards the config path to pkg.' + migration: 'Move each value into .pkgrc / pkg.config.{js,cjs,mjs} or the `pkg` field of package.json. `buildPkgArgs` forwards the config path to pkg.' breaking: 'yes — users setting any dropped input will see the unknown-input warning and the value will be ignored. Release note required.' minimum-pkg-version: | @yao-pkg/pkg >= 6.19.0 is required for the full build-flag surface in - pkg config. The default `pkg-version` input is pinned to `~6.19.0`. + pkg config; >= 6.21.0 adds the preBuild / postBuild / transform build + hooks. The default `pkg-version` input is pinned to `~6.21.0`. + build-hooks: | + Config-only feature (no CLI flags, no action inputs) — pkg reads + preBuild / postBuild / transform straight from the config the action + forwards via --config, so the pass-through surface needs no change. + One constraint is documented rather than enforced: a postBuild hook + must not move or rename the binary or `pkg-output-map` can no longer + locate it. + default-pkg-version-bump: | + `pkg-version` default moved ~6.19.0 -> ~6.21.0. + breaking: 'no API change, but every consumer on a floating action tag + who never set `pkg-version` gets a new pkg toolchain on their next + run. Release note REQUIRED, and it must call out the trust change + below — not just the version number.' + security: | + 6.21 hooks are arbitrary code execution sourced from the config in + the checked-out tree. A workflow that builds an untrusted ref (fork + pull_request) now grants that ref command execution in a job that + may hold signing certs and tokens. No such capability existed at + 6.19. Documented in docs/inputs.md + README; not enforceable from + the action side. + pin-vs-floor: | + `~` keeps patch-only so a pkg minor never lands without an action + release — deliberate, since the action replicates pkg's output + naming in `pkg-output-map` and a naming change would break it + silently. Cost: consumers need an action release for pkg minors. upstream-dependency: | RESOLVED in @yao-pkg/pkg v6.19.0 (2026-04-24) via yao-pkg/pkg#263 — closes yao-pkg/pkg#262. All CLI-only build flags now have config @@ -128,7 +154,7 @@ pending: live-credential-e2e: - Real signing — macOS Developer ID cert, Apple notarization creds, Windows code-signing cert. - Blocked on: organization decision about secret provisioning. + - 'Blocked on: organization decision about secret provisioning.' release-readiness: - Bump root `package.json#version` to 1.0.0 at tag time — auto-propagates via esbuild define into every bundled sub-action. diff --git a/action.yml b/action.yml index e133786..3602c83 100644 --- a/action.yml +++ b/action.yml @@ -10,16 +10,16 @@ branding: inputs: config: - description: 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.' + description: 'Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline.' config-inline: - description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.' + description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.' entry: description: 'Entry script when not specified in the config.' targets: description: 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.' pkg-version: - description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.' - default: '~6.19.0' + description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.' + default: '~6.21.0' pkg-path: description: 'Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g.' strip: @@ -142,13 +142,24 @@ runs: uses: actions/cache@v5 with: path: ${{ runner.temp }}/pkg-cache - key: ${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', '.pkgrc*', '**/pkg.config.{js,ts,json}')) }} + key: ${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', '.pkgrc', '.pkgrc.json', '**/pkg.config.js', '**/pkg.config.cjs', '**/pkg.config.mjs')) }} - name: Install @yao-pkg/pkg if: ${{ inputs.pkg-path == '' }} shell: bash + env: + PKG_VERSION: ${{ inputs.pkg-version }} run: | - npm i -g @yao-pkg/pkg@${{ inputs.pkg-version }} + # Read from env and quoted — never interpolated into this script by the + # workflow templater, or a crafted specifier would execute right here. + # The pattern lives in a variable because [[ ]] parses a bare > as an + # operator before it ever reaches the regex engine. + specifier_re='^[A-Za-z0-9.*|=<>~^ -]+$' + if [[ ! "${PKG_VERSION}" =~ $specifier_re ]]; then + echo "::error::Input 'pkg-version' is not a valid npm version specifier: ${PKG_VERSION}" + exit 1 + fi + npm i -g "@yao-pkg/pkg@${PKG_VERSION}" - name: Run pkg-action build id: pkg-action-build diff --git a/docs/inputs.md b/docs/inputs.md index a02efb2..7953737 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -8,11 +8,11 @@ Every `pkg-action` input, grouped by category. | Input | Default | Required | Secret | Description | | --- | --- | --- | --- | --- | -| `config` | — | no | no | Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline. | -| `config-inline` | — | no | yes | Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs. | +| `config` | — | no | no | Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline. | +| `config-inline` | — | no | yes | Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs. | | `entry` | — | no | no | Entry script when not specified in the config. | | `targets` | — | no | no | Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target. | -| `pkg-version` | `~6.19.0` | no | no | npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set. | +| `pkg-version` | `~6.21.0` | no | no | npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set. | | `pkg-path` | — | no | no | Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g. | ## Post-build @@ -74,6 +74,67 @@ Every `pkg-action` input, grouped by category. | `cache-key` | — | no | no | Override the auto-derived cache key. | | `step-summary` | `true` | no | no | Write a markdown summary of build time / size / checksum to the job summary. | +## Build hooks + +@yao-pkg/pkg 6.21.0+ runs three hooks from your pkg config. They have no CLI flags and +no action inputs — set them in the file you point `config` at (or let pkg auto-detect it). +The default `pkg-version` (`~6.21.0`) already includes them; only callers who pinned +an older pkg need to raise it. + +| Key | Accepts | Runs | +| --- | --- | --- | +| `preBuild` | shell string or function | once, before pkg walks the dependency graph | +| `postBuild` | shell string or function | once per produced binary, after pkg has written it (and, on macOS, ad-hoc signed it) | +| `transform` | function only | per packed file, after path refinement, before bytecode/compression | + +The shell form of `postBuild` gets the absolute output path in `PKG_OUTPUT`; the function +form gets it as its first argument. `transform(file, body)` returns a string or Buffer to +replace the contents, or `undefined` to leave the file alone. + +```js +// pkg.config.mjs +import { join } from 'node:path'; + +const SRC_DIR = join(import.meta.dirname, 'src'); + +export default { + targets: ['node22-linux-x64'], + preBuild: 'npm run build', + postBuild: (output) => console.log(`built ${output}`), + transform: (file, body) => + file.startsWith(SRC_DIR) && file.endsWith('.js') + ? body.toString().replaceAll('__VERSION__', () => process.env.GITHUB_SHA ?? 'dev') + : undefined, +}; +``` + +Two things that example is careful about: it scopes the rewrite to your own sources (a bare +`.endsWith('.js')` also rewrites every packed `node_modules` file), and it passes a replacer +function so `$&` and `` $` `` in the replacement are not interpreted. Only interpolate values +you trust — a branch name or PR title lands verbatim in the shipped binary. + +> **Hooks are arbitrary code execution.** `preBuild` / `postBuild` shell strings are spawned +> with the runner's full environment, and `transform` is arbitrary JS over every packed file. +> pkg picks a config up from the checked-out tree automatically, so on a workflow that builds +> an untrusted ref (a fork `pull_request`), whoever wrote that ref can run commands in the job — +> alongside any signing certificates and tokens it holds. This capability did not exist before +> pkg 6.21. Build untrusted refs in a job with no secrets, or pin `pkg-version` below 6.21. + +> **Do not move or rename the binary from `postBuild`.** The hook runs inside pkg, before +> the action's windows-metadata, signing, archive and checksum stages. Those stages locate +> outputs by predicting pkg's naming heuristic, then fall back to an exact, case-insensitive, +> and finally `-` substring match over the output directory — a rename defeats all +> three. Use the action's own archive and checksum inputs instead. + +### Hooks and `config-inline` + +`config-inline` is JSON, so it can only carry the shell-string form of `preBuild` / +`postBuild`. Function hooks and `transform` need a real `pkg.config.{js,cjs,mjs}` file. + +pkg refuses `--config ` together with a package.json entry, so whenever `config` points +at a standalone config — or `config-inline` is used — an entry script is required. The action +supplies your package.json `bin` automatically; set the `entry` input if there is no `bin`. + ## Outputs | Output | Description | diff --git a/packages/build/action.yml b/packages/build/action.yml index 751c1dd..a62a7ec 100644 --- a/packages/build/action.yml +++ b/packages/build/action.yml @@ -7,16 +7,16 @@ author: 'yao-pkg contributors' inputs: config: - description: 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.' + description: 'Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline.' config-inline: - description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.' + description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.' entry: description: 'Entry script when not specified in the config.' targets: description: 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.' pkg-version: - description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.' - default: '~6.19.0' + description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.' + default: '~6.21.0' pkg-path: description: 'Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g.' strip: diff --git a/packages/build/dist/index.mjs b/packages/build/dist/index.mjs index 3f22ce1..cf4f8e8 100644 --- a/packages/build/dist/index.mjs +++ b/packages/build/dist/index.mjs @@ -14770,12 +14770,12 @@ var INPUT_SPECS = [ { name: "config", category: "build", - description: "Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline." + description: "Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline." }, { name: "config-inline", category: "build", - description: "Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.", + description: "Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks \u2014 function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.", secret: !0 }, { @@ -14791,8 +14791,8 @@ var INPUT_SPECS = [ { name: "pkg-version", category: "build", - description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.", - default: "~6.19.0" + description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.", + default: "~6.21.0" }, { name: "pkg-path", @@ -15031,6 +15031,12 @@ function readInput(env, name) { let raw = readInputRaw(env, name); return raw !== void 0 ? raw : specFor(name)?.default; } +function readRequiredInput(env, name) { + let value = readInput(env, name); + if (value === void 0) + throw new ValidationError(`Input "${name}" has no value and no default in INPUT_SPECS.`); + return value; +} function parseBoolean(value, name) { if (value === void 0) return !1; let normalized = value.toLowerCase(); @@ -15097,7 +15103,7 @@ function parseInputs(opts = {}) { configInline, entry: readInput(env, "entry"), targets, - pkgVersion: readInput(env, "pkg-version") ?? "~6.19.0", + pkgVersion: readRequiredInput(env, "pkg-version"), pkgPath: readInput(env, "pkg-path") }, postBuild = { strip: parseBoolean(readInput(env, "strip"), "strip"), @@ -15108,7 +15114,7 @@ function parseInputs(opts = {}) { "7z", "none" ]), - filename: readInput(env, "filename") ?? "{name}-{version}-{os}-{arch}", + filename: readRequiredInput(env, "filename"), checksum: parseChecksumList(readInput(env, "checksum"), "checksum") }, performance2 = { cache: parseBoolean(readInput(env, "cache"), "cache"), @@ -15155,6 +15161,16 @@ function buildPkgArgs(inv) { let entry = inv.build.entry ?? "."; return args.push(entry), args; } +async function resolvePkgVersion(deps) { + try { + let result = await deps.exec(deps.pkgCommand, ["--version"], { ignoreReturnCode: !0 }); + if (result.exitCode !== 0) return; + let version = result.stdout.trim(); + return version === "" ? void 0 : version; + } catch { + return; + } +} async function runPkg(inv, deps) { let args = buildPkgArgs(inv); deps.logger.info(`[pkg-action] Invoking: ${deps.pkgCommand} ${args.join(" ")}`); @@ -15168,8 +15184,12 @@ async function runPkg(inv, deps) { } catch (err) { throw new PkgRunError(`Failed to spawn pkg: ${String(err)}`, { cause: err }); } - if (result.exitCode !== 0) - throw new PkgRunError(`pkg exited with code ${String(result.exitCode)}. See stderr above.`); + if (result.exitCode !== 0) { + let configHint = inv.build.config !== void 0 ? ` If a preBuild/postBuild/transform hook is set in ${inv.build.config}, check its output above \u2014 hook failures surface as a pkg exit.` : ""; + throw new PkgRunError( + `pkg exited with code ${String(result.exitCode)}. See stderr above.${configHint}` + ); + } return result; } @@ -15206,22 +15226,25 @@ async function mapPkgOutputs(targets, baseName, outputDir) { if (unresolved.length > 0) { let listing = await readdir2(outputDir); for (let { target, predicted: predictedName } of unresolved) { - let match = findFallbackMatch(listing, target, predictedName); + let match = findFallbackMatch(listing, target, predictedName, { + soleTarget: targets.length === 1 + }); if (match === void 0) throw new PkgRunError( - `pkg did not produce an output for ${formatTarget(target)}. Expected "${predictedName}" in ${outputDir}; directory contains: ${listing.join(", ") || "(empty)"}.` + `pkg did not produce an output for ${formatTarget(target)}. Expected "${predictedName}" in ${outputDir}; directory contains: ${listing.join(", ") || "(empty)"}. If a postBuild hook moves or renames the binary, drop that \u2014 the action locates outputs by name.` ); entries.push({ target, path: join4(outputDir, match) }); } } return entries; } -function findFallbackMatch(listing, target, predicted) { +function findFallbackMatch(listing, target, predicted, opts = { soleTarget: !1 }) { if (listing.includes(predicted)) return predicted; let lower = predicted.toLowerCase(), ci = listing.find((f) => f.toLowerCase() === lower); if (ci !== void 0) return ci; - let needle = `${target.os}-${target.arch}`; - return listing.find((f) => f.toLowerCase().includes(needle.toLowerCase())); + let needle = `${target.os}-${target.arch}`, byTriple = listing.find((f) => f.toLowerCase().includes(needle.toLowerCase())); + if (byTriple !== void 0) return byTriple; + if (opts.soleTarget && listing.length === 1) return listing[0]; } async function exists2(path4) { try { @@ -15370,6 +15393,14 @@ function formatDuration(ms) { // packages/core/src/project-info.ts import { readFile } from "node:fs/promises"; import { join as join5 } from "node:path"; +function resolveBinEntry(bin, name, projectDir) { + let rel = bin; + if (typeof rel == "object" && rel !== null) { + let map = rel, unscoped = name.split("/").pop(); + rel = (unscoped !== void 0 ? map[unscoped] : void 0) ?? Object.values(map)[0]; + } + return typeof rel == "string" && rel !== "" ? join5(projectDir, rel) : void 0; +} async function readProjectInfo(projectDir) { let path4 = join5(projectDir, "package.json"), raw; try { @@ -15390,7 +15421,7 @@ async function readProjectInfo(projectDir) { throw new ValidationError(`package.json at ${path4} is missing "name".`); if (typeof version != "string" || version === "") throw new ValidationError(`package.json at ${path4} is missing "version".`); - return { name, version }; + return { name, version, binEntry: resolveBinEntry(obj.bin, name, projectDir) }; } function tokensForTarget(target, project, env, nowDate = /* @__PURE__ */ new Date()) { let sha = (env.GITHUB_SHA ?? "").slice(0, 7), ref = env.GITHUB_REF_NAME ?? env.GITHUB_REF ?? "", tag = extractTag(env.GITHUB_REF), nodePart = target.node === "latest" ? "latest" : `node${String(target.node)}`; @@ -19250,14 +19281,17 @@ async function main() { setFailed(formatErrorChain(err)); return; } - let workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(), projectDir = (() => { + let workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(), projectDir = await (async () => { let cfg = inputs.build.config; - if (cfg !== void 0) { - let absCfg = pathResolve(workspace, cfg); - if (pathBasename(absCfg).toLowerCase() === "package.json") - return dirname5(absCfg); + if (cfg === void 0) return workspace; + let configDir = dirname5(pathResolve(workspace, cfg)); + if (pathBasename(pathResolve(workspace, cfg)).toLowerCase() === "package.json") + return configDir; + try { + return await stat4(join7(configDir, "package.json")), configDir; + } catch { + return workspace; } - return workspace; })(), project = await readProjectInfo(projectDir); logger.info(`[pkg-action] project dir: ${projectDir}`), logger.info(`[pkg-action] project: ${project.name}@${project.version}`); let resolvedTargets = inputs.build.targets === "host" ? [hostTarget()] : [...inputs.build.targets]; @@ -19272,11 +19306,22 @@ async function main() { invocationDir }); inputs.build.configInline !== void 0 && effectiveConfig !== void 0 && logger.info(`[pkg-action] materialized config-inline \u2192 ${effectiveConfig}`); - let pkgCommand = inputs.build.pkgPath ?? "pkg", cfgIsPackageJson = effectiveConfig !== void 0 && pathBasename(effectiveConfig).toLowerCase() === "package.json", pkgBuildInputs = { + let pkgCommand = inputs.build.pkgPath ?? "pkg", standaloneConfig = effectiveConfig !== void 0 && pathBasename(effectiveConfig).toLowerCase() === "package.json" ? void 0 : effectiveConfig, entry = inputs.build.entry; + if (standaloneConfig !== void 0 && entry === void 0) { + if (project.binEntry === void 0) + throw new ValidationError( + `Input "${inputs.build.configInline !== void 0 ? "config-inline" : "config"}" needs an entry script, but package.json at ${projectDir} has no "bin". Set the "entry" input, or add "bin" to package.json.` + ); + entry = project.binEntry, logger.info(`[pkg-action] entry resolved from package.json bin \u2192 ${entry}`); + } + let pkgBuildInputs = { ...inputs.build, - config: cfgIsPackageJson ? void 0 : effectiveConfig - }, pkgTargetsLabel = resolvedTargets.map(formatTarget).join(", "); - logger.startGroup(`[pkg-action] pkg build (targets=${pkgTargetsLabel})`); + config: standaloneConfig, + entry + }, pkgTargetsLabel = resolvedTargets.map(formatTarget).join(", "), resolvedPkgVersion = await resolvePkgVersion({ exec: execBridge, logger, pkgCommand }); + logger.info( + `[pkg-action] pkg ${resolvedPkgVersion ?? "(version unknown)"} (from "${inputs.build.pkgVersion}")` + ), logger.startGroup(`[pkg-action] pkg build (targets=${pkgTargetsLabel})`); let runStart = Date.now(); try { await runPkg( @@ -19339,11 +19384,11 @@ async function main() { } finalizedArtifacts.push(finalPath); let rowDigest = await finalizeChecksums(finalPath, inputs.postBuild.checksum); - for (let entry of rowDigest.entries) - shasumEntries.push(entry), logger.info(`[pkg-action] ${entry.algo} ${entry.digest} ${pathBasename(finalPath)}`); + for (let entry2 of rowDigest.entries) + shasumEntries.push(entry2), logger.info(`[pkg-action] ${entry2.algo} ${entry2.digest} ${pathBasename(finalPath)}`); if (rowDigest.entries.length > 0) { let key = pathBasename(finalPath), byAlgo = {}; - for (let entry of rowDigest.entries) byAlgo[entry.algo] = entry.digest; + for (let entry2 of rowDigest.entries) byAlgo[entry2.algo] = entry2.digest; digestsByArtifact[key] = byAlgo; } let { size } = await stat4(finalPath), row = { @@ -19373,7 +19418,7 @@ async function main() { ); await writeSummary(rowsWithTime, { actionVersion: VERSION, - pkgVersion: inputs.build.pkgVersion + pkgVersion: resolvedPkgVersion !== void 0 ? `${resolvedPkgVersion} (${inputs.build.pkgVersion})` : inputs.build.pkgVersion }); } setOutput("binaries", JSON.stringify(finalizedBinaries)), setOutput("artifacts", JSON.stringify(finalizedArtifacts)), setOutput("checksums", JSON.stringify(shasumsFiles)), setOutput("digests", JSON.stringify(digestsByArtifact)), setOutput("version", project.version), logger.info( diff --git a/packages/build/src/main.ts b/packages/build/src/main.ts index 1c75898..802c8e3 100644 --- a/packages/build/src/main.ts +++ b/packages/build/src/main.ts @@ -44,11 +44,13 @@ import { parseWindowsMetadataInputs, readProjectInfo, render, + resolvePkgVersion, runPkg, signMacos, signWindowsSigntool, signWindowsTrustedSigning, tokensForTarget, + ValidationError, writeShasumsFile, writeSidecar, writeSummary, @@ -121,20 +123,26 @@ async function main(): Promise { // 2. Project directory + metadata. // - // When `config` points at a package.json, the project root is its parent - // directory — pkg reads that package.json as the entry. When `config` is a - // non-package.json (e.g. .pkgrc.json) or is unset, the project dir is - // GITHUB_WORKSPACE (or cwd when running locally). + // An explicit `config` names the project: its parent directory is the root, + // whether it is a package.json or a standalone config sitting beside one. + // That is where package.json — name, version, and the `bin` used as the entry + // for a standalone config — is read from. A config kept away from the project + // (say `configs/pkg.config.mjs`) has no package.json next to it, so fall back + // to GITHUB_WORKSPACE (or cwd when running locally), as does an unset config. const workspace = process.env['GITHUB_WORKSPACE'] ?? process.cwd(); - const projectDir = (() => { + const projectDir = await (async () => { const cfg = inputs.build.config; - if (cfg !== undefined) { - const absCfg = pathResolve(workspace, cfg); - if (pathBasename(absCfg).toLowerCase() === 'package.json') { - return dirname(absCfg); - } + if (cfg === undefined) return workspace; + const configDir = dirname(pathResolve(workspace, cfg)); + if (pathBasename(pathResolve(workspace, cfg)).toLowerCase() === 'package.json') { + return configDir; + } + try { + await stat(join(configDir, 'package.json')); + return configDir; + } catch { + return workspace; } - return workspace; })(); const project = await readProjectInfo(projectDir); logger.info(`[pkg-action] project dir: ${projectDir}`); @@ -173,9 +181,25 @@ async function main(): Promise { const pkgCommand = inputs.build.pkgPath ?? 'pkg'; const cfgIsPackageJson = effectiveConfig !== undefined && pathBasename(effectiveConfig).toLowerCase() === 'package.json'; + const standaloneConfig = cfgIsPackageJson ? undefined : effectiveConfig; + // pkg rejects `--config ` alongside a package.json input, and the + // default positional `.` resolves to exactly that. So a standalone config + // has to name the entry script explicitly. + let entry = inputs.build.entry; + if (standaloneConfig !== undefined && entry === undefined) { + if (project.binEntry === undefined) { + throw new ValidationError( + `Input "${inputs.build.configInline !== undefined ? 'config-inline' : 'config'}" needs an entry script, ` + + `but package.json at ${projectDir} has no "bin". Set the "entry" input, or add "bin" to package.json.`, + ); + } + entry = project.binEntry; + logger.info(`[pkg-action] entry resolved from package.json bin → ${entry}`); + } const pkgBuildInputs = { ...inputs.build, - config: cfgIsPackageJson ? undefined : effectiveConfig, + config: standaloneConfig, + entry, }; // Fold the pkg invocation into its own group — "Walking dependencies", // "Downloading nodejs executable", "Generating SEA assets", plus the @@ -185,6 +209,13 @@ async function main(): Promise { // runPkg logs the full command itself via "Invoking: …" — no need to // pre-log the argv here. const pkgTargetsLabel = resolvedTargets.map(formatTarget).join(', '); + // Resolve before the build so the concrete version is in the log even when + // pkg then fails — that is exactly the run you need to identify afterwards. + const resolvedPkgVersion = await resolvePkgVersion({ exec: execBridge, logger, pkgCommand }); + logger.info( + `[pkg-action] pkg ${resolvedPkgVersion ?? '(version unknown)'} (from "${inputs.build.pkgVersion}")`, + ); + logger.startGroup(`[pkg-action] pkg build (targets=${pkgTargetsLabel})`); const runStart = Date.now(); try { @@ -349,7 +380,10 @@ async function main(): Promise { ); await writeSummary(rowsWithTime, { actionVersion: VERSION, - pkgVersion: inputs.build.pkgVersion, + pkgVersion: + resolvedPkgVersion !== undefined + ? `${resolvedPkgVersion} (${inputs.build.pkgVersion})` + : inputs.build.pkgVersion, }); } diff --git a/packages/core/src/inputs.ts b/packages/core/src/inputs.ts index d546ebb..58e456f 100644 --- a/packages/core/src/inputs.ts +++ b/packages/core/src/inputs.ts @@ -28,6 +28,20 @@ export interface InputSpec { readonly deprecated?: string; } +/** + * Config filenames pkg auto-detects, in its own precedence order (pkg's + * `PKGRC_FILENAMES`). Single source for the input descriptions, the docs, and + * the cache-key glob — those drifted apart once already, and a glob that misses + * a real config filename silently serves a stale pkg-fetch cache. + */ +export const PKG_CONFIG_FILENAMES = [ + '.pkgrc', + '.pkgrc.json', + 'pkg.config.js', + 'pkg.config.cjs', + 'pkg.config.mjs', +] as const; + // Registry. Used by codegen to produce action.yml. Kept exhaustive from day one // so codegen doesn't need to know which milestone added which input. export const INPUT_SPECS: readonly InputSpec[] = [ @@ -36,13 +50,13 @@ export const INPUT_SPECS: readonly InputSpec[] = [ name: 'config', category: 'build', description: - 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.', + 'Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline.', }, { name: 'config-inline', category: 'build', description: - 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.', + 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.', secret: true, }, { @@ -60,8 +74,8 @@ export const INPUT_SPECS: readonly InputSpec[] = [ name: 'pkg-version', category: 'build', description: - 'npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.', - default: '~6.19.0', + 'npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.', + default: '~6.21.0', }, { name: 'pkg-path', @@ -373,6 +387,19 @@ function readInput(env: EnvSource, name: string): string | undefined { return specFor(name)?.default; } +/** + * Resolve an input that the spec guarantees a default for. Keeps the default in + * INPUT_SPECS alone — a caller-side `?? 'literal'` reads as authoritative but is + * unreachable, so a bump touching only one of the two silently goes stale. + */ +function readRequiredInput(env: EnvSource, name: string): string { + const value = readInput(env, name); + if (value === undefined) { + throw new ValidationError(`Input "${name}" has no value and no default in INPUT_SPECS.`); + } + return value; +} + function parseBoolean(value: string | undefined, name: string): boolean { if (value === undefined) return false; const normalized = value.toLowerCase(); @@ -495,7 +522,7 @@ export function parseInputs(opts: ParseInputsOptions = {}): ActionInputs { configInline, entry: readInput(env, 'entry'), targets, - pkgVersion: readInput(env, 'pkg-version') ?? '~6.19.0', + pkgVersion: readRequiredInput(env, 'pkg-version'), pkgPath: readInput(env, 'pkg-path'), }; @@ -509,7 +536,7 @@ export function parseInputs(opts: ParseInputsOptions = {}): ActionInputs { '7z', 'none', ] as const), - filename: readInput(env, 'filename') ?? '{name}-{version}-{os}-{arch}', + filename: readRequiredInput(env, 'filename'), checksum: parseChecksumList(readInput(env, 'checksum'), 'checksum'), }; diff --git a/packages/core/src/pkg-output-map.ts b/packages/core/src/pkg-output-map.ts index 2f263d2..5e0cf40 100644 --- a/packages/core/src/pkg-output-map.ts +++ b/packages/core/src/pkg-output-map.ts @@ -100,11 +100,14 @@ export async function mapPkgOutputs( // (or its diff-parts) uniquely identifies each file. const listing = await readdir(outputDir); for (const { target, predicted: predictedName } of unresolved) { - const match = findFallbackMatch(listing, target, predictedName); + const match = findFallbackMatch(listing, target, predictedName, { + soleTarget: targets.length === 1, + }); if (match === undefined) { throw new PkgRunError( `pkg did not produce an output for ${formatTarget(target)}. ` + - `Expected "${predictedName}" in ${outputDir}; directory contains: ${listing.join(', ') || '(empty)'}.`, + `Expected "${predictedName}" in ${outputDir}; directory contains: ${listing.join(', ') || '(empty)'}. ` + + `If a postBuild hook moves or renames the binary, drop that — the action locates outputs by name.`, ); } entries.push({ target, path: join(outputDir, match) }); @@ -118,6 +121,7 @@ function findFallbackMatch( listing: readonly string[], target: Target, predicted: string, + opts: { readonly soleTarget: boolean } = { soleTarget: false }, ): string | undefined { // Try exact first (handles case-sensitivity quirks). if (listing.includes(predicted)) return predicted; @@ -125,10 +129,18 @@ function findFallbackMatch( const lower = predicted.toLowerCase(); const ci = listing.find((f) => f.toLowerCase() === lower); if (ci !== undefined) return ci; - // Last resort: any file containing BOTH the os and arch tokens. Avoids the - // fragility of the exact predicted name when pkg's heuristic drifts. + // Any file containing BOTH the os and arch tokens. Avoids the fragility of + // the exact predicted name when pkg's heuristic drifts. const needle = `${target.os}-${target.arch}`; - return listing.find((f) => f.toLowerCase().includes(needle.toLowerCase())); + const byTriple = listing.find((f) => f.toLowerCase().includes(needle.toLowerCase())); + if (byTriple !== undefined) return byTriple; + // One target, one file, and the directory is ours and was empty before this + // run — so that file is the output whatever pkg chose to call it. Needed + // because the base name can come from the config's `name`, which the action + // cannot read without executing the user's pkg.config.mjs. A single target + // gets no os/arch suffix, so nothing above can match. + if (opts.soleTarget && listing.length === 1) return listing[0]; + return undefined; } async function exists(path: string): Promise { diff --git a/packages/core/src/pkg-runner.ts b/packages/core/src/pkg-runner.ts index 0306ad9..ecd2ad5 100644 --- a/packages/core/src/pkg-runner.ts +++ b/packages/core/src/pkg-runner.ts @@ -72,6 +72,23 @@ export function buildPkgArgs(inv: PkgInvocation): string[] { return args; } +/** + * Ask pkg which version it actually is. The `pkg-version` input is a specifier + * (`~6.21.0` floats patches), so it can't identify the build that ran — which is + * what you need when a patch release regresses. Returns undefined rather than + * throwing: failing to label a summary must never fail a build. + */ +export async function resolvePkgVersion(deps: PkgRunnerDeps): Promise { + try { + const result = await deps.exec(deps.pkgCommand, ['--version'], { ignoreReturnCode: true }); + if (result.exitCode !== 0) return undefined; + const version = result.stdout.trim(); + return version === '' ? undefined : version; + } catch { + return undefined; + } +} + /** * Run pkg with the given invocation. Wraps non-zero exits in PkgRunError so * the orchestrator can surface the failure as a single `core.setFailed`. @@ -92,7 +109,15 @@ export async function runPkg(inv: PkgInvocation, deps: PkgRunnerDeps): Promise` + * together with a package.json input ("Specify either 'package.json' or config. + * Not both"), so a standalone config must be paired with an explicit entry. + */ + readonly binEntry: string | undefined; +} + +/** + * Pick the `bin` entry the way pkg does (lib/config.ts `resolveInput`): a string + * `bin` is the entry; an object is keyed by the unscoped package name, falling + * back to the first value. Mirrored rather than reimplemented so the action and + * pkg never disagree about which script gets packed. + */ +function resolveBinEntry(bin: unknown, name: string, projectDir: string): string | undefined { + let rel: unknown = bin; + if (typeof rel === 'object' && rel !== null) { + const map = rel as Record; + const unscoped = name.split('/').pop(); + rel = (unscoped !== undefined ? map[unscoped] : undefined) ?? Object.values(map)[0]; + } + return typeof rel === 'string' && rel !== '' ? join(projectDir, rel) : undefined; } /** @@ -47,7 +70,7 @@ export async function readProjectInfo(projectDir: string): Promise if (typeof version !== 'string' || version === '') { throw new ValidationError(`package.json at ${path} is missing "version".`); } - return { name, version }; + return { name, version, binEntry: resolveBinEntry(obj['bin'], name, projectDir) }; } /** @@ -56,7 +79,7 @@ export async function readProjectInfo(projectDir: string): Promise */ export function tokensForTarget( target: Target, - project: ProjectInfo, + project: Pick, env: Readonly>, nowDate: Date = new Date(), ): TemplateTokens { diff --git a/packages/core/test/unit/gen-action-yml.test.ts b/packages/core/test/unit/gen-action-yml.test.ts new file mode 100644 index 0000000..3c28051 --- /dev/null +++ b/packages/core/test/unit/gen-action-yml.test.ts @@ -0,0 +1,149 @@ +// The generated files are drift-checked in CI by `yarn gen && git diff --exit-code`, +// which proves they match the generator — not that the generator is right. These +// assert the parts with real consequences: the cache-key glob (a missed filename +// silently serves a stale pkg-fetch cache) and the shell-injection guard on the +// install step. + +import { test } from 'node:test'; +import { ok, match } from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PKG_CONFIG_FILENAMES } from '../../src/inputs.ts'; + +const REPO_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); + +const readGenerated = (rel: string): Promise => readFile(join(REPO_ROOT, rel), 'utf8'); + +test('cache key hashes every pkg config filename pkg auto-detects', async () => { + const yml = await readGenerated('action.yml'); + const keyLine = yml.split('\n').find((l) => l.includes('hashFiles(')); + ok(keyLine !== undefined, 'no hashFiles line in action.yml'); + for (const name of PKG_CONFIG_FILENAMES) { + ok(keyLine.includes(`'${name}'`) || keyLine.includes(`'**/${name}'`), `glob misses ${name}`); + } +}); + +test('cache key does not hash pkg.config.ts, which pkg never auto-detects', async () => { + const yml = await readGenerated('action.yml'); + ok(!yml.includes('pkg.config.{js,ts,json}'), 'stale pkg.config glob still present'); +}); + +test('pkg-version reaches the install step via env, never inline interpolation', async () => { + const yml = await readGenerated('action.yml'); + const install = yml.slice(yml.indexOf('Install @yao-pkg/pkg')); + ok( + !install.includes('npm i -g @yao-pkg/pkg@${{'), + 'pkg-version is interpolated straight into the run block — shell injection', + ); + ok(install.includes('PKG_VERSION: ${{ inputs.pkg-version }}'), 'pkg-version not passed via env'); + ok( + install.includes('npm i -g "@yao-pkg/pkg@${PKG_VERSION}"'), + 'install does not quote the value', + ); +}); + +test('install step validates the specifier before installing', async () => { + const yml = await readGenerated('action.yml'); + match(yml, /if \[\[ ! "\$\{PKG_VERSION\}" =~ \$specifier_re \]\]; then/); +}); + +/** Every `run: |` block in a generated action.yml, dedented. */ +function runBlocks(yml: string): string[] { + const blocks: string[] = []; + const lines = yml.split('\n'); + let current: string[] | undefined; + let indent = 0; + for (const line of lines) { + const start = /^(\s*)run: \|/.exec(line); + if (start?.[1] !== undefined) { + if (current !== undefined) blocks.push(current.join('\n')); + current = []; + indent = start[1].length; + continue; + } + if (current === undefined) continue; + if (line.trim() !== '' && line.length - line.trimStart().length <= indent) { + blocks.push(current.join('\n')); + current = undefined; + continue; + } + current.push(line.slice(indent + 2)); + } + if (current !== undefined) blocks.push(current.join('\n')); + return blocks; +} + +// `yarn lint` never sees the shell embedded in a YAML string, so a syntax +// error there only surfaces as a red CI job. `[[ ]]` parsing a bare `>` as an +// operator got through exactly this way. +test('generated run blocks are valid bash', async (t) => { + const yml = await readGenerated('action.yml'); + const blocks = runBlocks(yml); + ok(blocks.length > 0, 'no run blocks found — the extractor is broken'); + const dir = await mkdtemp(join(tmpdir(), 'pkgaction-shellcheck-')); + try { + for (const [i, block] of blocks.entries()) { + const file = join(dir, `block-${String(i)}.sh`); + await writeFile(file, block); + try { + execFileSync('bash', ['-n', file], { stdio: 'pipe' }); + } catch (err) { + const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? String(err); + t.diagnostic(block); + ok(false, `run block ${String(i)} is not valid bash: ${stderr}`); + } + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Actions expands `${{ }}` anywhere inside a run block — shell comments +// included — so a literal one in prose is a template parse error, not a +// comment. This failed CI on every job once; it must fail locally instead. +test('no run block contains a workflow expression', async () => { + for (const file of ['action.yml', 'packages/build/action.yml']) { + const lines = (await readGenerated(file)).split('\n'); + let inRun = false; + let runIndent = 0; + lines.forEach((line, i) => { + const runStart = /^(\s*)run: \|/.exec(line); + if (runStart?.[1] !== undefined) { + inRun = true; + runIndent = runStart[1].length; + return; + } + if (!inRun) return; + const indent = line.length - line.trimStart().length; + if (line.trim() !== '' && indent <= runIndent) { + inRun = false; + return; + } + ok( + !line.includes('${{'), + `${file}:${String(i + 1)} has a \${{ }} expression inside a run block: ${line.trim()}`, + ); + }); + } +}); + +test('docs document all three build hooks and the trust boundary', async () => { + const docs = await readGenerated('docs/inputs.md'); + ok(docs.includes('## Build hooks')); + for (const hook of ['preBuild', 'postBuild', 'transform']) { + ok(docs.includes(`\`${hook}\``), `hooks section does not mention ${hook}`); + } + ok(docs.includes('arbitrary code execution'), 'no trust-boundary warning'); + ok(docs.includes('untrusted ref'), 'trust warning does not mention untrusted refs'); +}); + +test('docs state the shipped default rather than a hardcoded version', async () => { + const docs = await readGenerated('docs/inputs.md'); + const { specFor } = await import('../../src/inputs.ts'); + const def = specFor('pkg-version')?.default; + ok(def !== undefined); + ok(docs.includes(def), `hooks section does not reference the default ${def}`); +}); diff --git a/packages/core/test/unit/inputs.test.ts b/packages/core/test/unit/inputs.test.ts index 9470ab3..cd630b9 100644 --- a/packages/core/test/unit/inputs.test.ts +++ b/packages/core/test/unit/inputs.test.ts @@ -4,6 +4,7 @@ import { closestInputName, INPUT_SPECS, parseInputs, + PKG_CONFIG_FILENAMES, readInputRaw, specFor, } from '../../src/inputs.ts'; @@ -64,7 +65,7 @@ test('parseInputs with no env uses defaults', () => { strictEqual(inputs.build.config, undefined); strictEqual(inputs.build.configInline, undefined); strictEqual(inputs.build.entry, undefined); - strictEqual(inputs.build.pkgVersion, '~6.19.0'); + strictEqual(inputs.build.pkgVersion, '~6.21.0'); strictEqual(inputs.build.pkgPath, undefined); strictEqual(inputs.postBuild.compress, 'none'); @@ -243,3 +244,25 @@ test('closestInputName suggests known input', () => { test('closestInputName returns null for far-off inputs', () => { strictEqual(closestInputName('xxxxxxxxxxxxxxxxxxx'), null); }); + +// Guards the removal of the `?? ''` fallbacks in parseInputs: those +// duplicated the INPUT_SPECS default, so a bump could update one and not the +// other. This asserts the spec is the only place the value lives. +test('parseInputs defaults come from INPUT_SPECS, not a second literal', () => { + const inputs = parseInputs({ env: {} }); + strictEqual(inputs.build.pkgVersion, specFor('pkg-version')?.default); + strictEqual(inputs.postBuild.filename, specFor('filename')?.default); +}); + +test('every spec default is a value parseInputs can actually resolve', () => { + const inputs = parseInputs({ env: {} }); + ok(inputs.build.pkgVersion !== undefined && inputs.build.pkgVersion !== ''); + ok(inputs.postBuild.filename !== undefined && inputs.postBuild.filename !== ''); +}); + +test('PKG_CONFIG_FILENAMES matches the filenames pkg auto-detects', () => { + deepStrictEqual( + [...PKG_CONFIG_FILENAMES], + ['.pkgrc', '.pkgrc.json', 'pkg.config.js', 'pkg.config.cjs', 'pkg.config.mjs'], + ); +}); diff --git a/packages/core/test/unit/pkg-output-map.test.ts b/packages/core/test/unit/pkg-output-map.test.ts index 59d0596..710b9cc 100644 --- a/packages/core/test/unit/pkg-output-map.test.ts +++ b/packages/core/test/unit/pkg-output-map.test.ts @@ -126,3 +126,44 @@ test('mapPkgOutputs: single-target, bare name', async () => { strictEqual(out[0]?.path, join(dir, 'app')); }); }); + +// A standalone pkg config can set its own `name`, and pkg prefers that over +// package.json — so the predicted base name misses. With one target there is +// no os/arch suffix for the triple fallback to grab either. +test('mapPkgOutputs resolves a single target whose name pkg chose itself', async () => { + await withOutputDir(['renamed-by-config'], async (dir) => { + const entries = await mapPkgOutputs( + [{ node: 22, os: 'linux', arch: 'x64' }], + 'predicted-name', + dir, + ); + strictEqual(entries.length, 1); + strictEqual(entries[0]?.path, join(dir, 'renamed-by-config')); + }); +}); + +test('mapPkgOutputs does not guess when one target left several files', async () => { + await withOutputDir(['one', 'two'], async (dir) => { + await rejects( + () => mapPkgOutputs([{ node: 22, os: 'linux', arch: 'x64' }], 'predicted-name', dir), + /did not produce an output/, + ); + }); +}); + +test('mapPkgOutputs does not guess for multi-target builds', async () => { + await withOutputDir(['only-one-file'], async (dir) => { + await rejects( + () => + mapPkgOutputs( + [ + { node: 22, os: 'linux', arch: 'x64' }, + { node: 22, os: 'macos', arch: 'arm64' }, + ], + 'predicted-name', + dir, + ), + /did not produce an output/, + ); + }); +}); diff --git a/packages/core/test/unit/pkg-runner.test.ts b/packages/core/test/unit/pkg-runner.test.ts index 51e2352..5648a5d 100644 --- a/packages/core/test/unit/pkg-runner.test.ts +++ b/packages/core/test/unit/pkg-runner.test.ts @@ -10,7 +10,7 @@ const BASE_BUILD: BuildInputs = { configInline: undefined, entry: undefined, targets: 'host', - pkgVersion: '~6.19.0', + pkgVersion: '~6.21.0', pkgPath: undefined, }; diff --git a/packages/core/test/unit/project-info.test.ts b/packages/core/test/unit/project-info.test.ts index 8275c00..8e09768 100644 --- a/packages/core/test/unit/project-info.test.ts +++ b/packages/core/test/unit/project-info.test.ts @@ -107,3 +107,48 @@ test('tokensForTarget uses "latest" as node prefix for latest--', () = strictEqual(tokens.node, 'latest'); strictEqual(tokens.target, 'latest-linux-x64'); }); + +// ─── bin entry resolution ───────────────────────────────────────────────── +// Mirrors pkg's own precedence (lib/config.ts resolveInput): string bin wins, +// object bin is keyed by the unscoped package name, else the first value. + +test('readProjectInfo resolves a string bin to an absolute entry', async () => { + await withProject({ name: 'app', version: '1.0.0', bin: 'cli.js' }, async (dir) => { + const info = await readProjectInfo(dir); + strictEqual(info.binEntry, join(dir, 'cli.js')); + }); +}); + +test('readProjectInfo picks the bin entry matching the unscoped package name', async () => { + await withProject( + { name: '@scope/app', version: '1.0.0', bin: { other: 'other.js', app: 'right.js' } }, + async (dir) => { + const info = await readProjectInfo(dir); + strictEqual(info.binEntry, join(dir, 'right.js')); + }, + ); +}); + +test('readProjectInfo falls back to the first bin value when no name match', async () => { + await withProject( + { name: 'app', version: '1.0.0', bin: { somethingElse: 'first.js', second: 'second.js' } }, + async (dir) => { + const info = await readProjectInfo(dir); + strictEqual(info.binEntry, join(dir, 'first.js')); + }, + ); +}); + +test('readProjectInfo reports no bin entry when bin is absent', async () => { + await withProject({ name: 'app', version: '1.0.0' }, async (dir) => { + const info = await readProjectInfo(dir); + strictEqual(info.binEntry, undefined); + }); +}); + +test('readProjectInfo ignores a non-string, non-object bin', async () => { + await withProject({ name: 'app', version: '1.0.0', bin: 42 }, async (dir) => { + const info = await readProjectInfo(dir); + strictEqual(info.binEntry, undefined); + }); +}); diff --git a/packages/windows-metadata/dist/index.mjs b/packages/windows-metadata/dist/index.mjs index 05e12cb..9993847 100644 --- a/packages/windows-metadata/dist/index.mjs +++ b/packages/windows-metadata/dist/index.mjs @@ -13363,12 +13363,12 @@ var INPUT_SPECS = [ { name: "config", category: "build", - description: "Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline." + description: "Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline." }, { name: "config-inline", category: "build", - description: "Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.", + description: "Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks \u2014 function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.", secret: !0 }, { @@ -13384,8 +13384,8 @@ var INPUT_SPECS = [ { name: "pkg-version", category: "build", - description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.", - default: "~6.19.0" + description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.", + default: "~6.21.0" }, { name: "pkg-path", diff --git a/scripts/gen-action-yml.ts b/scripts/gen-action-yml.ts index a01e809..28b6f0d 100644 --- a/scripts/gen-action-yml.ts +++ b/scripts/gen-action-yml.ts @@ -15,13 +15,21 @@ // // CI gate: `git diff --exit-code` over the generated files catches drift. -import { INPUT_SPECS, type InputSpec } from '@pkg-action/core'; +import { INPUT_SPECS, PKG_CONFIG_FILENAMES, specFor, type InputSpec } from '@pkg-action/core'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); +/** `hashFiles` args covering every auto-detected pkg config, derived from the one list. */ +const CACHE_CONFIG_GLOBS = PKG_CONFIG_FILENAMES.map((f) => + f.startsWith('.') ? `'${f}'` : `'**/${f}'`, +).join(', '); + +/** The `pkg-version` default, quoted for prose. Never hardcode it a second time. */ +const PKG_VERSION_DEFAULT = specFor('pkg-version')?.default ?? ''; + const OUTPUTS = [ { id: 'binaries', description: 'JSON array of pre-archive binary absolute paths.' }, { id: 'artifacts', description: 'JSON array of post-archive artifact absolute paths.' }, @@ -125,13 +133,24 @@ runs: uses: actions/cache@v5 with: path: \${{ runner.temp }}/pkg-cache - key: \${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', '.pkgrc*', '**/pkg.config.{js,ts,json}')) }} + key: \${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', ${CACHE_CONFIG_GLOBS})) }} - name: Install @yao-pkg/pkg if: \${{ inputs.pkg-path == '' }} shell: bash + env: + PKG_VERSION: \${{ inputs.pkg-version }} run: | - npm i -g @yao-pkg/pkg@\${{ inputs.pkg-version }} + # Read from env and quoted — never interpolated into this script by the + # workflow templater, or a crafted specifier would execute right here. + # The pattern lives in a variable because [[ ]] parses a bare > as an + # operator before it ever reaches the regex engine. + specifier_re='^[A-Za-z0-9.*|=<>~^ -]+$' + if [[ ! "\${PKG_VERSION}" =~ $specifier_re ]]; then + echo "::error::Input 'pkg-version' is not a valid npm version specifier: \${PKG_VERSION}" + exit 1 + fi + npm i -g "@yao-pkg/pkg@\${PKG_VERSION}" - name: Run pkg-action build id: pkg-action-build @@ -165,6 +184,72 @@ runs: // ─── docs/inputs.md ─────────────────────────────────────────────────────── +// Hooks are pkg-config keys, not action inputs, so they have no INPUT_SPECS +// entry to carry this prose. +const BUILD_HOOKS_SECTION: readonly string[] = [ + '## Build hooks', + '', + '@yao-pkg/pkg 6.21.0+ runs three hooks from your pkg config. They have no CLI flags and', + 'no action inputs — set them in the file you point `config` at (or let pkg auto-detect it).', + `The default \`pkg-version\` (\`${PKG_VERSION_DEFAULT}\`) already includes them; only callers who pinned`, + 'an older pkg need to raise it.', + '', + '| Key | Accepts | Runs |', + '| --- | --- | --- |', + '| `preBuild` | shell string or function | once, before pkg walks the dependency graph |', + '| `postBuild` | shell string or function | once per produced binary, after pkg has written it (and, on macOS, ad-hoc signed it) |', + '| `transform` | function only | per packed file, after path refinement, before bytecode/compression |', + '', + 'The shell form of `postBuild` gets the absolute output path in `PKG_OUTPUT`; the function', + 'form gets it as its first argument. `transform(file, body)` returns a string or Buffer to', + 'replace the contents, or `undefined` to leave the file alone.', + '', + '```js', + '// pkg.config.mjs', + "import { join } from 'node:path';", + '', + "const SRC_DIR = join(import.meta.dirname, 'src');", + '', + 'export default {', + " targets: ['node22-linux-x64'],", + " preBuild: 'npm run build',", + ' postBuild: (output) => console.log(`built ${output}`),', + ' transform: (file, body) =>', + " file.startsWith(SRC_DIR) && file.endsWith('.js')", + " ? body.toString().replaceAll('__VERSION__', () => process.env.GITHUB_SHA ?? 'dev')", + ' : undefined,', + '};', + '```', + '', + 'Two things that example is careful about: it scopes the rewrite to your own sources (a bare', + "`.endsWith('.js')` also rewrites every packed `node_modules` file), and it passes a replacer", + 'function so `$&` and `` $` `` in the replacement are not interpreted. Only interpolate values', + 'you trust — a branch name or PR title lands verbatim in the shipped binary.', + '', + '> **Hooks are arbitrary code execution.** `preBuild` / `postBuild` shell strings are spawned', + "> with the runner's full environment, and `transform` is arbitrary JS over every packed file.", + '> pkg picks a config up from the checked-out tree automatically, so on a workflow that builds', + '> an untrusted ref (a fork `pull_request`), whoever wrote that ref can run commands in the job —', + '> alongside any signing certificates and tokens it holds. This capability did not exist before', + '> pkg 6.21. Build untrusted refs in a job with no secrets, or pin `pkg-version` below 6.21.', + '', + '> **Do not move or rename the binary from `postBuild`.** The hook runs inside pkg, before', + "> the action's windows-metadata, signing, archive and checksum stages. Those stages locate", + "> outputs by predicting pkg's naming heuristic, then fall back to an exact, case-insensitive,", + '> and finally `-` substring match over the output directory — a rename defeats all', + "> three. Use the action's own archive and checksum inputs instead.", + '', + '### Hooks and `config-inline`', + '', + '`config-inline` is JSON, so it can only carry the shell-string form of `preBuild` /', + '`postBuild`. Function hooks and `transform` need a real `pkg.config.{js,cjs,mjs}` file.', + '', + 'pkg refuses `--config ` together with a package.json entry, so whenever `config` points', + 'at a standalone config — or `config-inline` is used — an entry script is required. The action', + 'supplies your package.json `bin` automatically; set the `entry` input if there is no `bin`.', + '', +]; + function renderInputsDocs(): string { const lines: string[] = []; lines.push(''); @@ -208,6 +293,8 @@ function renderInputsDocs(): string { lines.push(''); } + lines.push(...BUILD_HOOKS_SECTION); + lines.push('## Outputs'); lines.push(''); lines.push('| Output | Description |');