Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions .github/workflows/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,20 +295,21 @@ 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,
ANNOTATE_SCRIPT: concat(expr("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"`,
Expand All @@ -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
Expand Down Expand Up @@ -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: [
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/ci.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,15 @@ 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({
name: "Check formatting again",
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", {
Expand All @@ -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"`,
],
},
{
Expand All @@ -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"),
Expand All @@ -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"`,
],
},
),
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -228,14 +234,15 @@ runs:
}
echo "hash=$(hash_cache_dir)" >> "$GITHUB_OUTPUT"
- name: Check formatting
id: check
shell: bash
env:
CONFIG_PATH: "${{ inputs.config-path }}"
ANNOTATIONS: "${{ inputs.annotations }}"
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"
Expand Down
42 changes: 33 additions & 9 deletions scripts/annotate.mjs
Original file line number Diff line number Diff line change
@@ -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 <path to the newline delimited json output>
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";

Expand Down Expand Up @@ -34,32 +36,34 @@ 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) {
const suffix = entries.length === 1 ? "file" : "files";
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) {
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 38 additions & 3 deletions scripts/annotate_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[], runnerOs = "Linux") {
async function runAnnotate(workspace: string, entries: Record<string, unknown>[], env: Record<string, string> = {}) {
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"),
};
}

Expand Down Expand Up @@ -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<<DELIMITER\nsrc/bad.md\nbad.json\nDELIMITER\n",
);
} finally {
await Deno.remove(workspace, { recursive: true });
}
});

Deno.test("records the outputs without annotations when they're disabled", async () => {
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<<DELIMITER\nbad.json\nDELIMITER\n");
} finally {
await Deno.remove(workspace, { recursive: true });
}
Expand Down Expand Up @@ -225,7 +259,7 @@ Deno.test("recommends only running on linux when a windows checkout has crlf lin
// the config wants crlf, so the runner's checkout isn't the problem
file: `${workspace}/lf.md`,
diff: "--- original\n+++ formatted\n@@ -1 +1 @@\n-a\n+a\r\n",
}], "Windows");
}], { RUNNER_OS: "Windows" });
assertEquals(result.stderr, "");
assertEquals(result.code, 0);
assertStringIncludes(
Expand Down Expand Up @@ -308,6 +342,7 @@ Deno.test("prints nothing for empty output", async () => {
const result = await runAnnotate(workspace, []);
assertEquals(result.code, 0);
assertEquals(result.stdout, "");
assertEquals(result.outputs, "unformatted-count=0\nunformatted-files<<DELIMITER\nDELIMITER\n");
} finally {
await Deno.remove(workspace, { recursive: true });
}
Expand Down
Loading