diff --git a/.changeset/calm-nix-builds.md b/.changeset/calm-nix-builds.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-nix-builds.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/scripts/detect-code-changes.sh b/.github/scripts/detect-code-changes.sh index f832b72e0..45d31b5a1 100755 --- a/.github/scripts/detect-code-changes.sh +++ b/.github/scripts/detect-code-changes.sh @@ -28,19 +28,36 @@ is_docs_only_path() { esac } +is_nix_lock_input_path() { + local path="$1" + + case "$path" in + bun.lock | bunfig.toml | package.json | packages/*/package.json | flake.nix | flake.lock | nix/*) + return 0 + ;; + *) + return 1 + ;; + esac +} + code_changed=false +nix_lock_changed=false while IFS= read -r path; do [[ -z "$path" ]] && continue if ! is_docs_only_path "$path"; then code_changed=true - break + fi + if is_nix_lock_input_path "$path"; then + nix_lock_changed=true fi # Disable rename detection so code-to-docs renames still expose the removed code path. done < <(git diff --name-only --no-renames "$base_sha" "$head_sha") if [[ -n "${GITHUB_OUTPUT:-}" ]]; then echo "code_changed=$code_changed" >> "$GITHUB_OUTPUT" + echo "nix_lock_changed=$nix_lock_changed" >> "$GITHUB_OUTPUT" fi if [[ "$code_changed" == "true" ]]; then diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 1df76dd82..06272f716 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-latest outputs: code: ${{ steps.detect.outputs.code_changed }} + nix_lock: ${{ steps.detect.outputs.nix_lock_changed }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,6 +45,7 @@ jobs: extra-trusted-public-keys = nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= - name: Verify Nix dependency lockfile + if: needs.changes.outputs.nix_lock == 'true' run: | nix run .#update-bun-lock if ! git diff --exit-code nix/bun.lock.nix; then @@ -51,6 +53,8 @@ jobs: exit 1 fi + # The host check realizes bun2nix paths that the following cross-system + # evaluation references, even though that evaluation does not build outputs. - name: Check flake outputs run: nix flake check --print-build-logs diff --git a/scripts/quality/detect-code-changes.test.ts b/scripts/quality/detect-code-changes.test.ts new file mode 100644 index 000000000..123b173ae --- /dev/null +++ b/scripts/quality/detect-code-changes.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const repositoryRoots: string[] = []; +const detectorPath = path.resolve(import.meta.dir, "../../.github/scripts/detect-code-changes.sh"); + +afterEach(() => { + for (const root of repositoryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** Run Git in a temporary repository and fail with its diagnostic output. */ +function runGit(repositoryRoot: string, args: string[]): string { + const result = Bun.spawnSync(["git", ...args], { cwd: repositoryRoot }); + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString() || result.stdout.toString()); + } + return result.stdout.toString().trim(); +} + +/** Evaluate the change detector against files added after an initial commit. */ +function detectAddedPaths(paths: string[]): Record { + const repositoryRoot = mkdtempSync(path.join(tmpdir(), "hunk-detect-code-changes-")); + const outputPath = path.join(repositoryRoot, "github-output.txt"); + repositoryRoots.push(repositoryRoot); + + runGit(repositoryRoot, ["init", "--quiet"]); + runGit(repositoryRoot, ["config", "user.email", "tests@hunk.dev"]); + runGit(repositoryRoot, ["config", "user.name", "Hunk Tests"]); + writeFileSync(path.join(repositoryRoot, ".seed"), "initial\n"); + runGit(repositoryRoot, ["add", ".seed"]); + runGit(repositoryRoot, ["commit", "--quiet", "-m", "initial"]); + const baseSha = runGit(repositoryRoot, ["rev-parse", "HEAD"]); + + for (const changedPath of paths) { + const absolutePath = path.join(repositoryRoot, changedPath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, `${changedPath}\n`); + } + runGit(repositoryRoot, ["add", "."]); + runGit(repositoryRoot, ["commit", "--quiet", "-m", "changes"]); + const headSha = runGit(repositoryRoot, ["rev-parse", "HEAD"]); + + const result = Bun.spawnSync(["bash", detectorPath, baseSha, headSha], { + cwd: repositoryRoot, + env: { ...process.env, GITHUB_OUTPUT: outputPath }, + }); + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString() || result.stdout.toString()); + } + + return Object.fromEntries( + readFileSync(outputPath, "utf8") + .trim() + .split("\n") + .map((line) => line.split("=", 2)), + ); +} + +const nixLockInputs = [ + "bun.lock", + "bunfig.toml", + "package.json", + "packages/hunk/package.json", + "flake.nix", + "flake.lock", + "nix/bun.lock.nix", +]; + +describe("CI code-change detection", () => { + for (const lockInput of nixLockInputs) { + test.skipIf(process.platform === "win32")(`classifies ${lockInput} as a Nix lock input`, () => { + expect(detectAddedPaths([lockInput])).toEqual({ + code_changed: "true", + nix_lock_changed: "true", + }); + }); + } + + test.skipIf(process.platform === "win32")( + "does not classify unrelated code as a lock input", + () => { + expect(detectAddedPaths(["packages/hunk/src/main.tsx"])).toEqual({ + code_changed: "true", + nix_lock_changed: "false", + }); + }, + ); + + test.skipIf(process.platform === "win32")("keeps docs-only changes out of both gates", () => { + expect(detectAddedPaths(["docs/configuration.md"])).toEqual({ + code_changed: "false", + nix_lock_changed: "false", + }); + }); + + test.skipIf(process.platform === "win32")("detects a lock input in a mixed-file change", () => { + expect(detectAddedPaths(["docs/configuration.md", "packages/hunk/package.json"])).toEqual({ + code_changed: "true", + nix_lock_changed: "true", + }); + }); +});