Skip to content

Commit 5a79aa3

Browse files
pelikhanCopilot
andauthored
Require explicit multi: true for multi-repository checkout (#68816)
* Require explicit opt-in for multi-repository checkout Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align PR-target checkout test with single-repository expressions Require the pull_request_target security rejection now that repository expressions no longer imply multi-checkout mode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e797b43 commit 5a79aa3

29 files changed

Lines changed: 431 additions & 155 deletions

‎.changeset/explicit-multi-checkout.md‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎.changeset/major-runtime-repository-checkout.md‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎actions/setup/js/checkout_dynamic.cjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ const { getSetupTimeoutMs } = require("./child_process_timeouts.cjs");
99
// Only controlled messages may be propagated to workflow failure issues.
1010
class CheckoutPreparationError extends Error {}
1111

12+
/**
13+
* @param {string | undefined} value
14+
*/
15+
function validateSingleRepository(value) {
16+
if (!value || !/^[a-zA-Z0-9][a-zA-Z0-9-]*\/[a-zA-Z0-9_.-]+$/.test(value.trim()) || [".", ".."].includes(value.trim().split("/")[1])) {
17+
throw new CheckoutPreparationError("checkout.repository (or repo) must resolve to exactly one owner/repo slug unless checkout.multi is true");
18+
}
19+
}
20+
1221
/**
1322
* @param {string} value
1423
* @returns {string[]}
@@ -351,4 +360,4 @@ async function finish(options = {}) {
351360
return entries;
352361
}
353362

354-
module.exports = { prepare, finish, parseRepositories, parseAllowedRepos, workspacePath };
363+
module.exports = { prepare, finish, parseRepositories, parseAllowedRepos, workspacePath, validateSingleRepository };

‎actions/setup/js/checkout_dynamic.test.cjs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import fs from "fs";
33
import path from "path";
44
import { execFileSync } from "child_process";
5-
import { prepare, finish, parseRepositories, parseAllowedRepos, workspacePath } from "./checkout_dynamic.cjs";
5+
import { prepare, finish, parseRepositories, parseAllowedRepos, workspacePath, validateSingleRepository } from "./checkout_dynamic.cjs";
66
import { buildCheckoutManifest, readManifestEntriesFromEnv } from "./build_checkout_manifest.cjs";
77

8+
describe("single repository checkout", () => {
9+
it.each(["org/repo", "Org/Private.Repo", " org/repo \n", "org/repo.wiki"])("accepts exactly one repository without multi-repo policy: %s", repository => {
10+
expect(() => validateSingleRepository(repository)).not.toThrow();
11+
});
12+
13+
it.each([undefined, "", " \n", "org/a\norg/b", "org/a\nORG/A", "org/a,org/b", "org/*", "org/repo/extra", "org/..", "org/.", "org/a\rorg/b"])("rejects missing, multiple, or invalid repositories: %s", repository => {
14+
expect(() => validateSingleRepository(repository)).toThrow("exactly one owner/repo slug");
15+
});
16+
});
17+
818
describe("runtime repository checkout", () => {
919
let temp;
1020
let workspace;

‎docs/src/content/docs/reference/checkout.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ checkout:
4848

4949
| Field | Type | Description |
5050
|-------|------|-------------|
51-
| `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. |
51+
| `repository` | string | Explicit repository in `owner/repo` format, or a newline-separated list with `multi: true`. Without `multi`, expressions must resolve to exactly one repository and need no allowlist. Does not support wildcards. Defaults to the current repository. |
5252
| `repo` | string | Alias for `repository`; specify only one. Does not support wildcards. |
53-
| `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 `"*/*"`. |
54-
| `max-repos` | integer | Maximum unique repositories per runtime checkout entry. Requires `allowed-repos`; defaults to `10`. Must be a positive literal integer. |
55-
| `allow-private-repos` | boolean | Allow private/internal targets in a runtime list hosted by a private/internal repository. Requires `allowed-repos`; defaults to `false` and must be a literal boolean. Public hosts always reject non-public targets, even when `true`. |
53+
| `multi` | boolean | Explicitly enables runtime repository-list expansion and its restrictions. Defaults to `false`; must be a literal boolean. |
54+
| `allowed-repos` | string \| string[] | Mandatory allowlist for `multi: true`; does not enable multi mode by itself. Accepts newline-separated patterns, an array, or an expression; supports scoped patterns such as `owner/*` and `*/repo`, but not unrestricted `"*"` or `"*/*"`. Ignored in single-repository mode. |
55+
| `max-repos` | integer | Maximum unique repositories per `multi: true` checkout entry. Requires `allowed-repos` in multi mode; defaults to `10`. Must be a positive literal integer. Ignored in single-repository mode. |
56+
| `allow-private-repos` | boolean | With `multi: true`, allow private/internal targets in a runtime list hosted by a private/internal repository. Requires `allowed-repos` in multi mode; defaults to `false` and must be a literal boolean. Public hosts always reject non-public multi targets, even when `true`. Ignored in single-repository mode. |
5657
| `ref` | string | Branch, tag, or SHA to checkout. Defaults to the triggering ref. |
5758
| `path` | string | Path within `GITHUB_WORKSPACE` to place the checkout. Defaults to workspace root. |
5859
| `github-token` | string | Token for authentication. Use `${{ secrets.MY_TOKEN }}` syntax. |
@@ -68,7 +69,16 @@ checkout:
6869

6970
## Runtime Repository Lists
7071

71-
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:
72+
An entry with `multi: true` expands `repository` (or `repo`) at runtime into one checkout per newline-separated `owner/repo` slug. It requires `allowed-repos`. This allows scheduled workers to process a changing repository set supplied through variables, inputs, or earlier step outputs.
73+
74+
Without `multi: true` (including `multi: false`), the runtime requires exactly one `owner/repo` slug. Repository expressions do not require `allowed-repos` or a non-root path prefix. Normal single-checkout authentication, private-repository access, LFS, submodules, `current`, and `wiki` behavior applies; none of the multi-repo restrictions below apply. Merely specifying `allowed-repos` does not enable expansion or constrain a single checkout.
75+
76+
```yaml wrap
77+
checkout:
78+
repo: ${{ inputs.repo }}
79+
path: target
80+
current: true
81+
```
7282

7383
`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.
7484

@@ -78,14 +88,15 @@ An entry with `allowed-repos` expands `repository` (or `repo`) at runtime into o
7888
on:
7989
schedule: daily
8090
checkout:
81-
- repository: ${{ vars.WORKER_REPOS }}
91+
- multi: true
92+
repository: ${{ vars.WORKER_REPOS }}
8293
path: targets
8394
allowed-repos: ${{ vars.ALLOWED_WORKER_REPOS }}
8495
max-repos: 5
8596
github-token: ${{ secrets.CROSS_REPO_PAT }}
8697
```
8798

88-
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.
99+
For `WORKER_REPOS` containing `org/api` and `org/web` on separate lines, the checkouts are placed at `targets/org/api` and `targets/org/web`. With `multi: true`, `path` must be a literal, non-root prefix and `allowed-repos` is mandatory, even if the selector is literal or resolves to just the workflow repository. 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.
89100

90101
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. Runtime lists allow only public targets by default. Set `allow-private-repos: true` on an entry to also allow private/internal targets when the hosting repository is private/internal. **Public workflow repositories cannot check out private or internal repositories**, even when this flag is `true` and the supplied token can access them. The flag requires `allowed-repos` and a literal boolean; it does not change ordinary static checkouts. Both checkout jobs enforce the same policy, and merged entries permit non-public targets only if every entry explicitly opts in. Omission or `false` prevents an imported entry from broadening access. Failed or incomplete visibility checks stop the job.
91102

‎docs/src/content/docs/specs/checkout-behavior-specification.md‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ sidebar:
77

88
# Checkout Behavior Specification
99

10-
**Version**: 1.3.2<br>
10+
**Version**: 1.3.3<br>
1111
**Status**: Working Draft
1212
**Publication Date**: 2026-10-14<br>
1313
**Editor**: GitHub Agentic Workflows Team
@@ -82,7 +82,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S
8282
### 3.1 Checkout Entry Parsing
8383

8484
`checkout:` MUST accept either a single object or an array of objects.
85-
Each entry MAY define: `repository` (or `repo`), `allowed-repos`, `max-repos`, `allow-private-repos`, `ref`, `path`, `github-token` (or legacy `token`), `github-app` (or deprecated alias `app`), `safe-outputs-github-app`, `fetch-depth`, `fetch`, `sparse-checkout`, `submodules`, `lfs`, `current`, `wiki`, and `force-clean-git-credentials`.
85+
Each entry MAY define: `repository` (or `repo`), `multi`, `allowed-repos`, `max-repos`, `allow-private-repos`, `ref`, `path`, `github-token` (or legacy `token`), `github-app` (or deprecated alias `app`), `safe-outputs-github-app`, `fetch-depth`, `fetch`, `sparse-checkout`, `submodules`, `lfs`, `current`, `wiki`, and `force-clean-git-credentials`.
8686

8787
`github-token` and `github-app` MUST be mutually exclusive per entry.
8888

@@ -91,17 +91,24 @@ Each entry MAY define: `repository` (or `repo`), `allowed-repos`, `max-repos`, `
9191
#### Runtime Repository Lists
9292

9393
`repo` MUST be accepted as an alias for `repository`; specifying both MUST fail.
94-
Any GitHub Actions expression in `repository` or `repo` MUST require
95-
`allowed-repos` at compile time, even for a single selected repository, except
96-
the standalone `${{ github.repository }}` expression. Without `allowed-repos`,
97-
that expression MUST retain normal single-repository checkout behavior and MUST
98-
NOT require a path prefix. Whitespace within the expression MAY vary; combining
99-
it with other expressions or literal values MUST NOT qualify for the exception.
94+
`multi` MUST be a literal boolean and MUST default to false. Only `multi: true`
95+
MUST enable repository-list expansion and the multi-repo restrictions in this
96+
subsection. Neither an expression selector nor `allowed-repos` alone MUST
97+
implicitly enable multi mode. Without multi mode, an explicit `repository` or
98+
`repo` MUST resolve to exactly one `owner/repo` slug; empty, malformed, wildcard,
99+
and multiple resolved slugs (including duplicates) MUST fail before checkout.
100+
Literal lists MUST fail compilation. Runtime expression values MUST be validated
101+
as data before `actions/checkout` in both agent and safe-output jobs.
102+
Single-repository expressions MUST NOT require an allowlist, a non-root path
103+
prefix, visibility preflight, private-target opt-in, or explicit App scoping.
104+
Normal single-checkout LFS, submodule, `current`, and `wiki` support MUST remain
105+
available. Multi-only policy fields MUST NOT activate or constrain a single
106+
checkout; their declared types still apply.
100107
`repository` and `repo` MUST select explicit repository names, not wildcard
101108
patterns. Literal repository wildcards MUST fail at compile time, and wildcard
102109
values in expression-resolved lists MUST fail at runtime. Wildcards MUST only
103110
be used in `allowed-repos`; the allowlist MUST NOT discover or select repositories.
104-
An entry with `allowed-repos` MUST expand its repository value as newline-separated
111+
An entry with `multi: true` MUST expand its repository value as newline-separated
105112
`owner/repo` slugs at runtime. List expansion MUST require a non-empty allowlist
106113
and a literal, non-root path prefix. Each repository MUST be checked out under
107114
`<prefix>/<owner>/<repo>`. Matching and deduplication MUST be case-insensitive.
@@ -162,7 +169,9 @@ Safe-output jobs MUST retain their intended authenticated-write credentials.
162169

163170
### 3.2 Entry Merge Rules
164171

165-
Entries with the same `(repository, path, wiki)` key MUST merge with these rules:
172+
Entries with the same `(repository, path, wiki, multi)` key MUST merge with these rules.
173+
Single and multi entries MUST remain distinct; an imported multi entry MUST NOT
174+
silently turn a single entry into a repository list:
166175

167176
- `fetch-depth`: deepest wins (`0` wins over all)
168177
- `ref`: first non-empty wins
@@ -375,6 +384,7 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme
375384
- **T-CHK-017**: Activation sparse checkout pins supported pull-request events to the base SHA, retains non-PR and `workflow_call` fallback refs, excludes `pull_request_target`, and preserves the same-repo guard for optional App token fallback
376385
- **T-CHK-018**: Checkout-time and final cleanup fail closed on helper failure or residual credentials, including included config files; successful verified cleanup and the no-checkout case remain usable
377386
- **T-CHK-019**: Runtime private/internal targets require literal per-entry opt-in and a non-public host; public hosts reject them even with opt-in, merged entries cannot broaden access, and denials reach agent failure issues
387+
- **T-CHK-020**: Only literal `multi: true` enables lists; omitted/false mode accepts single-repository expressions without multi restrictions, rejects empty/multiple/wildcard resolved slugs before either job's checkout, and remains distinct when imports merge
378388

379389
### 7.2 Compliance Checklist
380390

@@ -394,6 +404,7 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme
394404
| Activation checkout ref and fallback provenance | T-CHK-017 | C1 | Required |
395405
| Verified fail-closed credential cleanup | T-CHK-018 | C1/C2 | Required |
396406
| Explicit private checkout opt-in and public-host isolation | T-CHK-019 | C1/C2 | Required |
407+
| Explicit multi opt-in and single-repository enforcement | T-CHK-020 | C1/C2 | Required |
397408

398409
### 7.3 Safeguards
399410

@@ -438,6 +449,12 @@ The following MUST-level norms govern credential and token safety during checkou
438449

439450
## 9. Change Log
440451

452+
### Version 1.3.3 (Working Draft)
453+
454+
- Required explicit `multi: true` for repository lists and scoped all list-only
455+
restrictions to that mode. Restored ordinary single-repository expressions,
456+
required runtime cardinality validation in both jobs, and added T-CHK-020.
457+
441458
### Version 1.3.2 (Working Draft)
442459

443460
- Required explicit `allow-private-repos` opt-in for runtime private/internal

0 commit comments

Comments
 (0)