Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Harden dynamic checkout runtime handling
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Sep 24, 2026
commit 22e30b8eb6bcd64bc9f624b2a48d7817d211ccac
58 changes: 33 additions & 25 deletions actions/setup/js/dynamic_checkouts.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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", "current", "wiki"]);
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()) {
Expand Down Expand Up @@ -37,9 +37,6 @@ function normalizeCheckout(entry, workspace) {
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.current !== undefined && typeof entry.current !== "boolean") {
throw new Error("dynamic checkout current must be a boolean");
}
if (entry.lfs !== undefined && typeof entry.lfs !== "boolean") {
throw new Error("dynamic checkout lfs must be a boolean");
}
Expand Down Expand Up @@ -68,13 +65,11 @@ function normalizeCheckout(entry, workspace) {
repository,
ref: String(entry.ref || "").trim(),
path: checkoutPath,
target: path.resolve(workspace, checkoutPath),
token: String(entry["github-token"] || entry.token || ""),
fetchDepth,
sparseCheckout: String(entry["sparse-checkout"] || ""),
submodules: entry.submodules,
lfs: entry.lfs === true,
current: entry.current === true,
};
}

Expand All @@ -99,20 +94,37 @@ function credentialArgs(serverURL, token, maskSecret = value => core.setSecret(v
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 = options.overrideToken || checkout.token || process.env.GH_TOKEN || "";
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);
if (fs.existsSync(checkout.target)) {
checkoutTarget = path.join(workspaceReal, checkout.path);
if (fs.existsSync(checkoutTarget)) {
throw new Error(`dynamic checkout path already exists: ${checkout.path}`);
}
fs.mkdirSync(path.dirname(checkout.target), { recursive: true });
const parentReal = fs.realpathSync(path.dirname(checkout.target));
if (parentReal !== workspaceReal && !parentReal.startsWith(workspaceReal + path.sep)) {
throw new Error(`dynamic checkout path escapes the workspace through a symbolic link: ${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 });
Expand All @@ -123,61 +135,60 @@ async function checkoutRepository(checkout, options = {}) {
if (checkout.fetchDepth > 0) {
cloneArgs.push("--depth", String(checkout.fetchDepth));
}
cloneArgs.push(`${serverURL}/${checkout.repository}.git`, checkout.target);
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);
if (checkout.ref) {
const fetchArgs = [...authArgs, "-C", checkout.target, "fetch", "--no-tags"];
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);
await runGit(["-C", checkout.target, "checkout", "--force", "FETCH_HEAD"]);
await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"]);
}

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

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", checkout.target, "submodule", "update", "--init"];
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", checkout.target, "lfs", "pull"]);
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", checkout.target, "config", credentialKey, `AUTHORIZATION: basic ${encoded}`]);
await runGit(["-C", checkoutTarget, "config", credentialKey, `AUTHORIZATION: basic ${encoded}`]);
} else {
try {
await runGit(["-C", checkout.target, "config", "--unset-all", credentialKey]);
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", checkout.target, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).replace(/^origin\//, "");
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,
current: checkout.current,
};
}

Expand Down Expand Up @@ -210,9 +221,6 @@ async function main(options = {}) {
}
const checkouts = parseDynamicCheckouts(options.value);
const normalized = checkouts.map(entry => normalizeCheckout(entry, workspace));
if (normalized.filter(entry => entry.current).length > 1) {
throw new Error("only one dynamic checkout may set current: true");
}
if (new Set(normalized.map(entry => entry.path.toLowerCase())).size !== normalized.length) {
throw new Error("dynamic checkout paths must be unique");
}
Expand Down
18 changes: 18 additions & 0 deletions actions/setup/js/dynamic_checkouts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("normalizeCheckout", () => {
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");
});
});

Expand All @@ -52,13 +53,30 @@ describe("checkoutRepository", () => {
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", () => {
Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/reference/checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ checkout: ${{ fromJSON(inputs.checkouts) }}
Dynamic entries are checked out in addition to the default workflow repository. Each
entry must set `repository`; `path` defaults to the repository name. Dynamic entries
support `repository`, `ref`, `path`, `github-token` (or `token`), `fetch-depth`,
`sparse-checkout`, `submodules`, `lfs`, `wiki`, and `current`.
`sparse-checkout`, `submodules`, `lfs`, and `wiki`.

The runtime validates repository names, prevents paths from escaping the workspace,
enforces unique paths and at most one `current: true` entry, and removes checkout
credentials before the agent starts. GitHub App authentication and additional `fetch`
patterns remain available only in statically declared checkout entries.
enforces unique paths, and removes checkout credentials before the agent starts. GitHub
App authentication, `current`, and additional `fetch` patterns remain available only in
statically declared checkout entries.

You can also use `checkout:` to check out additional repositories alongside the main repository:

Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/dynamic_checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ func buildDynamicCheckoutsPromptContent(expressions []string) string {
}
return "- **dynamic checkouts**: Additional repositories were selected and checked out at runtime. " +
"Inspect the workspace directories and `$RUNNER_TEMP/gh-aw/safeoutputs/checkout-manifest.json` " +
"to identify their repository names, paths, and current target. These checkouts are shallow and credential-free.\n"
"to identify their repository names and paths. These checkouts are shallow and credential-free.\n"
}
Loading