Composite Action Walkthrough
June 27, 2026 ยท View on GitHub
This document explains the current dotnet-format-plus architecture, the migration
away from the bundled TypeScript action, and the checks used to keep behavior aligned.
Why this changed
The old action shipped a compiled dist/index.js bundle produced by ncc. That made
the repository carry both source and generated JavaScript, plus a runtime dependency
set for the GitHub Actions toolkit, artifact client, Octokit, config merge, and YAML.
Most of this action is orchestration around existing command-line tools:
dotnet formatjscpdgit- GitHub PR comments, annotations, summaries, and outputs
GitHub composite actions are a better fit for that shape. The current action puts the
workflow in action.yml, uses shell for CLI calls, and uses
actions/github-script for GitHub API and @actions/core operations. The remaining
logic that would be risky in shell stays in small ES modules under scripts/.
Runtime Shape
action.yml is the product. There is no build step, no src/ tree, and no committed
dist/ bundle.
The action has three kinds of code:
- Shell steps run
dotnet restore,dotnet format,jscpd, cleanup, and git commit/push. actions/github-scriptsteps resolve config, list changed PR files, post reports, set summaries, emit annotations, and set outputs.scripts/*.mjshelpers hold pure logic: deep merge, config path resolution,dotnet formatargv planning, report markdown, and annotation payloads.
The github-script wrappers in scripts/steps/*.mjs are intentionally thin. They adapt
GitHub-provided objects (github, context, core, exec) and environment inputs
into calls to the pure helpers.
Dotnet Format Flow
The dotnet path is split into a planning step and shell execution.
scripts/steps/resolve-config.mjsreads action inputs from environment variables.- It merges defaults, root config, and workspace config through
scripts/read-config.mjs. - It optionally lists changed PR files through the GitHub API.
scripts/format-args.mjsturns the merged config into ready-to-rundotnetargv arrays.- The plan is written to
$RUNNER_TEMP/df-config.json. - The shell step runs each planned
dotnet formatcommand and writes format status to outputs. scripts/steps/format-report.mjsconverts non-empty report JSON files into markdown, writes the job summary, and creates or updates a PR comment.- The commit step removes report files, sets
hasChanges, and optionally commits and pushes fixes for pull requests.
The shell step treats formatter findings separately from runner failures. Formatting
findings are gated by failFast; missing SDKs, invalid workspaces, restore failures,
or crashes fail the action directly.
JSCPD Flow
The jscpd path stays close to the previous behavior but runs through the CLI.
- The shell step resolves the scan path from
workspace. - It chooses an existing
jscpdorcpdbinary fromPATH, otherwise falls back tonpx --yes jscpd@5. - It runs
jscpdwithjson,markdown,console-fullreporters into the configured artifact directory. scripts/steps/jscpd-report.mjsreadsjscpd-report.json, merges threshold config, writes the markdown summary/comment, emits annotations, setshasDuplicates, and fails whenjscpdCheckAsErrorand the threshold is exceeded.- The artifact is uploaded and the report directory is removed so later workflow steps see a clean workspace.
The action still ignores the jscpd CLI's threshold exit code and evaluates the JSON report itself. That preserves the previous contract.
Config Behavior
Config precedence is:
- action-derived defaults
- config path from the repository root
- same config filename inside
workspace
Arrays are concatenated and de-duplicated while preserving first occurrence order.
JSON config is parsed directly. YAML config is converted on demand by yq when it is
on PATH (GitHub-hosted runners ship it), otherwise a pinned npx -y js-yaml@4.1.0
fallback โ which keeps the action runtime dependency-free.
Format blocks are toggled with the isEnabled key. The legacy misspelled isEabled
key from older versions is no longer supported (breaking change).
Repository Layout
action.ymlis the composite action entrypoint.problem-matcher.jsonis referenced directly fromaction.yml.scripts/*.mjscontains pure helper logic.scripts/steps/*.mjscontains github-script wrappers.__tests__/*.test.mjscovers helper behavior with Node's built-in test runner.__tests__/dotnet/**contains .NET 10 fixtures for the end-to-end workflow..github/workflows/test-dotnet-format.ymlexercises the action against those fixtures.
Local Checks
Run the same checks as CI:
pnpm install --frozen-lockfile
pnpm run format-check
pnpm test
pnpm all
Useful extra checks:
git diff --check origin/main...HEAD
node -e "import('js-yaml').then(y=>{const fs=require('fs'); const doc=y.load(fs.readFileSync('action.yml','utf8')); console.log(doc.runs.using, doc.runs.steps.length, Object.keys(doc.outputs));})"
End-to-end behavior requires GitHub Actions because the composite action uses
actions/github-script, artifact upload, workflow commands, PR context, and
GITHUB_OUTPUT/GITHUB_ENV. The workflow .github/workflows/test-dotnet-format.yml
runs both fixture jobs with actions/setup-dotnet@v4 and .NET 10.
Release Checklist
pnpm install --frozen-lockfilepnpm run format-checkpnpm testpnpm allgit diff --check origin/main...HEAD- Run
.github/workflows/test-dotnet-format.ymlin GitHub Actions for both fixture jobs. - Confirm PR comments, summaries, annotations,
hasChanges, andhasDuplicatesmatch the expected behavior.