Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions STATUS.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,37 @@ input-surface-slim:
- no-dict
- debug
- extra-args
migration: 'Move each value into .pkgrc / pkg.config.{js,ts,json} or the `pkg` field of package.json. `buildPkgArgs` forwards the config path to pkg.'
migration: 'Move each value into .pkgrc / pkg.config.{js,cjs,mjs} or the `pkg` field of package.json. `buildPkgArgs` forwards the config path to pkg.'
breaking: 'yes — users setting any dropped input will see the unknown-input warning and the value will be ignored. Release note required.'
minimum-pkg-version: |
@yao-pkg/pkg >= 6.19.0 is required for the full build-flag surface in
pkg config. The default `pkg-version` input is pinned to `~6.19.0`.
pkg config; >= 6.21.0 adds the preBuild / postBuild / transform build
hooks. The default `pkg-version` input is pinned to `~6.21.0`.
build-hooks: |
Config-only feature (no CLI flags, no action inputs) — pkg reads
preBuild / postBuild / transform straight from the config the action
forwards via --config, so the pass-through surface needs no change.
One constraint is documented rather than enforced: a postBuild hook
must not move or rename the binary or `pkg-output-map` can no longer
locate it.
default-pkg-version-bump: |
`pkg-version` default moved ~6.19.0 -> ~6.21.0.
breaking: 'no API change, but every consumer on a floating action tag
who never set `pkg-version` gets a new pkg toolchain on their next
run. Release note REQUIRED, and it must call out the trust change
below — not just the version number.'
security: |
6.21 hooks are arbitrary code execution sourced from the config in
the checked-out tree. A workflow that builds an untrusted ref (fork
pull_request) now grants that ref command execution in a job that
may hold signing certs and tokens. No such capability existed at
6.19. Documented in docs/inputs.md + README; not enforceable from
the action side.
pin-vs-floor: |
`~` keeps patch-only so a pkg minor never lands without an action
release — deliberate, since the action replicates pkg's output
naming in `pkg-output-map` and a naming change would break it
silently. Cost: consumers need an action release for pkg minors.
upstream-dependency: |
RESOLVED in @yao-pkg/pkg v6.19.0 (2026-04-24) via yao-pkg/pkg#263 —
closes yao-pkg/pkg#262. All CLI-only build flags now have config
Expand Down Expand Up @@ -128,7 +154,7 @@ pending:

live-credential-e2e:
- Real signing — macOS Developer ID cert, Apple notarization creds, Windows code-signing cert.
Blocked on: organization decision about secret provisioning.
- 'Blocked on: organization decision about secret provisioning.'

release-readiness:
- Bump root `package.json#version` to 1.0.0 at tag time — auto-propagates via esbuild define into every bundled sub-action.
Expand Down
23 changes: 17 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ branding:

