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
27 changes: 23 additions & 4 deletions actions/setup/js/checkout_dynamic.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const { createInterface } = require("readline");
const { isRepoAllowed } = require("./repo_helpers.cjs");
const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");

const NO_OP_STEP = { shell: "bash", run: "true" };

// Only controlled messages may be propagated to workflow failure issues.
class CheckoutPreparationError extends Error {}

Expand Down Expand Up @@ -223,12 +225,13 @@ async function prepareCheckouts({ github, getOctokit }, token) {
// A composite action must contain at least one step, even for an empty scheduled batch.
try {
fs.mkdirSync(actionDir, { recursive: true });
fs.writeFileSync(path.join(actionDir, ".gitignore"), "*\n");
fs.writeFileSync(
path.join(actionDir, "action.yml"),
JSON.stringify({
name: "Runtime repository checkout",
description: "Checkout prevalidated runtime repositories",
runs: { using: "composite", steps: steps.length ? steps : [{ shell: "bash", run: "true" }] },
runs: { using: "composite", steps: steps.length ? steps : [NO_OP_STEP] },
})
);
} catch (error) {
Expand Down Expand Up @@ -394,10 +397,26 @@ async function finish(options = {}) {
}
const actionDir = workspacePath(config.workspace, `.gh-aw/checkouts/${config.index}`);
try {
fs.unlinkSync(path.join(actionDir, "action.yml"));
fs.rmdirSync(actionDir);
// The runner reloads this composite for checkout post hooks; retain a token-free no-op.
const temporaryDir = fs.mkdtempSync(path.join(actionDir, ".finalize-"));
try {
const temporaryManifest = path.join(temporaryDir, "action.yml");
fs.writeFileSync(
temporaryManifest,
JSON.stringify({
name: "Runtime repository checkout",
description: "Runtime repository checkout finalized",
runs: { using: "composite", steps: [NO_OP_STEP] },
}),
{ flag: "wx" }
);
// Rename atomically to preserve the original on failure without following a manifest symlink.
fs.renameSync(temporaryManifest, path.join(actionDir, "action.yml"));
} finally {
fs.rmSync(temporaryDir, { recursive: true, force: true });
}
} catch (error) {
throw new Error("Unable to remove the temporary runtime checkout action", { cause: error });
throw new Error("Unable to finalize the temporary runtime checkout action", { cause: error });
}
return entries;
}
Expand Down
70 changes: 67 additions & 3 deletions actions/setup/js/checkout_dynamic.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe("runtime repository checkout", () => {
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
fs.rmSync(temp, { recursive: true, force: true });
});
Expand Down Expand Up @@ -93,6 +94,69 @@ describe("runtime repository checkout", () => {
expect(action.runs.steps).toEqual([{ shell: "bash", run: "true" }]);
});

it.each([
["empty", ""],
["single", "org/a"],
["multi", "org/a\norg/b"],
])("keeps a token-free no-op composite available for post-job resolution: %s batch", async (_name, repositories) => {
vi.stubEnv("GH_AW_DYNAMIC_REPOSITORIES", repositories);
const entries = await prepare(runtime);
expect(await finish()).toEqual(entries);
const manifest = fs.readFileSync(path.join(workspace, ".gh-aw/checkouts/0/action.yml"), "utf8");
const action = JSON.parse(manifest);
expect(action.name).toBeTruthy();
expect(action.description).toBeTruthy();
expect(action.runs).toEqual({ using: "composite", steps: [{ shell: "bash", run: "true" }] });
expect(manifest).not.toMatch(/token|GH_AW_DYNAMIC|\$\{\{/i);
});

it.each(["writeFileSync", "renameSync"])("preserves the original manifest when replacement fails in %s", async operation => {
vi.stubEnv("GH_AW_DYNAMIC_REPOSITORIES", "");
await prepare(runtime);
const actionDir = path.join(workspace, ".gh-aw/checkouts/0");
const manifestPath = path.join(actionDir, "action.yml");
const original = fs.readFileSync(manifestPath, "utf8");
vi.spyOn(fs, operation).mockImplementation(() => {
throw new Error("Injected filesystem failure");
});
await expect(finish()).rejects.toThrow("Unable to finalize the temporary runtime checkout action");
expect(fs.readFileSync(manifestPath, "utf8")).toBe(original);
expect(fs.readdirSync(actionDir).sort()).toEqual([".gitignore", "action.yml"]);
});

it("replaces a manifest symlink without modifying its target", async () => {
vi.stubEnv("GH_AW_DYNAMIC_REPOSITORIES", "");
await prepare(runtime);
const manifestPath = path.join(workspace, ".gh-aw/checkouts/0/action.yml");
const outside = path.join(temp, "outside.yml");
fs.writeFileSync(outside, "unchanged");
fs.unlinkSync(manifestPath);
fs.symlinkSync(outside, manifestPath);
await finish();
expect(fs.readFileSync(outside, "utf8")).toBe("unchanged");
expect(fs.lstatSync(manifestPath).isSymbolicLink()).toBe(false);
expect(JSON.parse(fs.readFileSync(manifestPath, "utf8")).runs).toEqual({ using: "composite", steps: [{ shell: "bash", run: "true" }] });
});

it("keeps runner-owned manifests out of staging, cleanup, and untracked stashes", async () => {
const git = args => execFileSync("git", ["-C", workspace, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
git(["init", "--initial-branch=main"]);
git(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "commit", "--allow-empty", "-m", "Fixture"]);
vi.stubEnv("GH_AW_DYNAMIC_REPOSITORIES", "");
await prepare(runtime);
await finish();
const actionDir = path.join(workspace, ".gh-aw/checkouts/0");
git(["add", "-A"]);
expect(git(["diff", "--cached", "--name-only"])).toBe("");
git(["clean", "-fd"]);
const untracked = path.join(workspace, "untracked.txt");
fs.writeFileSync(untracked, "stash me");
git(["stash", "push", "-u"]);
expect(fs.existsSync(untracked)).toBe(false);
expect(fs.existsSync(path.join(actionDir, ".gitignore"))).toBe(true);
expect(JSON.parse(fs.readFileSync(path.join(actionDir, "action.yml"), "utf8")).runs).toEqual({ using: "composite", steps: [{ shell: "bash", run: "true" }] });
});

it.each(["0", "-1", "1.5", "NaN", "Infinity", "9007199254740992", "", " 2 ", "2e1", "2repos", "${{ inputs.max }}"])("rejects invalid max-repos before authenticated reads: %s", async value => {
vi.stubEnv("GH_AW_DYNAMIC_MAX_REPOS", value);
await expect(prepare(runtime)).rejects.toThrow("checkout.max-repos must be a positive integer");
Expand Down Expand Up @@ -364,7 +428,7 @@ describe("runtime repository checkout", () => {
expect(runGit.mock.calls[0][1].env).toEqual(options.env);
expect(runGit.mock.calls[0][1].stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(options.stdio).toEqual(["ignore", "pipe", "pipe"]);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0"))).toBe(false);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0/action.yml"))).toBe(true);
runGit.mockImplementation(() => {
throw new Error("secret-token");
});
Expand Down Expand Up @@ -513,7 +577,7 @@ describe("runtime repository checkout", () => {
expect(fs.readFileSync(path.join(dir, "config"), "utf8")).not.toContain("git-credentials-");
}
for (const include of includes) expect(fs.existsSync(include)).toBe(false);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0"))).toBe(false);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0/action.yml"))).toBe(true);
}, 30000);

it.each(["exit 7", "exit 0"])("fails closed when explicit cleanup returns '%s' but credentials remain", async cleaner => {
Expand Down Expand Up @@ -601,7 +665,7 @@ describe("runtime repository checkout", () => {
await finish();
expect(git(target, ["rev-parse", "origin/feature"])).toBe(git(source, ["rev-parse", "feature"]));
expect(git(target, ["config", "--local", "--list"])).not.toMatch(/promisor|partialclonefilter|extraheader|secret-token/);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0"))).toBe(false);
expect(fs.existsSync(path.join(workspace, ".gh-aw/checkouts/0/action.yml"))).toBe(true);
}, 30000);

it("never fetches repository-controlled .gitmodules remotes during finalization, even with recursive fetch configured", async () => {
Expand Down
Loading