diff --git a/.github/workflows/action.ts b/.github/workflows/action.ts index 597b54d..ae99305 100755 --- a/.github/workflows/action.ts +++ b/.github/workflows/action.ts @@ -295,12 +295,13 @@ const hashCacheBefore = step({ outputs: ["hash"] as const, }).dependsOn(restoreCache); -// with annotations enabled, the check runs with --json and the output is -// turned into readable diffs and annotations by a script; dprint before 0.57 -// rejects --json with exit code 10 (argument parsing error), in which case -// the check just runs again without it +// the check runs with --json and the output is turned into readable diffs, +// annotations and the step outputs by a script; dprint before 0.57 rejects +// --json with exit code 10 (argument parsing error), in which case the check +// just runs again without it const check = step({ name: "Check formatting", + id: "check", env: { CONFIG_PATH: configPath, ANNOTATIONS: inputs.annotations, @@ -308,7 +309,7 @@ const check = step({ }, run: [ `args=(\${CONFIG_PATH:+--config "$CONFIG_PATH"} ${inputs.args})`, - `if [ "$ANNOTATIONS" = "true" ] && command -v node > /dev/null; then`, + `if command -v node > /dev/null; then`, ` output="$RUNNER_TEMP/dprint-check.jsonl"`, ` set +e`, ` ~/.dprint/bin/dprint check --json "\${args[@]}" > "$output" 2> "$output.stderr"`, @@ -322,6 +323,7 @@ const check = step({ `fi`, `~/.dprint/bin/dprint check "\${args[@]}"`, ], + outputs: ["unformatted-count", "unformatted-files"] as const, }).dependsOn(install).comesAfter(hashCacheBefore); // runs even when the check fails so the compiled plugins and the incremental @@ -377,6 +379,14 @@ action({ description: "Whether the check changed the cache and so a new cache entry was saved", value: hashCacheAfter.outputs.changed, }, + "unformatted-count": { + description: "The number of files that aren't formatted (requires dprint 0.57+)", + value: check.outputs["unformatted-count"], + }, + "unformatted-files": { + description: "The files that aren't formatted, one per line (requires dprint 0.57+)", + value: check.outputs["unformatted-files"], + }, }, defaults: { run: { shell: "bash" } }, steps: [ diff --git a/.github/workflows/ci.generated.yml b/.github/workflows/ci.generated.yml index 79d818b..30fc0c8 100644 --- a/.github/workflows/ci.generated.yml +++ b/.github/workflows/ci.generated.yml @@ -111,12 +111,16 @@ jobs: with: cache: true config-path: cache-test.json - - name: Verify the check failed and the cache was saved + - name: "Verify the check failed, reported the file and the cache was saved" env: CACHE_CHANGED: "${{ steps.prime.outputs.cache-changed }}" + UNFORMATTED_COUNT: "${{ steps.prime.outputs.unformatted-count }}" + UNFORMATTED_FILES: "${{ steps.prime.outputs.unformatted-files }}" run: |- test "${{ steps.prime.outcome }}" = "failure" test "$CACHE_CHANGED" = "true" + test "$UNFORMATTED_COUNT" = "1" + test "$UNFORMATTED_FILES" = "poorly-formatted.json" - name: Remove the poorly-formatted file run: rm poorly-formatted.json - name: Check formatting again @@ -129,12 +133,16 @@ jobs: env: MATCHED_KEY: "${{ steps.hit.outputs.cache-matched-key }}" CACHE_CHANGED: "${{ steps.hit.outputs.cache-changed }}" + UNFORMATTED_COUNT: "${{ steps.hit.outputs.unformatted-count }}" + UNFORMATTED_FILES: "${{ steps.hit.outputs.unformatted-files }}" EXPECTED_KEY: 'dprint-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles(''cache-test.json'') }}-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }}' run: |- echo "matched key: $MATCHED_KEY" echo "expected: $EXPECTED_KEY" test "$MATCHED_KEY" = "$EXPECTED_KEY" test "$CACHE_CHANGED" = "false" + test "$UNFORMATTED_COUNT" = "0" + test -z "$UNFORMATTED_FILES" lint: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci.ts b/.github/workflows/ci.ts index 20a446f..1f9ebca 100755 --- a/.github/workflows/ci.ts +++ b/.github/workflows/ci.ts @@ -133,7 +133,7 @@ const cachePrimeCheck = step({ uses: "./", continueOnError: true, with: { cache: true, "config-path": cacheTestConfig }, - outputs: ["cache-changed"] as const, + outputs: ["cache-changed", "unformatted-count", "unformatted-files"] as const, }); const cacheHitCheck = step({ @@ -141,7 +141,7 @@ const cacheHitCheck = step({ id: "hit", uses: "./", with: { cache: true, "config-path": cacheTestConfig }, - outputs: ["cache-matched-key", "cache-changed"] as const, + outputs: ["cache-matched-key", "cache-changed", "unformatted-count", "unformatted-files"] as const, }); const cacheJob = job("cache", { @@ -155,11 +155,17 @@ const cacheJob = job("cache", { }, cachePrimeCheck, { - name: "Verify the check failed and the cache was saved", - env: { CACHE_CHANGED: cachePrimeCheck.outputs["cache-changed"] }, + name: "Verify the check failed, reported the file and the cache was saved", + env: { + CACHE_CHANGED: cachePrimeCheck.outputs["cache-changed"], + UNFORMATTED_COUNT: cachePrimeCheck.outputs["unformatted-count"], + UNFORMATTED_FILES: cachePrimeCheck.outputs["unformatted-files"], + }, run: [ `test "${expr("steps.prime.outcome")}" = "failure"`, `test "$CACHE_CHANGED" = "true"`, + `test "$UNFORMATTED_COUNT" = "1"`, + `test "$UNFORMATTED_FILES" = "poorly-formatted.json"`, ], }, { @@ -172,6 +178,8 @@ const cacheJob = job("cache", { env: { MATCHED_KEY: cacheHitCheck.outputs["cache-matched-key"], CACHE_CHANGED: cacheHitCheck.outputs["cache-changed"], + UNFORMATTED_COUNT: cacheHitCheck.outputs["unformatted-count"], + UNFORMATTED_FILES: cacheHitCheck.outputs["unformatted-files"], EXPECTED_KEY: concat( "dprint-cache-", expr("runner.os"), @@ -191,6 +199,8 @@ const cacheJob = job("cache", { `test "$MATCHED_KEY" = "$EXPECTED_KEY"`, // nothing new was checked, so the restored cache is left as-is `test "$CACHE_CHANGED" = "false"`, + `test "$UNFORMATTED_COUNT" = "0"`, + `test -z "$UNFORMATTED_FILES"`, ], }, ), diff --git a/README.md b/README.md index b478cc3..a61a17f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ This caches: | `dprint-version` | The version of dprint that was installed | | `cache-matched-key` | Key of the cache entry that was restored, if any | | `cache-changed` | Whether the check changed the cache and so a new cache entry was saved | +| `unformatted-count` | The number of files that aren't formatted | +| `unformatted-files` | The files that aren't formatted, one per line | ## Troubleshooting diff --git a/action.yml b/action.yml index 9d6ee30..d0d93f0 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,12 @@ outputs: cache-changed: description: Whether the check changed the cache and so a new cache entry was saved value: "${{ steps.cache-after.outputs.changed }}" + unformatted-count: + description: The number of files that aren't formatted (requires dprint 0.57+) + value: "${{ steps.check.outputs.unformatted-count }}" + unformatted-files: + description: 'The files that aren''t formatted, one per line (requires dprint 0.57+)' + value: "${{ steps.check.outputs.unformatted-files }}" runs: using: composite steps: @@ -228,6 +234,7 @@ runs: } echo "hash=$(hash_cache_dir)" >> "$GITHUB_OUTPUT" - name: Check formatting + id: check shell: bash env: CONFIG_PATH: "${{ inputs.config-path }}" @@ -235,7 +242,7 @@ runs: ANNOTATE_SCRIPT: "${{ github.action_path }}/scripts/annotate.mjs" run: |- args=(${CONFIG_PATH:+--config "$CONFIG_PATH"} ${{ inputs.args }}) - if [ "$ANNOTATIONS" = "true" ] && command -v node > /dev/null; then + if command -v node > /dev/null; then output="$RUNNER_TEMP/dprint-check.jsonl" set +e ~/.dprint/bin/dprint check --json "${args[@]}" > "$output" 2> "$output.stderr" diff --git a/scripts/annotate.mjs b/scripts/annotate.mjs index dab34c9..2361351 100644 --- a/scripts/annotate.mjs +++ b/scripts/annotate.mjs @@ -1,7 +1,9 @@ -// Prints the output of `dprint check --json` in a readable form and emits a -// GitHub Actions error annotation for each file that isn't formatted. +// Prints the output of `dprint check --json` in a readable form, emits a +// GitHub Actions error annotation for each file that isn't formatted (unless +// ANNOTATIONS=false) and records the files as step outputs. // // Usage: node annotate.mjs +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -34,25 +36,25 @@ const entries = fs.readFileSync(jsonlPath, "utf8") } }); +const relativePaths = entries.map((entry) => toRelativePath(entry.file, workspace)); +const annotationsEnabled = process.env.ANNOTATIONS !== "false"; // github ignores the annotations past its limit, so when there are too many // files the last annotation lists the ones that wouldn't be shown instead const annotatedCount = entries.length > MAX_ANNOTATIONS ? MAX_ANNOTATIONS - 1 : entries.length; entries.forEach((entry, index) => { - const relativePath = toRelativePath(entry.file, workspace); const changes = entry.diff == null ? undefined : parseChanges(entry.diff); // a diff that only changes line endings is every line of the file, so // summarize it like dprint's default output does const lineEndings = changes == null ? undefined : getLineEndingsOnlyChange(changes); - console.log(`from ${relativePath}:`); + console.log(`from ${relativePaths[index]}:`); console.log(describeDiff(entry.diff, lineEndings)); console.log("--"); - if (index < annotatedCount) { - console.log(annotation(relativePath, changes, lineEndings)); + if (annotationsEnabled && index < annotatedCount) { + console.log(annotation(relativePaths[index], changes, lineEndings)); } }); -if (annotatedCount < entries.length) { - const remainingPaths = entries.slice(annotatedCount).map((entry) => toRelativePath(entry.file, workspace)); - console.log(remainingFilesAnnotation(remainingPaths)); +if (annotationsEnabled && annotatedCount < entries.length) { + console.log(remainingFilesAnnotation(relativePaths.slice(annotatedCount))); } if (entries.length > 0) { @@ -60,6 +62,8 @@ if (entries.length > 0) { console.log(`Found ${entries.length} not formatted ${suffix}. Run dprint fmt to fix.`); } +writeOutputs(relativePaths); + /** Gets the diff in a readable form, or a short message when there's nothing useful to show. */ function describeDiff(diff, lineEndings) { if (diff == null) { @@ -151,6 +155,26 @@ function truncatedMessage(text, whatWasTruncated) { return `${text}\n(truncated, see the log for the full ${whatWasTruncated})`; } +/** Records the files that aren't formatted as step outputs when running in GitHub Actions. */ +function writeOutputs(relativePaths) { + const outputPath = process.env.GITHUB_OUTPUT; + if (outputPath == null) { + return; + } + // a multiline value ends at a delimiter, so use one no path can contain + const delimiter = `dprint-check-${randomUUID()}`; + fs.appendFileSync( + outputPath, + [ + `unformatted-count=${relativePaths.length}`, + `unformatted-files<<${delimiter}`, + ...relativePaths, + delimiter, + "", + ].join("\n"), + ); +} + function formatChange(change) { const header = `@@ -${hunkRange(change.oldStart, change.oldCount)} +${ hunkRange(change.newStart, change.newCount) diff --git a/scripts/annotate_test.ts b/scripts/annotate_test.ts index 922ebf7..336765d 100644 --- a/scripts/annotate_test.ts +++ b/scripts/annotate_test.ts @@ -3,17 +3,21 @@ import { fromFileUrl } from "jsr:@std/path@1"; const scriptPath = fromFileUrl(new URL("./annotate.mjs", import.meta.url)); -async function runAnnotate(workspace: string, entries: Record[], runnerOs = "Linux") { +async function runAnnotate(workspace: string, entries: Record[], env: Record = {}) { const jsonlPath = `${workspace}/dprint-check.jsonl`; await Deno.writeTextFile(jsonlPath, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + const outputsPath = `${workspace}/github-output`; + await Deno.writeTextFile(outputsPath, ""); const output = await new Deno.Command("node", { args: [scriptPath, jsonlPath], - env: { GITHUB_WORKSPACE: workspace, RUNNER_OS: runnerOs }, + env: { GITHUB_WORKSPACE: workspace, GITHUB_OUTPUT: outputsPath, RUNNER_OS: "Linux", ...env }, }).output(); return { code: output.code, stdout: new TextDecoder().decode(output.stdout), stderr: new TextDecoder().decode(output.stderr), + // the multiline delimiter is random, so replace it for comparison + outputs: (await Deno.readTextFile(outputsPath)).replaceAll(/dprint-check-[0-9a-f-]+/g, "DELIMITER"), }; } @@ -53,6 +57,36 @@ Deno.test("annotates each file at the first change and prints the diffs", async "Found 2 not formatted files. Run dprint fmt to fix.", "", ]); + assertEquals( + result.outputs, + "unformatted-count=2\nunformatted-files< { + const workspace = await Deno.makeTempDir(); + try { + const result = await runAnnotate(workspace, [{ + file: `${workspace}/bad.json`, + diff: "--- original\n+++ formatted\n@@ -1 +1 @@\n-{\"a\":1}\n+{ \"a\": 1 }\n", + }], { ANNOTATIONS: "false" }); + assertEquals(result.stderr, ""); + assertEquals(result.code, 0); + assertEquals(result.stdout.split("\n"), [ + "from bad.json:", + "--- original", + "+++ formatted", + "@@ -1 +1 @@", + "-{\"a\":1}", + "+{ \"a\": 1 }", + "--", + "Found 1 not formatted file. Run dprint fmt to fix.", + "", + ]); + assertEquals(result.outputs, "unformatted-count=1\nunformatted-files< { const result = await runAnnotate(workspace, []); assertEquals(result.code, 0); assertEquals(result.stdout, ""); + assertEquals(result.outputs, "unformatted-count=0\nunformatted-files<