Skip to content

build: align with actions/typescript-action template (ESM + rollup + core@3)#129

Merged
ahanoff merged 10 commits into
mainfrom
scope-c-full-template-alignment
Jun 22, 2026
Merged

build: align with actions/typescript-action template (ESM + rollup + core@3)#129
ahanoff merged 10 commits into
mainfrom
scope-c-full-template-alignment

Conversation

@ahanoff

@ahanoff ahanoff commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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

Area Before After
Module system CommonJS ESM ("type": "module")
Bundler @vercel/nccdist/main/index.js (CJS) rollupdist/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)
tsconfig es6 / commonjs ES2022 / NodeNext
Action entry dist/main/index.js dist/index.js
Version 2.2.0 3.0.0

For action consumers: no change needed. Same inputs, same outputs, same node24 runtime. The only visible difference is the main: path in action.yml.

Why

Stay current with the official actions/typescript-action template, which is now ESM-only:

  • @actions/core@3 is pure ESM (no require() support)
  • @actions/github@9 is pure ESM
  • Template uses rollup, not ncc
  • All new @actions/* releases will be ESM-only

How

Rollup bundler

  • rollup-plugin-esbuild for TypeScript transpilation (uses transform hook — @rollup/plugin-typescript v11/v12 use resolveId+load hooks which silently skip entry points, a known issue)
  • @rollup/plugin-commonjs converts CJS deps (adaptivecards stack) to ESM at build time
  • @rollup/plugin-jsoncritical: adaptivecards-templating's source does require('./../package.json') which rollup cannot handle natively
  • @rollup/plugin-node-resolve for module resolution

Source changes (minimal)

  • 2 relative imports get .js extensions for NodeNext: './card''./card.js'
  • No other source changes — @actions/core@3 APIs (getInput, info, warning, error, setFailed) are byte-identical to v1

Jest ESM mode

  • export default config (was module.exports)
  • extensionsToTreatAsEsm: ['.ts'], useESM: true, ts-jest-resolver
  • NODE_OPTIONS=--experimental-vm-modules in test script

Compatibility research

Before implementing, investigated all runtime deps for ESM compatibility:

Package Format Bundles via commonjs plugin?
adaptive-expressions CJS-only
adaptivecards CJS-only (broken for native ESM import — issue #9155) ✅ (commonjs plugin sidesteps the bug)
adaptivecards-templating CJS-only + require('./../package.json') ✅ + @rollup/plugin-json
cockatiel Dual (has module field) ✅ (rollup picks ESM automatically)

Bundle size

Metric ncc (before) rollup (after)
dist/index.js 1.7 MB (minified CJS) 3.8 MB (ESM + sourcemap)
Build time 4.0s 2.1s

Larger because rollup doesn't minify by default (template doesn't either). Functionally irrelevant for GitHub Actions runtime. Can add minification later if desired.

Verification

Check Result
npm run lint ✅ clean
npm run build (tsc --noEmit) ✅ clean
npm test (Jest 30 ESM mode) ✅ 28/28 pass in 0.43s
npm run package (rollup) ✅ builds in 2.1s
npm run all ✅ full pipeline passes

Deviation from template

Template uses We use Reason
@rollup/plugin-typescript rollup-plugin-esbuild Plugin's resolveId+load hooks don't intercept entry points (confirmed broken in v11.0.0–v12.3.0 with rollup 4.x). esbuild uses transform hook which works reliably. Type checking handled by tsc --noEmit.
rollup.config.ts rollup.config.mjs Eliminates need for --configPlugin flag (which would require @rollup/plugin-typescript we dropped). Config is a plain object, doesn't need TypeScript types.
No @actions/github @actions/github@9 Our action needs Octokit for workflow/job status queries.

Not in scope

  • Minification (rollup output is unminified, matching template behavior)
  • @github/local-action for local testing (can add separately)
  • Coverage badge generation (can add separately)

ahanoff added 8 commits June 20, 2026 02:35
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)
@ahanoff ahanoff changed the title Scope C: full ESM + rollup alignment with actions/typescript-action template build: align with actions/typescript-action template (ESM + rollup + core@3) Jun 19, 2026
ahanoff added 2 commits June 22, 2026 23:18
…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.
@ahanoff ahanoff merged commit f2ab696 into main Jun 22, 2026
4 checks passed
@ahanoff ahanoff deleted the scope-c-full-template-alignment branch June 22, 2026 15:32
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