diff --git a/actions/setup/js/checkout_dynamic.cjs b/actions/setup/js/checkout_dynamic.cjs index a2be99c670b..e61a6926f3a 100644 --- a/actions/setup/js/checkout_dynamic.cjs +++ b/actions/setup/js/checkout_dynamic.cjs @@ -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 {} @@ -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) { @@ -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; } diff --git a/actions/setup/js/checkout_dynamic.test.cjs b/actions/setup/js/checkout_dynamic.test.cjs index 8581d6a0548..4240d9e8bf4 100644 --- a/actions/setup/js/checkout_dynamic.test.cjs +++ b/actions/setup/js/checkout_dynamic.test.cjs @@ -63,6 +63,7 @@ describe("runtime repository checkout", () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); fs.rmSync(temp, { recursive: true, force: true }); }); @@ -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"); @@ -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"); }); @@ -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 => { @@ -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 () => {