fix!: drop the composite wrapper so the action resolves for consumers - #23
Open
robertsLando wants to merge 7 commits into
Open
fix!: drop the composite wrapper so the action resolves for consumers#23robertsLando wants to merge 7 commits into
robertsLando wants to merge 7 commits into
Conversation
The per-binary pipeline — rename via the filename template, patch Windows PE resources, sign, archive, checksum, build the summary row — lived inline in the orchestrator's loop, so the ordering that actually matters (patch before sign, sign before checksum, or the shasum describes bytes nobody shipped) could only be exercised through a full action run. finalizeBinary() takes the same collaborators by injection as the rest of core, returns everything the loop accumulated, and comes with 21 unit tests covering a real PE round-trip and all three signing paths. The ephemeral macOS keychain is returned rather than written with core.saveState: actions-state handoff is sub-action wiring, and putting it in core would make the pipeline untestable. Also adds a subpath export per module so callers can skip the barrel, and pkg-cache.ts, which derives the pkg-fetch cache key from PKG_CONFIG_FILENAMES. Closes the two items STATUS.yaml tracks under pending, architecture.
The top-level action was a composite whose build step said `uses: ./packages/build`. Inside a composite, a local `uses:` path resolves against the consumer's GITHUB_WORKSPACE rather than the action's own checkout (actions/runner#1348, still open), so every documented invocation — `uses: yao-pkg/pkg-action@v1` — would have failed on its first step with "Can't find 'action.yml' … for action './packages/build'". CI never caught it because every e2e job used `uses: ./`, which makes the workspace and the action directory the same tree, so the wrong-root lookup happened to land on the right files. /action.yml is now a plain `runs.using: node24` action pointing at packages/build/dist/{index,post}.mjs, and packages/build/action.yml is gone — one action.yml instead of two. The three composite steps moved into TypeScript: - PKG_CACHE_PATH export → core.exportVariable in main.ts - actions/cache@v5 step → @actions/cache restore in main.ts, save in post.ts - npm i -g @yao-pkg/pkg → installPkg() in core/pkg-runner.ts Cache keys are byte-identical to what the composite's hashFiles() produced, so existing caches still hit. Owning the run in one JS entrypoint also keeps a real `post:` step, which is what tears down the ephemeral signing keychains — a composite `run:` step has no post hook. Two unit tests replace the ones that asserted the generated bash: no published action.yml may reference a local action, and none may escape the repo root. Costs two runtime deps (@actions/cache, @actions/glob), filling the budget at 6/6, and grows index.mjs 0.96 MB → 2.5 MB. BREAKING CHANGE: `pkg-version` is validated by parseInputs and now rejects values that are not npm version specifiers. The composite deferred that check to a shell guard that ran later, after the cache had already been restored.
consumer-ref is the only job that reaches the action the way a caller does: `uses: <owner>/<repo>@<sha>` against a sparse checkout holding just the fixture, with an assertion that the workspace contains no action.yml, no packages/ and no dist/. Anything the action needs from its own repo has to come from the action download. It also verifies the sha256 in the `digests` output against a local recompute, and that PKG_CACHE_PATH reached the job. Skipped on fork PRs — their head sha is not reachable through the action-download path — so it gates pushes to main rather than every PR. self-hosted-node24 carries over the M-1 spike: `runs.using: node24` has to resolve where the runner's node is whatever the admin installed. It also asserts the post step is not leaking invocation temp dirs, which only shows up on a runner that is not a fresh VM. Gated on the HAS_SELF_HOSTED_LINUX repo variable so it stays skipped rather than queueing until timeout.
Conventional Commits pick the bump, generate CHANGELOG.md, and fill the GitHub release body. Nothing about a release is hand-edited any more. after:bump runs sync-workspace-versions.ts so packages/*/package.json stop claiming 0.0.0 inside a tagged release, then `yarn gen` and `yarn build` so the release commit carries bundles built from the version it is tagged with — the esbuild define inlines it, so a stale dist/ ships the wrong version string. Intra-workspace deps move 0.0.0 → `*` so the sync never rewrites ranges. after:release runs move-major-tag.ts, which force-moves `vN` to the new tag. Consumers all write `@v1`, so a release that does not move it reaches nobody. Prereleases are skipped: `@v1` must not hand somebody an rc. The workflow is workflow_dispatch and defaults to a dry run. It runs in CI rather than on a laptop because the release commit carries the dist/ bundles consumers execute, and CI is the only place they are guaranteed to be built by the Node in .node-version. Those packages are private:true and never published, so their versions are cosmetic — syncing them is a consistency choice, not a correctness one.
…removal SECURITY.md gives vulnerability reports somewhere private to land, and writes down the trust boundaries that are design rather than defect: pkg 6.21 build hooks are arbitrary code execution sourced from the ref you build, which makes building an untrusted ref in a job holding signing certs the caller's problem; config-inline is masked but not a secret channel; a `~` pkg-version range means a pkg patch can land without an action release. architecture.md §1/§4/§6/§8/§11 follow the code: one action.yml, finalizeBinary in the pipeline, the two new e2e jobs, and the release-it flow replacing the hand-written checklist. STATUS.yaml records the bug, why CI was green through it, and what now guards the regression.
`uses:` is not in GitHub's context-availability table, so
`uses: ${{ github.repository }}@${{ … }}` fails the whole workflow file with
"this run likely failed because of a workflow file issue" and no annotation
naming the line.
consumer-ref now checks the fixture out at the workspace root and the action
under test into `_action-under-test/`, so the action directory is not the
workspace root — which is the part of consumer-shaped invocation that the
composite bug depended on. A composite reaching for
`$GITHUB_WORKSPACE/packages/build` still finds nothing.
consumer-download covers what the subdirectory cannot: `uses:` accepts only a
literal ref, so a job can name `@main` but never the commit under test. It runs
on push to main as a post-merge canary of the real download path.
Adds a unit test asserting no workflow templates a `uses:` key, and one
asserting every third-party action is pinned to a major tag or a sha.
There was a problem hiding this comment.
Pull request overview
Migrates the repo’s top-level GitHub Action from a composite wrapper to a single Node 24 JS action so it resolves correctly when used via <owner>/<repo>@ref by external consumers, and adds release/security automation/documentation to support the v1.0.0 path.
Changes:
- Replaces the root composite action with a
runs.using: node24action pointing atpackages/build/dist/{index,post}.mjs, and removes the internalpackages/build/action.yml. - Moves former composite responsibilities (PKG_CACHE_PATH export,
actions/cache,npm i -g @yao-pkg/pkg@…, and finalize pipeline sequencing) into TypeScript with new unit/e2e coverage. - Adds release automation (release-it + conventional changelog, CI workflow) and introduces
SECURITY.mdplus related documentation updates.
Reviewed changes
Copilot reviewed 33 out of 36 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds lock entries for new runtime deps (@actions/cache, @actions/glob) and release tooling transitive deps. |
| STATUS.yaml | Updates project status, documents the composite-resolution blocker and its fix, and records release automation plan/status. |
| SECURITY.md | Adds a security policy, private reporting channel, and explicit trust-boundary documentation. |
| scripts/sync-workspace-versions.ts | New release hook script to sync root version into workspace package manifests. |
| scripts/move-major-tag.ts | New release hook script to force-move floating vN tag after releases (skipping prereleases). |
| scripts/gen-action-yml.ts | Updates generator to emit only the root action.yml for the Node action (no inner build action.yml). |
| README.md | Documents release dry-run and adds a security section pointing to SECURITY.md. |
| packages/windows-metadata/package.json | Switches workspace dep on @pkg-action/core from 0.0.0 to * for local linking/version sync flow. |
| packages/matrix/package.json | Switches workspace dep on @pkg-action/core from 0.0.0 to *. |
| packages/core/test/unit/pkg-runner.test.ts | Adds unit tests for installPkg() behavior and error wrapping. |
| packages/core/test/unit/inputs.test.ts | Adds tests for accepted/rejected pkg-version specifier shapes. |
| packages/core/test/unit/gen-action-yml.test.ts | Reworks generator tests to assert published action.yml constraints and cache-key glob correctness. |
| packages/core/test/unit/finalize-binary.test.ts | Adds extensive unit coverage for the extracted per-binary finalize pipeline. |
| packages/core/src/pkg-runner.ts | Adds installPkg() and updates runner behavior to support in-process install. |
| packages/core/src/pkg-cache.ts | New module for cache-key glob derivation and deterministic key formatting. |
| packages/core/src/inputs.ts | Adds pkg-version specifier parsing/validation and threads it through parseInputs(). |
| packages/core/src/index.ts | Exports new modules (pkg-cache, finalize-binary) from the core barrel. |
| packages/core/src/finalize-binary.ts | New extracted finalize pipeline to make ordering testable and orchestrator thinner. |
| packages/core/package.json | Adds explicit subpath exports for core modules to reduce barrel-only consumption. |
| packages/build/src/post.ts | Saves the pkg-fetch cache in post step before cleanup, and keeps teardown logic. |
| packages/build/src/pkg-cache-io.ts | New in-process @actions/cache restore/save wiring shared across main/post via action state. |
| packages/build/src/main.ts | Moves composite steps into the JS action entrypoint: cache restore, pkg install, finalize via finalizeBinary(). |
| packages/build/package.json | Adds runtime deps needed for in-process caching (@actions/cache, @actions/glob) and updates core dep to *. |
| packages/build/action.yml | Removes the internal build action definition (single root action.yml remains). |
| package.json | Adds release scripts and dev deps for release-it + conventional changelog plugin. |
| docs/architecture.md | Updates architecture docs for Node action entrypoint, new cache plumbing, and new release flow. |
| CONTRIBUTING.md | Updates contributor guidance for release-it-driven versioning and notes updated runtime-dep budget contents. |
| CHANGELOG.md | Adds initial changelog file header (to be populated by release tooling). |
| action.yml | Converts root action definition from composite to Node 24 action pointing at built bundles. |
| .release-it.json | Adds release-it configuration, hooks, and conventional-changelog plugin configuration. |
| .github/workflows/release.yml | Adds CI-driven release workflow (workflow_dispatch) with dry-run default. |
| .github/workflows/e2e.yml | Updates drift checks and adds consumer-ref and self-hosted-node24 e2e jobs to prevent regression. |
| .github/workflows/ci.yml | Updates drift gate to stop checking removed packages/build/action.yml. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+411
to
+420
| /** | ||
| * Characters an npm version specifier can legitimately contain — digits, dots, | ||
| * the range operators, dist-tag letters, and the space/`|` used by compound | ||
| * ranges (`>=6.19 <7`, `6.x || 7.x`). | ||
| * | ||
| * The specifier reaches `npm i -g @yao-pkg/pkg@<spec>` as one argv element, so | ||
| * this is not an injection guard; it fails a typo'd or hostile value fast, with | ||
| * the input name attached, instead of surfacing as an opaque npm error. | ||
| */ | ||
| const NPM_SPECIFIER_RE = /^[A-Za-z0-9.*|=<>~^ -]+$/; |
Comment on lines
+94
to
+96
| if (result.exitCode !== 0) { | ||
| throw new PkgRunError(`npm i -g ${spec} exited ${String(result.exitCode)}.`); | ||
| } |
Comment on lines
32
to
+36
| async function post(): Promise<void> { | ||
| // Cache first: it is the only step here whose output outlives the job, and a | ||
| // keychain that fails to delete must not cost the next run its downloads. | ||
| await savePkgCache(); | ||
|
|
Generation walked the whole history, so the first changelog advertised the Docker, SBOM, Homebrew, Scoop, provenance and release-attach work — features removed before any tag existed, which v1.0.0 does not have. Listing them would be wrong, not merely noisy. Hand-editing the entry does not survive: release-it rewrites CHANGELOG.md on every release, and the plugin's `infile` cannot be unset — `--no-plugins.… .infile` writes to a file literally named "false", and a config using `extends` deep-merges the parent's value back in. So the cut is at the commit range: gitRawCommitsOpts.from = 9781c17, the scope-cut commit. That key is release-scoped and must go once v1.0.0 ships, or every later release regenerates from the same start point. Tracked under release-readiness in STATUS.yaml. Also corrects STATUS.yaml where it had gone stale or was never right: - the two "release note required" notes on the dropped inputs and the pkg-version default bump. Nothing has shipped under a tag, so there is no released version to break from and no note to write. The trust change from pkg 6.21 hooks still needs documenting, and now lives in SECURITY.md. - gh-actions still listed actions/cache@v5, which nothing references since caching moved in-process. - the removed-scope entry describing "composite action.yml". - release-tooling.open-question, which the workspace-version decision resolved, and release-tooling.changelog, which claimed a generated-only file without saying why hand edits do not stick.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Clears the v1.0.0 blockers found while auditing
STATUS.yaml.The blocker
The top-level action was a composite whose build step said
uses: ./packages/build. Inside a composite, a localuses:path resolves against the consumer'sGITHUB_WORKSPACE, not the action's own checkout — actions/runner#1348, still open. Souses: yao-pkg/pkg-action@v1, the invocation in our own README, would have failed on step one withCan't find 'action.yml' … for action './packages/build'.Every e2e job said
uses: ./, which makes the workspace and the action directory the same tree, so the wrong-root lookup happened to land on the right files. CI was green through a bug that broke 100% of real callers.Fix
/action.ymlis now a plainruns.using: node24action pointing atpackages/build/dist/{index,post}.mjs.packages/build/action.ymlis deleted — one action.yml instead of two. The three composite steps moved into TypeScript:echo PKG_CACHE_PATH >> $GITHUB_ENVcore.exportVariableinmain.tsuses: actions/cache@v5@actions/cacherestore inmain.ts, save inpost.tsrun: npm i -g @yao-pkg/pkg@…installPkg()incore/pkg-runner.tsparsePkgVersionSpecifierininputs.tsCache keys are byte-identical to what the composite's
hashFiles()produced, so existing caches still hit. Keeping a JS entrypoint also keeps a realpost:step — a compositerun:step has no post hook, and that step is what tears down the ephemeral macOS signing keychains.Costs two runtime deps (
@actions/cache,@actions/glob), filling the budget at 6/6, and growsindex.mjs0.96 MB → 2.5 MB. Normal for an action that caches;actions/cache's own bundle is ~2 MB.Guards against the regression
consumer-refe2e job — the PR gate. The fixture is sparse-checked-out at the workspace root and the action under test into_action-under-test/, so the action directory is not the workspace root — the exact condition the composite bug depended on. It asserts up front that the workspace root has noaction.yml, nopackages/, nodist/, then verifies thedigestsoutput against a localsha256sumand thatPKG_CACHE_PATHreached a later step.uses:accepts no expressions (contexts reference has no row for it), so no job can name the commit under test by ref. The first push of this PR tried and took down the whole E2E workflow file — a unit test now catches that locally.consumer-downloade2e job — the real GitHub-resolved download path,uses: yao-pkg/pkg-action@main. A literal ref is alluses:takes, so this is a post-merge canary rather than a PR gate. Push-to-main only.Unit tests over all three published
action.ymlfiles: none may reference a local action, none may escape the repo root (covers the../packages/…mainpaths inmatrix/andwindows-metadata/, which were never exercised from a remote ref either), each must point at a bundle that exists, and a new action directory cannot silently skip those checks.Release automation
release-it (
.release-it.json) +@release-it/conventional-changelog, driven by.github/workflows/release.yml(workflow_dispatch, dry run by default).Conventional Commits pick the bump and generate
CHANGELOG.md; nothing about a release is hand-edited.after:bumprunssync-workspace-versions.ts→yarn gen→yarn build, so the release commit carries bundles built from the version it is tagged with (esbuild inlines it, so a staledist/ships the wrong version string).after:releaserunsmove-major-tag.ts, which force-movesvN— consumers all write@v1, so a release that does not move it reaches nobody. Prereleases are skipped so@v1never serves an rc.It runs in CI rather than on a laptop because the release commit carries the
dist/bundles consumers execute, and CI is the only place they are guaranteed to be built by the Node in.node-version.Intra-workspace deps moved
0.0.0→*so the version sync never has to rewrite ranges and yarn keeps linking locally.Also in here
SECURITY.md— a private reporting channel, plus the trust boundaries that are design rather than defect: pkg 6.21 build hooks are arbitrary code execution sourced from the ref you build;config-inlineis masked but not a secret channel; a~pkg-versionrange means a pkg patch can land without an action release.self-hosted-node24e2e job — carries over the M-1 spike, and asserts the post step is not leaking invocation temp dirs (only visible on a runner that is not a fresh VM). Gated on aHAS_SELF_HOSTED_LINUXrepo variable so it stays skipped rather than queueing until timeout.finalizeBinaryextracted into core + subpath exports — closes both itemsSTATUS.yamltracked under pending/architecture. The per-target ordering that actually matters (patch before sign, sign before checksum) is now unit-testable without a runner;main.tsdrops 516 → 362 lines.Verification
yarn lint,yarn typecheck,yarn build,yarn genall clean; no dist/codegen drift.yarn release-it --dry-run --cicompletes: picks 1.0.0 off theBREAKING CHANGEfooter, runs every hook, writes the changelog, and would movev1.consumer (action in a subdirectory). Its log confirms the whole path end to end:Installing @yao-pkg/pkg@~6.21.0,PKG_CACHE_PATH=/home/runner/work/_temp/pkg-cachevisible to a later step,binary output: tiny-app-cjs 0.0.1,sha256 verified. CI and CodeQL green too.consumer-downloadandself-hosted-node24skip by design (push-to-main only;HAS_SELF_HOSTED_LINUXunset).Left for a follow-up
STATUS.yaml#pendingstill lists the four extra e2e fixtures, live-credential signing, and GHES verification. Two release-readiness items are now human actions rather than code: setHAS_SELF_HOSTED_LINUXwhen a runner exists, and tick the Marketplace checkbox on the release page (no API for it).Changelog scope — done, but not by hand. Hand-editing the entry does not survive: release-it rewrites
CHANGELOG.mdon every release, and the plugin'sinfilecannot be unset (--no-plugins.….infilewrites to a file literally namedfalse; a config usingextendsdeep-merges the parent's value back in — both verified). So the cut is at the commit range instead:gitRawCommitsOpts.from = 9781c17, the scope-cut commit. The v1.0.0 dry run no longer mentions Docker, SBOM, Homebrew, Scoop, provenance or release-attach.That key is release-scoped — delete it once v1.0.0 ships, or every later release regenerates from the same start point. Tracked under
release-readinessin STATUS.yaml.One wart left that only history rewriting could fix: the
BREAKING CHANGESsection inherits the full squashed body ofb5a8624(pkg-CLI mirror inputs), including its own nested bullets and some bogusSTATUS.yaml#…autolinks. Worth a manual trim on the release page if it bothers you.🤖 Generated with Claude Code