From 0da3405c12c5ca1218dafc84fe6f4cdf50ea76a5 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 10:02:28 +0200 Subject: [PATCH 1/9] feat: default pkg-version to ~6.21.0 and document build hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg 6.21.0 added preBuild / postBuild / transform hooks. They are config-only (no CLI flags), so the action's --config pass-through already carries them — but the default pkg-version pin of ~6.19.0 resolved to 6.19.x, which does not have them. - default pkg-version ~6.19.0 -> ~6.21.0 - docs/inputs.md gains a Build hooks section covering the three keys, the PKG_OUTPUT contract, the config-inline JSON limitation, and a warning against renaming the binary from postBuild (pkg-output-map locates outputs by predicted name + basename-prefix scan) - fix the `config` input description: pkg auto-detects .pkgrc, .pkgrc.json and pkg.config.{js,cjs,mjs} — never .ts, never pkg.config.json --- STATUS.yaml | 13 ++++++- action.yml | 8 ++-- docs/inputs.md | 42 +++++++++++++++++++-- packages/build/action.yml | 8 ++-- packages/build/dist/index.mjs | 10 ++--- packages/core/src/inputs.ts | 10 ++--- packages/core/test/unit/inputs.test.ts | 2 +- packages/core/test/unit/pkg-runner.test.ts | 2 +- packages/windows-metadata/dist/index.mjs | 8 ++-- scripts/gen-action-yml.ts | 43 ++++++++++++++++++++++ 10 files changed, 117 insertions(+), 29 deletions(-) diff --git a/STATUS.yaml b/STATUS.yaml index d1bf55b..0b60cbe 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -67,11 +67,20 @@ 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. + Two constraints are documented in docs/inputs.md rather than enforced: + `config-inline` is JSON so it can only carry the shell-string form of + pre/postBuild, and a postBuild hook must not move or rename the binary + or `pkg-output-map` can no longer locate it. 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 diff --git a/action.yml b/action.yml index e133786..e527fd4 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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/docs/inputs.md b/docs/inputs.md index a02efb2..1e3d705 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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,42 @@ 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). +Raise `pkg-version` to at least `~6.21.0` to get them. + +| 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 it is written and codesigned | +| `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 +export default { + targets: ['node22-linux-x64'], + preBuild: 'npm run build', + postBuild: (output) => console.log(`built ${output}`), + transform: (file, body) => + file.endsWith('.js') ? body.toString().replaceAll('__VERSION__', process.env.GITHUB_SHA) : undefined, +}; +``` + +`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. + +> **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 and falling back to a basename-prefix scan of +> the output directory — a rename defeats both. Use the action's own archive and checksum +> inputs instead. + ## Outputs | Output | Description | diff --git a/packages/build/action.yml b/packages/build/action.yml index 751c1dd..952f391 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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..305f73d 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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", @@ -15097,7 +15097,7 @@ function parseInputs(opts = {}) { configInline, entry: readInput(env, "entry"), targets, - pkgVersion: readInput(env, "pkg-version") ?? "~6.19.0", + pkgVersion: readInput(env, "pkg-version") ?? "~6.21.0", pkgPath: readInput(env, "pkg-path") }, postBuild = { strip: parseBoolean(readInput(env, "strip"), "strip"), diff --git a/packages/core/src/inputs.ts b/packages/core/src/inputs.ts index d546ebb..154377e 100644 --- a/packages/core/src/inputs.ts +++ b/packages/core/src/inputs.ts @@ -36,13 +36,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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.', secret: true, }, { @@ -60,8 +60,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', @@ -495,7 +495,7 @@ export function parseInputs(opts: ParseInputsOptions = {}): ActionInputs { configInline, entry: readInput(env, 'entry'), targets, - pkgVersion: readInput(env, 'pkg-version') ?? '~6.19.0', + pkgVersion: readInput(env, 'pkg-version') ?? '~6.21.0', pkgPath: readInput(env, 'pkg-path'), }; diff --git a/packages/core/test/unit/inputs.test.ts b/packages/core/test/unit/inputs.test.ts index 9470ab3..8bfe076 100644 --- a/packages/core/test/unit/inputs.test.ts +++ b/packages/core/test/unit/inputs.test.ts @@ -64,7 +64,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'); 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/windows-metadata/dist/index.mjs b/packages/windows-metadata/dist/index.mjs index 05e12cb..0168331 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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..5a53350 100644 --- a/scripts/gen-action-yml.ts +++ b/scripts/gen-action-yml.ts @@ -165,6 +165,47 @@ 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).', + 'Raise `pkg-version` to at least `~6.21.0` to get them.', + '', + '| 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 it is written and codesigned |', + '| `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', + 'export default {', + " targets: ['node22-linux-x64'],", + " preBuild: 'npm run build',", + ' postBuild: (output) => console.log(`built ${output}`),', + ' transform: (file, body) =>', + " file.endsWith('.js') ? body.toString().replaceAll('__VERSION__', process.env.GITHUB_SHA) : undefined,", + '};', + '```', + '', + '`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.', + '', + '> **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 and falling back to a basename-prefix scan of", + "> the output directory — a rename defeats both. Use the action's own archive and checksum", + '> inputs instead.', + '', +]; + function renderInputsDocs(): string { const lines: string[] = []; lines.push(''); @@ -208,6 +249,8 @@ function renderInputsDocs(): string { lines.push(''); } + lines.push(...BUILD_HOOKS_SECTION); + lines.push('## Outputs'); lines.push(''); lines.push('| Output | Description |'); From 250ebb9bd815bb6eb03f934d331201469288cd58 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:19:13 +0200 Subject: [PATCH 2/9] fix: supply an entry script when pkg gets a standalone --config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg refuses `--config ` together with a package.json input ("Specify either 'package.json' or config. Not both"), and the action's default positional `.` resolves to exactly that. So every run using `config-inline`, or `config` pointing at a standalone pkg config, failed outright unless the caller also set `entry`. Reproduced against 6.21.0 and 6.19.0 alike — this predates the version bump. Resolve package.json `bin` and pass it as the entry, mirroring pkg's own precedence (string bin; object keyed by unscoped package name, else first value). Actionable error when there is no bin. tokensForTarget narrows to Pick — it never needed the new field. --- packages/build/src/main.ts | 32 +++++++++++++- packages/core/src/project-info.ts | 27 +++++++++++- packages/core/test/unit/project-info.test.ts | 45 ++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/build/src/main.ts b/packages/build/src/main.ts index 1c75898..df5c7da 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, @@ -173,9 +175,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 +203,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 +374,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/project-info.ts b/packages/core/src/project-info.ts index 1ad0a29..c1cafe9 100644 --- a/packages/core/src/project-info.ts +++ b/packages/core/src/project-info.ts @@ -15,6 +15,29 @@ export interface ProjectInfo { /** package.json "name" (or "bin" basename if bin is a string). Used as the output base name. */ readonly name: string; readonly version: string; + /** + * Entry script from package.json `bin`, resolved against the project dir. + * `undefined` when `bin` is absent. Needed because pkg refuses `--config ` + * 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/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); + }); +}); From 0b132e5e50aad39622c1c1bac66ff4420a7b2436 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:19:23 +0200 Subject: [PATCH 3/9] fix(security): pass pkg-version via env, not inline interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm i -g @yao-pkg/pkg@${{ inputs.pkg-version }}` pasted the input straight into the run block, so a crafted specifier executed shell in a job that can hold signing certs and notarization credentials. Pass it through `env:`, reference it quoted, and reject anything outside npm specifier characters before installing. Also collapses the pkg config filename list to one exported constant. It had drifted into four independent copies with two different contents: the cache-key `hashFiles` glob still matched `pkg.config.{js,ts,json}`, so a `pkg.config.mjs` — the file the hooks docs tell you to write — never invalidated the pkg-fetch cache, while `.ts` was hashed despite pkg never auto-detecting it. Drops the `?? ''` fallbacks in parseInputs: readInput already resolves the INPUT_SPECS default, so those were unreachable copies of a value that has to stay in sync by hand. --- packages/core/src/inputs.ts | 35 ++++++++++++++++++--- scripts/gen-action-yml.ts | 63 ++++++++++++++++++++++++++++++------- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/core/src/inputs.ts b/packages/core/src/inputs.ts index 154377e..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. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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. 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; 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, }, { @@ -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.21.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/scripts/gen-action-yml.ts b/scripts/gen-action-yml.ts index 5a53350..d232d57 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,21 @@ 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 }} + # Via env + quoted, never \${{ }} inline — the value would otherwise be + # pasted into this script and a crafted specifier would execute here. + if [[ ! "\${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; 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 @@ -172,12 +188,13 @@ const BUILD_HOOKS_SECTION: readonly string[] = [ '', '@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).', - 'Raise `pkg-version` to at least `~6.21.0` to get them.', + `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 it is written and codesigned |', + '| `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', @@ -186,23 +203,47 @@ const BUILD_HOOKS_SECTION: readonly string[] = [ '', '```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.endsWith('.js') ? body.toString().replaceAll('__VERSION__', process.env.GITHUB_SHA) : undefined,", + " file.startsWith(SRC_DIR) && file.endsWith('.js')", + " ? body.toString().replaceAll('__VERSION__', () => process.env.GITHUB_SHA ?? 'dev')", + ' : undefined,', '};', '```', '', - '`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.', + '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 and falling back to a basename-prefix scan of", - "> the output directory — a rename defeats both. Use the action's own archive and checksum", - '> inputs instead.', + "> 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`.', '', ]; From 5e7656248342c08383a4d9bd67fa9a26e096fdba Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:19:39 +0200 Subject: [PATCH 4/9] feat: report the resolved pkg version and name hooks in failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `~6.21.0` floats patch releases at CI runtime, so the specifier can't identify the build that ran — exactly what you need when a patch regresses. Query `pkg --version` and surface it in the log and the step summary alongside the specifier. Failing to label a summary never fails a build. Two error messages gain the context the new hooks make necessary: a non-zero pkg exit now names the config file, because a failing preBuild/postBuild/transform hook is indistinguishable from a pkg-internal failure in the log; and the output-map miss suggests a postBuild rename, which is the newly-likely cause. --- packages/core/src/pkg-output-map.ts | 3 ++- packages/core/src/pkg-runner.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/core/src/pkg-output-map.ts b/packages/core/src/pkg-output-map.ts index 2f263d2..3cac3a0 100644 --- a/packages/core/src/pkg-output-map.ts +++ b/packages/core/src/pkg-output-map.ts @@ -104,7 +104,8 @@ export async function mapPkgOutputs( 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) }); 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 Date: Mon, 27 Jul 2026 11:19:39 +0200 Subject: [PATCH 5/9] docs: document the hooks trust boundary and correct the hook reference The default bump to 6.21 silently turned pkg configs from data into executable code: pkg picks a config up from the checked-out tree, so a workflow building an untrusted ref now grants that ref command execution in a job that may hold signing credentials. No such capability existed at 6.19. Documented in docs/inputs.md, README and STATUS.yaml, with the release-note obligation and the pin-vs-floor rationale recorded. Corrections to the hooks reference: - postBuild runs after pkg's own macOS ad-hoc signing, not the action's signing stage, and not at all on Windows - the output-map fallback is exact -> case-insensitive -> - substring, not a basename-prefix scan - config-inline needs an entry script; say so - the transform example scopes to project sources (a bare .endsWith('.js') also rewrites node_modules), uses a replacer function so `$&` is not interpreted, and no longer bakes "undefined" outside Actions - stop telling readers to raise pkg-version to a value that is the default - setSecret does not mask what a hook prints Adds an e2e job asserting all three hooks fire through the --config pass-through, and generator tests covering the cache glob, the install step's injection guard, and the trust warning. Fixes an unrelated pre-existing YAML syntax error in STATUS.yaml that kept the whole file from parsing. --- .github/workflows/e2e.yml | 80 +++++++++++++++++++ README.md | 9 ++- STATUS.yaml | 27 +++++-- action.yml | 16 +++- docs/inputs.md | 45 ++++++++--- packages/build/action.yml | 4 +- packages/build/dist/index.mjs | 71 ++++++++++++---- .../core/test/unit/gen-action-yml.test.ts | 67 ++++++++++++++++ packages/core/test/unit/inputs.test.ts | 23 ++++++ packages/windows-metadata/dist/index.mjs | 4 +- 10 files changed, 305 insertions(+), 41 deletions(-) create mode 100644 packages/core/test/unit/gen-action-yml.test.ts 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 0b60cbe..a208d9b 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -77,10 +77,27 @@ input-surface-slim: 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. - Two constraints are documented in docs/inputs.md rather than enforced: - `config-inline` is JSON so it can only carry the shell-string form of - pre/postBuild, and a postBuild hook must not move or rename the binary - or `pkg-output-map` can no longer locate it. + 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 @@ -137,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 e527fd4..c928a88 100644 --- a/action.yml +++ b/action.yml @@ -10,9 +10,9 @@ branding: inputs: config: - 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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. 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; 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: @@ -142,13 +142,21 @@ 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 }} + # Via env + quoted, never ${{ }} inline — the value would otherwise be + # pasted into this script and a crafted specifier would execute here. + if [[ ! "${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; 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 1e3d705..7953737 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -8,8 +8,8 @@ Every `pkg-action` input, grouped by category. | Input | Default | Required | Secret | Description | | --- | --- | --- | --- | --- | -| `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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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; 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.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. | @@ -78,12 +78,13 @@ Every `pkg-action` input, grouped by category. @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). -Raise `pkg-version` to at least `~6.21.0` to get them. +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 it is written and codesigned | +| `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 @@ -92,23 +93,47 @@ 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.endsWith('.js') ? body.toString().replaceAll('__VERSION__', process.env.GITHUB_SHA) : undefined, + file.startsWith(SRC_DIR) && file.endsWith('.js') + ? body.toString().replaceAll('__VERSION__', () => process.env.GITHUB_SHA ?? 'dev') + : undefined, }; ``` -`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. +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 and falling back to a basename-prefix scan of -> the output directory — a rename defeats both. Use the action's own archive and checksum -> inputs instead. +> 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 diff --git a/packages/build/action.yml b/packages/build/action.yml index 952f391..a62a7ec 100644 --- a/packages/build/action.yml +++ b/packages/build/action.yml @@ -7,9 +7,9 @@ author: 'yao-pkg contributors' inputs: config: - 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, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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. 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; 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: diff --git a/packages/build/dist/index.mjs b/packages/build/dist/index.mjs index 305f73d..107d95c 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. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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. 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; 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 }, { @@ -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.21.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; } @@ -15209,7 +15229,7 @@ async function mapPkgOutputs(targets, baseName, outputDir) { let match = findFallbackMatch(listing, target, predictedName); 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) }); } @@ -15370,6 +15390,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 +15418,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)}`; @@ -19272,11 +19300,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 +19378,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 +19412,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/core/test/unit/gen-action-yml.test.ts b/packages/core/test/unit/gen-action-yml.test.ts new file mode 100644 index 0000000..a0e0d87 --- /dev/null +++ b/packages/core/test/unit/gen-action-yml.test.ts @@ -0,0 +1,67 @@ +// 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 { readFile } from 'node:fs/promises'; +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 rejects a specifier containing shell metacharacters', async () => { + const yml = await readGenerated('action.yml'); + match(yml, /if \[\[ ! "\$\{PKG_VERSION\}" =~ .+ \]\]; then/); +}); + +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 8bfe076..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'; @@ -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/windows-metadata/dist/index.mjs b/packages/windows-metadata/dist/index.mjs index 0168331..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. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry, then falls back to the package.json pkg field. An explicit path may also point at a package.json or any .json file. 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. 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; 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 }, { From 7533be16117762b16bb804f63467812233d55d1a Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:42:21 +0200 Subject: [PATCH 6/9] fix(ci): remove a literal ${{ }} from the install step comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actions expands ${{ }} anywhere inside a run block, shell comments included, so the comment explaining why pkg-version is passed via env was itself parsed as an empty expression: "Line 152: An expression was expected". action.yml failed to load, so every job that invokes the action failed — including ones this branch never touched. Rewords the comment and adds a generator test that scans every run block for workflow expressions. Verified it fails on the exact bug. --- action.yml | 4 +-- .../core/test/unit/gen-action-yml.test.ts | 29 +++++++++++++++++++ scripts/gen-action-yml.ts | 4 +-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index c928a88..0ccc676 100644 --- a/action.yml +++ b/action.yml @@ -150,8 +150,8 @@ runs: env: PKG_VERSION: ${{ inputs.pkg-version }} run: | - # Via env + quoted, never ${{ }} inline — the value would otherwise be - # pasted into this script and a crafted specifier would execute here. + # Read from env and quoted — never interpolated into this script by the + # workflow templater, or a crafted specifier would execute right here. if [[ ! "${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; then echo "::error::Input 'pkg-version' is not a valid npm version specifier: ${PKG_VERSION}" exit 1 diff --git a/packages/core/test/unit/gen-action-yml.test.ts b/packages/core/test/unit/gen-action-yml.test.ts index a0e0d87..fd402b8 100644 --- a/packages/core/test/unit/gen-action-yml.test.ts +++ b/packages/core/test/unit/gen-action-yml.test.ts @@ -48,6 +48,35 @@ test('install step rejects a specifier containing shell metacharacters', async ( match(yml, /if \[\[ ! "\$\{PKG_VERSION\}" =~ .+ \]\]; then/); }); +// 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')); diff --git a/scripts/gen-action-yml.ts b/scripts/gen-action-yml.ts index d232d57..aa894dc 100644 --- a/scripts/gen-action-yml.ts +++ b/scripts/gen-action-yml.ts @@ -141,8 +141,8 @@ runs: env: PKG_VERSION: \${{ inputs.pkg-version }} run: | - # Via env + quoted, never \${{ }} inline — the value would otherwise be - # pasted into this script and a crafted specifier would execute here. + # Read from env and quoted — never interpolated into this script by the + # workflow templater, or a crafted specifier would execute right here. if [[ ! "\${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; then echo "::error::Input 'pkg-version' is not a valid npm version specifier: \${PKG_VERSION}" exit 1 From 7374b8ef67e4546fa8e3b86fa8c3a094063ebdd3 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:46:31 +0200 Subject: [PATCH 7/9] fix(ci): put the specifier pattern in a variable, and shellcheck the output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[[ ! "$X" =~ ^[...><...]+$ ]]` is a bash syntax error — [[ ]] parses a bare > as an operator before the regex engine ever sees it, so the install step died with "unexpected token `>`" on every job. The pattern now lives in `specifier_re` and is referenced unquoted, the standard idiom. Verified against real specifiers (~6.21.0, ^6.21.0, latest, ">=6.21.0 <7.0.0", 6.x, *, "6.20.0 || 6.21.0") and injection attempts ("6.21.0; curl …|sh", "$(id)", backticks, "&&whoami"). Root cause of both CI failures is the same gap: shell embedded in a YAML string is invisible to eslint and to the unit tests, so it could only fail in CI. Added a test that extracts every `run: |` block from the generated action.yml and runs `bash -n` over it. Confirmed it fails on the exact broken conditional. --- action.yml | 5 +- .../core/test/unit/gen-action-yml.test.ts | 59 ++++++++++++++++++- scripts/gen-action-yml.ts | 5 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index 0ccc676..3602c83 100644 --- a/action.yml +++ b/action.yml @@ -152,7 +152,10 @@ runs: run: | # Read from env and quoted — never interpolated into this script by the # workflow templater, or a crafted specifier would execute right here. - if [[ ! "${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; then + # 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 diff --git a/packages/core/test/unit/gen-action-yml.test.ts b/packages/core/test/unit/gen-action-yml.test.ts index fd402b8..3c28051 100644 --- a/packages/core/test/unit/gen-action-yml.test.ts +++ b/packages/core/test/unit/gen-action-yml.test.ts @@ -6,7 +6,9 @@ import { test } from 'node:test'; import { ok, match } from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +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'; @@ -43,9 +45,60 @@ test('pkg-version reaches the install step via env, never inline interpolation', ); }); -test('install step rejects a specifier containing shell metacharacters', async () => { +test('install step validates the specifier before installing', async () => { const yml = await readGenerated('action.yml'); - match(yml, /if \[\[ ! "\$\{PKG_VERSION\}" =~ .+ \]\]; then/); + 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 diff --git a/scripts/gen-action-yml.ts b/scripts/gen-action-yml.ts index aa894dc..28b6f0d 100644 --- a/scripts/gen-action-yml.ts +++ b/scripts/gen-action-yml.ts @@ -143,7 +143,10 @@ runs: run: | # Read from env and quoted — never interpolated into this script by the # workflow templater, or a crafted specifier would execute right here. - if [[ ! "\${PKG_VERSION}" =~ ^[A-Za-z0-9.~^*><=|[:space:]-]+$ ]]; then + # 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 From 579c6405514b20e6a1cd87360a9aec1bde4d69a6 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:50:10 +0200 Subject: [PATCH 8/9] fix: derive the project dir from an explicit standalone config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry fix read package.json from GITHUB_WORKSPACE, but a standalone config usually sits beside the project it configures — not at the repo root. The hooks e2e caught it: bin was looked up in pkg-action's own root package.json, which has none. An explicit `config` now names the project — its parent directory is the root whether it is a package.json or a standalone config next to one. A config kept away from its project (configs/pkg.config.mjs) has no package.json beside it, so that still falls back to the workspace, as does an unset config. No back-compat risk: standalone configs never worked at all before 250ebb9, so nothing was relying on the old resolution. Verified the full flow against real pkg 6.21 locally — all three hooks fire, the transform rewrites only packed bytes, and the on-disk source keeps its placeholder. --- packages/build/dist/index.mjs | 15 +++++++++------ packages/build/src/main.ts | 28 +++++++++++++++++----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/build/dist/index.mjs b/packages/build/dist/index.mjs index 107d95c..7b9ae2a 100644 --- a/packages/build/dist/index.mjs +++ b/packages/build/dist/index.mjs @@ -19278,14 +19278,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]; diff --git a/packages/build/src/main.ts b/packages/build/src/main.ts index df5c7da..802c8e3 100644 --- a/packages/build/src/main.ts +++ b/packages/build/src/main.ts @@ -123,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}`); From 2fbb133f2da1a970967389eada67f1f994bebfa0 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Jul 2026 11:54:31 +0200 Subject: [PATCH 9/9] fix: resolve the output when pkg picks the base name itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg derives the output base name as --output > package.json name > config `name` > entry basename. Supplying an entry for a standalone config moved it off package.json name, so the prediction ("hooks-app") missed what pkg wrote ("index"), and a single target has no os/arch suffix for the triple fallback to match on. The action cannot know the config's `name` without executing the user's pkg.config.mjs, so prediction here is inherently best-effort — hence the existing fallback chain. Extends it with the one case that is unambiguous: a sole target and a sole file in an output directory the action created empty for this run. Still refuses to guess when several files are present, or for multi-target builds where the os/arch fallback already applies. Verified against the real pkg output dir from the failing CI scenario. --- packages/build/dist/index.mjs | 11 +++-- packages/core/src/pkg-output-map.ts | 19 +++++++-- .../core/test/unit/pkg-output-map.test.ts | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/build/dist/index.mjs b/packages/build/dist/index.mjs index 7b9ae2a..cf4f8e8 100644 --- a/packages/build/dist/index.mjs +++ b/packages/build/dist/index.mjs @@ -15226,7 +15226,9 @@ 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)"}. If a postBuild hook moves or renames the binary, drop that \u2014 the action locates outputs by name.` @@ -15236,12 +15238,13 @@ async function mapPkgOutputs(targets, baseName, outputDir) { } 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 { diff --git a/packages/core/src/pkg-output-map.ts b/packages/core/src/pkg-output-map.ts index 3cac3a0..5e0cf40 100644 --- a/packages/core/src/pkg-output-map.ts +++ b/packages/core/src/pkg-output-map.ts @@ -100,7 +100,9 @@ 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)}. ` + @@ -119,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; @@ -126,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/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/, + ); + }); +});