build: align with actions/typescript-action template (ESM + rollup + core@3)#129
Merged
Conversation
Breaking infrastructure migration to align with the actions/typescript-action template's ESM module system.
Production deps:
- @actions/core: ^1.10.1 -> ^3.0.1 (pure ESM, APIs unchanged)
- @actions/github: ^6.0.0 -> ^9.0.0 (pure ESM, eliminates http-client v2/v4 conflict with core@3)
Dev deps:
- remove @vercel/ncc (replaced by rollup)
- add rollup, @rollup/plugin-commonjs, @rollup/plugin-node-resolve, @rollup/plugin-json, rollup-plugin-esbuild
- add rimraf, tslib, @jest/globals, ts-jest-resolver for ESM test mode
Package metadata:
- add "type": "module"
- add "exports": {".": "./dist/index.js"}
- add "engines": {"node": ">=24.0.0"}
- update "main" to "dist/index.js"
- bump version to 3.0.0 (major: ESM runtime migration)
- build script: tsc -> tsc --noEmit (type-check only; bundling handled by rollup)
- package script: ncc -> rollup
- test script: add NODE_OPTIONS=--experimental-vm-modules for Jest ESM mode
…tion
Required for ESM package ("type": "module"). Relative imports must now use .js extensions.
- target: es6 -> ES2022
- module/moduleResolution: commonjs -> NodeNext
- add isolatedModules, resolveJsonModule, forceConsistentCasingInFileNames
- exclude __tests__ from type-check scope (jest handles test compilation via ts-jest)
Replaces @vercel/ncc. Outputs ESM (format: 'es') to dist/index.js to match the actions/typescript-action template.
Plugins:
- rollup-plugin-esbuild: TypeScript transpilation (uses transform hook; @rollup/plugin-typescript v11/v12 use resolveId+load which silently skip entry points)
- @rollup/plugin-node-resolve: resolve node_modules imports
- @rollup/plugin-json: required because adaptivecards-templating does require('./../package.json')
- @rollup/plugin-commonjs: convert CJS deps (adaptivecards stack) to ESM
Convert from CJS (module.exports) to ESM (export default) since package.json now has "type": "module". - add extensionsToTreatAsEsm: ['.ts'] - add resolver: 'ts-jest-resolver' for .js -> .ts resolution in ESM imports - add useESM: true in ts-jest transform options - add testPathIgnorePatterns for /dist/ and /node_modules/
Under NodeNext module resolution, relative ESM imports must include the .js extension even when the source file is .ts. TypeScript resolves the .js -> .ts mapping at compile time. - src/main.ts: './card' -> './card.js' - src/__tests__/card.test.ts: '../card' -> '../card.js'
main: dist/main/index.js -> dist/index.js (rollup single-file output)
Rollup ESM output replaces the ncc CJS bundle. Size: 1.7MB (ncc CJS) -> 3.8MB (rollup ESM + sourcemap). Larger because rollup does not minify by default (template doesn't either) and ESM interop code is slightly more verbose. Functionally irrelevant for GitHub Actions runtime. Build time: 4.0s (ncc) -> 2.1s (rollup).
Enable esbuild minify in the rollup esbuild plugin. The template ships unminified, but for a production action the size reduction is significant: - dist/index.js: 3.8MB -> 1.5MB (60.9% reduction) - Now smaller than the previous ncc CJS bundle (1.7MB) - Build time unchanged (~2.1s) - Sourcemap still generated for debugging (5.5MB, separate file)
…ative code (#130) ## What Eliminate the two heaviest dependency chains from the action. Stacked on top of PR #129 (Scope C). | Chain | Deps removed | Bundle saved | |---|---|---| | `adaptivecards-templating` → adaptive-expressions + antlr4ts + xpath + xmldom | 4 packages | ~900 KB | | `@actions/github` → undici + @octokit/* + @actions/http-client | 3 packages (+transitives) | ~400 KB | | `cockatiel` (unused — never imported) | 1 package | — | **Production dependencies: 6 → 1** (just `@actions/core`). ## Bundle size progression | Stage | `dist/index.js` | Build time | |---|---|---| | Scope B (ncc CJS) | 1.7 MB | 4.0s | | Scope C (rollup ESM minified) | 1.5 MB | 2.1s | | **This PR** | **411 KB** | **0.67s** | 73% smaller than Scope C. 76% smaller than Scope B. ## How ### `adaptivecards-templating` → manual JS object builder The library's `${$root.x}` template syntax was only used for simple property access. Replaced with JS template literals: ```typescript // Before: 2.2MB of transitive deps for this one call const template = new Template(templateData) const content = template.expand({$root: data}) // After: zero deps const content = buildCard(data) // plain JS object with template literals ``` An `s()` helper converts `undefined`/`null` → `''` to match adaptivecards-templating's expansion behavior. All 28 existing tests pass unchanged (they validate output shape, not implementation). **Side benefit**: eliminates the JSON injection bug class (PR #121) entirely — no template expansion engine to misuse. ### `@actions/github` → native `fetch` + env vars `github.context` was just `GITHUB_*` env var reads + `GITHUB_EVENT_PATH` JSON parse. `github.getOctokit(token)` was used for exactly 2 GET requests: ```typescript // Before const o = github.getOctokit(token) const jobs = await o.rest.actions.listJobsForWorkflowRun({owner, repo, run_id}) // After const jobs = await fetch(`${apiBase}/repos/${owner}/${repo}/actions/runs/${runId}/jobs`, { headers: {Authorization: `Bearer ${token}`, ...} }) ``` No change for consumers — token still comes from `github-token: ${{ github.token }}`. `GITHUB_API_URL` respected for GHE compatibility. ## Verification | Check | Result | |---|---| | `npm run lint` | ✅ clean | | `npm run build` (`tsc --noEmit`) | ✅ clean | | `npm test` (28 tests) | ✅ all pass in 0.20s | | `npm run package` | ✅ 411 KB in 670ms | ## Remaining 411 KB Mostly `@actions/core`'s transitive chain (`@actions/http-client`, `@actions/exec`, `tunnel`, `undici`). Removing `@actions/core` itself is possible (replace 5 API calls with `process.env` + `process.exitCode`) but diminishing returns — the action would become dependency-free at ~20KB. ## Risk **Medium**. The card output shape is identical (28 tests confirm). The GitHub API calls match the same endpoints Octokit used. The main risk is edge cases in env var parsing or API response shapes that Octokit's types previously caught at compile time — mitigated by runtime type assertions on fetch responses.
The action's public interface (inputs, outputs, Teams card behavior, runtime) is unchanged. Consumers using opsless/ms-teams-github-actions@v2 do not need to change anything. The ESM migration, rollup bundler swap, and dependency removals are internal infrastructure changes.
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.
What
Complete the template alignment by migrating from CJS+ncc to ESM+rollup. This is Scope C — the remaining work after PR #126 (Scope B) which covered ESLint flat config + Jest 30 + dep version bumps.
Breaking changes
"type": "module")@vercel/ncc→dist/main/index.js(CJS)dist/index.js(ESM)@actions/core^1.10.1(CJS)^3.0.1(pure ESM)@actions/github^6.0.0(CJS, http-client v2 conflict)^9.0.0(pure ESM, eliminates conflict)es6/commonjsES2022/NodeNextdist/main/index.jsdist/index.jsFor action consumers: no change needed. Same inputs, same outputs, same
node24runtime. The only visible difference is themain:path in action.yml.Why
Stay current with the official actions/typescript-action template, which is now ESM-only:
@actions/core@3is pure ESM (norequire()support)@actions/github@9is pure ESM@actions/*releases will be ESM-onlyHow
Rollup bundler
rollup-plugin-esbuildfor TypeScript transpilation (usestransformhook —@rollup/plugin-typescriptv11/v12 useresolveId+loadhooks which silently skip entry points, a known issue)@rollup/plugin-commonjsconverts CJS deps (adaptivecards stack) to ESM at build time@rollup/plugin-json— critical: adaptivecards-templating's source doesrequire('./../package.json')which rollup cannot handle natively@rollup/plugin-node-resolvefor module resolutionSource changes (minimal)
.jsextensions for NodeNext:'./card'→'./card.js'@actions/core@3APIs (getInput,info,warning,error,setFailed) are byte-identical to v1Jest ESM mode
export defaultconfig (wasmodule.exports)extensionsToTreatAsEsm: ['.ts'],useESM: true,ts-jest-resolverNODE_OPTIONS=--experimental-vm-modulesin test scriptCompatibility research
Before implementing, investigated all runtime deps for ESM compatibility:
adaptive-expressionsadaptivecardsadaptivecards-templatingrequire('./../package.json')@rollup/plugin-jsoncockatielmodulefield)Bundle size
dist/index.jsLarger because rollup doesn't minify by default (template doesn't either). Functionally irrelevant for GitHub Actions runtime. Can add minification later if desired.
Verification
npm run lintnpm run build(tsc --noEmit)npm test(Jest 30 ESM mode)npm run package(rollup)npm run allDeviation from template
@rollup/plugin-typescriptrollup-plugin-esbuildresolveId+loadhooks don't intercept entry points (confirmed broken in v11.0.0–v12.3.0 with rollup 4.x). esbuild usestransformhook which works reliably. Type checking handled bytsc --noEmit.rollup.config.tsrollup.config.mjs--configPluginflag (which would require@rollup/plugin-typescriptwe dropped). Config is a plain object, doesn't need TypeScript types.@actions/github@actions/github@9Not in scope
@github/local-actionfor local testing (can add separately)