inputs:
config:
description: 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.'
description: 'Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline.'
config-inline:
description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.'
description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.'
entry:
description: 'Entry script when not specified in the config.'
targets:
description: 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.'
pkg-version:
description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.'
default: '~6.19.0'
description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.'
default: '~6.21.0'
pkg-path:
description: 'Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g.'
strip:
Expand Down Expand Up @@ -142,13 +142,24 @@ runs:
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/pkg-cache
key: ${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', '.pkgrc*', '**/pkg.config.{js,ts,json}')) }}
key: ${{ inputs.cache-key || format('pkg-fetch-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('**/package.json', '.pkgrc', '.pkgrc.json', '**/pkg.config.js', '**/pkg.config.cjs', '**/pkg.config.mjs')) }}

- name: Install @yao-pkg/pkg
if: ${{ inputs.pkg-path == '' }}
shell: bash
env:
PKG_VERSION: ${{ inputs.pkg-version }}
run: |
npm i -g @yao-pkg/pkg@${{ inputs.pkg-version }}
# Read from env and quoted — never interpolated into this script by the
# workflow templater, or a crafted specifier would execute right here.
# The pattern lives in a variable because [[ ]] parses a bare > as an
# operator before it ever reaches the regex engine.
specifier_re='^[A-Za-z0-9.*|=<>~^ -]+$'
if [[ ! "${PKG_VERSION}" =~ $specifier_re ]]; then
echo "::error::Input 'pkg-version' is not a valid npm version specifier: ${PKG_VERSION}"
exit 1
fi
npm i -g "@yao-pkg/pkg@${PKG_VERSION}"

- name: Run pkg-action build
id: pkg-action-build
Expand Down
67 changes: 64 additions & 3 deletions docs/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Every `pkg-action` input, grouped by category.

| Input | Default | Required | Secret | Description |
| --- | --- | --- | --- | --- |
| `config` | — | no | no | Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline. |
| `config-inline` | — | no | yes | Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs. |
| `config` | — | no | no | Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline. |
| `config-inline` | — | no | yes | Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs. |
| `entry` | — | no | no | Entry script when not specified in the config. |
| `targets` | — | no | no | Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target. |
| `pkg-version` | `~6.19.0` | no | no | npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set. |
| `pkg-version` | `~6.21.0` | no | no | npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set. |
| `pkg-path` | — | no | no | Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g. |

## Post-build
Expand Down Expand Up @@ -74,6 +74,67 @@ Every `pkg-action` input, grouped by category.
| `cache-key` | — | no | no | Override the auto-derived cache key. |
| `step-summary` | `true` | no | no | Write a markdown summary of build time / size / checksum to the job summary. |

## Build hooks

@yao-pkg/pkg 6.21.0+ runs three hooks from your pkg config. They have no CLI flags and
no action inputs — set them in the file you point `config` at (or let pkg auto-detect it).
The default `pkg-version` (`~6.21.0`) already includes them; only callers who pinned
an older pkg need to raise it.

| Key | Accepts | Runs |
| --- | --- | --- |
| `preBuild` | shell string or function | once, before pkg walks the dependency graph |
| `postBuild` | shell string or function | once per produced binary, after pkg has written it (and, on macOS, ad-hoc signed it) |
| `transform` | function only | per packed file, after path refinement, before bytecode/compression |

The shell form of `postBuild` gets the absolute output path in `PKG_OUTPUT`; the function
form gets it as its first argument. `transform(file, body)` returns a string or Buffer to
replace the contents, or `undefined` to leave the file alone.

```js
// pkg.config.mjs
import { join } from 'node:path';

const SRC_DIR = join(import.meta.dirname, 'src');

export default {
targets: ['node22-linux-x64'],
preBuild: 'npm run build',
postBuild: (output) => console.log(`built ${output}`),
transform: (file, body) =>
file.startsWith(SRC_DIR) && file.endsWith('.js')
? body.toString().replaceAll('__VERSION__', () => process.env.GITHUB_SHA ?? 'dev')
: undefined,
};
```

Two things that example is careful about: it scopes the rewrite to your own sources (a bare
`.endsWith('.js')` also rewrites every packed `node_modules` file), and it passes a replacer
function so `$&` and `` $` `` in the replacement are not interpreted. Only interpolate values
you trust — a branch name or PR title lands verbatim in the shipped binary.

> **Hooks are arbitrary code execution.** `preBuild` / `postBuild` shell strings are spawned
> with the runner's full environment, and `transform` is arbitrary JS over every packed file.
> pkg picks a config up from the checked-out tree automatically, so on a workflow that builds
> an untrusted ref (a fork `pull_request`), whoever wrote that ref can run commands in the job —
> alongside any signing certificates and tokens it holds. This capability did not exist before
> pkg 6.21. Build untrusted refs in a job with no secrets, or pin `pkg-version` below 6.21.

> **Do not move or rename the binary from `postBuild`.** The hook runs inside pkg, before
> the action's windows-metadata, signing, archive and checksum stages. Those stages locate
> outputs by predicting pkg's naming heuristic, then fall back to an exact, case-insensitive,
> and finally `<os>-<arch>` 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 <file>` together with a package.json entry, so whenever `config` points
at a standalone config — or `config-inline` is used — an entry script is required. The action
supplies your package.json `bin` automatically; set the `entry` input if there is no `bin`.

## Outputs

| Output | Description |
Expand Down
8 changes: 4 additions & 4 deletions packages/build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ author: 'yao-pkg contributors'

inputs:
config:
description: 'Path to a pkg config (.pkgrc, pkg.config.{js,ts,json}, or package.json). Auto-detected when omitted. Mutually exclusive with config-inline.'
description: 'Path to a pkg config. When omitted, pkg auto-detects .pkgrc, .pkgrc.json, pkg.config.js, pkg.config.cjs or pkg.config.mjs next to the entry; the package.json pkg field is used when the entry itself resolves to a package.json. An explicit path may point at a package.json or a standalone config; for a standalone config pkg also needs an entry script, so the action supplies package.json bin when the entry input is unset. Mutually exclusive with config-inline.'
config-inline:
description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Registered with core.setSecret so exact matches are redacted from logs; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.'
description: 'Pkg config as a JSON string. Written to a temp file and passed to pkg via --config. Mutually exclusive with config. Being JSON, it can only carry the shell-string form of the preBuild/postBuild hooks — function hooks and transform need a pkg.config.{js,cjs,mjs} file via config. Registered with core.setSecret so exact matches are redacted from logs, which does not extend to output a hook prints; still written to a temp file on the runner, so prefer config for anything beyond trivial knobs.'
entry:
description: 'Entry script when not specified in the config.'
targets:
description: 'Comma- or newline-separated pkg target triples, e.g. node22-linux-x64,node22-macos-arm64. Defaults to the host target.'
pkg-version:
description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.19.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature). Bypassed when pkg-path is set.'
default: '~6.19.0'
description: 'npm version specifier for @yao-pkg/pkg (e.g. ~6.21.0). 6.19.0+ is required for the full build-flag surface in pkg config (compress, fallbackToSource, public, publicPackages, options, bytecode, nativeBuild, noDictionary, debug, signature); 6.21.0+ adds the preBuild, postBuild and transform build hooks. Bypassed when pkg-path is set.'
default: '~6.21.0'
pkg-path:
description: 'Absolute path to a pre-installed pkg binary. Skips the implicit npm i -g.'
strip:
Expand Down
Loading