Skip to content

feat!: migrate to the node24 runtime#6

Merged
netmenya merged 10 commits intopuzl-cloud:mainfrom
LayZeeDK:LayZeeDK/migrate-to-node24-runtime
Apr 24, 2026
Merged

feat!: migrate to the node24 runtime#6
netmenya merged 10 commits intopuzl-cloud:mainfrom
LayZeeDK:LayZeeDK/migrate-to-node24-runtime

Conversation

@LayZeeDK
Copy link
Copy Markdown
Contributor

@LayZeeDK LayZeeDK commented Apr 16, 2026

Closes #5.

Description

Migrate the action from the deprecated node20 runtime to node24, and from CJS to ESM. Also refresh the license compliance tooling (the Licensed CI workflow and the dev container's license tooling) so both stay green against the new dependency tree.

This is a major version bump (v4 -> v5) because the runtime change is breaking: self-hosted runners need GitHub Actions Runner v2.327.1 or later.

What changed

Runtime & version

  • runs.using updated from node20 to node24 in all three action.yml files
  • Version bumped from 4.0.0 to 5.0.0

ESM migration (matching the pattern used by GitHub's official actions: upload-artifact v7, download-artifact v8)

  • Added "type": "module" to package.json
  • Updated tsconfig.json module to NodeNext with NodeNext module resolution
  • Added .js extensions to all relative imports (required by NodeNext)
  • Renamed jest.config.js to jest.config.cjs

Dependencies

  • @actions/core ^1.10.0 -> ^3.0.0 (node24-native, ESM-only)
  • @actions/glob 0.1.2 -> ^0.6.1 (node24-native, ESM-only)
  • @types/node ^20.12.11 -> ^24.12.2
  • typescript ^5.4.5 -> ^5.9.3 (latest 5.x, capped below 6.0 by ts-jest peer dependency)
  • actions/publish-action v0.3.0 -> v0.4.0 (node24-native)
  • Removed unused dependencies: @actions/cache, @actions/exec, @actions/io, filenamify (not imported anywhere in source -- this action uses its own puzl.cloud cache implementation)
  • Regenerated package-lock.json under the dev container's npm 11.9 to drop 12 redundant "peer": true markers; stabilizes the lockfile so contributor rebuilds don't leave a dirty working tree

Why TypeScript 5.9 instead of 6.0?
ts-jest@29.1.2 declares peerDependencies.typescript: ">=4.3 <6", blocking TypeScript 6.x. Upgrading ts-jest to ^29.4.9 (which supports <7) would unblock TypeScript 6.0. No other dev dependencies block it -- @typescript-eslint 7.x lists TypeScript as optional with no version range, ts-api-utils allows >=4.2.0, and @vercel/ncc has no TypeScript peer dependency.

TypeScript toolchain

  • Target/lib updated from ES6 to ES2024 (per Node Target Mapping)
  • Added skipLibCheck for type definition compatibility

Test infrastructure

  • Added tsconfig.test.json with CJS module output so jest.mock() continues to work
  • Added jest-resolver.cjs to resolve ESM-only package exports via main field fallback
  • Added babel-jest with @babel/plugin-transform-modules-commonjs and @babel/plugin-transform-export-namespace-from to transform ESM @actions/* packages for Jest
  • Added eslint-import-resolver-typescript so eslint-plugin-import understands .js extension imports

CI workflows

  • actions/checkout v4 -> v6
  • actions/setup-node v4 -> v6
  • actions/upload-artifact v4 -> v7
  • Node.js version 20.x -> 24.x

Dev container

  • Updated image from Node 22 to Node 24
  • Fixed a workspace-mount UID mismatch that caused npm install to fail with ERR_INVALID_ARG_TYPE / Received null on some Docker Desktop setups (observed on Windows arm64 with Dev Drive, where the bind mount arrives owned by UID 1001 with mode 700). sudo chown -R node:node . at the start of postCreateCommand re-owns the mount so the node user can traverse the workspace; it is a no-op on hosts where the UID already matches
  • Added Ruby 3.3 via ghcr.io/devcontainers/features/ruby:1 and cmake via ghcr.io/devcontainers-extra/features/cmake:1 so contributors can run licensed status / licensed cache locally against the same tool version CI uses. cmake is required to compile libgit2 for the rugged native extension pulled in transitively via the licensee gem

License compliance tooling

  • Regenerated .licenses/ cache for the post-Node 24 dependency tree: 65 dependency records removed (the old @actions/cache / @azure/storage-blob / @azure/core-* / @opentelemetry / @bufbuild / @protobuf-ts / node-fetch cascade that dropped out during the migration), and 2 records refreshed for the @actions/core and @actions/glob major bumps
  • .github/workflows/licensed.yml switched from a binary tarball download to ruby/setup-ruby@v1 + gem install licensed:2.12.2. Upstream licensee/licensed stopped publishing binary release tarballs after 4.5.0 (August 2024), and 2.12.2 itself only shipped linux-x64 and darwin-x64 binaries -- the tarball path is a dead-end for non-amd64 runners and for any future version bump
  • Ruby pinned to 3.3 on both CI (via ruby/setup-ruby@v1) and the dev container (via the Ruby feature). 3.3 is the newest Ruby where csv is still a default gem; on Ruby 3.4+ csv was moved to bundled gems, which would break licensed 2.12.2's implicit require 'csv' in lib/licensed/sources/gradle.rb without an explicit gem install csv. Pinning both ends to the same Ruby version also prevents subtle .licenses/ YAML drift between contributor-generated caches and CI-generated caches
  • licensed itself still pinned to 2.12.2 to keep the existing .licenses/ metadata in sync with the tool version; a future commit could upgrade to licensed 5.x (which declares csv as a runtime dep explicitly), but that is a separate concern

Motivation and Context

The node20 runtime is deprecated. On June 2nd, 2026, GitHub Actions runners will default to node24. See Deprecation of Node 20 on GitHub Actions runners.

The @actions/* 3.x toolkit packages are ESM-only, requiring the project to migrate from CJS to ESM. This is the same approach used by GitHub's own first-party actions.

How Has This Been Tested?

Dev container (mcr.microsoft.com/devcontainers/typescript-node:4-24-bookworm)

  • npm run format-check -- all files pass Prettier
  • npm run lint -- all files pass ESLint (with eslint-import-resolver-typescript for .js imports)
  • npm run build -- TypeScript 5.9.3 compiles and ncc bundles all four entry points in ESM mode
  • npm run test -- 79/79 tests pass (all 8 suites), including integration tests
  • npm run build && git diff dist/ -- dist matches committed bundles
  • licensed status -- 3 dependencies checked, 0 errors found (after cache regeneration)

Local workflow testing with nektos/act v0.2.84 (platform: catthehacker/ubuntu:act-latest)

  • check-dist job: passed -- build output matches committed dist
  • static-tests job: 73/79 tests pass (6 integration test failures due to missing pigz on act image -- these pass in the dev container)
  • Licensed job: passed (with --container-architecture linux/amd64 so the emulated runner sees an x86-64 shell) -- 3 dependencies checked, 0 errors found. Also verified at the intermediate commit where only the cache regen had landed but the CI still used the original tarball install, to confirm the 4-commit license-tooling series is bisect-safe
  • Tested both with and without FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true -- identical results, confirming the env var is a no-op since the action already declares node24

E2E cache round-trip with nektos/act on Windows 11 arm64 (Docker Desktop, arm64-native, no QEMU emulation)

  • Setup: custom image derived from catthehacker/ubuntu:act-latest with pigz and tree installed, plus a host bind mount (-v <host>/.act-cache/puzl:/.puzl) to simulate the persistent /.puzl volume that puzl-ubuntu-latest provides in production. Jobs invoked with -P puzl-ubuntu-latest=act-puzl:latest --pull=false
  • test-save: passed -- all 6 cache entries written as pigz-compressed tarballs under /.puzl/cache/test-Linux-1/ and persisted to the host bind mount. Covers the three path shapes from the workflow: working-directory path (test-cache), outside-workdir path (~/test-cache), and ./test/**/dist glob match. Save completed with Cache saved with key: test-Linux-1
  • test-restore: passed -- all 6 entries restored from the bind-mounted cache across a separate act invocation (i.e. a separate container lifecycle, matching the cross-job semantics of the real runner). Every verify-cache-files.sh step confirmed a File content: Linux 1 round-trip for all three path shapes, and cache-hit=true was emitted
  • Idempotency confirmed: because test-restore declares needs: test-save, act re-ran test-save against the already-populated cache, and the second save short-circuited with Cache hit occurred on the primary key test-Linux-1, not saving cache. -- matching production behaviour

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation (add or update README or docs)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
    • No CONTRIBUTING document exists in this repository.
  • I have added tests to cover my changes.
    • No new tests were added because no new functionality was introduced.
  • All new and existing tests passed.
    • All 79 existing tests pass in the dev container, confirming backward compatibility.

Reflect version `4.0.0` of action and pinned version of `@actions/cache`.
@LayZeeDK LayZeeDK force-pushed the LayZeeDK/migrate-to-node24-runtime branch 7 times, most recently from b63ffc8 to 139d0c0 Compare April 16, 2026 14:01
@LayZeeDK LayZeeDK marked this pull request as ready for review April 16, 2026 14:22
BREAKING CHANGE: The action now requires the Node.js 24 runtime.
Self-hosted runners need GitHub Actions Runner v2.327.1 or later.

Migrate the project from CJS/node20 to ESM/node24 to support the
node24-native @actions/* 3.x toolkit packages (which are ESM-only).
This matches the pattern used by GitHub's official first-party actions
(upload-artifact v7, download-artifact v8).

Runtime:
- Update runs.using from node20 to node24 in all three action.yml files
- Bump version from 4.0.0 to 5.0.0

ESM migration:
- Add "type": "module" to package.json
- Update tsconfig module to NodeNext with NodeNext module resolution
- Add .js extensions to all relative imports (required by NodeNext)
- Rename jest.config.js to jest.config.cjs (CJS config in ESM project)

TypeScript toolchain:
- Update target/lib from ES6 to ES2024 (per Node Target Mapping)
- Add skipLibCheck for type definition compatibility
- Bump typescript from ^5.4.5 to ^5.9.3 (latest 5.x, capped below
  6.0 by ts-jest 29.1.2 peer dependency)
- Update tsconfig comments with valid values for TS 5.6+

Dependencies:
- Bump @actions/core from ^1.10.0 to ^3.0.0 (node24-native, ESM-only)
- Bump @actions/glob from 0.1.2 to ^0.6.1 (node24-native, ESM-only)
- Bump @types/node from ^20.12.11 to ^24.12.2
- Bump actions/publish-action from v0.3.0 to v0.4.0 (node24-native)
- Remove unused dependencies not imported anywhere in source:
  @actions/cache, @actions/exec, @actions/io, filenamify
  (this action uses its own puzl.cloud cache implementation)

Test infrastructure:
- Add tsconfig.test.json with CJS module output so jest.mock()
  continues to work (Jest's ESM mode breaks jest.mock hoisting)
- Add jest-resolver.cjs to resolve ESM-only package exports by
  stripping the exports field and falling back to the main field
- Add babel-jest with @babel/plugin-transform-modules-commonjs and
  @babel/plugin-transform-export-namespace-from to transform
  ESM-only @actions/* 3.x packages for Jest's CJS test runner
- Add moduleNameMapper to strip .js extensions from relative imports
  so ts-jest in CJS mode finds the .ts source files
- Remove vestigial @actions/cache mock from actionUtils test

Linting:
- Add eslint-import-resolver-typescript so eslint-plugin-import
  understands NodeNext .js extension imports
- Fix pre-existing prettier formatting issues

Dev container:
- Update image from 1-22-bookworm to 4-24-bookworm

Build: ncc auto-detects "type": "module" and bundles in ESM mode,
resolving @actions/* 3.x exports via the "import" condition.
Update all version references in code examples:
- puzl-cloud/github-actions-cache@v4 -> @v5
- puzl-cloud/github-actions-cache/restore@v4 -> /restore@v5
- puzl-cloud/github-actions-cache/save@v4 -> /save@v5
- puzl-cloud/github-actions-cache@v3 -> @v5 (stale reference)
- actions/checkout@v4 -> @v6 in example workflows
- actions/checkout v4 -> v6 (first version with native node24)
- actions/setup-node v4 -> v6
- actions/upload-artifact v4 -> v7
- Node.js setup version 20.x -> 24.x in static-tests and check-dist
Rebuild all four ncc bundles (restore, save, save-only, restore-only)
with the updated ESM toolchain and node24-native @actions/* 3.x
dependencies. ncc now outputs ESM (import/export) instead of CJS
(require/module.exports), matching the "type": "module" declaration.

Each dist/ directory also gains a package.json generated by ncc to
declare the bundle's module type.
@LayZeeDK LayZeeDK force-pushed the LayZeeDK/migrate-to-node24-runtime branch from 139d0c0 to c58e4c4 Compare April 16, 2026 19:41
@sergeimonakhov
Copy link
Copy Markdown
Member

@LayZeeDK Thanks for the PR! Could you please run licensed cache and commit the updated .licenses files?

…er's npm

Running npm install inside the dev container introduced in the
previous commits (mcr.microsoft.com/devcontainers/typescript-node:
4-24-bookworm, which ships Node 24 and npm 11.9) regenerates the
lockfile with 12 redundant "peer": true markers omitted.

npm 11.9's resolver does not write "peer": true on entries that
are already listed in devDependencies, where older npm versions
(including the 11.6 shipped by some host environments) still do.
Both lockfile variants are semantically equivalent -- same resolved
versions, same integrity hashes, same resolution graph -- but they
differ in presence of those flags.

Commit the regenerated form now, so the next time a contributor
runs npm install inside the dev container they do not end up with
a dirty working tree. This is purely a cosmetic stabilization: the
lockfile may still flap back if npm install is later run under an
older npm (e.g. on a host or CI runner that has not caught up to
11.9), but it will stop flapping on every dev container rebuild.
Running `licensed cache` brings the .licenses/ records in sync with
the current package-lock.json after the Node 24 / ESM migration
(d1028cf):

- 65 dependency records removed for packages no longer in the tree.
  The old action pulled @actions/cache, which transitively depended
  on @azure/storage-blob and its full Azure SDK cascade
  (@azure/core-*, @azure/ms-rest-js, @azure/abort-controller),
  plus @opentelemetry/api, @bufbuild/*, @protobuf-ts/*, node-fetch,
  and related transitive deps. The new action uses @actions/core
  and @actions/glob only, so all of the above dropped out.
- 2 records refreshed for @actions/core and @actions/glob, which
  were bumped to new major versions in the migration. These were
  the two records licensed status reported as "cached dependency
  record out of date" on this branch until the cache was
  regenerated.
Switch from the binary tarball download to ruby/setup-ruby + gem install.
The upstream project (licensee/licensed) only ever published linux-x64
and darwin-x64 tarballs for 2.12.2, and stopped publishing binary
tarballs entirely after 4.5.0 (August 2024) -- the tarball approach
is a dead-end for non-amd64 runners and for any future version bump.

Ruby is pinned to 3.3 via ruby/setup-ruby@v1, matching the dev
container (which pins the same version via the Ruby dev container
feature). This alignment prevents .licenses/ YAML drift between CI
regenerations and contributor regenerations that would otherwise
occur when the two environments resolve different Ruby minors from
their respective "default" sources.

3.3 specifically is the newest Ruby where csv is still a default gem.
Ruby 3.4 moved csv to bundled gems, which breaks licensed 2.12.2's
implicit require 'csv' in sources/gradle.rb unless csv is installed
alongside. Staying on 3.3 keeps the install a single gem install.

build-essential and cmake are installed via apt because neither the
runner baseline (confirmed via `act` against catthehacker/ubuntu:
act-latest) nor ruby/setup-ruby provides them. They are required to
compile the rugged native extension (libgit2 bindings) pulled in
transitively via the licensee gem. pkg-config and libssl-dev are
already present on any reasonable Ubuntu runner and are not
reinstalled.

gem install runs without sudo because ruby/setup-ruby installs Ruby
to a user-local hostedtoolcache path and prepends its bin directory
to PATH; sudo gem install would reset PATH and fail to find gem.

Version pinned to 2.12.2 to keep the existing .licenses/ metadata
in sync with the tool version.
On some Docker Desktop setups (observed on Windows arm64 with a Dev
Drive workspace), the host-to-container bind mount arrives with an
unexpected owner (e.g. UID 1001, GID 116) and mode 700 on the mount
root. The container's node user (UID 1000) then lacks traverse
permission on the workspace directory, so even though files inside
are world-readable, they cannot be reached.

This manifests as npm install failing with a cryptic error:

  npm error code ERR_INVALID_ARG_TYPE
  npm error The "path" argument must be of type string or an instance
  of Buffer or URL. Received null

The null comes from npm walking up from cwd looking for package.json
and hitting a directory it cannot stat; it then calls path.resolve on
an unresolved value.

Running sudo chown -R node:node . at the very start of
postCreateCommand re-owns the mount so the node user can read, write,
and traverse the workspace. The fix is a no-op on hosts where the UID
already matches (typical Linux hosts with host UID 1000), and safe on
all bind-mount backends tested (grpc-fuse on Windows Docker Desktop,
overlay on native Linux).

The recursive flag is kept for portability: on grpc-fuse (Windows,
some macOS configurations) chown at the mount root propagates to all
entries automatically, but on native Linux bind mounts only the target
inode is affected, so contained files would keep their original UIDs
without -R.
Adds the same licensed version CI uses (2.12.2) to the dev container,
so contributors can reproduce the licensed status check locally and
run licensed cache to regenerate .licenses/ metadata before pushing.

A binary-tarball approach was considered but abandoned: licensed 2.12.2
only ships linux-x64 and darwin-x64 release assets (no linux-arm64),
and the upstream project stopped publishing binary tarballs entirely
after 4.5.0, so the tarball path is a dead-end for arm64 hosts and
for future version bumps. The gem-based install works on both
linux/amd64 and linux/arm64 containers.

Two dev container features supply the tooling neither the base image
(mcr.microsoft.com/devcontainers/typescript-node:4-24-bookworm) nor
the apt step provides:

- ghcr.io/devcontainers/features/ruby:1 pinned to 3.3. The version
  is explicit because 3.3 is the newest Ruby where csv is still a
  default gem; Ruby 3.4 moved csv to bundled gems, which breaks
  licensed 2.12.2's implicit require 'csv' in sources/gradle.rb.
  Staying on 3.3 keeps the install a single gem install, no csv
  workaround needed. This version also matches the Ruby pinned on
  the CI side via ruby/setup-ruby@v1, so .licenses/ regenerations
  produce identical YAML on both ends.
- ghcr.io/devcontainers-extra/features/cmake:1. cmake is required
  to compile libgit2 for the rugged gem (pulled in transitively via
  the licensee gem), and the base image does not ship it. The
  community cmake feature installs it declaratively alongside the
  Ruby feature, keeping the apt step minimal.

The apt step now only installs pigz, which is needed at runtime by
the action itself (src/constants.ts defines TAR_COMMAND = "tar -I
pigz") and by the integration tests.

gem install runs without sudo because the Ruby feature installs
into a user-accessible RVM path; sudo would reset PATH and the gem
shim would not be found.

--no-document skips rdoc/ri generation to keep postCreateCommand fast.

Version pinned to 2.12.2 to stay in sync with the existing .licenses/
metadata and the CI workflow.
@netmenya netmenya merged commit 2cb97e2 into puzl-cloud:main Apr 24, 2026
9 checks passed
@LayZeeDK LayZeeDK deleted the LayZeeDK/migrate-to-node24-runtime branch May 1, 2026 22:23
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.

Migrate action to the node24 runtime

3 participants