diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6d7de12..d091181 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -253,16 +253,18 @@ jobs: # ────────────────────────────────────────────────────────────────────── # Real-world smoke: pull @anthropic-ai/claude-code from npm, build a - # native binary per runner OS/arch with Zstd-compressed pkg payload + - # tar.gz archive, execute the binary with --version, then verify the - # archive round-trips and the sha256 sidecar matches the archive bytes. + # native binary per runner OS/arch in SEA mode with Zstd-compressed + # bundled sources, tar.gz archive, execute the binary with --version, + # then verify the archive round-trips and the sha256 sidecar matches + # the archive bytes. # - # claude-code is ESM, so mode: sea (standard pkg can't bytecode-compile - # ESM). Each matrix entry builds for the runner's native target so the - # produced binary can be launched in-place for the smoke assertion, - # giving real cross-OS + cross-arch coverage (x64 and arm64 on Linux, - # arm64 on macOS, x64 on Windows). Zstd bundled-binary compression - # requires Node.js >= 22.15 on the build host; .node-version is 22.22. + # claude-code is ESM, so SEA mode is required (standard pkg can't + # bytecode-compile ESM). Both `sea: true` and `compress: 'Zstd'` are + # declared in the pkg config (package.json#pkg) — expressible in + # config starting @yao-pkg/pkg v6.19.0 (yao-pkg/pkg#263). Each matrix + # entry builds for the runner's native target so the produced binary + # can be launched in-place, giving real cross-OS + cross-arch + # coverage (x64 and arm64 on Linux, arm64 on macOS, x64 on Windows). claude-code-smoke: name: claude-code-smoke / ${{ matrix.os }} / ${{ matrix.target }} runs-on: ${{ matrix.os }} @@ -332,8 +334,13 @@ jobs: const p = './package.json'; const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); pkg.bin = './' + process.argv[1]; + // Full pkg-layer config lives here — no CLI-mirror inputs. + // Requires @yao-pkg/pkg >= 6.19.0 for \`compress\` in config + // (yao-pkg/pkg#263). pkg.pkg = { - assets: ['node_modules/@anthropic-ai/claude-code/**/*'] + assets: ['node_modules/@anthropic-ai/claude-code/**/*'], + sea: true, + compress: 'Zstd', }; fs.writeFileSync(p, JSON.stringify(pkg, null, 2)); " "$entry" @@ -350,11 +357,10 @@ jobs: with: config: ${{ steps.fix.outputs.fixture }}/package.json targets: ${{ matrix.target }} - mode: sea - compress-node: Zstd - # Zstd landed in @yao-pkg/pkg 6.17. The action's default - # (~6.16.0) predates it, so pin a Zstd-capable version. - pkg-version: ~6.18.0 + # 6.19.0+ is required for the `compress` key in pkg config. + # SEA (`sea: true`) and Zstd (`compress: 'Zstd'`) live in the + # package.json#pkg field, not as action inputs. + pkg-version: ~6.19.0 compress: tar.gz checksum: sha256 filename: 'claude-{version}-{os}-{arch}' diff --git a/README.md b/README.md index 19bbbfb..8f46bf4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,63 @@ Tracking issue: [yao-pkg/pkg#248](https://github.com/yao-pkg/pkg/issues/248). path: "${{ join(fromJson(steps.build.outputs.artifacts), '\n') }}" ``` +## pkg configuration + +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 +[yao-pkg/pkg's README](https://github.com/yao-pkg/pkg#config) for the full +schema. + +Example — SEA mode with Brotli-compressed bundle + fallback, saved as `.pkgrc.json`: + +```json +{ + "bin": "src/main.js", + "sea": true, + "compress": "Brotli", + "fallbackToSource": true +} +``` + +```yaml +- uses: yao-pkg/pkg-action@v1 + with: + config: .pkgrc.json + targets: node22-linux-x64 +``` + +### Inline config + +For trivial setups you can skip the file and pass the config as a JSON string +via `config-inline` — the action writes it to a temp file and points pkg at +it. Mutually exclusive with `config`. + +```yaml +- uses: yao-pkg/pkg-action@v1 + with: + targets: node22-linux-x64 + config-inline: | + { + "bin": "src/main.js", + "sea": true, + "compress": "Brotli" + } +``` + +> The value is registered with `core.setSecret`, so exact matches are redacted +> from logs — but it is still written verbatim to a temp file on the runner. +> Prefer `config` (a committed file) for anything beyond a couple of knobs, +> and source long-lived secrets from GitHub Actions secrets / OIDC, not from +> `config-inline`. + +The action's inputs cover only concerns pkg config cannot express: matrix +targets, pkg install version/path, archive format, filename template, +checksum algorithms, Windows-metadata resedit patch, macOS/Windows signing, +cache, step summary. + ## Outputs | Output | Shape | diff --git a/STATUS.yaml b/STATUS.yaml index 196e934..d1bf55b 100644 --- a/STATUS.yaml +++ b/STATUS.yaml @@ -10,13 +10,14 @@ meta: repo: yao-pkg/pkg-action branch: main - last-updated: '2026-04-23' + last-updated: '2026-04-24' action-status: ALPHA — ready for early adopters to try in their own workflows - ci-status: green (222 unit tests) + ci-status: green (227 unit tests) e2e-status: | - 6 jobs after the scope cut — tiny-cjs (ubuntu/macos/windows), codegen-drift, - matrix plan→fanout, multi-target-linux, windows-metadata. Signing e2e still - deferred until live credentials are available. + 7 jobs after the scope cut — tiny-cjs (ubuntu/macos/windows), codegen-drift, + matrix plan→fanout, multi-target-linux, windows-metadata, claude-code-smoke + (SEA + Zstd, 4 OS/arch combos). Signing e2e still deferred until live + credentials are available. # ─── Scope decision (2026-04-23) ───────────────────────────────────────── # @@ -48,6 +49,37 @@ recent-hardening: - '`yamlString` codegen helper throws on embedded control chars — prevents silent action.yml corruption (S2).' - 'CI coverage gate at 85% via scripts/check-coverage.ts parsing lcov (S6).' +# ─── Input-surface slim (2026-04-23) ───────────────────────────────────── +# Stop mirroring pkg's CLI. Pkg-specific knobs live in the user's pkg config; +# the action owns only concerns pkg config cannot express (CI-matrix targets, +# pkg install version, archive, checksum, windows-metadata, signing, cache). + +input-surface-slim: + dropped-inputs: + - mode + - node-version + - compress-node + - fallback-to-source + - public + - public-packages + - options + - no-bytecode + - 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.' + 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`. + 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 + equivalents: compress, fallbackToSource, public, publicPackages, + options, bytecode (inverts --no-bytecode), nativeBuild (inverts + --no-native-build), noDictionary (renamed from --no-dict), debug, + signature. + # ─── Removed (2026-04-23) ──────────────────────────────────────────────── # Distribution scope reset. Use dedicated downstream actions instead. diff --git a/action.yml b/action.yml index 9a809fa..e133786 100644 --- a/action.yml +++ b/action.yml @@ -10,43 +10,16 @@ branding: inputs: config: - description: 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted.' + description: '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: + 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.' 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.' - mode: - description: 'standard | sea — selects pkg Standard or SEA mode.' - default: 'standard' - node-version: - description: 'pkg''s bundled Node.js major (e.g. 22, 24). Does not affect the action''s own Node runtime.' - default: '22' - compress-node: - description: 'pkg''s bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.' - default: 'None' - fallback-to-source: - description: 'Pass pkg --fallback-to-source for bytecode-fabricator failures.' - default: 'false' - public: - description: 'Pass pkg --public (ships sources as plaintext).' - default: 'false' - public-packages: - description: 'Comma-separated package names to mark public (pkg --public-packages).' - options: - description: 'Comma-separated V8 options baked into the binary (pkg --options).' - no-bytecode: - description: 'Pass pkg --no-bytecode.' - default: 'false' - no-dict: - description: 'Comma-separated list of packages for pkg --no-dict (or * for all).' - debug: - description: 'Pass pkg --debug.' - default: 'false' - extra-args: - description: 'Raw extra flags appended to the pkg CLI invocation.' pkg-version: - description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set.' - default: '~6.16.0' + 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' pkg-path: description: 'Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g.' strip: @@ -169,7 +142,7 @@ runs: uses: actions/cache@v5 with: path: ${{ runner.temp }}/pkg-cache - key: ${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-node{2}-{3}', runner.os, runner.arch, inputs.node-version, 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*', '**/pkg.config.{js,ts,json}')) }} - name: Install @yao-pkg/pkg if: ${{ inputs.pkg-path == '' }} @@ -182,19 +155,9 @@ runs: uses: ./packages/build with: config: ${{ inputs.config }} + config-inline: ${{ inputs.config-inline }} entry: ${{ inputs.entry }} targets: ${{ inputs.targets }} - mode: ${{ inputs.mode }} - node-version: ${{ inputs.node-version }} - compress-node: ${{ inputs.compress-node }} - fallback-to-source: ${{ inputs.fallback-to-source }} - public: ${{ inputs.public }} - public-packages: ${{ inputs.public-packages }} - options: ${{ inputs.options }} - no-bytecode: ${{ inputs.no-bytecode }} - no-dict: ${{ inputs.no-dict }} - debug: ${{ inputs.debug }} - extra-args: ${{ inputs.extra-args }} pkg-version: ${{ inputs.pkg-version }} pkg-path: ${{ inputs.pkg-path }} strip: ${{ inputs.strip }} diff --git a/docs/architecture.md b/docs/architecture.md index 5b19bcb..fd4dc4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,8 +69,8 @@ directly). | `targets.ts` | `Target` type, `parseTarget`, `hostTarget`, `formatTarget` | | `templates.ts` | `{name}/{version}/{os}/{arch}/…` filename renderer + token bag | | `checksum.ts` | sha256/sha512/md5 streaming, `writeShasumsFile`, `writeSidecar` | -| `inputs.ts` | `INPUT_SPECS` (source of truth for every input) + `parseInputs` | -| `pkg-runner.ts` | `@yao-pkg/pkg` CLI bridge + `buildPkgArgs` | +| `inputs.ts` | `INPUT_SPECS` (action-layer inputs only) + `parseInputs` | +| `pkg-runner.ts` | `@yao-pkg/pkg` CLI bridge + `buildPkgArgs` (no pkg-flag mirroring) | | `pkg-output-map.ts` | Reconciles pkg on-disk outputs to `Target[]` | | `archive.ts` | tar.gz / tar.xz / zip / 7z writers (yazl for zip) | | `summary.ts` | Markdown table for `GITHUB_STEP_SUMMARY` | @@ -156,6 +156,17 @@ Source of truth: `packages/core/src/inputs.ts::INPUT_SPECS`. One `InputSpec` record per input with `name / description / default? / required? / category / deprecated? / secret?`. +**Input-surface scope** (2026-04-23): the action intentionally does not +mirror pkg's CLI. Pkg-specific knobs are expressed via the user's pkg config +file, which `buildPkgArgs` forwards through `--config`. The action owns only +concerns that pkg config cannot express: CI-matrix `targets`, the +`pkg-version` / `pkg-path` install choice, archive format, filename template, +checksum algorithms, Windows-metadata resedit patch, signing, cache, and step +summary. Rationale: decouple the action from pkg's CLI evolution — each new +pkg flag otherwise forced a back-compat-preserving input bump here. +Authoritative list of dropped inputs + migration note lives in +[`STATUS.yaml`](../STATUS.yaml) under `input-surface-slim`. + Emitted: - `/action.yml` — top-level composite. Every input forwarded explicitly to the diff --git a/docs/inputs.md b/docs/inputs.md index e83d6ea..a02efb2 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -8,21 +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. | +| `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. | | `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. | -| `mode` | `standard` | no | no | standard \| sea — selects pkg Standard or SEA mode. | -| `node-version` | `22` | no | no | pkg's bundled Node.js major (e.g. 22, 24). Does not affect the action's own Node runtime. | -| `compress-node` | `None` | no | no | pkg's bundled-binary compression: Brotli \| GZip \| Zstd \| None. Zstd requires Node.js >= 22.15 on the build host. | -| `fallback-to-source` | `false` | no | no | Pass pkg --fallback-to-source for bytecode-fabricator failures. | -| `public` | `false` | no | no | Pass pkg --public (ships sources as plaintext). | -| `public-packages` | — | no | no | Comma-separated package names to mark public (pkg --public-packages). | -| `options` | — | no | no | Comma-separated V8 options baked into the binary (pkg --options). | -| `no-bytecode` | `false` | no | no | Pass pkg --no-bytecode. | -| `no-dict` | — | no | no | Comma-separated list of packages for pkg --no-dict (or * for all). | -| `debug` | `false` | no | no | Pass pkg --debug. | -| `extra-args` | — | no | no | Raw extra flags appended to the pkg CLI invocation. | -| `pkg-version` | `~6.16.0` | no | no | npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set. | +| `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-path` | — | no | no | Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g. | ## Post-build diff --git a/packages/build/action.yml b/packages/build/action.yml index a0bc649..751c1dd 100644 --- a/packages/build/action.yml +++ b/packages/build/action.yml @@ -7,43 +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.' + description: '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: + 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.' 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.' - mode: - description: 'standard | sea — selects pkg Standard or SEA mode.' - default: 'standard' - node-version: - description: 'pkg''s bundled Node.js major (e.g. 22, 24). Does not affect the action''s own Node runtime.' - default: '22' - compress-node: - description: 'pkg''s bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.' - default: 'None' - fallback-to-source: - description: 'Pass pkg --fallback-to-source for bytecode-fabricator failures.' - default: 'false' - public: - description: 'Pass pkg --public (ships sources as plaintext).' - default: 'false' - public-packages: - description: 'Comma-separated package names to mark public (pkg --public-packages).' - options: - description: 'Comma-separated V8 options baked into the binary (pkg --options).' - no-bytecode: - description: 'Pass pkg --no-bytecode.' - default: 'false' - no-dict: - description: 'Comma-separated list of packages for pkg --no-dict (or * for all).' - debug: - description: 'Pass pkg --debug.' - default: 'false' - extra-args: - description: 'Raw extra flags appended to the pkg CLI invocation.' pkg-version: - description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set.' - default: '~6.16.0' + 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' 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 37ddb89..3f22ce1 100644 --- a/packages/build/dist/index.mjs +++ b/packages/build/dist/index.mjs @@ -14552,6 +14552,12 @@ async function createInvocationTemp(parent) { let id = randomUUID2(), dir = join3(parent, `pkg-action-${id}`); return await mkdir2(dir, { recursive: !0, mode: 448 }), await chmod2(dir, 448), dir; } +var PKG_CONFIG_INLINE_FILENAME = "pkg-config.inline.json"; +async function materializePkgConfigInline(opts) { + if (opts.configInline === void 0) return opts.config; + let path4 = join3(opts.invocationDir, PKG_CONFIG_INLINE_FILENAME); + return await writeFile2(path4, opts.configInline, "utf8"), path4; +} async function atomicWriteFile(path4, data) { await mkdir2(dirname3(path4), { recursive: !0 }); let tmp = `${path4}.tmp-${randomUUID2()}`; @@ -14764,7 +14770,13 @@ 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." + description: "Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. 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.", + secret: !0 }, { name: "entry", @@ -14776,68 +14788,11 @@ var INPUT_SPECS = [ category: "build", description: "Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target." }, - { - name: "mode", - category: "build", - description: "standard | sea \u2014 selects pkg Standard or SEA mode.", - default: "standard" - }, - { - name: "node-version", - category: "build", - description: "pkg's bundled Node.js major (e.g. 22, 24). Does not affect the action's own Node runtime.", - default: "22" - }, - { - name: "compress-node", - category: "build", - description: "pkg's bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.", - default: "None" - }, - { - name: "fallback-to-source", - category: "build", - description: "Pass pkg --fallback-to-source for bytecode-fabricator failures.", - default: "false" - }, - { - name: "public", - category: "build", - description: "Pass pkg --public (ships sources as plaintext).", - default: "false" - }, - { - name: "public-packages", - category: "build", - description: "Comma-separated package names to mark public (pkg --public-packages)." - }, - { - name: "options", - category: "build", - description: "Comma-separated V8 options baked into the binary (pkg --options)." - }, - { - name: "no-bytecode", - category: "build", - description: "Pass pkg --no-bytecode.", - default: "false" - }, - { - name: "no-dict", - category: "build", - description: "Comma-separated list of packages for pkg --no-dict (or * for all)." - }, - { name: "debug", category: "build", description: "Pass pkg --debug.", default: "false" }, - { - name: "extra-args", - category: "build", - description: "Raw extra flags appended to the pkg CLI invocation." - }, { name: "pkg-version", category: "build", - description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set.", - default: "~6.16.0" + 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" }, { name: "pkg-path", @@ -15120,27 +15075,29 @@ function parseInputs(opts = {}) { let targetsRaw = readInput(env, "targets"), targets = targetsRaw === void 0 ? "host" : parseTargetList(targetsRaw); if (Array.isArray(targets) && targets.length === 0) throw new ValidationError('Input "targets" was set but resolved to an empty list.'); + let configPath = readInput(env, "config"), configInline = readInput(env, "config-inline"); + if (configPath !== void 0 && configInline !== void 0) + throw new ValidationError( + 'Inputs "config" and "config-inline" are mutually exclusive \u2014 pick one.' + ); + if (configInline !== void 0) + try { + let parsed = JSON.parse(configInline); + if (parsed === null || typeof parsed != "object" || Array.isArray(parsed)) + throw new ValidationError( + 'Input "config-inline" must parse to a JSON object (got ' + (parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed) + ")." + ); + } catch (err) { + throw err instanceof ValidationError ? err : new ValidationError( + `Input "config-inline" is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + ); + } let build = { - config: readInput(env, "config"), + config: configPath, + configInline, entry: readInput(env, "entry"), targets, - mode: parseEnum(readInput(env, "mode"), "mode", ["standard", "sea"]), - nodeVersion: readInput(env, "node-version") ?? "22", - compressNode: parseEnum(readInput(env, "compress-node"), "compress-node", [ - "Brotli", - "GZip", - "Zstd", - "None" - ]), - fallbackToSource: parseBoolean(readInput(env, "fallback-to-source"), "fallback-to-source"), - public: parseBoolean(readInput(env, "public"), "public"), - publicPackages: parseList(readInput(env, "public-packages")), - options: parseList(readInput(env, "options")), - noBytecode: parseBoolean(readInput(env, "no-bytecode"), "no-bytecode"), - noDict: parseList(readInput(env, "no-dict")), - debug: parseBoolean(readInput(env, "debug"), "debug"), - extraArgs: readInput(env, "extra-args"), - pkgVersion: readInput(env, "pkg-version") ?? "~6.16.0", + pkgVersion: readInput(env, "pkg-version") ?? "~6.19.0", pkgPath: readInput(env, "pkg-path") }, postBuild = { strip: parseBoolean(readInput(env, "strip"), "strip"), @@ -15194,9 +15151,7 @@ function levenshtein2(a, b) { // packages/core/src/pkg-runner.ts function buildPkgArgs(inv) { let args = []; - if (inv.targets.length > 0 && args.push("--targets", inv.targets.map(formatTarget).join(",")), inv.build.config !== void 0 && args.push("--config", inv.build.config), inv.build.mode === "sea" && args.push("--sea"), inv.build.compressNode !== "None" && args.push("--compress", inv.build.compressNode), inv.build.fallbackToSource && args.push("--fallback-to-source"), inv.build.public && args.push("--public"), inv.build.publicPackages.length > 0 && args.push("--public-packages", inv.build.publicPackages.join(",")), inv.build.options.length > 0 && args.push("--options", inv.build.options.join(",")), inv.build.noBytecode && args.push("--no-bytecode"), inv.build.noDict.length > 0 && args.push("--no-dict", inv.build.noDict.join(",")), inv.build.debug && args.push("--debug"), args.push("--out-path", inv.outputDir), inv.build.extraArgs !== void 0 && inv.build.extraArgs.trim() !== "") - for (let tok of inv.build.extraArgs.split(/\s+/).filter((s) => s.length > 0)) - args.push(tok); + inv.targets.length > 0 && args.push("--targets", inv.targets.map(formatTarget).join(",")), inv.build.config !== void 0 && args.push("--config", inv.build.config), args.push("--out-path", inv.outputDir); let entry = inv.build.entry ?? "."; return args.push(entry), args; } @@ -19311,7 +19266,16 @@ async function main() { saveState("invocationDir", invocationDir); let pkgOutputDir = join7(invocationDir, "pkg-out"); await mkdir3(pkgOutputDir, { recursive: !0 }); - let pkgCommand = inputs.build.pkgPath ?? "pkg", pkgBuildInputs = inputs.build.config !== void 0 && pathBasename(inputs.build.config).toLowerCase() === "package.json" ? { ...inputs.build, config: void 0 } : inputs.build, pkgTargetsLabel = resolvedTargets.map(formatTarget).join(", "); + let effectiveConfig = await materializePkgConfigInline({ + config: inputs.build.config, + configInline: inputs.build.configInline, + 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 = { + ...inputs.build, + config: cfgIsPackageJson ? void 0 : effectiveConfig + }, pkgTargetsLabel = resolvedTargets.map(formatTarget).join(", "); logger.startGroup(`[pkg-action] pkg build (targets=${pkgTargetsLabel})`); let runStart = Date.now(); try { diff --git a/packages/build/src/main.ts b/packages/build/src/main.ts index e47ddef..1c75898 100644 --- a/packages/build/src/main.ts +++ b/packages/build/src/main.ts @@ -38,6 +38,7 @@ import { formatTarget, hostTarget, mapPkgOutputs, + materializePkgConfigInline, parseInputs, parseSigningInputs, parseWindowsMetadataInputs, @@ -151,6 +152,18 @@ async function main(): Promise { const pkgOutputDir = join(invocationDir, 'pkg-out'); await mkdir(pkgOutputDir, { recursive: true }); + // 4.5. Materialize `config-inline` to disk, if set. parseInputs already + // validated it as a JSON object and enforced mutual exclusion with + // `config`, so the helper just writes the bytes and returns the path. + const effectiveConfig = await materializePkgConfigInline({ + config: inputs.build.config, + configInline: inputs.build.configInline, + invocationDir, + }); + if (inputs.build.configInline !== undefined && effectiveConfig !== undefined) { + logger.info(`[pkg-action] materialized config-inline → ${effectiveConfig}`); + } + // 5. Run pkg from the project directory. // // When a package.json was used to locate the project, drop the explicit @@ -159,9 +172,11 @@ async function main(): Promise { // standalone pkg config like .pkgrc.json). const pkgCommand = inputs.build.pkgPath ?? 'pkg'; const cfgIsPackageJson = - inputs.build.config !== undefined && - pathBasename(inputs.build.config).toLowerCase() === 'package.json'; - const pkgBuildInputs = cfgIsPackageJson ? { ...inputs.build, config: undefined } : inputs.build; + effectiveConfig !== undefined && pathBasename(effectiveConfig).toLowerCase() === 'package.json'; + const pkgBuildInputs = { + ...inputs.build, + config: cfgIsPackageJson ? undefined : effectiveConfig, + }; // Fold the pkg invocation into its own group — "Walking dependencies", // "Downloading nodejs executable", "Generating SEA assets", plus the // GH-Actions `[command]` echo and any warnings, can easily be 30+ lines diff --git a/packages/core/src/fs-utils.ts b/packages/core/src/fs-utils.ts index 9f85b6e..4aefda4 100644 --- a/packages/core/src/fs-utils.ts +++ b/packages/core/src/fs-utils.ts @@ -28,6 +28,35 @@ export async function createInvocationTemp(parent: string): Promise { return dir; } +/** + * Filename used by {@link materializePkgConfigInline}. Exposed as a constant so + * tests can assert the exact placement without re-stringifying it. + */ +export const PKG_CONFIG_INLINE_FILENAME = 'pkg-config.inline.json'; + +/** + * Resolve the effective `--config` path pkg will be invoked with. + * + * - If `configInline` is defined, write it to + * `/pkg-config.inline.json` and return that path. + * - Otherwise return `config` unchanged (may be undefined). + * + * `parseInputs` guarantees mutual exclusion + JSON-object shape, so this + * helper does no validation of its own — it is strictly the "bytes to disk" + * step. Kept here rather than in the orchestrator so it is unit-testable in + * isolation. + */ +export async function materializePkgConfigInline(opts: { + readonly config: string | undefined; + readonly configInline: string | undefined; + readonly invocationDir: string; +}): Promise { + if (opts.configInline === undefined) return opts.config; + const path = join(opts.invocationDir, PKG_CONFIG_INLINE_FILENAME); + await writeFile(path, opts.configInline, 'utf8'); + return path; +} + /** * Atomic write: write to .tmp- then rename over . Avoids * the window where a reader sees a half-written file. diff --git a/packages/core/src/inputs.ts b/packages/core/src/inputs.ts index 3121ff0..d546ebb 100644 --- a/packages/core/src/inputs.ts +++ b/packages/core/src/inputs.ts @@ -36,84 +36,32 @@ 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.', + 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.', }, { - name: 'entry', - category: 'build', - description: 'Entry script when not specified in the config.', - }, - { - name: 'targets', + name: 'config-inline', category: 'build', description: - 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.', - }, - { - name: 'mode', - category: 'build', - description: 'standard | sea — selects pkg Standard or SEA mode.', - default: 'standard', + '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.', + secret: true, }, { - name: 'node-version', + name: 'entry', category: 'build', - description: - "pkg's bundled Node.js major (e.g. 22, 24). Does not affect the action's own Node runtime.", - default: '22', + description: 'Entry script when not specified in the config.', }, { - name: 'compress-node', + name: 'targets', category: 'build', description: - "pkg's bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.", - default: 'None', - }, - { - name: 'fallback-to-source', - category: 'build', - description: 'Pass pkg --fallback-to-source for bytecode-fabricator failures.', - default: 'false', - }, - { - name: 'public', - category: 'build', - description: 'Pass pkg --public (ships sources as plaintext).', - default: 'false', - }, - { - name: 'public-packages', - category: 'build', - description: 'Comma-separated package names to mark public (pkg --public-packages).', - }, - { - name: 'options', - category: 'build', - description: 'Comma-separated V8 options baked into the binary (pkg --options).', - }, - { - name: 'no-bytecode', - category: 'build', - description: 'Pass pkg --no-bytecode.', - default: 'false', - }, - { - name: 'no-dict', - category: 'build', - description: 'Comma-separated list of packages for pkg --no-dict (or * for all).', - }, - { name: 'debug', category: 'build', description: 'Pass pkg --debug.', default: 'false' }, - { - name: 'extra-args', - category: 'build', - description: 'Raw extra flags appended to the pkg CLI invocation.', + 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.', }, { name: 'pkg-version', category: 'build', description: - 'npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set.', - default: '~6.16.0', + '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', }, { name: 'pkg-path', @@ -355,32 +303,26 @@ export function specFor(name: string): InputSpec | undefined { // ─── Typed parsed inputs ────────────────────────────────────────────────── -export type CompressionMode = 'Brotli' | 'GZip' | 'Zstd' | 'None'; /** Archive format as seen by the input layer — extends archive.ts's set with 'none' (skip archiving). */ export type ArchiveFormatInput = 'tar.gz' | 'tar.xz' | 'zip' | '7z' | 'none'; -export type PkgMode = 'standard' | 'sea'; /** - * Parsed inputs — the M1-active subset. Later milestones extend this shape - * with Windows/signing/publishing fields. We keep separate interfaces per - * category so M3/M4 can add their own without perturbing the base. + * Parsed inputs. The build surface is intentionally thin: pkg-specific knobs + * (mode, compression, public, bytecode, v8 options, debug, raw extra-args, …) + * are expressed in the user's pkg config file, not mirrored as action inputs. + * This keeps the action decoupled from pkg's CLI evolution. */ export interface BuildInputs { readonly config: string | undefined; + /** + * Inline JSON pkg config. Mutually exclusive with `config`. Orchestrator + * materializes this to a file under the invocation temp dir and forwards + * the resulting path to pkg via `--config`. + */ + readonly configInline: string | undefined; readonly entry: string | undefined; /** 'host' = use the host target; otherwise a non-empty list. */ readonly targets: Target[] | 'host'; - readonly mode: PkgMode; - readonly nodeVersion: string; - readonly compressNode: CompressionMode; - readonly fallbackToSource: boolean; - readonly public: boolean; - readonly publicPackages: string[]; - readonly options: string[]; - readonly noBytecode: boolean; - readonly noDict: string[]; - readonly debug: boolean; - readonly extraArgs: string | undefined; readonly pkgVersion: string; readonly pkgPath: string | undefined; } @@ -523,27 +465,37 @@ export function parseInputs(opts: ParseInputsOptions = {}): ActionInputs { throw new ValidationError(`Input "targets" was set but resolved to an empty list.`); } + const configPath = readInput(env, 'config'); + const configInline = readInput(env, 'config-inline'); + if (configPath !== undefined && configInline !== undefined) { + throw new ValidationError( + 'Inputs "config" and "config-inline" are mutually exclusive — pick one.', + ); + } + if (configInline !== undefined) { + try { + const parsed: unknown = JSON.parse(configInline); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ValidationError( + 'Input "config-inline" must parse to a JSON object (got ' + + (parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed) + + ').', + ); + } + } catch (err) { + if (err instanceof ValidationError) throw err; + throw new ValidationError( + `Input "config-inline" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + const build: BuildInputs = { - config: readInput(env, 'config'), + config: configPath, + configInline, entry: readInput(env, 'entry'), targets, - mode: parseEnum(readInput(env, 'mode'), 'mode', ['standard', 'sea'] as const), - nodeVersion: readInput(env, 'node-version') ?? '22', - compressNode: parseEnum(readInput(env, 'compress-node'), 'compress-node', [ - 'Brotli', - 'GZip', - 'Zstd', - 'None', - ] as const), - fallbackToSource: parseBoolean(readInput(env, 'fallback-to-source'), 'fallback-to-source'), - public: parseBoolean(readInput(env, 'public'), 'public'), - publicPackages: parseList(readInput(env, 'public-packages')), - options: parseList(readInput(env, 'options')), - noBytecode: parseBoolean(readInput(env, 'no-bytecode'), 'no-bytecode'), - noDict: parseList(readInput(env, 'no-dict')), - debug: parseBoolean(readInput(env, 'debug'), 'debug'), - extraArgs: readInput(env, 'extra-args'), - pkgVersion: readInput(env, 'pkg-version') ?? '~6.16.0', + pkgVersion: readInput(env, 'pkg-version') ?? '~6.19.0', pkgPath: readInput(env, 'pkg-path'), }; diff --git a/packages/core/src/pkg-runner.ts b/packages/core/src/pkg-runner.ts index 4ff73ab..0306ad9 100644 --- a/packages/core/src/pkg-runner.ts +++ b/packages/core/src/pkg-runner.ts @@ -47,6 +47,11 @@ export interface PkgInvocation { /** * Build the argv passed to pkg from a `PkgInvocation`. Exposed for unit tests. + * + * The action deliberately owns only a thin action-layer surface: targets (CI + * matrix concern), config path, output directory, entry. Pkg-specific knobs + * (mode, compression, public, bytecode, v8 options, debug, …) belong in the + * user's pkg config file so the action doesn't have to track pkg's CLI. */ export function buildPkgArgs(inv: PkgInvocation): string[] { const args: string[] = []; @@ -57,45 +62,9 @@ export function buildPkgArgs(inv: PkgInvocation): string[] { if (inv.build.config !== undefined) { args.push('--config', inv.build.config); } - if (inv.build.mode === 'sea') { - args.push('--sea'); - } - if (inv.build.compressNode !== 'None') { - args.push('--compress', inv.build.compressNode); - } - if (inv.build.fallbackToSource) { - args.push('--fallback-to-source'); - } - if (inv.build.public) { - args.push('--public'); - } - if (inv.build.publicPackages.length > 0) { - args.push('--public-packages', inv.build.publicPackages.join(',')); - } - if (inv.build.options.length > 0) { - args.push('--options', inv.build.options.join(',')); - } - if (inv.build.noBytecode) { - args.push('--no-bytecode'); - } - if (inv.build.noDict.length > 0) { - args.push('--no-dict', inv.build.noDict.join(',')); - } - if (inv.build.debug) { - args.push('--debug'); - } args.push('--out-path', inv.outputDir); - // Extra raw flags (escape hatch). Split on whitespace for a naive but usable - // tokenization — users needing quoted args can set them via env. The pkg CLI - // itself doesn't accept quoted bundled tokens. - if (inv.build.extraArgs !== undefined && inv.build.extraArgs.trim() !== '') { - for (const tok of inv.build.extraArgs.split(/\s+/).filter((s) => s.length > 0)) { - args.push(tok); - } - } - // Positional entry / project root — must come LAST, after flags. const entry = inv.build.entry ?? '.'; args.push(entry); diff --git a/packages/core/src/targets.ts b/packages/core/src/targets.ts index ce2cfc1..b1d8467 100644 --- a/packages/core/src/targets.ts +++ b/packages/core/src/targets.ts @@ -213,11 +213,11 @@ export function crossCompileRisk(host: Target, target: Target): string | null { } // Non-Linux → Linux-arm64 without QEMU hits the fabrication bug on Node 22. if (target.os === 'linux' && target.arch === 'arm64' && host.arch !== 'arm64') { - return 'Linux-arm64 cross-compile from a non-arm64 host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a linux/arm64 runner or pass --fallback-to-source.'; + return 'Linux-arm64 cross-compile from a non-arm64 host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a linux/arm64 runner or set `fallbackToSource: true` in your pkg config.'; } // Non-Windows → win-x64 on Node 22 also hits the fabricator bug. if (target.os === 'win' && target.arch === 'x64' && host.os !== 'win') { - return 'win-x64 cross-compile from a non-Windows host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a windows runner or pass --fallback-to-source.'; + return 'win-x64 cross-compile from a non-Windows host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a windows runner or set `fallbackToSource: true` in your pkg config.'; } // macOS-arm64 requires signing. if (target.os === 'macos' && target.arch === 'arm64' && host.os !== 'macos') { diff --git a/packages/core/test/unit/fs-utils.test.ts b/packages/core/test/unit/fs-utils.test.ts index c946401..c8333ba 100644 --- a/packages/core/test/unit/fs-utils.test.ts +++ b/packages/core/test/unit/fs-utils.test.ts @@ -7,6 +7,8 @@ import { atomicWriteFile, createInvocationTemp, exists, + materializePkgConfigInline, + PKG_CONFIG_INLINE_FILENAME, zeroFillAndRemove, } from '../../src/fs-utils.ts'; @@ -87,3 +89,57 @@ test('exists returns false for missing and true for present', async () => { strictEqual(await exists(join(parent, 'yep')), true); }); }); + +test('materializePkgConfigInline passes through config when inline is unset', async () => { + await withTempParent(async (parent) => { + const out = await materializePkgConfigInline({ + config: '.pkgrc.json', + configInline: undefined, + invocationDir: parent, + }); + strictEqual(out, '.pkgrc.json'); + // Nothing should have been written. + strictEqual(await exists(join(parent, PKG_CONFIG_INLINE_FILENAME)), false); + }); +}); + +test('materializePkgConfigInline returns undefined when neither is set', async () => { + await withTempParent(async (parent) => { + const out = await materializePkgConfigInline({ + config: undefined, + configInline: undefined, + invocationDir: parent, + }); + strictEqual(out, undefined); + strictEqual(await exists(join(parent, PKG_CONFIG_INLINE_FILENAME)), false); + }); +}); + +test('materializePkgConfigInline writes inline JSON and returns its path', async () => { + await withTempParent(async (parent) => { + const payload = '{"bin":"src/main.js","sea":true,"compress":"Brotli"}'; + const out = await materializePkgConfigInline({ + config: undefined, + configInline: payload, + invocationDir: parent, + }); + const expected = join(parent, PKG_CONFIG_INLINE_FILENAME); + strictEqual(out, expected); + strictEqual(await readFile(expected, 'utf8'), payload); + }); +}); + +test('materializePkgConfigInline prefers inline over config when inline is set', async () => { + // parseInputs enforces mutual exclusion, but the helper's contract is + // "inline wins" — guard against a future caller that accidentally passes + // both. + await withTempParent(async (parent) => { + const out = await materializePkgConfigInline({ + config: '.pkgrc.json', + configInline: '{"bin":"x.js"}', + invocationDir: parent, + }); + strictEqual(out, join(parent, PKG_CONFIG_INLINE_FILENAME)); + strictEqual(await readFile(out as string, 'utf8'), '{"bin":"x.js"}'); + }); +}); diff --git a/packages/core/test/unit/inputs.test.ts b/packages/core/test/unit/inputs.test.ts index aabf2ad..9470ab3 100644 --- a/packages/core/test/unit/inputs.test.ts +++ b/packages/core/test/unit/inputs.test.ts @@ -61,12 +61,11 @@ test('readInputRaw preserves dashes in env keys — matches @actions/core', () = test('parseInputs with no env uses defaults', () => { const inputs = parseInputs({ env: {} }); strictEqual(inputs.build.targets, 'host'); - strictEqual(inputs.build.mode, 'standard'); - strictEqual(inputs.build.nodeVersion, '22'); - strictEqual(inputs.build.compressNode, 'None'); - strictEqual(inputs.build.fallbackToSource, false); - strictEqual(inputs.build.public, false); - strictEqual(inputs.build.pkgVersion, '~6.16.0'); + 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.pkgPath, undefined); strictEqual(inputs.postBuild.compress, 'none'); strictEqual(inputs.postBuild.strip, false); @@ -77,32 +76,89 @@ test('parseInputs with no env uses defaults', () => { strictEqual(inputs.performance.stepSummary, true); }); +test('parseInputs threads pkg-version + pkg-path through', () => { + const inputs = parseInputs({ + env: env(['pkg-version', '~6.99.0'], ['pkg-path', '/opt/pkg/bin/pkg']), + }); + strictEqual(inputs.build.pkgVersion, '~6.99.0'); + strictEqual(inputs.build.pkgPath, '/opt/pkg/bin/pkg'); +}); + test('parseInputs parses a realistic build config', () => { const inputs = parseInputs({ env: env( ['targets', 'node22-linux-x64,node22-macos-arm64'], - ['mode', 'sea'], - ['compress-node', 'Brotli'], + ['config', '.pkgrc.json'], + ['entry', 'src/main.js'], ['compress', 'tar.gz'], ['checksum', 'sha256,sha512'], ['strip', 'true'], - ['fallback-to-source', 'true'], - ['public', 'true'], - ['public-packages', 'express,lodash'], - ['no-dict', '*'], ), }); ok(Array.isArray(inputs.build.targets)); strictEqual((inputs.build.targets as never[]).length, 2); - strictEqual(inputs.build.mode, 'sea'); - strictEqual(inputs.build.compressNode, 'Brotli'); + strictEqual(inputs.build.config, '.pkgrc.json'); + strictEqual(inputs.build.entry, 'src/main.js'); strictEqual(inputs.postBuild.compress, 'tar.gz'); deepStrictEqual(inputs.postBuild.checksum, ['sha256', 'sha512']); strictEqual(inputs.postBuild.strip, true); - strictEqual(inputs.build.fallbackToSource, true); - strictEqual(inputs.build.public, true); - deepStrictEqual(inputs.build.publicPackages, ['express', 'lodash']); - deepStrictEqual(inputs.build.noDict, ['*']); +}); + +test('parseInputs accepts config-inline with valid JSON object', () => { + const inputs = parseInputs({ + env: env(['config-inline', '{"bin":"src/main.js","mode":"sea"}']), + }); + strictEqual(inputs.build.config, undefined); + strictEqual(inputs.build.configInline, '{"bin":"src/main.js","mode":"sea"}'); +}); + +test('parseInputs rejects config + config-inline set together', () => { + throws( + () => + parseInputs({ + env: env(['config', '.pkgrc.json'], ['config-inline', '{"bin":"x.js"}']), + }), + ValidationError, + ); +}); + +test('parseInputs rejects config-inline with invalid JSON', () => { + throws( + () => parseInputs({ env: env(['config-inline', '{not json']) }), + (err: unknown) => err instanceof ValidationError && /not valid JSON/.test(err.message), + ); +}); + +test('parseInputs rejects config-inline that is not a JSON object', () => { + throws( + () => parseInputs({ env: env(['config-inline', '"bare-string"']) }), + (err: unknown) => err instanceof ValidationError && /JSON object/.test(err.message), + ); + throws( + () => parseInputs({ env: env(['config-inline', '[1,2,3]']) }), + (err: unknown) => err instanceof ValidationError && /JSON object/.test(err.message), + ); + throws( + () => parseInputs({ env: env(['config-inline', 'null']) }), + (err: unknown) => err instanceof ValidationError && /JSON object/.test(err.message), + ); +}); + +test('parseInputs flags removed pkg-layer inputs as unknown', () => { + const unknown: string[] = []; + parseInputs({ + env: env( + ['mode', 'sea'], + ['compress-node', 'Brotli'], + ['public', 'true'], + ['no-bytecode', 'true'], + ['extra-args', '--foo'], + ), + onUnknownInput: (n) => unknown.push(n), + }); + for (const n of ['mode', 'compress-node', 'public', 'no-bytecode', 'extra-args']) { + ok(unknown.includes(n), `expected "${n}" to be flagged as unknown`); + } }); test('parseInputs coerces multiple boolean spellings', () => { @@ -119,16 +175,7 @@ test('parseInputs rejects invalid boolean', () => { }); test('parseInputs rejects invalid enum value', () => { - throws(() => parseInputs({ env: env(['mode', 'fast']) }), ValidationError); throws(() => parseInputs({ env: env(['compress', 'rar']) }), ValidationError); - // Case-sensitive: only the canonical 'Zstd' (and 'Brotli' / 'GZip' / 'None') - // is accepted. Lowercase 'zstd' still fails. - throws(() => parseInputs({ env: env(['compress-node', 'zstd']) }), ValidationError); -}); - -test('parseInputs accepts Zstd compress-node', () => { - const inputs = parseInputs({ env: env(['compress-node', 'Zstd']) }); - strictEqual(inputs.build.compressNode, 'Zstd'); }); test('parseInputs checksum accepts "none" and drops to empty list', () => { diff --git a/packages/core/test/unit/pkg-runner.test.ts b/packages/core/test/unit/pkg-runner.test.ts index 86a5907..51e2352 100644 --- a/packages/core/test/unit/pkg-runner.test.ts +++ b/packages/core/test/unit/pkg-runner.test.ts @@ -7,23 +7,25 @@ import { PkgRunError } from '../../src/errors.ts'; const BASE_BUILD: BuildInputs = { config: undefined, + configInline: undefined, entry: undefined, targets: 'host', - mode: 'standard', - nodeVersion: '22', - compressNode: 'None', - fallbackToSource: false, - public: false, - publicPackages: [], - options: [], - noBytecode: false, - noDict: [], - debug: false, - extraArgs: undefined, - pkgVersion: '~6.16.0', + pkgVersion: '~6.19.0', pkgPath: undefined, }; +const PKG_FLAGS_OWNED_BY_CONFIG = [ + '--sea', + '--compress', + '--fallback-to-source', + '--public', + '--public-packages', + '--options', + '--no-bytecode', + '--no-dict', + '--debug', +]; + test('buildPkgArgs: minimal invocation', () => { const args = buildPkgArgs({ build: BASE_BUILD, @@ -33,43 +35,15 @@ test('buildPkgArgs: minimal invocation', () => { deepStrictEqual(args, ['--targets', 'node22-linux-x64', '--out-path', '/tmp/out', '.']); }); -test('buildPkgArgs: SEA mode + compression + fallback', () => { +test('buildPkgArgs: never emits pkg-layer flags (those belong in config)', () => { const args = buildPkgArgs({ - build: { ...BASE_BUILD, mode: 'sea', compressNode: 'Brotli', fallbackToSource: true }, - targets: [{ node: 22, os: 'linux', arch: 'x64' }], - outputDir: '/tmp/out', - }); - const joined = args.join(' '); - strictEqual(joined.includes('--sea'), true); - strictEqual(joined.includes('--compress Brotli'), true); - strictEqual(joined.includes('--fallback-to-source'), true); -}); - -test('buildPkgArgs: public + public-packages + options', () => { - const args = buildPkgArgs({ - build: { - ...BASE_BUILD, - public: true, - publicPackages: ['express', 'lodash'], - options: ['expose-gc', 'max-old-space-size=4096'], - }, - targets: [{ node: 22, os: 'linux', arch: 'x64' }], - outputDir: '/tmp/out', - }); - strictEqual(args.includes('--public'), true); - strictEqual(args.indexOf('--public-packages') + 1, args.indexOf('express,lodash')); - strictEqual(args.indexOf('--options') + 1, args.indexOf('expose-gc,max-old-space-size=4096')); -}); - -test('buildPkgArgs: no-bytecode + no-dict + debug', () => { - const args = buildPkgArgs({ - build: { ...BASE_BUILD, noBytecode: true, noDict: ['*'], debug: true }, + build: BASE_BUILD, targets: [{ node: 22, os: 'linux', arch: 'x64' }], outputDir: '/tmp/out', }); - strictEqual(args.includes('--no-bytecode'), true); - strictEqual(args.indexOf('--no-dict') + 1, args.indexOf('*')); - strictEqual(args.includes('--debug'), true); + for (const flag of PKG_FLAGS_OWNED_BY_CONFIG) { + strictEqual(args.includes(flag), false, `unexpected flag ${flag}`); + } }); test('buildPkgArgs: custom config + entry', () => { @@ -97,22 +71,6 @@ test('buildPkgArgs: multi-target list is comma-joined', () => { strictEqual(args[i + 1], 'node22-linux-x64,node22-macos-arm64,node22-win-x64'); }); -test('buildPkgArgs: extra-args tokens get appended before entry', () => { - const args = buildPkgArgs({ - build: { ...BASE_BUILD, extraArgs: '--foo bar --baz' }, - targets: [{ node: 22, os: 'linux', arch: 'x64' }], - outputDir: '/tmp/out', - }); - // Extra args appear after --out-path but before entry. - const outIdx = args.indexOf('--out-path'); - const fooIdx = args.indexOf('--foo'); - const bazIdx = args.indexOf('--baz'); - const entryIdx = args.indexOf('.'); - strictEqual(outIdx < fooIdx, true); - strictEqual(fooIdx < bazIdx, true); - strictEqual(bazIdx < entryIdx, true); -}); - test('runPkg passes through a successful exec', async () => { const calls: Array<[string, readonly string[]]> = []; const exec: ExecFn = async (command, args): Promise => { diff --git a/packages/matrix/dist/index.mjs b/packages/matrix/dist/index.mjs index 649fa07..5070e70 100644 --- a/packages/matrix/dist/index.mjs +++ b/packages/matrix/dist/index.mjs @@ -13434,7 +13434,7 @@ function expandMatrix(targets, overrides = {}) { } function crossCompileRisk(host, target) { let sameOs = host.os === target.os, sameArch = host.arch === target.arch; - return sameOs && sameArch ? null : host.os === "linux" && target.os === "macos" ? "Linux host \u2192 macOS target produces non-functional binaries (see yao-pkg/pkg#183). Use a macOS runner." : target.os === "linux" && target.arch === "arm64" && host.arch !== "arm64" ? "Linux-arm64 cross-compile from a non-arm64 host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a linux/arm64 runner or pass --fallback-to-source." : target.os === "win" && target.arch === "x64" && host.os !== "win" ? "win-x64 cross-compile from a non-Windows host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a windows runner or pass --fallback-to-source." : target.os === "macos" && target.arch === "arm64" && host.os !== "macos" ? "macos-arm64 binaries must be signed to run; cross-compiling without a codesign toolchain will produce an unusable binary." : null; + return sameOs && sameArch ? null : host.os === "linux" && target.os === "macos" ? "Linux host \u2192 macOS target produces non-functional binaries (see yao-pkg/pkg#183). Use a macOS runner." : target.os === "linux" && target.arch === "arm64" && host.arch !== "arm64" ? "Linux-arm64 cross-compile from a non-arm64 host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a linux/arm64 runner or set `fallbackToSource: true` in your pkg config." : target.os === "win" && target.arch === "x64" && host.os !== "win" ? "win-x64 cross-compile from a non-Windows host hits the pkg bytecode fabricator bug (#87/#181) on Node 22. Use a windows runner or set `fallbackToSource: true` in your pkg config." : target.os === "macos" && target.arch === "arm64" && host.os !== "macos" ? "macos-arm64 binaries must be signed to run; cross-compiling without a codesign toolchain will produce an unusable binary." : null; } // packages/core/src/version.ts diff --git a/packages/windows-metadata/dist/index.mjs b/packages/windows-metadata/dist/index.mjs index c8fc69e..05e12cb 100644 --- a/packages/windows-metadata/dist/index.mjs +++ b/packages/windows-metadata/dist/index.mjs @@ -13363,7 +13363,13 @@ 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." + description: "Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. 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.", + secret: !0 }, { name: "entry", @@ -13375,68 +13381,11 @@ var INPUT_SPECS = [ category: "build", description: "Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target." }, - { - name: "mode", - category: "build", - description: "standard | sea \u2014 selects pkg Standard or SEA mode.", - default: "standard" - }, - { - name: "node-version", - category: "build", - description: "pkg's bundled Node.js major (e.g. 22, 24). Does not affect the action's own Node runtime.", - default: "22" - }, - { - name: "compress-node", - category: "build", - description: "pkg's bundled-binary compression: Brotli | GZip | Zstd | None. Zstd requires Node.js >= 22.15 on the build host.", - default: "None" - }, - { - name: "fallback-to-source", - category: "build", - description: "Pass pkg --fallback-to-source for bytecode-fabricator failures.", - default: "false" - }, - { - name: "public", - category: "build", - description: "Pass pkg --public (ships sources as plaintext).", - default: "false" - }, - { - name: "public-packages", - category: "build", - description: "Comma-separated package names to mark public (pkg --public-packages)." - }, - { - name: "options", - category: "build", - description: "Comma-separated V8 options baked into the binary (pkg --options)." - }, - { - name: "no-bytecode", - category: "build", - description: "Pass pkg --no-bytecode.", - default: "false" - }, - { - name: "no-dict", - category: "build", - description: "Comma-separated list of packages for pkg --no-dict (or * for all)." - }, - { name: "debug", category: "build", description: "Pass pkg --debug.", default: "false" }, - { - name: "extra-args", - category: "build", - description: "Raw extra flags appended to the pkg CLI invocation." - }, { name: "pkg-version", category: "build", - description: "npm version specifier for @yao-pkg/pkg (e.g. ~6.16.0). Bypassed when pkg-path is set.", - default: "~6.16.0" + 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" }, { name: "pkg-path", diff --git a/scripts/gen-action-yml.ts b/scripts/gen-action-yml.ts index 354e853..a01e809 100644 --- a/scripts/gen-action-yml.ts +++ b/scripts/gen-action-yml.ts @@ -125,7 +125,7 @@ runs: uses: actions/cache@v5 with: path: \${{ runner.temp }}/pkg-cache - key: \${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-node{2}-{3}', runner.os, runner.arch, inputs.node-version, 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*', '**/pkg.config.{js,ts,json}')) }} - name: Install @yao-pkg/pkg if: \${{ inputs.pkg-path == '' }}