Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9740272
Add allowlisted runtime repository checkout lists
pelikhan Oct 13, 2026
9b483bb
Enforce runtime checkout allowlists and report rejected repositories
pelikhan Oct 13, 2026
382cb07
Report all runtime checkout preparation failures
pelikhan Oct 13, 2026
847b9a9
Block LFS endpoints from bypassing runtime checkout allowlists
pelikhan Oct 13, 2026
292b7de
Verify dynamic checkout credentials and exempt workflow repository ex…
pelikhan Oct 14, 2026
973fc26
Merge origin/main and preserve runtime checkout failure reporting
pelikhan Oct 14, 2026
cb1d3d5
Merge remote-tracking branch 'origin/main' into pelikhan-checkout-syntax
Copilot Oct 14, 2026
bafe428
Block submodule allowlist bypass for all runtime checkout lists
Copilot Oct 14, 2026
9366956
docs(adr): add draft ADR-68593 for allowlisted runtime repository che…
github-actions[bot] Oct 14, 2026
4741e30
Limit runtime repository checkout lists with max-repos
Copilot Oct 14, 2026
010d030
Address runtime checkout review feedback
pelikhan Oct 14, 2026
7ce3df7
Merge remote-tracking branch 'origin/main' into pelikhan-checkout-syntax
Copilot Oct 14, 2026
dfac0a4
Specify runtime checkout safe-output and formal contracts
pelikhan Oct 14, 2026
feafdd6
Require explicit private runtime checkout opt-in
pelikhan Oct 14, 2026
e6ef50f
Merge main after grouped checkout review
pelikhan Oct 14, 2026
ce773fa
Merge main into runtime checkout branch
pelikhan Oct 14, 2026
99f460f
Merge latest main and pin available Prettier
pelikhan Oct 14, 2026
d4c0803
Merge main after grouped checkout feedback verification
pelikhan Oct 14, 2026
87f14e9
Update workflows and preserve setup-node v7.1.0
pelikhan Oct 14, 2026
31e6560
Pin Vite to the 1ES-available 8.3.1 release
pelikhan Oct 14, 2026
0bbfff5
Pin Node types to the 1ES-available 26.6.3 release
pelikhan Oct 14, 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
11 changes: 11 additions & 0 deletions .changeset/major-runtime-repository-checkout.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 23 additions & 6 deletions actions/setup/js/build_checkout_manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ function readManifestEntriesFromEnv() {
token: process.env[`GH_AW_CHECKOUT_TOKEN_${i}`] || "",
});
}
const dynamicIndexes = process.env.GH_AW_DYNAMIC_CHECKOUT_INDEXES;
if (dynamicIndexes) {
const runnerTemp = process.env.RUNNER_TEMP;
if (!runnerTemp) throw new Error("RUNNER_TEMP is required to read runtime checkout plans");
for (const index of dynamicIndexes.split(",")) {
if (!/^\d+$/.test(index)) throw new Error("Runtime checkout manifest index must be a non-negative integer");
const planPath = path.join(runnerTemp, "gh-aw", "dynamic-checkouts", `${index}.json`);
try {
const plan = parseManifestEntries(fs.readFileSync(planPath, "utf8"));
entries.push(...plan);
} catch (error) {
throw new Error("Unable to read runtime checkouts for the checkout manifest", { cause: error });
}
}
}
return entries;
}

