Skip to content

featmigrate action to native ESM and upgrade @actions/* to ESM majors#2

Closed
dhensby wants to merge 2 commits intomasterfrom
esm-migration
Closed

featmigrate action to native ESM and upgrade @actions/* to ESM majors#2
dhensby wants to merge 2 commits intomasterfrom
esm-migration

Conversation

@dhensby
Copy link
Copy Markdown
Owner

@dhensby dhensby commented Apr 21, 2026

Migrate the action to native ESM on Node 24 so it can consume the ESM-only majors of the @actions/* toolkit (and any future ESM-only dependencies) without a transpile-to-CJS step. This supersedes Dependabot PR tediousjs#206, which cannot be merged against the current CJS build.

Changes

Two atomic commits:

1. feat!: rewrite as an ESM module

Tooling:

  • "type": "module"; emit ESM throughout.
  • @vercel/nccRollup (@rollup/plugin-typescript, -node-resolve, -commonjs, -json, -terser). Output is still lib/main/index.js so action.yml is unchanged.
  • ts-node / tsxNode 24 native TypeScript type-stripping. .ts files execute directly under node; mocha runs with no TS loader.
  • nyc + @istanbuljs/nyc-config-typescriptc8. .nycrc.json is retained as the config file (c8 reads it natively).
  • Relative imports use .ts extensions throughout; tsconfig.json sets allowImportingTsExtensions + rewriteRelativeImportExtensions.
  • misc/generate-docs.ts ESM-ified (JSON import attribute, import.meta.url).

Source adjustments to keep sinon stubbing working: modules that expose runtime values now export default a plain object alongside their named exports; call-sites use import foo from './foo.ts'; foo.bar(). This is required because ES-module named-export bindings are read-only and cannot be rebound by sinon.

2. feat!: upgrade @actions/* toolkit to v3/v4 ESM majors

Bumps matching Dependabot tediousjs#206:

Package From To
@actions/core ^1.11.1 ^3.0.0
@actions/exec ^1.1.1 ^3.0.0
@actions/glob ^0.5.0 ^0.6.1
@actions/http-client ^2.2.3 ^4.0.0
@actions/io ^1.1.3 ^3.0.2
@actions/tool-cache ^2.0.2 ^4.0.0

Because these are now true ESM (rather than ESM-wrapped CJS), their named exports are non-configurable and cannot be stubbed directly. A new src/actions.ts wrapper re-exports each @actions/* package as a plain mutable object (typed back to the original module type). Both source and tests import from this wrapper so sinon has a single shared object to stub.

Verification

  • npm run build — clean build via Rollup.
  • npm test82 passing, 0 failing.
  • npm run test:coverage99.48% statements / 98.81% branches / 100% functions / 100% lines.
  • npm run lint — clean.

BREAKING CHANGE

The action is now an ESM module and targets Node 24. Consumers using @v3 or later will continue to work unchanged at the action-invocation level, but this warrants a major version bump (hence feat!). Local development now requires Node 24+ (previously Node 20+).

Closes tediousjs#206.

dhensby and others added 2 commits April 21, 2026 19:52
Migrate the action from CommonJS to native ESM so that it can consume the
ESM-only majors of the `@actions/*` toolkit packages (and any future ESM-only
dependencies) without a transpile-to-CJS step.

Tooling changes:

- Set `"type": "module"` and emit ESM throughout.
- Replace `@vercel/ncc` (which cannot emit ESM) with Rollup
  (`@rollup/plugin-typescript`, `-node-resolve`, `-commonjs`, `-json`,
  `-terser`). Output is still `lib/main/index.js` so `action.yml` is unchanged.
- Drop `ts-node`/`tsx` in favour of Node 24's native TypeScript type-stripping
  (`.ts` files execute directly under `node`). Mocha invokes tests without any
  TypeScript loader.
- Replace `nyc` + `@istanbuljs/nyc-config-typescript` with `c8` for coverage,
  which needs no source instrumentation under ESM. `.nycrc.json` is retained as
  the config file (c8 reads it natively for nyc compatibility).
- Switch relative imports throughout src/, test/, and misc/ to use `.ts`
  extensions and set `allowImportingTsExtensions` +
  `rewriteRelativeImportExtensions` in `tsconfig.json` so both Node and Rollup
  resolve them directly.
- Update `misc/generate-docs.ts` to ESM (JSON import attribute,
  `import.meta.url` in place of `__dirname`).

Source-code adjustments to keep sinon stubbing working under ESM:

- Modules that expose runtime values used from other modules now `export
  default` a plain object alongside their named exports, and call sites use
  `import foo from './foo.ts'; foo.bar()`. ES module named-export bindings are
  read-only and cannot be rebound by sinon, so stubbing has to operate on a
  shared mutable object.
- `@actions/*` packages are imported via default (`import core from
  '@actions/core'`) rather than as a namespace (`import * as core`). The
  default import resolves to the underlying CJS `module.exports` (mutable and
  stub-friendly), whereas a namespace import is a frozen snapshot that doesn't
  reflect sinon mutations.
- `node:fs` / `node:fs/promises` are imported as defaults for the same reason.

Docs:

- Update `.github/copilot-instructions.md` to describe the new toolchain
  (Rollup, Node 24 native TS, c8) and document the ESM stubbing patterns.

BREAKING CHANGE: The action is now an ESM module and targets Node 24.
Consumers pinning the action via commit SHA will need to re-pin; consumers
using `@v3` or later will continue to work unchanged. Local development now
requires Node 24+ (previously Node 20+).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bump the `@actions/*` toolkit to the latest majors, all of which are now
published as native ESM (see `actions/toolkit` release notes for each package):

- `@actions/core`: `^1.11.1` → `^3.0.0`
- `@actions/exec`: `^1.1.1` → `^3.0.0`
- `@actions/glob`: `^0.5.0` → `^0.6.1`
- `@actions/http-client`: `^2.2.3` → `^4.0.0`
- `@actions/io`: `^1.1.3` → `^3.0.2`
- `@actions/tool-cache`: `^2.0.2` → `^4.0.0`

Because the upgraded packages are true ESM (rather than ESM-wrapped CJS), their
named exports are now non-configurable and cannot be stubbed directly via
`sinon.stub(ns)`. Add `src/actions.ts`, which re-exports each `@actions/*`
package as a plain mutable object (typed back to the original module type).
Both source code and tests now import from `src/actions.ts`, giving sinon a
single shared object to stub while preserving the full upstream API surface.

Update `.github/copilot-instructions.md` to describe the new wrapper module
and refresh the guidance that was previously written for the CJS shape of the
toolkit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dhensby dhensby closed this Apr 21, 2026
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.

1 participant