Skip to content

chore: adopt typescript-action-template's modernization bundle - #427

Draft
joris974 wants to merge 2 commits into
mainfrom
modernize-template
Draft

chore: adopt typescript-action-template's modernization bundle#427
joris974 wants to merge 2 commits into
mainfrom
modernize-template

Conversation

@joris974

@joris974 joris974 commented Sep 2, 2026

Copy link
Copy Markdown
Member

The end-to-end suite has since run green: all 21 checks pass, including every test matrix job (ubuntu-latest, macOS-latest, windows-latest x nightly, lts, lts-22.7, lts-12.26), test-stack-yamls, test-fails-on-error, and the new build job. Windows was the one open question — the old build ran sed -i 's/\x0D$//' over ncc's output and that step is gone; the new dist/index.js contains zero CR bytes, and Windows confirms it.

Ports the current modernization bundle from freckle/typescript-action-template#555 to this repo. The action's runtime behavior does not change: action.yml and generate-matrix/action.yml are untouched, and the rebuilt dist/index.js was diffed against the previous one under a fake-stack harness with identical output.

Summary

  • Package manager: yarn -> pnpm 11.24.0, pinned with a sha512 integrity hash in packageManager. yarn.lock deleted, pnpm-lock.yaml added.
  • typescript rolled back ^7.0.2 -> ^6.0.3.
  • Added ESLint flat config (eslint.config.mjs), knip.json, .prettierrc, tsconfig.build.json.
  • tsconfig.json modernized to ES2023, redundant-with-strict flags dropped, include no longer excludes test files so pnpm typecheck covers them.
  • Vitest coverage added and gated at 70% on all four metrics. Coverage went from 48.27% to 84.82% lines by adding tests, not by moving the threshold.
  • CI now runs format-check, typecheck, lint, knip, coverage, build and uploads dist/index.js.
  • build is now tsc -p tsconfig.build.json && ncc build lib/main.js --minify.
  • .github/CODEOWNERS set to @freckle/team-student-activities.
  • Release config repaired for pnpm; conventional-changelog-conventionalcommits pinned to @9.
  • Removed @types/js-yaml, ts-node, and the action-docs devDependency.
  • Fixed a real type error in src/with-cache.ts that the new typecheck scope exposed.
  • README gains a ## Development section; LICENSE year becomes open-ended, both matching the bundle.

Not breaking for consumers

Consumers use this action as uses: freckle/stack-action@v5 in a workflow, not as an npm import, so the ESM/pnpm/TypeScript changes here are invisible to them. action.yml's inputs, outputs, and runs.using: node24 are byte-for-byte unchanged:

$ git diff --cached --stat -- action.yml generate-matrix/action.yml
(no output)

freckle/stack-cache-action delegates to freckle/stack-action@v5 with working-directory, cache-prefix, stack-build-arguments: --dry-run, install-stack: false, upgrade-stack: false, and env.STACK_YAML. All six still exist and are unchanged. (Unrelated to this PR: that action declares an input named prefix but passes ${{ inputs.cache-prefix }}, so its prefix has never actually reached us. Not touched here.)

Commit type is chore: rather than feat!: deliberately. Nothing consumer-visible changes, so this should not cut a release on its own; the next real fix:/feat: will pick up the rebuilt dist/ via @semantic-release/exec.

dist/ behavioral equivalence

Given how widely this action is used, --minify was verified rather than assumed, in two independent ways.

1. Compiled output diff. Compiled the original src/ and the ported src/ with the same tsc, then normalized both through Prettier to remove the quote/semicolon churn. The entire semantic difference across all modules is three lines:

--- libold/hash-project.js
+++ libnew/hash-project.js
-export const ALL_SOURCES_PATTERNS = `**\n!**${path.sep}.stack-work\n!.git\n`
-export const BUILD_FILES_PATTERNS = `**${path.sep}package.yaml\n**${path.sep}*.cabal\n`
+const ALL_SOURCES_PATTERNS = `**\n!**${path.sep}.stack-work\n!.git\n`
+const BUILD_FILES_PATTERNS = `**${path.sep}package.yaml\n**${path.sep}*.cabal\n`

--- libold/hie.js
+++ libnew/hie.js
-export const HIE_YAML = 'hie.yaml'
+const HIE_YAML = 'hie.yaml'

main.ts and every other module compile identically. The with-cache.ts change is type-only and emits nothing.

2. End-to-end diff of the built bundles. Ran the old and new dist/index.js against a fake stack on PATH (answering query, path, and exec -- which gen-hie) in a fresh git repo, with install-stack: false, upgrade-stack: false, color: true, cache-prefix: v9/, compiler-tools: hlint. This exercises input parsing, shell-word splitting and envsubst, project hashing, stack path/stack query YAML parsing, cache-key construction, and the setup/build-dependencies/compiler-tools sequence, before failing at cache save as expected outside a real runner.

$ diff -u normalized-old.txt normalized-new.txt && echo "IDENTICAL BEHAVIOR"
=== old lines:       89  new lines:       89 ===
IDENTICAL BEHAVIOR

Sample of the 89 identical lines, showing the commands actually issued:

Cache key: v9/darwinarm64/lts-22.7/deps--
[command].../bin/stack --color=always setup
[command].../bin/stack --color=always build --test --no-run-tests --dependencies-only --fast --pedantic
[command].../bin/stack --color=always install --copy-compiler-tool hlint

The dist/ build is also deterministic — rebuilding after committing leaves the tree clean, which is what CI would assert:

$ pnpm build && git diff --stat -- dist
(no output)
$ shasum -a 256 dist/index.js
dd2d3ef5d90ae7de708bb3901a9e6971f206fe8827416cfc95ca706470ad7a50

Size dropped 4,604,727 -> 1,769,988 bytes from --minify. One consequence worth naming: an uncaught stack trace in dist/index.js would now show minified frames. In practice the action reports failures through core.setFailed(error.message), so no stack traces surface today.

dist/package.json ({"type": "module"}) is now committed. ncc has always emitted it for an ESM build; it was simply never committed here, and nothing in CI checked. It resolves to the same module type the root package.json already gives, so it changes nothing at runtime. The template commits it too.

TypeScript rollback

package.json declared "typescript": "^7.0.2", and 7.0.2 was what installed. Rolled back to the template's ^6.0.3typescript-eslint hard-errors on 7.x, and holding at 6.x is the standing convention.

$ pnpm exec tsc --version
Version 6.0.3

Full suite re-run after the rollback; results in Verification below. Nothing broke that wasn't already broken.

Type error found by the new typecheck scope

The old tsconfig.json had "exclude": ["./src/**/*.test.ts"], so test files were never type-checked. The template's split moves that exclusion into tsconfig.build.json and lets pnpm typecheck see tests. Five errors appeared immediately, all the same one:

src/with-cache.test.ts(45,39): error TS2345: Argument of type '(_paths: string[], primaryKey: string, _restoreKeys?: string[] | undefined) => Promise<string | undefined>' is not assignable to parameter of type '(paths: string[], primaryKey: string, restoreKeys?: string[] | undefined) => Promise<string>'.

The root cause is in production code, not the tests. CacheDelegate.restoreCache was typed => Promise<string>, but @actions/cache resolves undefined on a cache miss, and with-cache.ts already handles that (if (restoredKey) { ... } else { core.warning('No cache found') }). The real delegate escaped the check only because cache ?? realCache infers a union rather than checking assignability. Widened the interface to match reality:

export interface CacheDelegate {
  // Mirrors @actions/cache: a miss resolves to undefined
  restoreCache: (
    paths: string[],
    primaryKey: string,
    restoreKeys?: string[]
  ) => Promise<string | undefined>

Type-only; emits no JavaScript.

Coverage

Baseline with the gate in place but no new tests: 48.27% lines, 62.12% branches, 53.19% functions, 48.27% statements — all four below 70%. inputs.ts, hash-project.ts, and hie.ts were at 0%.

Rather than lower the threshold, added src/inputs.test.ts (5 tests), src/hash-project.test.ts (1), src/hie.test.ts (5), and 9 tests to src/stack-cli.test.ts covering installed, install, upgrade, which, path, query, read, and the $STACK_YAML config fallback. Test count went 44 -> 65.

 % Coverage report from v8
-------------------|---------|----------|---------|---------|-------------------
File               | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files          |   84.82 |    84.84 |   91.48 |   84.82 |
 dirty-files.ts    |   41.66 |    42.85 |      70 |   41.66 | 17-49
 stack-yaml.ts     |   83.33 |       75 |   83.33 |   83.33 | 14-15
 with-cache.ts     |      76 |    77.27 |     100 |      76 | 38-40,49-52,57
-------------------|---------|----------|---------|---------|-------------------
Statements   : 84.82% ( 123/145 )
Branches     : 84.84% ( 56/66 )
Functions    : 91.48% ( 43/47 )
Lines        : 84.82% ( 123/145 )

src/main.ts is excluded from the metric, as in the template. Here the justification is stronger than the template's "thin wiring": main.ts drives real stack invocations, and .github/workflows/example.yml already runs it end to end across three runners and four resolvers.

Prettier

This repo had no .prettierrc at all — format-check existed as a script but ran against Prettier's defaults and was never wired into CI. Adopted the template's config, which reformatted all 20 source and test files (double quotes -> single, semicolons removed, printWidth 100, bracketSpacing: false). Done with pnpm format, not by hand.

One option was not carried forward. The template's .prettierrc includes jsxBracketSameLine, which Prettier 3 renamed to bracketSameLine; 3.9.6 does not recognize the old name:

$ pnpm exec prettier --support-info | ...
jsxBracketSameLine: false
bracketSameLine: true

Renamed it. The value is false either way, so no formatting changes as a result. The template itself still carries the dead key — worth fixing upstream.

Reformatting does not affect the build: the compiled-output diff above is clean apart from the three export lines.

Release workflow

Both release.yml and .releaserc.yaml already existed and follow the template's mechanism — cycjimmy/semantic-release-action, git tags plus semantic-release-major-tag for the @v5 pointer, and @semantic-release/git pushing the rebuilt dist/index.js back. No npm publish anywhere, as expected for an Action. Two reconciliations:

  • .releaserc.yaml's @semantic-release/exec prepareCmd was yarn install && yarn run build, which the pnpm migration would have broken. Now pnpm install && pnpm run build.
  • release.yml pins conventional-changelog-conventionalcommits@9 with the template's explanatory comment. Unpinned, extra_plugins would resolve v10, which needs conventional-changelog-writer@9 that semantic-release@25 does not ship.

The template's if: false release guard is a placeholder for generated repos and is correctly absent here.

Follow-up fix (post-review): switching prepareCmd to pnpm above wasn't enough on its own — release.yml's release job never installed pnpm, so the pnpm install && pnpm run build step would have failed the first time this job actually ran (GitHub-hosted ubuntu-latest doesn't ship pnpm the way it ships yarn). release.yml only triggers on push to main/rc/*, never on pull_request, so this PR's green CI never exercised that path — an independent review caught it. Added the same pnpm/action-setup@v6 + actions/setup-node@v7 steps ci.yml already has.

lodash

Not applicable. This repo has no lodash dependency:

$ grep -c lodash package.json
0

Its runtime dependencies are @actions/cache, @actions/core, @actions/exec, @actions/glob, js-yaml, and shellwords-ts. None were touched.

Dependencies removed

pnpm knip flagged three devDependencies and four exports. Each was genuinely dead:

  • @types/js-yaml@4.0.9js-yaml@5.4.1 ships its own types ("types": "./dist/js-yaml.d.ts" in its package.json). Removed; typecheck still passes.
  • ts-node@10.9.2 — no reference anywhere in src/, config, or workflows.
  • action-docs@2.5.1 — the readme script invokes it as npx action-docs, which is exactly what the template does without declaring it.
  • ALL_SOURCES_PATTERNS, BUILD_FILES_PATTERNS, HIE_YAML, and the Compiler type were exported but imported nowhere. Made module-internal.

Divergences from typescript-action-template

Every remaining difference, and why it stays:

  1. Runtime dependencies. This repo ships @actions/cache, @actions/exec, @actions/glob, js-yaml, and shellwords-ts; the template ships @actions/core and @actions/github. The template's @actions/github, @octokit/plugin-rest-endpoint-methods, and @octokit/types exist to demo a GitHub API call. This action makes none, so they are not added.

  2. No integration job in ci.yml. The template's job downloads dist and runs a bare uses: ./. Here that would invoke stack build with no Haskell project at the repo root and fail. .github/workflows/example.yml is this repo's integration suite and is considerably more thorough: 3 runners x 4 resolvers against example/, a generate-matrix sub-action job, a stack.yaml matrix job with a symlink guard, and a should-fail job asserting a bad resolver fails the action. Its dist job moved to pnpm.

  3. example.yml's dist job pins working-directory: .. The workflow sets defaults.run.working-directory: example for all jobs, which the later stack-yaml symlink check depends on. yarn walks up to find package.json; pnpm does not, and errors with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND. A job-level override is the smallest correct fix and leaves the other jobs alone.

  4. generate-matrix/action.yml has no template counterpart. It is a composite sub-action with no TypeScript and no build target, so nothing in the bundle applies to it. Left as is.

  5. .github/CODEOWNERS is @freckle/team-student-activities, not the template's @freckle/team-platform. This is a deliberate ownership reassignment across this batch of repos, decided independently of the template. Not template matching; the divergence is the intent.

  6. .prettierrc uses bracketSameLine where the template uses jsxBracketSameLine. Covered above — the template's key is not a recognized Prettier 3.9.6 option.

  7. vitest.config.js's coverage-exclusion comment differs. The template says main.ts is "thin wiring, covered by the integration CI job"; here it points at .github/workflows/example.yml, which is the workflow that actually covers it.

  8. release.yml has no if: false. The template's guard exists so generated repos do not release before they are ready. This repo releases.

  9. No ARCHITECTURE.md. The template's predates PR #555 and is not part of this bundle. This repo documents itself in a long README instead.

  10. package.json metadata. name, description, and repository are this repo's. main was dropped, matching the template — nothing read it, since build hardcodes lib/main.js and the action entry point is action.yml's runs.main.

Now aligned with the template and previously not: .nvmrc (24.20.0, was already correct), renovate.json (already identical), .restyled.yaml (!dist/index.js -> !dist/**/*), and mergeabot.yml (@v3 -> @v3.2.0).

Verification

All from a clean state: rm -rf node_modules lib coverage && pnpm install --frozen-lockfile, on Node 24.20.0 (.nvmrc) with pnpm 11.24.0.

$ pnpm install --frozen-lockfile
+ prettier 3.9.6
+ typescript 6.0.3
+ typescript-eslint 8.69.0
+ vitest 4.1.11
Done in 3.6s using pnpm v11.24.0

$ pnpm format-check
Checking formatting...
All matched files use Prettier code style!

$ pnpm typecheck
$ tsc --noEmit
(no output)

$ pnpm lint
$ eslint
(no output)

$ pnpm knip
$ knip
(no output)

$ pnpm coverage
 Test Files  11 passed (11)
      Tests  65 passed (65)
Statements   : 84.82% ( 123/145 )
Branches     : 84.84% ( 56/66 )
Functions    : 91.48% ( 43/47 )
Lines        : 84.82% ( 123/145 )

$ pnpm build
ncc: Version 0.45.0
ncc: Compiling file index.js into ESM
   0kB  dist/package.json
1729kB  dist/index.js
1729kB  [2603ms] - ncc 0.45.0

$ git diff --stat -- dist
(no output — dist/ is up to date and the build is deterministic)

Integration smoke test of the built bundle, standing in for the template's integration job:

$ env -i PATH=... INPUT_INSTALL-STACK=false INPUT_UPGRADE-STACK=false ... node dist/index.js
::group::Install/upgrade stack
::endgroup::
::error::Error: The executable stack is not present on $PATH%0AMake sure it is installed in a preceding step, or use%0A`install-stack: true` to have it installed for you.
::error::The executable stack is not present on $PATH%0AMake sure it is installed in a preceding step, or use%0A`install-stack: true` to have it installed for you.
EXIT=1

Byte-identical to the same invocation against the pre-change dist/index.js. The fuller fake-stack harness diff is in the dist section above.

.github/workflows/example.yml will run the real end-to-end suite on this PR; that is the check to watch before merging.

Ports the bundle from freckle/typescript-action-template#555: pnpm,
ESLint flat config, knip, a 70% coverage gate, format-check in CI, an
ES2023/NodeNext tsconfig with a build/typecheck split, and the house
Prettier config.

The action's own behavior is unchanged. action.yml is untouched, so the
inputs and outputs freckle/stack-cache-action delegates to still exist.
dist/index.js is now minified; the built bundle was diffed against the
previous one under a fake-stack harness and produces byte-identical
output, and the compiled lib/ differs only by three dropped `export`
keywords on module-internal constants.

Deliberate deviations from a straight template copy:

- .github/CODEOWNERS moves to @freckle/team-student-activities. This is
  an ownership reassignment across a batch of repos, not template
  matching; the template's own CODEOWNERS says @freckle/team-platform.
- .prettierrc drops jsxBracketSameLine. Prettier 3 renamed it to
  bracketSameLine and 3.9.6 does not recognize the old name
  (`prettier --support-info` confirms), so the template's copy is dead
  config rather than something to carry forward.
- ci.yml gains no `integration` job. The template's bare `uses: ./`
  would fail here, since there is no Haskell project at the repo root.
  example.yml already runs the built action end to end across three
  runners and four resolvers; its dist job moves to pnpm.

typescript rolls back from ^7.0.2 to the template's ^6.0.3, since
typescript-eslint hard-errors on 7.x.

Fixes surfaced by the new tooling rather than ported:

- CacheDelegate.restoreCache returned Promise<string>, but
  @actions/cache resolves undefined on a miss and with-cache.ts already
  handles that. typecheck now covers test files and caught it.
- Removes @types/js-yaml (js-yaml 5.4.1 bundles its own types),
  ts-node (unreferenced), and action-docs (invoked via npx, as the
  template does). knip flagged all three.
- Drops `export` from ALL_SOURCES_PATTERNS, BUILD_FILES_PATTERNS,
  HIE_YAML, and the Compiler type; nothing imports them.

Adds tests for inputs.ts, hash-project.ts, and hie.ts (all at 0%) and
extends stack-cli.test.ts, taking coverage from 48.27% to 84.82% lines
so the 70% gate passes on merit rather than a lowered threshold.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@joris974
joris974 marked this pull request as ready for review September 2, 2026 21:59
@joris974
joris974 requested a review from a team as a code owner September 2, 2026 21:59
@joris974
joris974 requested review from z0isch and removed request for a team and z0isch September 2, 2026 21:59
@joris974
joris974 marked this pull request as draft September 2, 2026 22:20
…uilds dist

Release runs on GitHub-hosted ubuntu-latest, which does not ship pnpm.
.releaserc.yaml's prepareCmd (pnpm install && pnpm run build) would fail
the first time this job actually runs, since release.yml never triggers
on pull_request and so this path isn't covered by this PR's green CI.
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