Replace the bundled TypeScript GitHub Action (compiled with ncc into a committed
dist/index.js) with a hybrid composite action. The win we're after is killing
the build step and the committed bundle, shrinking the dependency surface, and making
what runs match what you read in action.yml.
Most of the action is orchestration around two CLIs (dotnet format, jscpd) plus
GitHub plumbing — all of which is simpler as composite steps. But two parts are
error-prone in bash:
- Config merging — a 3-way deep merge (code defaults → root config → workspace
config) over JSON and YAML, with array de-duplication and the
isEnabled ?? isEabled ?? defaultfallback plus simple-vs-granular precedence. --include/--excludelist building — space-joined argument lists are a classic bash quoting hazard.
Those two stay in small, unit-tested JavaScript helpers. Everything else becomes
shell or actions/github-script.
| Kind | Used for |
|---|---|
| shell | run the CLIs: dotnet format, jscpd, git, nuget restore |
actions/github-script |
anything touching the GitHub API or @actions/core: changed-files lookup, PR comments, job summary, outputs, annotations, problem matcher |
tested scripts/*.mjs helpers |
the two error-prone pure-logic bits (config merge, report→markdown), require()d from a github-script step so they stay unit-testable |
actions/github-script hands you a pre-authenticated github (octokit), context,
core, glob, io, and exec with no build and no committed bundle — removing
almost the entire reason dist/ exists today.
action.yml becomes runs.using: "composite", same inputs and outputs. Data flows
between steps via a normalized JSON file in $RUNNER_TEMP (resolved config + ready-to-run
arg arrays) and small step outputs for scalars.
Gotcha baked in everywhere: composite steps don't auto-expose
inputsto the step body. Every shell/github-script step receives what it needs viaenv:, and github-script auth usesgithub-token: ${{ inputs.authToken }}.
- setup env +
::add-matcher::(shell) - resolve config →
$RUNNER_TEMP/df-config.json(github-script →read-config.mjs+format-args.mjs) - changed files, if
onlyChangedFiles+ PR event (github-script) dotnet formatper enabled block (shell, consumes config json)- format report → markdown → summary + PR comment (github-script →
dotnet-report.mjs) actions/upload-artifact@v7.0.1(dotnet report)- commit & push with rebase-retry +
hasChangesoutput (shell) ::remove-matcher::(shell)- jscpd run (shell, PATH-or-
npx jscpd@5) - jscpd report → threshold → annotations → comment → summary +
hasDuplicates(github-script →jscpd-report.mjs) actions/upload-artifact@v7.0.1(jscpd report)failFastgate (shell)
Note: artifact upload is the one thing github-script can't do (no @actions/artifact
bundled), so it uses the standard upload-artifact action — simpler than the current
DefaultArtifactClient code anyway.
- T0: Worktree
../dotnet-format-plus-compositeon branchfeat/composite-action. ✅
- T1 ✅:
scripts/merge.mjs(deep-merge + dedupe, replacesdeepmerge) +scripts/read-config.mjs— portreadConfig3-way merge (defaults → root → workspace), JSON+YAML (parser injected), array-dedup.isEnabled ?? isEabled ?? default+ simple-vs-granular precedence live informat-args.mjs. - T2 ✅:
scripts/format-args.mjs— portbuildArgs(esp.--include/--excludelist joining) so the shell step never does bash quoting, plusbuildDefaultOptions,finalizeEnabled,checkIsDryRun, and aplanFormatconvenience emitting ready-to-rundotnetargv arrays +isDryRun. - T3 ✅:
scripts/report-common.mjs(shared footer) +scripts/dotnet-report.mjs+scripts/jscpd-report.mjs— port JSON→markdown with the GitHub blob links (context injected, not read from@actions/github). - T4 ✅: Ported the existing
node:testsuite onto these helpers (config-merge / arg / report coverage). YAML via inlinejs-yamlparse ornpx -y js-yamlconversion — no reponode_modulesneeded at action runtime.
- T5 ✅: Rewrite
action.yml→ composite, same inputs/outputs; shipproblem-matcher.jsonat action root; matcher add/remove via workflow commands.
- T6 ✅: config-resolve step → temp JSON.
- T7 ✅: changed-files github-script step.
- T8 ✅: format runner (shell loop over blocks).
- T9 ✅: report→summary→comment github-script step (reuse existing-comment-by-header + bot-user matching).
- T10 ✅:
upload-artifact(dotnet). - T11 ✅: commit/push shell step +
hasChanges.
- T12 ✅: jscpd runner (shell).
- T13 ✅: jscpd report github-script step (threshold, annotations, comment, summary,
hasDuplicates). - T14 ✅:
upload-artifact(jscpd) plus cleanup after upload.
- T15 ✅:
failFastgate. - T16 ✅: Delete
dist/, droppackage/ncc/finalize-dist, remove@actions/*,@octokit/rest,deepmergefrom deps (keep only what helpers/tests need).package.jsonshrinks to lint + test. - T17 ✅: Update CI: drop the dist-sync check; run biome + helper tests only.
- T18 ✅: Rewrite README (no build step) + WALKTHROUGH for the composite migration.
- T19 ✅: Local verification completed (
pnpm install --frozen-lockfile,pnpm run format-check,pnpm test,pnpm all,git diff --check, action metadata parse). GitHub-hosted end-to-end confirmation remains the release gate: runtest-dotnet-format.yml(both fixture jobs) and verify PR comment, summary,hasChanges/hasDuplicates, and annotations.
- 3-way deep merge + array dedup +
isEabledfallback (T1) — highest risk; the tests in T4 exist to pin it. --includewith changed-files list quoting — handled by keeping it in JS (T2).- Existing-comment matching: header
startsWith+user.type === 'Bot'/ login (T9/T13). - Non-PR events (push): comments skipped, summary still written.
- Outputs are exactly the strings
"true"/"false".
No bundler, no committed dist/, dependency surface drops from 8 runtime deps to ~1,
and the only "real code" left is ~4 small tested helpers.