Skip to content

fix!: drop the composite wrapper so the action resolves for consumers - #23

Open
robertsLando wants to merge 7 commits into
mainfrom
fix/composite-local-action-resolution
Open

fix!: drop the composite wrapper so the action resolves for consumers#23
robertsLando wants to merge 7 commits into
mainfrom
fix/composite-local-action-resolution

Conversation

@robertsLando

@robertsLando robertsLando commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 local uses: path resolves against the consumer's GITHUB_WORKSPACE, not the action's own checkout — actions/runner#1348, still open. So uses: yao-pkg/pkg-action@v1, the invocation in our own README, would have failed on step one with Can'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.yml is now a plain runs.using: node24 action pointing at packages/build/dist/{index,post}.mjs. packages/build/action.yml is deleted — one action.yml instead of two. The three composite steps moved into TypeScript:

was now
echo PKG_CACHE_PATH >> $GITHUB_ENV core.exportVariable in main.ts
uses: actions/cache@v5 @actions/cache restore in main.ts, save in post.ts
run: npm i -g @yao-pkg/pkg@… installPkg() in core/pkg-runner.ts
bash specifier regex parsePkgVersionSpecifier in inputs.ts

Cache keys are byte-identical to what the composite's hashFiles() produced, so existing caches still hit. Keeping a JS entrypoint also keeps a real post: step — a composite run: 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 grows index.mjs 0.96 MB → 2.5 MB. Normal for an action that caches; actions/cache's own bundle is ~2 MB.

Guards against the regression

  • consumer-ref e2e 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 no action.yml, no packages/, no dist/, then verifies the digests output against a local sha256sum and that PKG_CACHE_PATH reached 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-download e2e job — the real GitHub-resolved download path, uses: yao-pkg/pkg-action@main. A literal ref is all uses: takes, so this is a post-merge canary rather than a PR gate. Push-to-main only.

  • Unit tests over all three published action.yml files: none may reference a local action, none may escape the repo root (covers the ../packages/… main paths in matrix/ and windows-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:bump runs sync-workspace-versions.tsyarn genyarn build, so the release commit carries bundles built from the version it is tagged with (esbuild inlines it, so a stale dist/ ships the wrong version string). after:release runs move-major-tag.ts, which force-moves vN — consumers all write @v1, so a release that does not move it reaches nobody. Prereleases are skipped so @v1 never 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-inline is masked but not a secret channel; a ~ pkg-version range means a pkg patch can land without an action release.
  • self-hosted-node24 e2e 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 a HAS_SELF_HOSTED_LINUX repo variable so it stays skipped rather than queueing until timeout.
  • finalizeBinary extracted into core + subpath exports — closes both items STATUS.yaml tracked under pending/architecture. The per-target ordering that actually matters (patch before sign, sign before checksum) is now unit-testable without a runner; main.ts drops 516 → 362 lines.

Verification

  • 276 unit tests pass, coverage 98.03% (gate is 85%).
  • yarn lint, yarn typecheck, yarn build, yarn gen all clean; no dist/codegen drift.
  • yarn release-it --dry-run --ci completes: picks 1.0.0 off the BREAKING CHANGE footer, runs every hook, writes the changelog, and would move v1.
  • E2E green on this branch (run) — 16 jobs pass, including 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-cache visible to a later step, binary output: tiny-app-cjs 0.0.1, sha256 verified. CI and CodeQL green too.
  • consumer-download and self-hosted-node24 skip by design (push-to-main only; HAS_SELF_HOSTED_LINUX unset).

Left for a follow-up

STATUS.yaml#pending still lists the four extra e2e fixtures, live-credential signing, and GHES verification. Two release-readiness items are now human actions rather than code: set HAS_SELF_HOSTED_LINUX when 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.md on every release, and the plugin's infile cannot be unset (--no-plugins.….infile writes to a file literally named false; a config using extends deep-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-readiness in STATUS.yaml.

One wart left that only history rewriting could fix: the BREAKING CHANGES section inherits the full squashed body of b5a8624 (pkg-CLI mirror inputs), including its own nested bullets and some bogus STATUS.yaml#… autolinks. Worth a manual trim on the release page if it bothers you.

🤖 Generated with Claude Code

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.
Copilot AI review requested due to automatic review settings July 27, 2026 13:31
`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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: node24 action pointing at packages/build/dist/{index,post}.mjs, and removes the internal packages/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.md plus 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants