Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4e22b94
Support expression-valued dynamic checkouts
Copilot Sep 24, 2026
22e30b8
Harden dynamic checkout runtime handling
Copilot Sep 24, 2026
faa0782
Reject secrets. references in dynamic checkout expressions
Copilot Sep 24, 2026
3c1e797
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 24, 2026
0250e45
Harden dynamic checkout configuration
Copilot Sep 24, 2026
a40339d
Clarify dynamic checkout allowlist errors
Copilot Sep 24, 2026
801b499
Validate deferred dynamic checkout errors
Copilot Sep 24, 2026
faa71e2
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 24, 2026
53fad24
Polish dynamic checkout validation
Copilot Sep 24, 2026
55c159a
Allow dynamic wiki checkout lists
Copilot Sep 24, 2026
5e57e8d
Harden dynamic checkout docs and paths
Copilot Sep 24, 2026
e90d8f6
Polish dynamic checkout path validation
Copilot Sep 24, 2026
7062938
Prepare dynamic checkout review follow-up
Copilot Sep 24, 2026
1a90bef
Revert unrelated generated workflow change
Copilot Sep 24, 2026
2e9f6b7
Regenerate workflow locks after checkout changes
Copilot Sep 24, 2026
b948232
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 24, 2026
81577d7
Document dynamic checkout runtime modules
Copilot Sep 25, 2026
2b17a5e
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 25, 2026
3a3800e
docs: add dynamic checkout sets to spec and review skill
Copilot Sep 25, 2026
fab70be
Rename dynamic checkout field to repos
Copilot Sep 25, 2026
e517a96
Polish dynamic checkout repos validation
Copilot Sep 25, 2026
e5648f1
Document dynamic checkout field migration
Copilot Sep 25, 2026
05ba9a8
Clarify dynamic checkout migration error
Copilot Sep 25, 2026
553d2a1
Name unsupported dynamic checkout fields
Copilot Sep 25, 2026
3ce28a7
Stabilize dynamic checkout field errors
Copilot Sep 25, 2026
f38643f
Clarify dynamic checkout field list errors
Copilot Sep 25, 2026
b832d3d
Refine dynamic checkout diagnostics
Copilot Sep 25, 2026
88c6d55
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 25, 2026
fd68ce6
Clarify static vs dynamic checkout syntax
Copilot Sep 25, 2026
e82a8d7
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 25, 2026
5fbbffb
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 26, 2026
b13bdf4
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 26, 2026
de9f545
Merge main into dynamic checkouts branch
Copilot Sep 26, 2026
bfae6b1
Resolve checkout documentation merge conflicts
Copilot Sep 26, 2026
2b83bd4
Harden dynamic checkout ref, repository, sparse-checkout, and server …
Copilot Sep 26, 2026
c580198
Merge branch 'main' into copilot/dynamic-checkouts-github-action
Copilot Sep 26, 2026
a3202ef
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 27, 2026
1247780
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 27, 2026
1ce527c
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 27, 2026
b5015e2
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 28, 2026
c7e114f
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 28, 2026
6c95d54
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 28, 2026
80e0b93
Merge branch 'main' into copilot/dynamic-checkouts-github-action
github-actions[bot] Sep 28, 2026
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
12 changes: 11 additions & 1 deletion actions/setup/js/build_checkout_manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,17 @@ function buildCheckoutManifest(entries, options = {}) {
throw new Error(`Failed to create directory ${manifestDir}: ${getErrorMessage(err)}`, { cause: err });
}
const manifestPath = path.join(manifestDir, "checkout-manifest.json");
const manifest = {};
let manifest = {};
try {
if (fs.existsSync(manifestPath)) {
const existing = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
manifest = existing;
}
}
} catch (error) {
core.debug(`checkout-manifest: ignoring unreadable existing manifest: ${getErrorMessage(error)}`);
}
core.info(`checkout-manifest: building manifest for ${entries.length} checkout entries`);

for (const entry of entries) {
Expand Down
18 changes: 18 additions & 0 deletions actions/setup/js/build_checkout_manifest.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,22 @@ describe("build_checkout_manifest.cjs", () => {
const fileContents = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
expect(fileContents).toEqual(manifest);
});

it("preserves dynamic entries already written to the manifest", () => {
const workspace = createTempDir("checkout-manifest-workspace-");
const runnerTemp = createTempDir("checkout-manifest-runner-temp-");
tempDirs.push(workspace, runnerTemp);
const manifestDir = path.join(runnerTemp, "gh-aw", "safeoutputs");
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(path.join(manifestDir, "checkout-manifest.json"), JSON.stringify({ "owner/dynamic": { repository: "owner/dynamic", path: "dynamic", default_branch: "main" } }));

const { manifest } = buildCheckoutManifest([{ repository: "owner/static", path: "static" }], {
workspace,
runnerTemp,
runGH: () => "main\n",
});

expect(manifest).toHaveProperty("owner/dynamic");
expect(manifest).toHaveProperty("owner/static");
});
});
251 changes: 251 additions & 0 deletions actions/setup/js/dynamic_checkouts.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
// @ts-check

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Smoke test review comment #2: Nice addition of dynamic_checkouts.cjs! Adding a brief header comment explaining the module contract (inputs/outputs) would help future maintainers understand the dynamic checkout resolution flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a module contract header in actions/setup/js/dynamic_checkouts.cjs describing the environment inputs, validation/runtime checkout work, and checkout-manifest output. Addressed in 81577d7.

/// <reference types="@actions/github-script" />

require("./shim.cjs");

const fs = require("fs");
const path = require("path");
const { getErrorMessage } = require("./error_helpers.cjs");

const supportedFields = new Set(["repository", "ref", "path", "github-token", "token", "fetch-depth", "sparse-checkout", "submodules", "lfs", "wiki"]);

function parseDynamicCheckouts(value = process.env.GH_AW_DYNAMIC_CHECKOUTS || "") {
if (!value.trim()) {
return [];
}
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error(`checkout expression must resolve to a JSON object or array: ${getErrorMessage(error)}`, { cause: error });
}
const entries = Array.isArray(parsed) ? parsed : [parsed];
if (entries.some(entry => !entry || typeof entry !== "object" || Array.isArray(entry))) {
throw new Error("checkout expression must resolve to a checkout object or an array of checkout objects");
}
return entries;
}

function normalizeCheckout(entry, workspace) {
for (const field of Object.keys(entry)) {
if (!supportedFields.has(field)) {
throw new Error(`dynamic checkout field '${field}' is not supported`);
}
}

let repository = String(entry.repository || "").trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(`dynamic checkout repository must use owner/repo format, got '${repository}'`);
}
if (entry.lfs !== undefined && typeof entry.lfs !== "boolean") {
throw new Error("dynamic checkout lfs must be a boolean");
}
if (entry.wiki !== undefined && typeof entry.wiki !== "boolean") {
throw new Error("dynamic checkout wiki must be a boolean");
}

let checkoutPath = entry.path === undefined ? repository.split("/")[1] : String(entry.path).trim();
checkoutPath = checkoutPath.replaceAll("\\", "/").replace(/^\.\//, "");
if (!checkoutPath || path.isAbsolute(checkoutPath) || checkoutPath.split("/").includes("..")) {
throw new Error(`dynamic checkout path must be a non-empty relative path under the workspace, got '${checkoutPath}'`);
}

const fetchDepth = entry["fetch-depth"] === undefined ? 1 : Number(entry["fetch-depth"]);
if (!Number.isInteger(fetchDepth) || fetchDepth < 0) {
throw new Error("dynamic checkout fetch-depth must be a non-negative integer");
}
if (entry.submodules !== undefined && !["true", "false", "recursive", true, false].includes(entry.submodules)) {
throw new Error("dynamic checkout submodules must be true, false, or recursive");
}

if (entry.wiki === true) {
repository += ".wiki";
}
return {
repository,
ref: String(entry.ref || "").trim(),
path: checkoutPath,
token: String(entry["github-token"] || entry.token || ""),
fetchDepth,
sparseCheckout: String(entry["sparse-checkout"] || ""),
submodules: entry.submodules,
lfs: entry.lfs === true,
};
}

async function defaultRunGit(args, options = {}) {
const result = await exec.getExecOutput("git", args, { silent: true, ...options });
if (result.exitCode !== 0) {
throw new Error("git command failed while preparing a dynamic checkout");
}
return result.stdout.trim();
}

function credentialArgs(serverURL, token, maskSecret = value => core.setSecret(value)) {
if (!token) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L85: stdlib: manual key-validation loop with Set membership. Object.keys(entry).every(k => supportedFields.has(k)) with one failure branch, fewer control lines.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change for this style-only simplification: the current explicit loop keeps the unsupported field name available for the precise validation error.

return [];
}
maskSecret(token);
const encoded = Buffer.from(`x-access-token:${token}`).toString("base64");
maskSecret(encoded);
return ["-c", `http.${serverURL}/.extraheader=AUTHORIZATION: basic ${encoded}`];
}

async function checkoutRepository(checkout, options = {}) {
const workspace = options.workspace || process.env.GITHUB_WORKSPACE || "";
const serverURL = (options.serverURL || process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "");
const token = checkout.token || options.overrideToken || process.env.GH_TOKEN || "";
const persistCredentials = options.persistCredentials === true;
const runGit = options.runGit || defaultRunGit;

let workspaceReal;
let checkoutTarget;
try {
workspaceReal = fs.realpathSync(workspace);
checkoutTarget = path.join(workspaceReal, checkout.path);
if (fs.existsSync(checkoutTarget)) {
throw new Error(`dynamic checkout path already exists: ${checkout.path}`);
}

let parent = workspaceReal;
const parentSegments = path
.dirname(checkout.path)
.split(path.sep)
.filter(segment => segment && segment !== ".");
for (const segment of parentSegments) {
const candidate = path.join(parent, segment);
if (fs.existsSync(candidate)) {
if (fs.lstatSync(candidate).isSymbolicLink()) {
throw new Error(`dynamic checkout path traverses a symbolic link: ${checkout.path}`);
}
} else {
fs.mkdirSync(candidate);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L123: shrink: duplicated token alias handling (github-token and token) every normalize call. Resolve once (const token = entry["github-token"] ?? entry.token ?? "") and stringify once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change for this style-only simplification: the current normalization keeps the token aliases close to the returned checkout shape without changing behavior.

parent = fs.realpathSync(candidate);
if (parent !== workspaceReal && !parent.startsWith(workspaceReal + path.sep)) {
throw new Error(`dynamic checkout path escapes the workspace: ${checkout.path}`);
}
}
Comment on lines +186 to +213

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5e57e8d and polished in e90d8f6: the final checkout path component is now inspected with lstatSync before clone and rejected if it is a symlink, with a regression test covering a dangling symlink target.

} catch (error) {
throw new Error(`failed to prepare dynamic checkout path '${checkout.path}': ${getErrorMessage(error)}`, { cause: error });
}

const authArgs = credentialArgs(serverURL, token, options.maskSecret);
const cloneArgs = [...authArgs, "clone", "--no-tags"];
if (checkout.fetchDepth > 0) {
cloneArgs.push("--depth", String(checkout.fetchDepth));
}
cloneArgs.push(`${serverURL}/${checkout.repository}.git`, checkoutTarget);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] lfs: false (the default) doesn't stop git clone itself from running the locally-installed LFS smudge filter during clone/checkout, so LFS objects can still be downloaded even when the user opted out — the actual checkout.lfs gate only adds an extra git lfs pull on top. This mirrors the existing unresolved Copilot review comment on this line.

💡 Suggested fix

Pass GIT_LFS_SKIP_SMUDGE=1 in the clone/fetch/checkout env when checkout.lfs is false, and only unset it (or run git lfs pull) when lfs: true:

const gitEnv = checkout.lfs ? undefined : { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" };
await runGit(cloneArgs, { env: gitEnv });

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in 801b499: clone, checkout, sparse-checkout, and submodule worktree updates now run with GIT_LFS_SKIP_SMUDGE=1, and LFS objects are fetched only in the explicit lfs: true branch.


core.info(`Checking out ${checkout.repository} into ${checkout.path}`);
await runGit(cloneArgs);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dynamic checkout clone, checkout, sparse checkout, and submodule operations now disable LFS smudging; only the explicit LFS branch pulls objects. Addressed in 801b499.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified addressed in 801b499: clone, checkout, sparse-checkout, and submodule operations run with GIT_LFS_SKIP_SMUDGE=1, and LFS objects are fetched only by the explicit lfs: true pull path. Local validation passes.

if (checkout.ref) {
const fetchArgs = [...authArgs, "-C", checkoutTarget, "fetch", "--no-tags"];
if (checkout.fetchDepth > 0) {
fetchArgs.push("--depth", String(checkout.fetchDepth));
}
fetchArgs.push("origin", checkout.ref);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] checkout.ref is spliced into git fetch args after origin with no -- separator, so a ref value beginning with - (e.g. --upload-pack=...) is parsed as an option rather than a literal refname — the same root cause GHAS already flagged on this line. normalizeCheckout doesn't reject leading-dash refs either.

💡 Suggested fix
fetchArgs.push("origin", "--", checkout.ref);

Add a regression test with a ref like --upload-pack=touch /tmp/pwned; to confirm it's now rejected/treated literally.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current branch: checkout.ref is rejected when it starts with -, and the fetch command uses origin -- <ref> so runtime values cannot be parsed as Git options.

await runGit(fetchArgs);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current branch: dynamic checkout refs are rejected when they start with -, git fetch passes refs after --, sparse-checkout patterns are rejected when option-like, and sparse patterns are passed after --.

await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"]);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}

if (checkout.sparseCheckout.trim()) {
const patterns = checkout.sparseCheckout
.split(/\r?\n/)
.map(pattern => pattern.trim())
.filter(Boolean);
await runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", ...patterns]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sparse-checkout patterns are now validated and passed after Git's option terminator. Addressed in 801b499.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified addressed in 801b499/2b83bd4: sparse-checkout patterns are validated and passed after Git’s -- option terminator. Local validation (make fmt, make lint, make test-unit, make test, make recompile) passes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current branch: dynamic checkout refs are validated against leading - and passed to git fetch after --; sparse-checkout patterns are also validated and passed after --.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Same argument-injection class as the git fetch call above: sparse-checkout patterns are appended after --no-cone with no -- terminator, so a pattern beginning with - (e.g. --stdin) is parsed as an option instead of a pattern.

💡 Suggested fix
await runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", "--", ...patterns]);

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current branch: sparse-checkout patterns are validated to reject leading -, and the command now uses sparse-checkout set --no-cone -- ...patterns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current branch: sparse-checkout patterns beginning with - are rejected before invoking Git, and valid patterns are passed after Git's -- option terminator.

}
if (checkout.submodules === true || checkout.submodules === "true" || checkout.submodules === "recursive") {
const args = [...authArgs, "-C", checkoutTarget, "submodule", "update", "--init"];
if (checkout.submodules === "recursive") {
args.push("--recursive");
}
await runGit(args);
}
if (checkout.lfs) {
await runGit([...authArgs, "-C", checkoutTarget, "lfs", "pull"]);
}

const credentialKey = `http.${serverURL}/.extraheader`;
if (persistCredentials && token) {
const encoded = Buffer.from(`x-access-token:${token}`).toString("base64");
await runGit(["-C", checkoutTarget, "config", credentialKey, `AUTHORIZATION: basic ${encoded}`]);
} else {
try {
await runGit(["-C", checkoutTarget, "config", "--unset-all", credentialKey]);
} catch (error) {
core.debug(`No persisted credential needed removal: ${getErrorMessage(error)}`);
}
}

let defaultBranch = "";
try {
defaultBranch = (await runGit(["-C", checkoutTarget, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).replace(/^origin\//, "");
} catch (error) {
core.debug(`Could not resolve dynamic checkout default branch: ${getErrorMessage(error)}`);
}
return {
repository: checkout.repository,
path: checkout.path,
default_branch: defaultBranch,
};
}

function writeManifest(entries, runnerTemp = process.env.RUNNER_TEMP || "") {
if (!runnerTemp) {
throw new Error("RUNNER_TEMP is required for dynamic checkouts");
}
const manifestDir = path.join(runnerTemp, "gh-aw", "safeoutputs");
const manifestPath = path.join(manifestDir, "checkout-manifest.json");
let manifest = {};
try {
fs.mkdirSync(manifestDir, { recursive: true });
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
}
for (const entry of entries) {
manifest[entry.repository.toLowerCase()] = entry;
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
} catch (error) {
throw new Error(`failed to write dynamic checkout manifest: ${getErrorMessage(error)}`, { cause: error });
}
return manifestPath;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L215: yagni: tri-branch submodule mode check (true/"true"/"recursive") plus conditional flag push. const recursive = checkout.submodules === "recursive"; if (checkout.submodules !== false) run once with optional --recursive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change for this style-only simplification: the current explicit submodule handling keeps the accepted true, "true", and "recursive" modes easy to audit alongside the runtime validation.


async function main(options = {}) {
const workspace = options.workspace || process.env.GITHUB_WORKSPACE || "";
if (!workspace) {
throw new Error("GITHUB_WORKSPACE is required for dynamic checkouts");
}
const checkouts = parseDynamicCheckouts(options.value);
const normalized = checkouts.map(entry => normalizeCheckout(entry, workspace));
if (new Set(normalized.map(entry => entry.path.toLowerCase())).size !== normalized.length) {
throw new Error("dynamic checkout paths must be unique");
}

const persistCredentials = options.persistCredentials ?? process.env.GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS === "true";
const overrideToken = options.overrideToken ?? process.env.GH_AW_DYNAMIC_CHECKOUT_TOKEN ?? "";
const manifestEntries = [];
for (const checkout of normalized) {
manifestEntries.push(
await checkoutRepository(checkout, {
...options,
workspace,
persistCredentials,
overrideToken,
})
);
}
writeManifest(manifestEntries, options.runnerTemp);
return manifestEntries;
}

module.exports = {
checkoutRepository,
main,
normalizeCheckout,
parseDynamicCheckouts,
writeManifest,
};
94 changes: 94 additions & 0 deletions actions/setup/js/dynamic_checkouts.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// @ts-check

import { describe, expect, it } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";

const { checkoutRepository, normalizeCheckout, parseDynamicCheckouts, writeManifest } = require("./dynamic_checkouts.cjs");

describe("parseDynamicCheckouts", () => {
it("accepts one object or an array", () => {
expect(parseDynamicCheckouts('{"repository":"owner/repo"}')).toHaveLength(1);
expect(parseDynamicCheckouts('[{"repository":"owner/a"},{"repository":"owner/b"}]')).toHaveLength(2);
});

it("rejects scalar results", () => {
expect(() => parseDynamicCheckouts('"owner/repo"')).toThrow("checkout object or an array");
});
});

describe("normalizeCheckout", () => {
it("derives a safe path and defaults to a shallow checkout", () => {
const checkout = normalizeCheckout({ repository: "owner/repo" }, "/workspace");
expect(checkout.path).toBe("repo");
expect(checkout.fetchDepth).toBe(1);
});

it("rejects traversal and unsupported fields", () => {
expect(() => normalizeCheckout({ repository: "owner/repo", path: "../repo" }, "/workspace")).toThrow("relative path");
expect(() => normalizeCheckout({ repository: "owner/repo", fetch: ["main"] }, "/workspace")).toThrow("field 'fetch' is not supported");
expect(() => normalizeCheckout({ repository: "owner/repo", current: true }, "/workspace")).toThrow("field 'current' is not supported");
});
});

describe("checkoutRepository", () => {
it("uses an ephemeral credential and leaves no credential when persistence is disabled", async () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-"));
const "repo");
const calls = [];
const runGit = async args => {
calls.push(args);
if (args.includes("clone")) {
fs.mkdirSync(path.join(target, ".git"), { recursive: true });
}
if (args.includes("symbolic-ref")) {
return "origin/main";
}
return "";
};

const checkout = normalizeCheckout({ repository: "owner/repo", "github-token": "secret" }, workspace);
const result = await checkoutRepository(checkout, {
workspace,
runGit,
serverURL: "https://github.com",
overrideToken: "fallback-token",
maskSecret: () => {},
});

expect(result.default_branch).toBe("main");
expect(calls[0]).toContain("http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46c2VjcmV0");
expect(calls.some(args => args.includes("--unset-all"))).toBe(true);
});

it("rejects a symlinked parent before creating directories outside the workspace", async () => {
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-workspace-"));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-outside-"));
fs.symlinkSync(outside, path.join(workspace, "linked"));
const checkout = normalizeCheckout({ repository: "owner/repo", path: "linked/nested/repo" }, workspace);

await expect(
checkoutRepository(checkout, {
workspace,
runGit: async () => "",
maskSecret: () => {},
})
).rejects.toThrow("traverses a symbolic link");
expect(fs.existsSync(path.join(outside, "nested"))).toBe(false);
});
});

describe("writeManifest", () => {
it("merges dynamic entries into an existing manifest", () => {
const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-manifest-"));
const manifestDir = path.join(runnerTemp, "gh-aw", "safeoutputs");
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(path.join(manifestDir, "checkout-manifest.json"), JSON.stringify({ "owner/static": { repository: "owner/static", path: "static" } }));

const manifestPath = writeManifest([{ repository: "owner/dynamic", path: "dynamic", default_branch: "main" }], runnerTemp);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
expect(manifest).toHaveProperty("owner/static");
expect(manifest).toHaveProperty("owner/dynamic");
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Good coverage of the happy path, symlink escape, and manifest merge — but there's no test exercising the actual command arrays passed to runGit for the ref fetch or sparse-checkout set calls (e.g. asserting a -- separator is present, or that a leading-dash ref/pattern doesn't get treated as an option). Given the two GHAS/Copilot findings on those exact lines, a regression test would have caught this and should be added before merge.

💡 Suggested test
it("does not let a ref starting with '-' be treated as a git option", async () => {
  const calls = [];
  const runGit = async args => { calls.push(args); return ""; };
  const checkout = normalizeCheckout({ repository: "owner/repo", ref: "--upload-pack=evil" }, workspace);
  await checkoutRepository(checkout, { workspace, runGit, maskSecret: () => {} });
  const fetchCall = calls.find(a => a.includes("fetch"));
  expect(fetchCall.slice(fetchCall.indexOf("origin")+1)).toEqual(["--", "--upload-pack=evil"]);
});

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in 801b499 and preserved in the current tests: dynamic_checkouts.test.cjs now asserts the actual fetch argument terminator and sparse-checkout command handling, plus LFS smudge suppression for worktree-updating commands.

Loading
Loading