Expand Down Expand Up @@ -136,12 +151,14 @@ function buildCheckoutManifest(entries, options = {}) {
continue;
}
const checkoutPath = String(entry.path || "");
const defaultBranch = resolveDefaultBranch(repository, checkoutPath, {
workspace: options.workspace,
runGit,
runGH,
checkoutToken: entry.token || "",
});
const defaultBranch =
entry.default_branch ||
resolveDefaultBranch(repository, checkoutPath, {
workspace: options.workspace,
runGit,
runGH,
checkoutToken: entry.token || "",
});
manifest[repository.toLowerCase()] = {
repository,
path: checkoutPath,
Expand Down
339 changes: 339 additions & 0 deletions actions/setup/js/checkout_dynamic.cjs

Large diffs are not rendered by default.

409 changes: 409 additions & 0 deletions actions/setup/js/checkout_dynamic.test.cjs

Large diffs are not rendered by default.

25 changes: 18 additions & 7 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ function buildFailureMatchCategories(options) {
// Keep agent_failure as the fallback class only when no existing
// specific category already explains the failure cause.
const hasSpecificFailureCategory = categories.length > 0;
if (options.agentConclusion === "failure" && !options.isTimedOut && !hasSpecificFailureCategory) {
if ((options.agentConclusion === "failure" || options.hasCheckoutFailure) && !options.isTimedOut && !hasSpecificFailureCategory) {
categories.push("agent_failure");
}

Expand Down Expand Up @@ -1854,18 +1854,21 @@ async function getFailedAgentDiagnostics() {

/**
* Add step-level context to generic agent and infrastructure failures.
* @param {{failureCategories: string[], failingStep: string, agentConclusion?: string, attributionUnavailable?: string, engineFailureContext: string, items?: Array<any>}} options
* @param {{failureCategories: string[], failingStep: string, agentConclusion?: string, attributionUnavailable?: string, engineFailureContext: string, checkoutError?: string, items?: Array<any>}} options
* @returns {string}
*/
function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentConclusion = "", attributionUnavailable = "", engineFailureContext, items = [] }) {
function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentConclusion = "", attributionUnavailable = "", engineFailureContext, checkoutError = "", items = [] }) {
const checkoutContext = checkoutError
? `\n### Runtime Checkout Rejected\n\n${renderSafeInlineCodeSpan(checkoutError)}\n\nThe checkout job failed before creating the selected checkouts. Update the explicit repository list or its \`allowed-repos\` policy; public workflows cannot check out private or internal repositories.\n\n`
: "";
const infrastructureMessages = items.filter(item => item?.type === "report_incomplete" && item.reason === "infrastructure_error");
const isGenericFailure = failureCategories.includes("agent_failure") || failureCategories.includes("engine_driver_failure") || infrastructureMessages.length > 0;
const hasModelEndpointMismatch = failureCategories.includes("model_endpoint_mismatch");
if (!isGenericFailure && !hasModelEndpointMismatch) {
return "";
return checkoutContext;
}

let context = "\n### Failure Diagnostics\n\n";
let context = checkoutContext + "\n### Failure Diagnostics\n\n";
context += "**Agent job:** `agent` \n";
if (agentConclusion) {
const validatedConclusion = ALLOWED_AGENT_CONCLUSIONS.has(agentConclusion) ? agentConclusion : "unknown";
Expand All @@ -1886,6 +1889,8 @@ function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentC
context += engineFailureContext.trim() + "\n\n";
} else if (hasModelEndpointMismatch) {
context += "The model/endpoint mismatch above is the classified cause.\n\n";
} else if (checkoutError) {
context += "Runtime checkout validation above is the failure cause.\n\n";
} else {
context += "The runtime did not record a specific cause. Check the linked run for failure details.\n\n";
}
Expand Down Expand Up @@ -4114,6 +4119,8 @@ async function main() {

// Check if any frontmatter skill installs failed (regardless of agent job status)
const hasSkillInstallFailures = parseInt(skillInstallFailureCount, 10) > 0;
const checkoutError = process.env.GH_AW_CHECKOUT_ERROR || "";
const hasCheckoutFailure = checkoutError !== "";

// Check if there are create_discussion errors (regardless of agent job status)
const hasCreateDiscussionErrors = parseInt(createDiscussionErrorCount, 10) > 0;
Expand Down Expand Up @@ -4269,6 +4276,7 @@ async function main() {
!hasAssignmentErrors &&
!hasAssignCopilotFailures &&
!hasSkillInstallFailures &&
!hasCheckoutFailure &&
!hasCreateDiscussionErrors &&
!hasCodePushFailures &&
!hasPushRepoMemoryFailure &&
Expand All @@ -4295,7 +4303,7 @@ async function main() {
}

// If we only have noop outputs (and no report_incomplete/cache-miss/missing-tool/data/tool-denials-exceeded), skip failure handling
if (hasOnlyNoopOutputs && !hasReportIncomplete && !hasCacheMissMisconfiguration && !hasMissingTool && !hasMissingData && !hasToolDenialsExceeded && !modelEndpointMismatchError) {
if (hasOnlyNoopOutputs && !hasCheckoutFailure && !hasReportIncomplete && !hasCacheMissMisconfiguration && !hasMissingTool && !hasMissingData && !hasToolDenialsExceeded && !modelEndpointMismatchError) {
core.info("Agent completed with only noop outputs - skipping failure handling");
return;
}
Expand All @@ -4305,7 +4313,7 @@ async function main() {
// This prevents false-positive failure issues when a transient AI model error occurs
// after the agent has already finished its task (e.g., create_discussion produced but
// the server returned a spurious error on teardown).
if (hasCompletedDespiteJobFailure && !hasReportIncomplete && !hasCacheMissMisconfiguration && !modelEndpointMismatchError) {
if (hasCompletedDespiteJobFailure && !hasCheckoutFailure && !hasReportIncomplete && !hasCacheMissMisconfiguration && !modelEndpointMismatchError) {
core.info("Agent completed with valid safe outputs despite job failure (terminal_reason: completed) — skipping failure handling");
return;
}
Expand Down Expand Up @@ -4440,6 +4448,7 @@ async function main() {
hasAssignmentErrors,
hasAssignCopilotFailures,
hasSkillInstallFailures,
hasCheckoutFailure,
hasCreateDiscussionErrors,
hasCodePushFailures,
hasRepoMemoryValidationErrors: repoMemoryValidationErrors.length > 0,
Expand Down Expand Up @@ -4660,6 +4669,7 @@ async function main() {
failureCategories,
...failureDiagnostics,
engineFailureContext,
checkoutError,
items: agentOutputItems,
});
// Build timeout context
Expand Down Expand Up @@ -4913,6 +4923,7 @@ async function main() {
failureCategories,
...failureDiagnostics,
engineFailureContext,
checkoutError,
items: agentOutputItems,
});

Expand Down
47 changes: 47 additions & 0 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,20 @@ describe("handle_agent_failure", () => {
});

describe("failure diagnostics", () => {
it("includes the runtime checkout rejection and remediation in failure diagnostics", () => {
const rendered = buildFailureDiagnosticsContext({
failureCategories: ["agent_failure"],
failingStep: "Validate runtime checkout repositories (0)",
agentConclusion: "failure",
engineFailureContext: "",
checkoutError: "Runtime checkout repository org/b is not permitted by allowed-repos",
});
expect(rendered).toContain("### Runtime Checkout Rejected");
expect(rendered).toContain("org/b is not permitted by allowed-repos");
expect(rendered).toContain("Update the explicit repository list");
expect(rendered).not.toContain("The runtime did not record a specific cause.");
});

it("includes the last failed agent step when available", async () => {
global.github = {
paginate: vi.fn().mockResolvedValue([
Expand Down Expand Up @@ -1240,6 +1254,39 @@ describe("handle_agent_failure", () => {
}
});

it.each(["failure", "success"])("reports runtime checkout rejection in the failure issue when the agent conclusion is %s", async conclusion => {
const output = path.join(tmpDir, "output.json");
fs.writeFileSync(output, JSON.stringify({ items: [{ type: "noop", message: "Done" }] }));
vi.stubEnv("GH_AW_AGENT_OUTPUT", output);
vi.stubEnv("GH_AW_CHECKOUT_ERROR", "Runtime checkout repository org/b is not permitted by allowed-repos");
vi.stubEnv("GH_AW_AGENT_CONCLUSION", conclusion);
vi.stubEnv("GH_AW_FAILURE_REPORT_AS_ISSUE", "true");
vi.stubEnv("GH_AW_UNIFIED_SESSION", "");
fs.writeFileSync(path.join(promptsDir, "agent_failure_issue.md"), fs.readFileSync(new URL("../md/agent_failure_issue.md", import.meta.url), "utf8"));
const create = vi.fn().mockResolvedValue({ data: { number: 101, html_url: "https://github.com/owner/repo/issues/101", node_id: "I_101" } });
global.github = {
rest: {
search: { issuesAndPullRequests: vi.fn(async () => ({ data: { total_count: 0, items: [] } })) },
issues: { create, createComment: vi.fn() },
pulls: { get: vi.fn() },
actions: { listJobsForWorkflowRun: vi.fn() },
},
paginate: vi.fn(async () => []),
graphql: vi.fn(),
};
try {
await main();
expect(create).toHaveBeenCalledOnce();
const body = create.mock.calls[0][0].body;
expect(body).toContain("### Runtime Checkout Rejected");
expect(body).toContain("org/b is not permitted by allowed-repos");
expect(body).toContain("failure_categories: agent_failure");
expect(global.core.setFailed).not.toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
}
});

it("adds a comment only when the existing issue metadata matches exactly", async () => {
const createCommentMock = vi.fn(async () => ({ data: { id: 1001 } }));
const createIssueMock = vi.fn();
Expand Down
8 changes: 8 additions & 0 deletions actions/setup/sh/clean_git_credentials.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ clean_git_config() {
# Get the workspace directory (defaults to current GITHUB_WORKSPACE)
WORKSPACE="${GITHUB_WORKSPACE:-.}"

# Explicit checkout configs must not depend on bounded workspace discovery.
if [ "$#" -gt 0 ]; then
for git_config in "$@"; do
clean_git_config "${git_config}"
done
exit 0
fi

# Collect all git config files to clean from workspace and /tmp/.
# Includes both repository configs and submodule configs under .git/modules/**/config.
# Use maxdepth 15 as a conservative cap to cover deeply nested submodules
Expand Down
30 changes: 29 additions & 1 deletion docs/src/content/docs/reference/checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ checkout:

| Field | Type | Description |
|-------|------|-------------|
| `repository` | string | Repository in `owner/repo` format. Defaults to the current repository. |
| `repository` | string | Explicit repository in `owner/repo` format, or a newline-separated list with `allowed-repos`. Expressions require `allowed-repos`. Does not support wildcards. Defaults to the current repository. |
| `repo` | string | Alias for `repository`; specify only one. Does not support wildcards. |
| `allowed-repos` | string \| string[] | Mandatory allowlist for runtime repository-list expansion. Accepts newline-separated patterns, an array, or an expression; supports scoped patterns such as `owner/*` and `*/repo`, but not unrestricted `"*"` or `"*/*"`. |
| `ref` | string | Branch, tag, or SHA to checkout. Defaults to the triggering ref. |
| `path` | string | Path within `GITHUB_WORKSPACE` to place the checkout. Defaults to workspace root. |
| `github-token` | string | Token for authentication. Use `${{ secrets.MY_TOKEN }}` syntax. |
Expand All @@ -62,6 +64,32 @@ checkout:
| `current` | boolean | Marks this checkout as the primary working repository. The agent uses this as the default target for all GitHub operations. Only one checkout may set `current: true`; the compiler rejects workflows where multiple checkouts enable it. |
| `force-clean-git-credentials` | boolean | When `true`, the checkout step is generated with `persist-credentials: true` and followed by a dedicated cleanup step that scrubs both repo and submodule git credentials. Use this for submodule-heavy or sparse checkouts where the default `persist-credentials: false` post-step cleanup fails. See [Cleaning Submodule Credentials](#cleaning-submodule-credentials). |

## Runtime Repository Lists

An entry with `allowed-repos` expands `repository` (or `repo`) at runtime into one checkout per newline-separated `owner/repo` slug. This allows scheduled workers to process a changing repository set supplied through variables, inputs, or earlier step outputs:

`repository` and `repo` select explicit repository names, not wildcard patterns. Their expressions must resolve to concrete `owner/repo` slugs. Wildcards are supported only in `allowed-repos`, which filters the selected list and never discovers or selects repositories.

```aw wrap
on:
schedule: daily
checkout:
- repository: ${{ vars.WORKER_REPOS }}
path: targets
allowed-repos: ${{ vars.ALLOWED_WORKER_REPOS }}
github-token: ${{ secrets.CROSS_REPO_PAT }}
```

For `WORKER_REPOS` containing `org/api` and `org/web` on separate lines, the checkouts are placed at `targets/org/api` and `targets/org/web`. `path` must be a literal, non-root prefix. The compiler requires `allowed-repos` for `repository` or `repo` expressions, even when they select a single repository, except for the standalone `${{ github.repository }}` expression. That expression selects the workflow repository using normal single-checkout behavior and needs neither an allowlist nor a path prefix. Expressions that combine it with other values still require `allowed-repos`. The allowlist may contain scoped wildcard patterns such as `org/*` or `*/api`. Unrestricted patterns such as `*` and `*/*` are rejected, including when an expression resolves to them at runtime. Matching and deduplication are case-insensitive; an empty selected list produces no additional checkouts, not all allowlisted repositories.

Every selected repository must pass the allowlist and GitHub API metadata checks before the checkout action is generated. Redirected repository names are checked against the allowlist again. **Public workflow repositories cannot check out private or internal repositories**, even if the supplied token can access them. Failed or incomplete visibility checks stop the job. Submodule checkout is rejected in public workflows because submodule visibility cannot be verified.

A disallowed repository fails the entire checkout step before any selected repository is cloned; it is never silently skipped. The rejection is included in the workflow's agent failure issue, subject to the existing `safe-outputs.report-failure-as-issue` settings. Invalid runtime selectors or allowlists, unsafe paths, missing credentials, and failed visibility checks also report their preparation failure through this path. Unexpected dependency errors report a generic preparation failure rather than exposing raw dependency diagnostics in the issue. Failures in the agent and safe-output checkout jobs both use this reporting path.

Runtime entries support the normal ref, history, sparse-checkout, fetch, and authentication options. `lfs: true` is rejected at compilation and runtime for all repository lists: repository-controlled LFS endpoints could download content outside `allowed-repos` using the checkout token. Static checkouts continue to support LFS. GitHub App authentication requires explicit `owner` and `repositories` scoping. `current: true` and `wiki: true` are not supported for repository lists. Existing destination directories and symlinked path ancestors are rejected rather than cleaned or followed.

The checkout manifest records each expanded repository, its exact workspace path, and its default branch. Before agent execution, finalization explicitly cleans and verifies every expanded checkout's git config and submodule configs using the validated paths, regardless of prefix depth. Missing configs, unavailable cleanup helpers, and residual credentials stop the job. PR safe-output jobs use the same expansion and directory layout, retaining their authenticated-write credentials; the agent receives credential-free checkouts.

## Fetching Additional Refs

By default, `actions/checkout` performs a shallow clone (`fetch-depth: 1`) of a single ref. For workflows that need to work with other branches — for example, a scheduled workflow that must push changes to open pull-request branches — use the `fetch:` option to retrieve additional refs after the checkout step.
Expand Down
Loading
Loading