Skip to content

Commit 5c6c7ef

Browse files
authored
feat: migrate action to composite workflow (#117)
1 parent 961eb02 commit 5c6c7ef

47 files changed

Lines changed: 1898 additions & 150701 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/CI.yml

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,20 @@ on:
55
pull_request:
66
paths-ignore:
77
- "**.md"
8-
- dist/**
98
- __tests__/dotnet/**
109
push:
1110
branches:
1211
- main
1312
paths-ignore:
1413
- "**.md"
15-
- dist/**
1614
- __tests__/dotnet/**
15+
1716
jobs:
18-
build:
19-
name: Build
17+
check:
18+
name: Lint & Test
2019
runs-on: ubuntu-latest
2120
steps:
2221
- uses: actions/checkout@v4
23-
with:
24-
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
2522

2623
- name: Set up pnpm
2724
uses: pnpm/action-setup@v6
@@ -34,37 +31,8 @@ jobs:
3431

3532
- run: pnpm install
3633

37-
- run: |
38-
pnpm all
39-
40-
- name: Compare the expected and actual dist/ directories
41-
id: diff
42-
run: |
43-
if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then
44-
echo "Detected uncommitted changes after build. See status below:"
45-
git diff
46-
echo "diff=true" >> $GITHUB_ENV
47-
else
48-
echo "diff=false" >> $GITHUB_ENV
49-
fi
34+
- name: Lint & format check
35+
run: pnpm run format-check
5036

51-
- name: commit build
52-
id: commit
53-
if: ${{ env.diff == 'true' }}
54-
run: |
55-
git config --local user.email "maxisam@gmail.com"
56-
git config --local user.name "maxisam"
57-
git add dist/
58-
git commit -m "chore: build, dist updated"
59-
git push origin ${{ github.event.pull_request.head.ref || github.ref_name }}
60-
sha=$(git rev-parse HEAD)
61-
echo "sha=$sha" >> $GITHUB_OUTPUT
62-
63-
- name: Status update
64-
if: ${{ env.diff == 'true' }}
65-
uses: myrotvorets/set-commit-status-action@v2.0.0
66-
with:
67-
status: "success"
68-
sha: ${{ steps.commit.outputs.sha }}
69-
description: "Build Dist Updated"
70-
context: "Build Dist Updated"
37+
- name: Test
38+
run: pnpm test

MIGRATION.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Migration Plan — Bundled JS Action → Hybrid Composite Action
2+
3+
## Goal
4+
5+
Replace the bundled TypeScript GitHub Action (compiled with `ncc` into a committed
6+
`dist/index.js`) with a **hybrid composite action**. The win we're after is killing
7+
the build step and the committed bundle, shrinking the dependency surface, and making
8+
what runs match what you read in `action.yml`.
9+
10+
## Why hybrid (not pure shell)
11+
12+
Most of the action is orchestration around two CLIs (`dotnet format`, `jscpd`) plus
13+
GitHub plumbing — all of which is *simpler* as composite steps. But two parts are
14+
error-prone in bash:
15+
16+
1. **Config merging** — a 3-way deep merge (code defaults → root config → workspace
17+
config) over JSON **and** YAML, with array de-duplication and the
18+
`isEnabled ?? isEabled ?? default` fallback plus simple-vs-granular precedence.
19+
2. **`--include`/`--exclude` list building** — space-joined argument lists are a
20+
classic bash quoting hazard.
21+
22+
Those two stay in small, unit-tested JavaScript helpers. Everything else becomes
23+
shell or [`actions/github-script`](https://github.com/actions/github-script).
24+
25+
## Three kinds of steps
26+
27+
| Kind | Used for |
28+
|---|---|
29+
| **shell** | run the CLIs: `dotnet format`, `jscpd`, `git`, nuget restore |
30+
| **`actions/github-script`** | anything touching the GitHub API or `@actions/core`: changed-files lookup, PR comments, job summary, outputs, annotations, problem matcher |
31+
| **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 |
32+
33+
`actions/github-script` hands you a pre-authenticated `github` (octokit), `context`,
34+
`core`, `glob`, `io`, and `exec` with **no build and no committed bundle** — removing
35+
almost the entire reason `dist/` exists today.
36+
37+
## Architecture / step sequence
38+
39+
`action.yml` becomes `runs.using: "composite"`, same `inputs` and `outputs`. Data flows
40+
between steps via a normalized JSON file in `$RUNNER_TEMP` (resolved config + ready-to-run
41+
arg arrays) and small step outputs for scalars.
42+
43+
> **Gotcha baked in everywhere:** composite steps don't auto-expose `inputs` to the
44+
> step body. Every shell/github-script step receives what it needs via `env:`, and
45+
> github-script auth uses `github-token: ${{ inputs.authToken }}`.
46+
47+
1. setup env + `::add-matcher::` (shell)
48+
2. resolve config → `$RUNNER_TEMP/df-config.json` (github-script → `read-config.mjs` + `format-args.mjs`)
49+
3. changed files, if `onlyChangedFiles` + PR event (github-script)
50+
4. `dotnet format` per enabled block (shell, consumes config json)
51+
5. format report → markdown → summary + PR comment (github-script → `dotnet-report.mjs`)
52+
6. `actions/upload-artifact@v7.0.1` (dotnet report)
53+
7. commit & push with rebase-retry + `hasChanges` output (shell)
54+
8. `::remove-matcher::` (shell)
55+
9. jscpd run (shell, PATH-or-`npx jscpd@5`)
56+
10. jscpd report → threshold → annotations → comment → summary + `hasDuplicates` (github-script → `jscpd-report.mjs`)
57+
11. `actions/upload-artifact@v7.0.1` (jscpd report)
58+
12. `failFast` gate (shell)
59+
60+
Note: artifact upload is the one thing github-script can't do (no `@actions/artifact`
61+
bundled), so it uses the standard `upload-artifact` action — simpler than the current
62+
`DefaultArtifactClient` code anyway.
63+
64+
## Task list (commit after each)
65+
66+
### Phase 0 — scaffold
67+
- **T0**: Worktree `../dotnet-format-plus-composite` on branch `feat/composite-action`. ✅
68+
69+
### Phase 1 — tested helpers (port logic 1:1) ✅
70+
- **T1** ✅: `scripts/merge.mjs` (deep-merge + dedupe, replaces `deepmerge`) +
71+
`scripts/read-config.mjs` — port `readConfig` 3-way merge (defaults → root → workspace),
72+
JSON+YAML (parser injected), array-dedup. `isEnabled ?? isEabled ?? default` +
73+
simple-vs-granular precedence live in `format-args.mjs`.
74+
- **T2** ✅: `scripts/format-args.mjs` — port `buildArgs` (esp. `--include`/`--exclude`
75+
list joining) so the shell step never does bash quoting, plus `buildDefaultOptions`,
76+
`finalizeEnabled`, `checkIsDryRun`, and a `planFormat` convenience emitting ready-to-run
77+
`dotnet` argv arrays + `isDryRun`.
78+
- **T3** ✅: `scripts/report-common.mjs` (shared footer) + `scripts/dotnet-report.mjs` +
79+
`scripts/jscpd-report.mjs` — port JSON→markdown with the GitHub blob links (context
80+
injected, not read from `@actions/github`).
81+
- **T4** ✅: Ported the existing `node:test` suite onto these helpers (config-merge
82+
/ arg / report coverage). YAML via inline `js-yaml` parse or `npx -y js-yaml` conversion —
83+
no repo `node_modules` needed at action runtime.
84+
85+
### Phase 2 — composite skeleton ✅
86+
- **T5** ✅: Rewrite `action.yml` → composite, same inputs/outputs; ship `problem-matcher.json`
87+
at action root; matcher add/remove via workflow commands.
88+
89+
### Phase 3 — dotnet format path ✅
90+
- **T6** ✅: config-resolve step → temp JSON.
91+
- **T7** ✅: changed-files github-script step.
92+
- **T8** ✅: format runner (shell loop over blocks).
93+
- **T9** ✅: report→summary→comment github-script step (reuse existing-comment-by-header +
94+
bot-user matching).
95+
- **T10** ✅: `upload-artifact` (dotnet).
96+
- **T11** ✅: commit/push shell step + `hasChanges`.
97+
98+
### Phase 4 — jscpd path ✅
99+
- **T12** ✅: jscpd runner (shell).
100+
- **T13** ✅: jscpd report github-script step (threshold, annotations, comment, summary,
101+
`hasDuplicates`).
102+
- **T14** ✅: `upload-artifact` (jscpd) plus cleanup after upload.
103+
104+
### Phase 5 — failFast + teardown of the build pipeline ✅
105+
- **T15** ✅: `failFast` gate.
106+
- **T16** ✅: Delete `dist/`, drop `package`/`ncc`/`finalize-dist`, remove `@actions/*`,
107+
`@octokit/rest`, `deepmerge` from deps (keep only what helpers/tests need).
108+
`package.json` shrinks to lint + test.
109+
- **T17** ✅: Update CI: drop the dist-sync check; run biome + helper tests only.
110+
111+
### Phase 6 — docs & end-to-end ✅
112+
- **T18** ✅: Rewrite README (no build step) + WALKTHROUGH for the composite migration.
113+
- **T19** ✅: Local verification completed (`pnpm install --frozen-lockfile`, `pnpm run
114+
format-check`, `pnpm test`, `pnpm all`, `git diff --check`, action metadata parse).
115+
GitHub-hosted end-to-end confirmation remains the release gate: run
116+
`test-dotnet-format.yml` (both fixture jobs) and verify PR comment, summary,
117+
`hasChanges`/`hasDuplicates`, and annotations.
118+
119+
## Parity-risk checklist (things that can silently diverge)
120+
121+
- 3-way deep merge + array dedup + `isEabled` fallback (T1) — highest risk; the tests in
122+
T4 exist to pin it.
123+
- `--include` with changed-files list quoting — handled by keeping it in JS (T2).
124+
- Existing-comment matching: header `startsWith` + `user.type === 'Bot'` / login (T9/T13).
125+
- Non-PR events (push): comments skipped, summary still written.
126+
- Outputs are exactly the strings `"true"` / `"false"`.
127+
128+
## Net result
129+
130+
No bundler, no committed `dist/`, dependency surface drops from 8 runtime deps to ~1,
131+
and the only "real code" left is ~4 small tested helpers.

README.md

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,19 @@ Yet another dotnet format. It combines dotnet format with jscpd to provide a sin
1616

1717
## Requirements
1818

19-
- **Node 24** runtime — the action runs on `node24` (handled automatically by GitHub).
19+
This is a **composite action** — it orchestrates shell steps and
20+
[`actions/github-script`](https://github.com/actions/github-script), so there is no
21+
bundled JavaScript to build or ship.
22+
2023
- **.NET SDK** must be available on the runner (e.g. via `actions/setup-dotnet`). The
21-
bundled example/tests target **.NET 10**.
24+
included example fixtures target **.NET 10**.
25+
- **GitHub-hosted runners** provide everything else it needs (`bash`, `jq`, `git`,
26+
Node/`npx`). On self-hosted runners make sure those are present.
2227
- **jscpd 5** — when `jscpdCheck` is enabled, the action uses a `jscpd`/`cpd` binary found
2328
on `PATH`, otherwise it fetches it on demand with `npx --yes jscpd@5` (a one-time download).
2429
To avoid the download, install jscpd on the runner (`npm i -g jscpd`).
30+
- **YAML config** (`.dotnet-format.yml` / `.jscpd.yml`) is parsed on demand via
31+
`npx -y js-yaml`; JSON config needs nothing extra.
2532

2633
> Note on config keys: the granular `options`/`styleOptions`/`analyzersOptions`/`whitespaceOptions`
2734
> blocks are toggled with `isEnabled`. The historical misspelling `isEabled` is still accepted for
@@ -53,25 +60,39 @@ example:
5360

5461
## Development
5562

56-
This is a TypeScript GitHub Action bundled with [`ncc`](https://github.com/vercel/ncc) into
57-
`dist/`. The toolchain is modern and dependency-light:
63+
This is a **composite action**: [`action.yml`](./action.yml) wires together shell steps
64+
(which run `dotnet format`, `jscpd`, and `git`) and
65+
[`actions/github-script`](https://github.com/actions/github-script) steps (which call the
66+
GitHub API and `@actions/core`). The error-prone pure logic — config merge, `dotnet format`
67+
argument building, and report→markdown — lives in small, dependency-free ES modules under
68+
`scripts/` that the github-script steps load with a dynamic `import()`. **There is no build
69+
step and nothing is bundled or committed to `dist/`.**
70+
71+
Toolchain:
5872

59-
- **Node 24** (ESM, `"type": "module"`)
6073
- **[pnpm](https://pnpm.io/)** as the package manager (`packageManager` field; CI uses `pnpm/action-setup`)
6174
- **[Biome](https://biomejs.dev/)** for linting + formatting (`biome.json`)
62-
- **`node:test`** (Node's built-in runner) for tests — no Jest
75+
- **`node:test`** (Node's built-in runner) for the helper unit tests — no Jest, no transpile
6376

6477
```bash
6578
pnpm install
66-
pnpm build # tsc --noEmit typecheck
67-
pnpm lint # biome lint
68-
pnpm test # node --test
69-
pnpm package # ncc build -> dist/index.js
70-
pnpm all # everything CI runs (build, format-check, package, test, finalize dist)
79+
pnpm lint # biome lint
80+
pnpm format-check # biome check (lint + format + import order)
81+
pnpm test # node --test (unit tests for scripts/)
82+
pnpm all # everything CI runs (biome check + node --test)
7183
```
7284

73-
The committed `dist/` must stay in sync with the source — run `pnpm all` and commit `dist/`
74-
before pushing. See [WALKTHROUGH.md](./WALKTHROUGH.md) for the design rationale and migration notes.
85+
Layout:
86+
87+
- `scripts/*.mjs` — pure, dependency-free helpers (merge, config read, format-args, report markdown), unit-tested
88+
- `scripts/steps/*.mjs` — thin github-script wrappers (resolve-config, format-report, jscpd-report, comment)
89+
- `__tests__/*.test.mjs``node:test` coverage for the pure helpers
90+
- `__tests__/dotnet/**``.NET 10` fixtures for the end-to-end workflow
91+
- `problem-matcher.json` — referenced by `action.yml` via `${{ github.action_path }}`
92+
93+
End-to-end behavior is exercised by `.github/workflows/test-dotnet-format.yml` against the
94+
`net10.0` fixtures. See [WALKTHROUGH.md](./WALKTHROUGH.md) for the design rationale and
95+
[MIGRATION.md](./MIGRATION.md) for the migration plan.
7596

7697
## Acknowledgements
7798

0 commit comments

Comments
 (0)