migrate to ESM and upgrade @actions/* to latest majors - #21
Merged
Conversation
Records the design for upgrading @actions/core to 3.x and migrating the codebase from CommonJS to ESM, which the upgrade forces. Covers the atomic three-package bump (core/cache/exec), the src/ and __tests__/ conversion, replacing @vercel/ncc with rollup, and the documentation constraints this inverts in AGENTS.md and .agents/INSTRUCTIONS.md. Also gitignores docs/superpowers/plans/, matching the convention that plans are ephemeral working docs while specs are preserved in git. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@actions/core 3.x, @actions/cache 6.x, and @actions/exec 3.x are ESM-only,
so require() of them throws at runtime. Migrate src/ and __tests__/ to
native ESM and bump all three together — @actions/cache@5 pins
@actions/core@^2, so bumping core alone resolves a second nested CommonJS
copy of core into the bundle.
ESM module namespace objects are frozen, so the test suite's
jest.spyOn(module, 'export') mocking no longer works. Tests now substitute
modules with jest.unstable_mockModule against fixtures in __fixtures__/.
Two fixtures are deliberately partial rather than pure stubs. Mocking a
module replaces it for every consumer, not just for the code under test:
__fixtures__/core.js re-exports the real @actions/core and overrides only
the asserted functions, because @actions/cache imports named exports from
it (setSecret among them) and a narrower mock makes main.test.js fail to
load. __fixtures__/fs-promises.js does the same to keep readFile real so
the TOML fixtures still load, mocking only symlink and stat.
Drops the jest.spyOn(main, 'run') self-spy from main.test.js, which ESM
makes impossible and which only asserted that run reached its end.
Coverage is unchanged: 9 tests, 100% statements/lines/functions, 92.85%
branches.
Verified the bundle loads and runs as ESM, not just that it builds:
env 'INPUT_INSTALL-CONFIG-FILE=__tests__/data/pyproject.empty.toml' \
node dist/index.js
prints "Nothing to install." and exits 0; a missing file emits ::error::
and exits 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aligns the bundler with the upstream actions/javascript-action template
this repository derives from, and drops a largely dormant dependency. The
bundle shrinks from ~3.4MB to ~2.9MB.
Two deliberate deviations from the upstream config:
- @rollup/plugin-json is required. It is absent upstream, where the only
dependency is @actions/core. Here @actions/cache imports its own
package.json and the build fails outright without it.
- sourcemap is disabled. The generated map is ~16.5MB, and because dist/
is committed that would land in git history on every dependency bump
and bury the dist/ diff check-dist.yml reports.
rollup-plugin-license replaces ncc's --license flag, attributing 43
bundled packages (all MIT/ISC/Apache-2.0/BSD-3-Clause, matching the
allowed list in .licensed.yml). The bundle inlines the whole production
dependency tree, including the Azure Storage SDK @actions/cache@6 adds,
so those notices have to ship with it.
Removes dist/package.json, which only existed because ncc emits a
{"type":"module"} marker alongside an ESM bundle. dist/ is back to
exactly index.js and licenses.txt.
Verified the rollup bundle at runtime, not just at build time: the empty
pyproject fixture prints "Nothing to install." and exits 0, a missing file
emits ::error:: and exits 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves Jest configuration out of package.json into jest.config.js and sets
transform: {}, which is correct for native ESM and means babel-jest is
never invoked.
Removes @babel/core, @babel/eslint-parser, @babel/preset-env, and
babel-preset-jest. There is no babel configuration anywhere in this
repository, so these have been inert since the original template
scaffold — applying the repository's own standing rule against carrying
unused dependencies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes a silent bundling bug that broke @actions/cache's saveCache on
windows-latest only.
Several bundled dependencies probe for optional modules with require()
inside a try/catch: minimatch resolves `path` that way, and undici probes
node:http2 and node:crypto. @rollup/plugin-commonjs leaves requires inside
try/catch untouched by default, and in an ESM bundle `require` is
undefined, so the ReferenceError was swallowed by the empty catch and each
dependency silently took its fallback path. No error, no failed build.
For minimatch the fallback is `{ sep: '/' }`. minimatch only rewrites
backslashes to forward slashes when sep !== '/', so on Windows the file
paths @actions/glob feeds it were never normalized and stopped matching the
pattern. glob returned zero matches, and @actions/cache reported "Path
Validation Error: Path(s) specified in the action for caching do(es) not
exist" for C:\Program Files (x86)\pipx\venvs\poetry. Linux was unaffected
because sep is '/' there anyway.
This was a bundler regression from ncc, not from the @actions/cache major:
resolvePaths is byte-identical between cache 5.2.0 and 6.2.0, and minimatch
is 3.1.5 in both trees. ncc/webpack rewrites try/catch requires into its own
module registry, so the bundle it produced worked.
The bug was only reachable on a cache miss, which is why CI hid it — both
OS jobs normally restore from an existing key and skip saveCache. Confirmed
by salting the cache key on a throwaway branch to force a miss: windows
failed and ubuntu saved all three packages.
Adds __tests__/dist.test.js, which asserts the committed bundle contains no
executable bare require(). Verified it fails without this fix, catching all
six real offenders. Two documented pitfalls added to .agents/INSTRUCTIONS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
super-linter's NATURAL_LANGUAGE check flags "builtins" and "sourcemap" via its terminology rules. Uses "built-ins", "source map", and "source maps" in prose; occurrences inside code blocks are left alone since they are literal identifiers (preferBuiltins, sourcemap: false). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design listed two deviations from the upstream rollup config; there are three. ignoreTryCatch: false is mandatory, not stylistic, and was found during implementation rather than design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BrandonLWhite
force-pushed
the
chore/esm-migration-actions-core-v3
branch
from
August 13, 2026 22:04
f599921 to
80e3808
Compare
BrandonLWhite
marked this pull request as ready for review
August 13, 2026 22:27
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.
Summary:
Upgrades
@actions/coreto 3.0.1, which is ESM-only, and migrates therepository from CommonJS to native ESM to make that possible.
@actions/cacheand@actions/execmove to 6.2.0 and 3.0.0 in the samechange because they are ESM-only at those majors too, and because
@actions/cache@5pins@actions/core@^2— bumping core alone wouldresolve a second, nested CommonJS copy of core into the bundle. This lifts
the constraint that
AGENTS.mdand.agents/INSTRUCTIONS.mdpreviouslycarried as the repository's central hard rule.
The action's workflow interface is untouched:
action.ymlinputs arebyte-identical,
runs.usingstaysnode24, and observable behavior isunchanged.
Actions:
@actions/core2.0.3 to 3.0.1,@actions/cache5.2.0 to 6.2.0,and
@actions/exec2.0.0 to 3.0.0, and set"type": "module".src/and__tests__/toimport/export,node:builtinspecifiers, and explicit
.jsextensions on relative imports.jest.spyOn(module, 'export')mocking, which frozenESM namespaces make impossible, with
jest.unstable_mockModuleagainstnew fixtures in
__fixtures__/. Two fixtures are deliberately partialrather than pure stubs, because mocking a module replaces it for every
consumer:
core.jsre-exports the real@actions/coresince@actions/cacheimports named exports from it (setSecretamong them),and
fs-promises.jskeepsreadFilereal so the TOML fixtures stillload while mocking only
symlinkandstat.jest.spyOn(main, 'run')self-spy, which ESM makesimpossible and which only asserted that
runreached its end.@vercel/nccwith rollup, matching the upstreamactions/javascript-actiontemplate. Three deviations from that template:@rollup/plugin-jsonis required here because@actions/cacheimportsits own
package.json, and sourcemaps are off because the map is ~16.5MBand
dist/is committed.commonjs({ ignoreTryCatch: false }), which is mandatory rather thanstylistic. Several bundled dependencies probe for optional modules with
require()inside a try/catch (minimatch resolvespaththat way; undiciprobes
node:http2andnode:crypto). The plugin leaves those alone bydefault, and in an ESM bundle
requireis undefined, so the throw isswallowed and each dependency silently takes a degraded fallback. minimatch
fell back to
sep: '/', which stopped Windows paths from matching and broke@actions/cache'ssaveCacheonwindows-latestonly. This was aregression from ncc, not from the
@actions/cachemajor —resolvePathsisbyte-identical between cache 5.2.0 and 6.2.0 and minimatch is 3.1.5 in both
trees. Added
__tests__/dist.test.jsto assert the committed bundle has noexecutable bare
require(, and verified it fails without the fix.dist/licenses.txtviarollup-plugin-license, attributing 43bundled packages. The bundle inlines the whole production tree, including
the Azure Storage SDK that
@actions/cache@6adds.jest.config.jswithtransformdisabled,and removed four dead Babel devDependencies that had no config anywhere
in the repository.
@actions/*past their CommonJSmajors" rule in
AGENTS.mdand.agents/INSTRUCTIONS.mdinto ESMguidance, and recorded the design under
docs/superpowers/specs/.exercise it: both OS jobs normally restore from an existing key and skip
saveCache, so the Windows bug was invisible. Forcing a miss by salting thecache key on a throwaway branch reproduced the failure, and after the fix
windows-latest wrote all three caches (13.19 MB poetry, 6.99 MB tox, 531 KB
poethepoet). Both pitfalls are now documented in
.agents/INSTRUCTIONS.md.100% statements/lines/functions and 92.85% branches, a clean
npm cireports 0 vulnerabilities, the committed
dist/matches a fresh build,and the bundle was executed directly to confirm it loads as ESM —
printing
Nothing to install.and exiting 0 for the empty-pyprojectfixture, and emitting
::error::with exit 1 for a missing file.BREAKING CHANGE: The repository's source and its bundled
dist/index.jsarenow ESM rather than CommonJS. This does not change the action's workflow
interface —
action.ymlinputs,runs.using: node24, and runtime behaviorare all unchanged, so no consumer workflow needs updating. It does break
forks, patches, or tooling that expect
require()/module.exportsinsrc/ordist/index.js.