diff --git a/.changeset/idempotent-action-builds.md b/.changeset/idempotent-action-builds.md new file mode 100644 index 0000000..73bd942 --- /dev/null +++ b/.changeset/idempotent-action-builds.md @@ -0,0 +1,8 @@ +--- +"@savvy-web/github-action-builder": patch +--- + +## Bug Fixes + +- Action builds are now reproducible. When `build.ignore` was set, the ignore stub was written to a random `mkdtemp` directory whose absolute path rspack embedded in the bundle as a module id, so building the same source twice produced byte-different output and noisy diffs in committed action code. The stub now lives at a deterministic path under `node_modules/.cache`, so repeated builds are byte-identical. +- Bundled third-party license banners are kept inline instead of being extracted to `*.LICENSE.txt` sidecar files, removing the extra committed file from action output while preserving license attribution. diff --git a/.gitignore b/.gitignore index 602fb6a..f0a3440 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ pnpm-release.json # Claude AI files .claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/cache .claude/plans/ .claude/plugins-debug.log .playwright-mcp diff --git a/__test__/integration/fixtures/license-comments/action.yml b/__test__/integration/fixtures/license-comments/action.yml new file mode 100644 index 0000000..f6284cb --- /dev/null +++ b/__test__/integration/fixtures/license-comments/action.yml @@ -0,0 +1,5 @@ +name: license-comments fixture +description: Fixture for issue #94 — license-comment handling in bundled output. +runs: + using: node24 + main: dist/main.js diff --git a/__test__/integration/fixtures/license-comments/src/main.ts b/__test__/integration/fixtures/license-comments/src/main.ts new file mode 100644 index 0000000..b667e23 --- /dev/null +++ b/__test__/integration/fixtures/license-comments/src/main.ts @@ -0,0 +1,6 @@ +// Imports a bundled dependency that carries a license banner. Minifiers extract +// such `/*! */` banners to `main.js.LICENSE.txt` by default — noise for a +// committed action (issue #94). See vendor/banner-dep.cjs. +import { greeting } from "../vendor/banner-dep.cjs"; + +process.stdout.write(`${greeting}\n`); diff --git a/__test__/integration/fixtures/license-comments/vendor/banner-dep.cjs b/__test__/integration/fixtures/license-comments/vendor/banner-dep.cjs new file mode 100644 index 0000000..d85af73 --- /dev/null +++ b/__test__/integration/fixtures/license-comments/vendor/banner-dep.cjs @@ -0,0 +1,5 @@ +/*! @license MIT | example-licensed-dep v1.2.3 | (c) 2026 Example Corp */ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.greeting = void 0; +exports.greeting = "licensed-bundle-ok"; diff --git a/__test__/integration/fixtures/license-comments/vendor/banner-dep.d.cts b/__test__/integration/fixtures/license-comments/vendor/banner-dep.d.cts new file mode 100644 index 0000000..58ffe05 --- /dev/null +++ b/__test__/integration/fixtures/license-comments/vendor/banner-dep.d.cts @@ -0,0 +1 @@ +export declare const greeting: string; diff --git a/__test__/integration/idempotent-build.int.test.ts b/__test__/integration/idempotent-build.int.test.ts new file mode 100644 index 0000000..7165ee3 --- /dev/null +++ b/__test__/integration/idempotent-build.int.test.ts @@ -0,0 +1,88 @@ +/** + * Integration regression test for issue #94. + * + * Building the same source with the same config twice must produce byte-identical + * output. The compiled action is committed to git, so a changing identifier in the + * bundle creates pure diff noise. The build must also not emit `*.LICENSE.txt` + * sidecar files, which are npm-publishing niceties that are noise for committed + * action code. + * + * `build.ignore` is the trigger: the builder aliases ignored modules to a stub + * file whose path rspack embeds as the module id. A per-build `mkdtemp` directory + * made that path — and therefore the bundle — change on every run. + */ +import { createHash } from "node:crypto"; +import { cpSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { GitHubAction } from "../../src/index.js"; + +const sourceFixture = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/ignore-modules"); + +function buildOnce(cwd: string) { + const action = GitHubAction.create({ + cwd, + skipValidation: true, + config: { + // minify is off so a diff is readable; the embedded stub path is + // non-deterministic regardless of minification. + build: { minify: false, ignore: ["excluded-native-dep"] }, + persistLocal: { enabled: false }, + }, + }); + return action.build(); +} + +function firstDiffingLines(a: string, b: string, max = 6): string { + const la = a.split("\n"); + const lb = b.split("\n"); + const out: string[] = []; + for (let i = 0; i < Math.max(la.length, lb.length) && out.length < max; i++) { + if (la[i] !== lb[i]) { + out.push(`L${i + 1}:\n - ${la[i] ?? ""}\n + ${lb[i] ?? ""}`); + } + } + return out.join("\n"); +} + +describe("issue #94: idempotent builds", () => { + let first: string; + let second: string; + let licenseFiles: string[]; + + beforeAll(async () => { + // Build inside an isolated copy of the fixture so this never races the #81 + // ignore-modules test, which shares the same fixture's dist/. + const work = mkdtempSync(join(tmpdir(), "gha-idempotent-")); + cpSync(sourceFixture, work, { recursive: true }); + const mainJs = join(work, "dist", "main.js"); + + const r1 = await buildOnce(work); + if (!r1.success) { + throw new Error(`build 1 failed: ${r1.error ?? "unknown error"}`); + } + first = readFileSync(mainJs, "utf8"); + licenseFiles = readdirSync(join(work, "dist")).filter((f) => f.endsWith(".LICENSE.txt")); + + const r2 = await buildOnce(work); + if (!r2.success) { + throw new Error(`build 2 failed: ${r2.error ?? "unknown error"}`); + } + second = readFileSync(mainJs, "utf8"); + }, 240_000); + + it("produces byte-identical output across builds", () => { + if (first !== second) { + expect.fail(`main.js differs between builds:\n${firstDiffingLines(first, second)}`); + } + const hash = (s: string) => createHash("sha256").update(s).digest("hex"); + expect(hash(second)).toBe(hash(first)); + }); + + it("does not emit *.LICENSE.txt sidecar files", () => { + expect(licenseFiles).toEqual([]); + }); +}); diff --git a/__test__/integration/license-comments.int.test.ts b/__test__/integration/license-comments.int.test.ts new file mode 100644 index 0000000..2b5f3d3 --- /dev/null +++ b/__test__/integration/license-comments.int.test.ts @@ -0,0 +1,51 @@ +/** + * Integration regression test for issue #94 (license sidecars). + * + * A minified action bundle must not emit `*.LICENSE.txt` sidecar files — they are + * an npm-publishing nicety and pure noise for a committed action. License notices + * from bundled code must instead stay inline so attribution is still preserved. + */ +import { cpSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { GitHubAction } from "../../src/index.js"; + +const sourceFixture = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/license-comments"); + +describe("issue #94: no license sidecar files", () => { + let distFiles: string[]; + let bundle: string; + + beforeAll(async () => { + const work = mkdtempSync(join(tmpdir(), "gha-license-")); + cpSync(sourceFixture, work, { recursive: true }); + + const action = GitHubAction.create({ + cwd: work, + skipValidation: true, + config: { + build: { minify: true }, + persistLocal: { enabled: false }, + }, + }); + const result = await action.build(); + if (!result.success) { + throw new Error(`build failed: ${result.error ?? "unknown error"}`); + } + + const distDir = join(work, "dist"); + distFiles = readdirSync(distDir); + bundle = readFileSync(join(distDir, "main.js"), "utf8"); + }, 240_000); + + it("emits no *.LICENSE.txt sidecar", () => { + expect(distFiles.filter((f) => f.endsWith(".LICENSE.txt"))).toEqual([]); + }); + + it("keeps the license notice inline in the bundle", () => { + expect(bundle).toContain("@license MIT | example-licensed-dep"); + }); +}); diff --git a/src/services/build-live.ts b/src/services/build-live.ts index f6a4669..b828950 100644 --- a/src/services/build-live.ts +++ b/src/services/build-live.ts @@ -3,9 +3,8 @@ * BuildService Layer implementation. * */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; import { createRsbuild } from "@rsbuild/core"; import { Effect, Layer } from "effect"; @@ -124,21 +123,19 @@ function bundleEntry( const outputDir = resolve(cwd, "dist"); // Modules listed in `build.ignore` are aliased to a throwing stub so - // they are neither bundled nor resolved against node_modules. The stub - // lives in a private per-build temp directory created with mkdtemp, so - // a predictable shared path cannot be pre-created or symlinked by - // another process. See + // they are neither bundled nor resolved against node_modules. See // docs/superpowers/specs/2026-05-15-build-ignore-option-design.md. const externalsSet = new Set(config.build.externals); const ignoreSet = new Set(config.build.ignore); const ignoreAlias: Record = {}; - let stubDir: string | undefined; if (config.build.ignore.length > 0) { - stubDir = yield* Effect.try({ - try: () => mkdtempSync(join(tmpdir(), "github-action-builder-")), - catch: (error) => new WriteError({ path: tmpdir(), cause: error }), - }); - const stubPath = resolve(stubDir, "ignore-stub.mjs"); + // rspack embeds the stub's path verbatim as the ignored modules' module + // id, so the path must be deterministic — a per-build `mkdtemp` directory + // made the committed bundle change on every run (#94). This cache path is + // project-local (under node_modules, not a world-writable shared tmp), so + // a fixed name keeps the symlink-safety property the original design (#81) + // relied on while making the output reproducible. + const stubPath = resolve(cwd, "node_modules", ".cache", "github-action-builder", "ignore-stub.mjs"); yield* writeFile(stubPath, IGNORE_STUB_SOURCE); for (const moduleName of config.build.ignore) { ignoreAlias[`${moduleName}$`] = stubPath; @@ -187,6 +184,10 @@ function bundleEntry( return false; }, cleanDistPath: false, + // Keep third-party license banners inline instead of extracting them + // to `*.LICENSE.txt` sidecars (rspack's "linked" behavior), which are + // committed-action noise — while still preserving attribution (#94). + legalComments: "inline", minify: config.build.minify, sourceMap: config.build.sourceMap ? { js: "source-map" as const } : false, }, @@ -237,12 +238,6 @@ function bundleEntry( }), }); - // Remove the private stub directory now the bundle has been written. - if (stubDir !== undefined) { - const dir = stubDir; - yield* Effect.ignore(Effect.try(() => rmSync(dir, { recursive: true, force: true }))); - } - const outputPath = resolve(outputDir, `${entry.type}.js`); const size = yield* Effect.try({ try: () => statSync(outputPath).size,