Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.
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
8 changes: 8 additions & 0 deletions .changeset/idempotent-action-builds.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions __test__/integration/fixtures/license-comments/action.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions __test__/integration/fixtures/license-comments/src/main.ts
Original file line number Diff line number Diff line change
@@ -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`);
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const greeting: string;
88 changes: 88 additions & 0 deletions __test__/integration/idempotent-build.int.test.ts
Original file line number Diff line number Diff line change
@@ -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] ?? "<eof>"}\n + ${lb[i] ?? "<eof>"}`);
}
}
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([]);
});
});
51 changes: 51 additions & 0 deletions __test__/integration/license-comments.int.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
33 changes: 14 additions & 19 deletions src/services/build-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, string> = {};
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;
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
Expand Down
Loading