From 4e22b94ec9cf20084d5f393a480c1cd392ca8898 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:13:40 +0000 Subject: [PATCH 01/26] Support expression-valued dynamic checkouts Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/build_checkout_manifest.cjs | 12 +- .../setup/js/build_checkout_manifest.test.cjs | 18 ++ actions/setup/js/dynamic_checkouts.cjs | 243 ++++++++++++++++++ actions/setup/js/dynamic_checkouts.test.cjs | 76 ++++++ docs/src/content/docs/reference/checkout.md | 19 ++ pkg/parser/schemas/main_workflow_schema.json | 7 +- pkg/workflow/compiler_safe_outputs_steps.go | 15 +- pkg/workflow/compiler_yaml_checkout.go | 13 +- pkg/workflow/dynamic_checkout.go | 42 +++ pkg/workflow/dynamic_checkout_test.go | 55 ++++ pkg/workflow/frontmatter_parsing.go | 22 +- pkg/workflow/frontmatter_types.go | 1 + pkg/workflow/unified_prompt_step.go | 5 +- pkg/workflow/workflow_builder.go | 13 +- pkg/workflow/workflow_data.go | 1 + 15 files changed, 519 insertions(+), 23 deletions(-) create mode 100644 actions/setup/js/dynamic_checkouts.cjs create mode 100644 actions/setup/js/dynamic_checkouts.test.cjs create mode 100644 pkg/workflow/dynamic_checkout.go create mode 100644 pkg/workflow/dynamic_checkout_test.go diff --git a/actions/setup/js/build_checkout_manifest.cjs b/actions/setup/js/build_checkout_manifest.cjs index c44a98959f6..0dd7fb031e2 100644 --- a/actions/setup/js/build_checkout_manifest.cjs +++ b/actions/setup/js/build_checkout_manifest.cjs @@ -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) { diff --git a/actions/setup/js/build_checkout_manifest.test.cjs b/actions/setup/js/build_checkout_manifest.test.cjs index d5e3b8b2d98..c8f8abf13da 100644 --- a/actions/setup/js/build_checkout_manifest.test.cjs +++ b/actions/setup/js/build_checkout_manifest.test.cjs @@ -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"); + }); }); diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs new file mode 100644 index 00000000000..6dc11dbb8c9 --- /dev/null +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -0,0 +1,243 @@ +// @ts-check +/// + +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", "current", "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.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"); + } + 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, + 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, + }; +} + +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) { + 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 = options.overrideToken || checkout.token || process.env.GH_TOKEN || ""; + const persistCredentials = options.persistCredentials === true; + const runGit = options.runGit || defaultRunGit; + + let workspaceReal; + try { + workspaceReal = fs.realpathSync(workspace); + if (fs.existsSync(checkout.target)) { + 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}`); + } + } 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`, checkout.target); + + core.info(`Checking out ${checkout.repository} into ${checkout.path}`); + await runGit(cloneArgs); + if (checkout.ref) { + const fetchArgs = [...authArgs, "-C", checkout.target, "fetch", "--no-tags"]; + if (checkout.fetchDepth > 0) { + fetchArgs.push("--depth", String(checkout.fetchDepth)); + } + fetchArgs.push("origin", checkout.ref); + await runGit(fetchArgs); + await runGit(["-C", checkout.target, "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]); + } + if (checkout.submodules === true || checkout.submodules === "true" || checkout.submodules === "recursive") { + const args = [...authArgs, "-C", checkout.target, "submodule", "update", "--init"]; + if (checkout.submodules === "recursive") { + args.push("--recursive"); + } + await runGit(args); + } + if (checkout.lfs) { + await runGit([...authArgs, "-C", checkout.target, "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}`]); + } else { + try { + await runGit(["-C", checkout.target, "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\//, ""); + } 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, + }; +} + +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; +} + +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 (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"); + } + + 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, +}; diff --git a/actions/setup/js/dynamic_checkouts.test.cjs b/actions/setup/js/dynamic_checkouts.test.cjs new file mode 100644 index 00000000000..e69cf2ad2d1 --- /dev/null +++ b/actions/setup/js/dynamic_checkouts.test.cjs @@ -0,0 +1,76 @@ +// @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"); + }); +}); + +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 target = path.join(workspace, "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", + 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); + }); +}); + +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"); + }); +}); diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 416813e35c1..b6538491148 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -31,6 +31,25 @@ checkout: private-key: ${{ secrets.APP_PRIVATE_KEY }} ``` +### Dynamic Checkout Sets + +Use a GitHub Actions expression when the repositories are only known at runtime. The +expression must resolve to one checkout object or an array of checkout objects: + +```yaml wrap +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`. + +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. + You can also use `checkout:` to check out additional repositories alongside the main repository: ```yaml wrap diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c95d6061544..3cb10c2801e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13262,7 +13262,7 @@ "additionalProperties": false }, "checkout": { - "description": "Checkout configuration for the agent job. Controls how actions/checkout is invoked. Can be a single checkout configuration, an array for multiple checkouts, or false to disable the default checkout step entirely (dev-mode checkouts are unaffected).", + "description": "Checkout configuration for the agent job. Controls how repositories are checked out. Can be a single checkout configuration, an array for multiple checkouts, a GitHub Actions expression resolving to either shape, or false to disable the default checkout step entirely (dev-mode checkouts are unaffected).", "oneOf": [ { "$ref": "#/$defs/checkoutConfig", @@ -13279,6 +13279,11 @@ "type": "boolean", "enum": [false], "description": "Set to false to disable the default checkout step. The agent job will not check out any repository (dev-mode checkouts are unaffected)." + }, + { + "type": "string", + "pattern": "^\\s*\\$\\{\\{[\\s\\S]+\\}\\}\\s*$", + "description": "A GitHub Actions expression that resolves at runtime to one checkout object or an array of checkout objects. Dynamic entries are additional checkouts and must identify a repository." } ] }, diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index a8a98888876..3b9b99c340a 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "path" "sort" "strings" @@ -29,7 +30,7 @@ var consolidatedSafeOutputsStepsLog = logger.New("workflow:compiler_safe_outputs // branch per target repository at apply time (it runs `git fetch origin ` and // branches from `origin/` in the target repo's directory), so a checkout-time base // ref is unnecessary. This is the same mechanism the multi-repo path already relied on. -func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { +func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { //nolint:largefunc // Checkout step ordering is kept together. consolidatedSafeOutputsStepsLog.Print("Building shared PR checkout steps (mirroring agent job layout)") // Build the same CheckoutManager the agent job builds from the workflow's checkout: config. @@ -92,6 +93,10 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { checkoutMgr.GenerateAdditionalCheckoutSteps(c.getActionPin), condition, )...) + steps = append(steps, injectStepCondition( + c.generateDynamicCheckoutSteps(data.CheckoutExpressions, prCheckoutToken, true), + condition, + )...) // Configure Git credentials so the safe_outputs job can push. The agent job never // pushes, so this step has no agent-job equivalent. Reuse the token resolved above so @@ -151,7 +156,7 @@ func (c *Compiler) buildHandlerManagerStep(data *WorkflowData) ([]string, error) // steps that must precede the handler manager step. For each registered handler that has a // per-handler github-app configured, a dedicated token step is minted whose permissions are // scoped to only that handler's needs (principle of least privilege). -func (c *Compiler) addAppTokenMintingSteps(data *WorkflowData) []string { +func (c *Compiler) addAppTokenMintingSteps(data *WorkflowData) []string { //nolint:largefunc // Existing token step assembly remains centralized. if data.SafeOutputs == nil { return nil } @@ -330,7 +335,7 @@ func buildCustomScriptFilesStep(scripts map[string]*SafeScriptConfig) ([]string, scriptConfig := scripts[scriptName] normalizedName := stringutil.NormalizeSafeOutputIdentifier(scriptName) filename := safeOutputScriptFilename(normalizedName) - filePath := SetupActionDestinationShell + "/" + filename + filePath := path.Join(SetupActionDestinationShell, filename) scriptContent := generateSafeOutputScriptContent(scriptName, scriptConfig) delimiter := GenerateHeredocDelimiterFromContent("SAFE_OUTPUT_SCRIPT_"+strings.ToUpper(normalizedName), scriptContent) @@ -352,7 +357,7 @@ func buildCustomScriptFilesStep(scripts map[string]*SafeScriptConfig) ([]string, // manager step: the agent output reference, allowed-domains configuration, URL policy, // GitHub server/API URLs, custom handler registration maps, and the delegated per-handler // config env vars. -func (c *Compiler) addSafeOutputCoreEnvVars(steps *[]string, data *WorkflowData) error { +func (c *Compiler) addSafeOutputCoreEnvVars(steps *[]string, data *WorkflowData) error { //nolint:largefunc // Existing environment assembly remains centralized. *steps = append(*steps, " GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}\n") *steps = append(*steps, " GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}\n") @@ -470,7 +475,7 @@ func isCITriggerTokenDisabled(safeOutputs *SafeOutputsConfig) bool { // addSafeOutputTokenEnvVars appends token-related environment variables required by the // handler manager step: the CI-trigger token, project URL/token, assign-to-agent token, // agent-session token, and the optional GITHUB_TOKEN override for cross-repo PR operations. -func (c *Compiler) addSafeOutputTokenEnvVars(steps *[]string, data *WorkflowData) { +func (c *Compiler) addSafeOutputTokenEnvVars(steps *[]string, data *WorkflowData) { //nolint:largefunc // Existing token environment assembly remains centralized. addCITriggerTokenEnvVar(steps, data) // Add GH_AW_PROJECT_URL and GH_AW_PROJECT_GITHUB_TOKEN environment variables for project operations. diff --git a/pkg/workflow/compiler_yaml_checkout.go b/pkg/workflow/compiler_yaml_checkout.go index 73c19dcfc7c..afe2787c350 100644 --- a/pkg/workflow/compiler_yaml_checkout.go +++ b/pkg/workflow/compiler_yaml_checkout.go @@ -66,6 +66,9 @@ func (c *Compiler) generateInitialAndCheckoutSteps(yaml *strings.Builder, data * for _, line := range additionalLines { yaml.WriteString(line) } + for _, step := range c.generateDynamicCheckoutSteps(data.CheckoutExpressions, "", false) { + yaml.WriteString(step) + } // Emit a manifest step that records the path and resolved default branch for each // non-default cross-repo checkout. The safe-outputs MCP server reads this file to @@ -231,10 +234,10 @@ func parseRepositoryImportSpec(importSpec string) (owner, repo, ref string) { // Split on @ to get path and ref parts := strings.Split(cleanSpec, "@") - pathPart := parts[0] - ref = "main" // default ref + pathPart := parts[0] //nolint:uncheckedsliceindex // strings.Split always returns at least one element. + ref = "main" // default ref if len(parts) > 1 { - ref = parts[1] + ref = parts[1] //nolint:uncheckedsliceindex // len(parts) is checked above. } // Parse path: owner/repo @@ -243,8 +246,8 @@ func parseRepositoryImportSpec(importSpec string) (owner, repo, ref string) { return "", "", "" } - owner = slashParts[0] - repo = slashParts[1] + owner = slashParts[0] //nolint:uncheckedsliceindex // len(slashParts) == 2. + repo = slashParts[1] //nolint:uncheckedsliceindex // len(slashParts) == 2. return owner, repo, ref } diff --git a/pkg/workflow/dynamic_checkout.go b/pkg/workflow/dynamic_checkout.go new file mode 100644 index 00000000000..bf684b111c2 --- /dev/null +++ b/pkg/workflow/dynamic_checkout.go @@ -0,0 +1,42 @@ +package workflow + +import ( + "fmt" + "strings" +) + +// generateDynamicCheckoutSteps emits one runtime checkout step per expression-valued +// checkout declaration. GitHub Actions cannot expand an expression into a variable +// number of steps, so the bundled script performs the additional checkouts with git. +func (c *Compiler) generateDynamicCheckoutSteps(expressions []string, overrideToken string, persistCredentials bool) []string { + var steps []string + for index, expression := range expressions { + var step strings.Builder + fmt.Fprintf(&step, " - name: Checkout dynamic repositories (%d)\n", index+1) + fmt.Fprintf(&step, " uses: %s\n", c.getActionPin("actions/github-script")) + step.WriteString(" env:\n") + fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUTS: %s\n", wrapExpressionWithToJSON(expression)) + step.WriteString(" GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\n") + if overrideToken != "" { + fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUT_TOKEN: %s\n", overrideToken) + } + fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS: %t\n", persistCredentials) + step.WriteString(" with:\n") + step.WriteString(" script: |\n") + step.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") + step.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") + step.WriteString(" const { main } = require('${{ runner.temp }}/gh-aw/actions/dynamic_checkouts.cjs');\n") + step.WriteString(" await main();\n") + steps = append(steps, step.String()) + } + return steps +} + +func buildDynamicCheckoutsPromptContent(expressions []string) string { + if len(expressions) == 0 { + return "" + } + 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" +} diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go new file mode 100644 index 00000000000..041a50461f9 --- /dev/null +++ b/pkg/workflow/dynamic_checkout_test.go @@ -0,0 +1,55 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseFrontmatterConfigDynamicCheckout(t *testing.T) { + expression := "${{ fromJSON(inputs.checkouts) }}" + config, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": expression, + }) + + require.NoError(t, err) + assert.Equal(t, []string{expression}, config.CheckoutExpressions) + assert.Empty(t, config.CheckoutConfigs) + assert.False(t, config.CheckoutDisabled) +} + +func TestGenerateDynamicCheckoutSteps(t *testing.T) { + compiler := NewCompiler() + steps := compiler.generateDynamicCheckoutSteps( + []string{"${{ fromJSON(inputs.checkouts) }}"}, + "${{ secrets.PUSH_TOKEN }}", + true, + ) + + require.Len(t, steps, 1) + assert.Contains(t, steps[0], "name: Checkout dynamic repositories (1)") + assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUTS: ${{ toJSON(fromJSON(inputs.checkouts)) }}") + assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUT_TOKEN: ${{ secrets.PUSH_TOKEN }}") + assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS: true") + assert.Contains(t, steps[0], "dynamic_checkouts.cjs") +} + +func TestGenerateDynamicCheckoutStepsPreservesToJSON(t *testing.T) { + compiler := NewCompiler() + steps := compiler.generateDynamicCheckoutSteps([]string{"${{ toJSON(inputs.checkouts) }}"}, "", false) + + require.Len(t, steps, 1) + assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUTS: ${{ toJSON(inputs.checkouts) }}") + assert.NotContains(t, steps[0], "toJSON(toJSON(") +} + +func TestBuildDynamicCheckoutsPromptContent(t *testing.T) { + content := buildDynamicCheckoutsPromptContent([]string{"${{ inputs.checkouts }}"}) + + assert.Contains(t, content, "selected and checked out at runtime") + assert.Contains(t, content, "checkout-manifest.json") + assert.Empty(t, buildDynamicCheckoutsPromptContent(nil)) +} diff --git a/pkg/workflow/frontmatter_parsing.go b/pkg/workflow/frontmatter_parsing.go index 890b77c8796..8c4bf0647f9 100644 --- a/pkg/workflow/frontmatter_parsing.go +++ b/pkg/workflow/frontmatter_parsing.go @@ -9,7 +9,7 @@ import ( // ParseFrontmatterConfig creates a FrontmatterConfig from a raw frontmatter map // This provides a single entry point for converting untyped frontmatter into // a structured configuration with better error handling. -func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, error) { +func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, error) { //nolint:largefunc // Existing frontmatter parsing remains centralized. frontmatterTypesLog.Printf("Parsing frontmatter config with %d fields", len(frontmatter)) var config FrontmatterConfig @@ -90,12 +90,16 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err frontmatterTypesLog.Print("Skipping default checkout: permissions.contents is none") } - // Parse checkout field - supports single object, array of objects, or false to disable + // Parse checkout field - supports a single object, an array of objects, a GitHub + // Actions expression resolving to either shape, or false to disable. if config.Checkout != nil { if checkoutValue, ok := config.Checkout.(bool); ok && !checkoutValue { config.CheckoutDisabled = true config.CheckoutExplicitlyDisabled = true frontmatterTypesLog.Print("Checkout disabled via checkout: false") + } else if checkoutExpression, ok := config.Checkout.(string); ok && isExpression(checkoutExpression) { + config.CheckoutExpressions = []string{checkoutExpression} + frontmatterTypesLog.Print("Parsed expression-valued checkout configuration") } else { checkoutConfigs, err := ParseCheckoutConfigs(config.Checkout) if err == nil { @@ -190,7 +194,7 @@ func parseOnNeedsConfig(on map[string]any) ([]string, error) { } // parseRuntimesConfig converts a map[string]any to RuntimesConfig -func parseRuntimesConfig(runtimes map[string]any) (*RuntimesConfig, error) { +func parseRuntimesConfig(runtimes map[string]any) (*RuntimesConfig, error) { //nolint:largefunc // Existing runtime parsing remains centralized. config := &RuntimesConfig{} for runtimeID, configAny := range runtimes { @@ -229,8 +233,14 @@ func parseRuntimesConfig(runtimes map[string]any) (*RuntimesConfig, error) { } // Extract action-repo and action-version overrides (optional) - actionRepo, _ := configMap["action-repo"].(string) - actionVersion, _ := configMap["action-version"].(string) + actionRepo, actionRepoOK := configMap["action-repo"].(string) + if !actionRepoOK { + actionRepo = "" + } + actionVersion, actionVersionOK := configMap["action-version"].(string) + if !actionVersionOK { + actionVersion = "" + } // Extract run-install-scripts flag (optional) var runInstallScripts *bool @@ -291,7 +301,7 @@ func parseRuntimesConfig(runtimes map[string]any) (*RuntimesConfig, error) { } // parsePermissionsConfig converts a map[string]any to PermissionsConfig -func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, error) { +func parsePermissionsConfig(permissions map[string]any) (*PermissionsConfig, error) { //nolint:largefunc // Existing permissions parsing remains centralized. config := &PermissionsConfig{} // Check if it's a shorthand permission (single string value) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index bc91cef63cf..ae601075c2b 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -464,6 +464,7 @@ type FrontmatterConfig struct { // Set to false to disable the default checkout step entirely. Checkout any `json:"checkout,omitempty"` // Raw value (object, array, or false) CheckoutConfigs []*CheckoutConfig `json:"-"` // Parsed checkout configs (not in JSON) + CheckoutExpressions []string `json:"-"` // Runtime expressions resolving to checkout config objects or arrays CheckoutDisabled bool `json:"-"` // true when checkout: false is set in frontmatter CheckoutExplicitlyDisabled bool `json:"-"` // true only when checkout: false is explicitly written by the user in frontmatter CheckoutSkipDefault bool `json:"-"` // true when permissions.contents: none skips only the default workflow-repository checkout diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 24174be4876..992a347a2cd 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -172,8 +172,9 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { / if data.TrialMode && data.TrialLogicalRepo != "" { combinedPromptText = applyTrialLogicalRepoToGitHubContext(combinedPromptText, data.TrialLogicalRepo) } - if checkoutsContent := buildCheckoutsPromptContent(data.CheckoutConfigs); checkoutsContent != "" { - unifiedPromptLog.Printf("Injecting checkout list into GitHub context (%d checkouts)", len(data.CheckoutConfigs)) + checkoutsContent := buildCheckoutsPromptContent(data.CheckoutConfigs) + buildDynamicCheckoutsPromptContent(data.CheckoutExpressions) + if checkoutsContent != "" { + unifiedPromptLog.Printf("Injecting checkout context into GitHub context (%d static, %d dynamic)", len(data.CheckoutConfigs), len(data.CheckoutExpressions)) const closeTag = "" if idx := strings.LastIndex(combinedPromptText, closeTag); idx >= 0 { combinedPromptText = combinedPromptText[:idx] + checkoutsContent + combinedPromptText[idx:] diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 42fb5076ad7..b491bb22688 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -14,7 +14,7 @@ import ( var workflowBuilderLog = logger.New("workflow:workflow_builder") // buildInitialWorkflowData creates the initial WorkflowData struct with basic fields populated. -func (c *Compiler) buildInitialWorkflowData( +func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing workflow data assembly remains centralized. result *parser.FrontmatterResult, toolsResult *toolsProcessingResult, engineSetup *engineSetupResult, @@ -113,6 +113,7 @@ func (c *Compiler) buildInitialWorkflowData( // (e.g. due to unrecognised tool config shapes like bash: ["*"]). if toolsResult.parsedFrontmatter != nil { workflowData.CheckoutConfigs = toolsResult.parsedFrontmatter.CheckoutConfigs + workflowData.CheckoutExpressions = toolsResult.parsedFrontmatter.CheckoutExpressions workflowData.CheckoutDisabled = toolsResult.parsedFrontmatter.CheckoutDisabled workflowData.CheckoutExplicitlyDisabled = toolsResult.parsedFrontmatter.CheckoutExplicitlyDisabled workflowData.CheckoutSkipDefault = toolsResult.parsedFrontmatter.CheckoutSkipDefault @@ -121,6 +122,8 @@ func (c *Compiler) buildInitialWorkflowData( if checkoutValue, ok := rawCheckout.(bool); ok && !checkoutValue { workflowData.CheckoutDisabled = true workflowData.CheckoutExplicitlyDisabled = true + } else if checkoutExpression, ok := rawCheckout.(string); ok && isExpression(checkoutExpression) { + workflowData.CheckoutExpressions = append(workflowData.CheckoutExpressions, checkoutExpression) } else if configs, err := ParseCheckoutConfigs(rawCheckout); err == nil { workflowData.CheckoutConfigs = configs } @@ -147,6 +150,10 @@ func (c *Compiler) buildInitialWorkflowData( workflowBuilderLog.Printf("Failed to unmarshal imported checkout JSON: %v", err) continue } + if checkoutExpression, ok := raw.(string); ok && isExpression(checkoutExpression) { + workflowData.CheckoutExpressions = append(workflowData.CheckoutExpressions, checkoutExpression) + continue + } importedConfigs, err := ParseCheckoutConfigs(raw) if err != nil { workflowBuilderLog.Printf("Failed to parse imported checkout configs: %v", err) @@ -160,7 +167,7 @@ func (c *Compiler) buildInitialWorkflowData( // checkout: entries (own repo, imports) are configured, since that leaves the // agent with no working-directory checkout at all (effectively equivalent to // checkout: false, but without the explicit intent that flag signals). - if workflowData.CheckoutSkipDefault && !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 { + if workflowData.CheckoutSkipDefault && !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.CheckoutExpressions) == 0 { warningMsg := "permissions.contents: none skips the default workflow-repository checkout, " + "but no other checkout: entries are configured; the agent job will have no repository " + "checked out. Add a target checkout: entry, or set checkout: false to make the intent explicit." @@ -183,7 +190,7 @@ func (c *Compiler) buildInitialWorkflowData( // suppresses the checkout_pr_branch.cjs step regardless of checkout configuration. workflowData.IsPullRequestTarget = true - if !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 { + if !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.CheckoutExpressions) == 0 { if _, checkoutExplicitlySet := result.Frontmatter["checkout"]; !checkoutExplicitlySet { workflowBuilderLog.Print("Auto-disabling checkout for pull_request_target workflow") workflowData.CheckoutDisabled = true diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index 2332ecaeebe..413d1af5b92 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -180,6 +180,7 @@ type WorkflowData struct { HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter + CheckoutExpressions []string // GitHub Actions expressions resolving to dynamic checkout settings CheckoutDisabled bool // true when checkout: false is set in frontmatter, or auto-disabled for pull_request_target CheckoutExplicitlyDisabled bool // true only when checkout: false is explicitly set in frontmatter (not auto-disabled) CheckoutSkipDefault bool // true when permissions.contents: none skips only the default workflow-repository checkout From 22e30b8eb6bcd64bc9f624b2a48d7817d211ccac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:22:06 +0000 Subject: [PATCH 02/26] Harden dynamic checkout runtime handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dynamic_checkouts.cjs | 58 ++++++++++++--------- actions/setup/js/dynamic_checkouts.test.cjs | 18 +++++++ docs/src/content/docs/reference/checkout.md | 8 +-- pkg/workflow/dynamic_checkout.go | 2 +- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index 6dc11dbb8c9..82afc5cd7a6 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -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()) { @@ -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"); } @@ -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, }; } @@ -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); + } + parent = fs.realpathSync(candidate); + if (parent !== workspaceReal && !parent.startsWith(workspaceReal + path.sep)) { + throw new Error(`dynamic checkout path escapes the workspace: ${checkout.path}`); + } } } catch (error) { throw new Error(`failed to prepare dynamic checkout path '${checkout.path}': ${getErrorMessage(error)}`, { cause: error }); @@ -123,18 +135,18 @@ 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); 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); await runGit(fetchArgs); - await runGit(["-C", checkout.target, "checkout", "--force", "FETCH_HEAD"]); + await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"]); } if (checkout.sparseCheckout.trim()) { @@ -142,26 +154,26 @@ async function checkoutRepository(checkout, options = {}) { .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]); } 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)}`); } @@ -169,7 +181,7 @@ async function checkoutRepository(checkout, options = {}) { 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)}`); } @@ -177,7 +189,6 @@ async function checkoutRepository(checkout, options = {}) { repository: checkout.repository, path: checkout.path, default_branch: defaultBranch, - current: checkout.current, }; } @@ -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"); } diff --git a/actions/setup/js/dynamic_checkouts.test.cjs b/actions/setup/js/dynamic_checkouts.test.cjs index e69cf2ad2d1..45445f8e334 100644 --- a/actions/setup/js/dynamic_checkouts.test.cjs +++ b/actions/setup/js/dynamic_checkouts.test.cjs @@ -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"); }); }); @@ -52,6 +53,7 @@ describe("checkoutRepository", () => { workspace, runGit, serverURL: "https://github.com", + overrideToken: "fallback-token", maskSecret: () => {}, }); @@ -59,6 +61,22 @@ describe("checkoutRepository", () => { 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", () => { diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index b6538491148..526542adc48 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -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: diff --git a/pkg/workflow/dynamic_checkout.go b/pkg/workflow/dynamic_checkout.go index bf684b111c2..7bd2a8749e0 100644 --- a/pkg/workflow/dynamic_checkout.go +++ b/pkg/workflow/dynamic_checkout.go @@ -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" } From faa07824620b63114e1597a41c24405122bb6fd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:17:51 +0000 Subject: [PATCH 03/26] Reject secrets. references in dynamic checkout expressions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/checkout.md | 16 ++ pkg/workflow/compiler_validators.go | 7 + .../dynamic_checkout_secrets_validation.go | 104 +++++++++++ ...ynamic_checkout_secrets_validation_test.go | 161 ++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 pkg/workflow/dynamic_checkout_secrets_validation.go create mode 100644 pkg/workflow/dynamic_checkout_secrets_validation_test.go diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 526542adc48..d38477ca488 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -50,6 +50,22 @@ enforces unique paths, and removes checkout credentials before the agent starts. App authentication, `current`, and additional `fetch` patterns remain available only in statically declared checkout entries. +The resolved expression is serialized once into a single runtime JSON payload +(`GH_AW_DYNAMIC_CHECKOUTS`), so it must not reference `secrets.*` directly — doing so +would embed the secret's value into that payload instead of a statically declared +environment variable, hiding the secret usage from auditing and static analysis. +Declare any secret the expression needs in the workflow's top-level `env:` section and +reference it through `env.NAME` instead: + +```yaml wrap +env: + CHECKOUT_TOKEN: ${{ secrets.MY_TOKEN }} +checkout: ${{ fromJSON(format('[{"repository":"{0}","github-token":"{1}"}]', inputs.repo, env.CHECKOUT_TOKEN)) }} +``` + +Compilation fails (or warns, in non-strict mode) if a dynamic checkout expression +references `secrets.*` directly. + You can also use `checkout:` to check out additional repositories alongside the main repository: ```yaml wrap diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 679deac06d2..d42a0d6fec2 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -32,6 +32,13 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Dynamic checkout expressions must not embed secrets directly: the resolved + // expression is serialized once into the GH_AW_DYNAMIC_CHECKOUTS runtime JSON + // payload, so secrets used there would not be statically listed as env vars. + if err := c.validateDynamicCheckoutSecretsUsage(workflowData); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Validate expression safety - check that all GitHub Actions expressions are in the allowed list. // In non-strict mode, ${{ toJSON(secrets) }} occurrences were already warned about above; // neutralize them so the allowlist does not re-surface them as errors. diff --git a/pkg/workflow/dynamic_checkout_secrets_validation.go b/pkg/workflow/dynamic_checkout_secrets_validation.go new file mode 100644 index 00000000000..5965ee43ab2 --- /dev/null +++ b/pkg/workflow/dynamic_checkout_secrets_validation.go @@ -0,0 +1,104 @@ +// This file provides validation for GitHub Actions expressions used in +// expression-valued `checkout:` declarations that reference secrets directly. +// +// # Dynamic Checkout Secrets Validation +// +// A dynamic checkout expression (e.g. checkout: ${{ fromJSON(inputs.checkouts) }}) +// is serialized once and passed to the runtime checkout script as a single JSON +// payload (GH_AW_DYNAMIC_CHECKOUTS). If the expression itself references +// secrets.* (for example to embed a per-repository github-token value), the +// resolved secret value is written into that JSON payload rather than being +// assigned to its own statically declared environment variable. This makes the +// secret usage invisible to static analysis and to anyone auditing the compiled +// workflow for which secrets a step consumes. +// +// Instead, secrets needed by a dynamic checkout expression should be declared in +// the workflow's top-level `env:` section (or another step-scoped env var) and +// referenced via `env.NAME` inside the checkout expression. Workflow-level env +// vars are available in the `env` context for every job and step, so this keeps +// the secret usage statically visible while still allowing the expression to be +// resolved at runtime. +// +// The validation uses the same strict/non-strict pattern as other secret checks: +// - In strict mode an error is returned. +// - In non-strict mode a warning is printed and compilation continues. +// +// For the sibling "toJSON(secrets)" check, see expression_secrets_serialization_validation.go. + +package workflow + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/sliceutil" +) + +var dynamicCheckoutSecretsLog = logger.New("workflow:dynamic_checkout_secrets_validation") + +// dynamicCheckoutSecretsDotPattern matches direct secrets.NAME references +// anywhere within an expression. +var dynamicCheckoutSecretsDotPattern = regexp.MustCompile(`\bsecrets\.[A-Za-z_][A-Za-z0-9_]*\b`) + +// dynamicCheckoutSecretsBracketPattern matches direct bracket-indexed secrets +// references (e.g. secrets['MY_TOKEN'] or secrets["my-token"]) once the quoted +// key has been blanked out by maskQuotedExpressionLiterals, leaving only +// whitespace between the brackets. +var dynamicCheckoutSecretsBracketPattern = regexp.MustCompile(`\bsecrets\s*\[\s+\]`) + +// findDynamicCheckoutSecretsExpressions returns the subset of the given checkout +// expressions that reference secrets.* directly, either via dot notation +// (secrets.NAME) or bracket notation (secrets['NAME']), excluding matches +// inside quoted string literals unrelated to secrets access. +func findDynamicCheckoutSecretsExpressions(expressions []string) []string { + var found []string + for _, expr := range expressions { + masked := maskQuotedExpressionLiterals(expr) + if dynamicCheckoutSecretsDotPattern.MatchString(masked) || dynamicCheckoutSecretsBracketPattern.MatchString(masked) { + found = append(found, expr) + } + } + return found +} + +// validateDynamicCheckoutSecretsUsage scans expression-valued checkout +// declarations for direct secrets.* references. Referencing secrets directly in +// a dynamic checkout expression embeds their resolved values into the runtime +// GH_AW_DYNAMIC_CHECKOUTS JSON payload instead of a statically declared +// environment variable. +// +// In strict mode this returns an error; in non-strict mode it emits a warning to +// stderr and increments the compiler warning count. +func (c *Compiler) validateDynamicCheckoutSecretsUsage(workflowData *WorkflowData) error { + found := findDynamicCheckoutSecretsExpressions(workflowData.CheckoutExpressions) + if len(found) == 0 { + dynamicCheckoutSecretsLog.Printf("No dynamic checkout secrets usage found") + return nil + } + + found = sliceutil.Deduplicate(found) + sort.Strings(found) + + dynamicCheckoutSecretsLog.Printf("Detected %d dynamic checkout expression(s) referencing secrets directly: %v", len(found), found) + + msg := fmt.Sprintf( + "dynamic checkout expression(s) reference secrets directly, embedding secret values into the runtime checkout JSON payload instead of a statically declared environment variable. "+ + "Found: %s. "+ + "Declare the secret in the workflow's top-level env: section and reference it via env.NAME inside the checkout expression instead.", + strings.Join(found, ", "), + ) + + effectiveStrict := c.effectiveStrictMode(workflowData.RawFrontmatter) + if effectiveStrict { + return fmt.Errorf("strict mode: %s", msg) + } + + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Warning: "+msg)) + c.IncrementWarningCount() + return nil +} diff --git a/pkg/workflow/dynamic_checkout_secrets_validation_test.go b/pkg/workflow/dynamic_checkout_secrets_validation_test.go new file mode 100644 index 00000000000..7062507875c --- /dev/null +++ b/pkg/workflow/dynamic_checkout_secrets_validation_test.go @@ -0,0 +1,161 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindDynamicCheckoutSecretsExpressions(t *testing.T) { + tests := []struct { + name string + expressions []string + wantLen int + }{ + { + name: "no expressions", + expressions: nil, + wantLen: 0, + }, + { + name: "safe expression referencing inputs only", + expressions: []string{"${{ fromJSON(inputs.checkouts) }}"}, + wantLen: 0, + }, + { + name: "safe expression referencing env", + expressions: []string{"${{ fromJSON(env.CHECKOUTS_JSON) }}"}, + wantLen: 0, + }, + { + name: "direct secrets reference", + expressions: []string{"${{ fromJSON(format('[{\"repository\":\"{0}\",\"github-token\":\"{1}\"}]', inputs.repo, secrets.MY_TOKEN)) }}"}, + wantLen: 1, + }, + { + name: "quoted string literal mentioning secrets is not flagged", + expressions: []string{"${{ fromJSON('secrets.NOT_REAL') }}"}, + wantLen: 0, + }, + { + name: "quoted string literal mentioning bracket-style secrets is not flagged", + expressions: []string{"${{ fromJSON('secrets[\"NOT_REAL\"]') }}"}, + wantLen: 0, + }, + { + name: "multiple expressions — only the dangerous one flagged", + expressions: []string{ + "${{ fromJSON(inputs.checkouts) }}", + "${{ fromJSON(format('[{0}]', secrets.OTHER_TOKEN)) }}", + }, + wantLen: 1, + }, + { + name: "bracket-indexed secrets reference", + expressions: []string{"${{ fromJSON(format('[{0}]', secrets['MY_TOKEN'])) }}"}, + wantLen: 1, + }, + { + name: "bracket-indexed secrets reference with double quotes", + expressions: []string{"${{ fromJSON(format('[{0}]', secrets[\"MY_TOKEN\"])) }}"}, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findDynamicCheckoutSecretsExpressions(tt.expressions) + assert.Len(t, got, tt.wantLen) + }) + } +} + +func TestValidateDynamicCheckoutSecretsUsage(t *testing.T) { + tests := []struct { + name string + expressions []string + rawFrontmatter map[string]any + strictMode bool + wantError bool + errorContains string + wantWarning bool + }{ + { + name: "no dynamic checkout expressions", + expressions: nil, + strictMode: true, + wantError: false, + }, + { + name: "safe dynamic checkout expression", + expressions: []string{"${{ fromJSON(inputs.checkouts) }}"}, + strictMode: true, + wantError: false, + }, + { + name: "secrets reference in strict mode errors", + expressions: []string{"${{ fromJSON(format('[{0}]', secrets.MY_TOKEN)) }}"}, + rawFrontmatter: map[string]any{}, + strictMode: true, + wantError: true, + errorContains: "strict mode", + }, + { + name: "secrets reference in non-strict mode warns", + expressions: []string{"${{ fromJSON(format('[{0}]', secrets.MY_TOKEN)) }}"}, + rawFrontmatter: map[string]any{"strict": false}, + strictMode: false, + wantError: false, + wantWarning: true, + }, + { + name: "duplicate secrets reference across expressions is deduplicated", + expressions: []string{ + "${{ fromJSON(format('[{0}]', secrets.MY_TOKEN)) }}", + "${{ fromJSON(format('[{0}]', secrets.MY_TOKEN)) }}", + }, + rawFrontmatter: map[string]any{}, + strictMode: true, + wantError: true, + errorContains: "strict mode", + }, + { + name: "nil raw frontmatter still errors in strict mode", + expressions: []string{"${{ fromJSON(format('[{0}]', secrets.MY_TOKEN)) }}"}, + strictMode: true, + wantError: true, + errorContains: "strict mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + compiler.strictMode = tt.strictMode + workflowData := &WorkflowData{ + CheckoutExpressions: tt.expressions, + RawFrontmatter: tt.rawFrontmatter, + } + + warningsBefore := compiler.GetWarningCount() + err := compiler.validateDynamicCheckoutSecretsUsage(workflowData) + + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + return + } + require.NoError(t, err) + if tt.wantWarning { + assert.Greater(t, compiler.GetWarningCount(), warningsBefore) + } else { + assert.Equal(t, warningsBefore, compiler.GetWarningCount()) + } + }) + } +} From 0250e45275a13761dd6390fd5860880e5db9cfe6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:53:24 +0000 Subject: [PATCH 04/26] Harden dynamic checkout configuration Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/dynamic_checkouts.cjs | 44 +++++++++++-- actions/setup/js/dynamic_checkouts.test.cjs | 54 +++++++++++++++- docs/src/content/docs/reference/checkout.md | 19 ++++-- pkg/parser/schemas/main_workflow_schema.json | 21 ++++++- pkg/workflow/compiler_safe_outputs_steps.go | 2 +- pkg/workflow/compiler_validators.go | 17 +++--- pkg/workflow/compiler_yaml_checkout.go | 2 +- pkg/workflow/dynamic_checkout.go | 24 ++++++-- pkg/workflow/dynamic_checkout_config.go | 61 +++++++++++++++++++ .../dynamic_checkout_context_validation.go | 19 ++++++ ...ynamic_checkout_context_validation_test.go | 21 +++++++ .../dynamic_checkout_secrets_validation.go | 2 +- ...ynamic_checkout_secrets_validation_test.go | 8 ++- pkg/workflow/dynamic_checkout_test.go | 33 +++++++--- pkg/workflow/expression_patterns.go | 1 + pkg/workflow/frontmatter_parsing.go | 7 ++- pkg/workflow/frontmatter_types.go | 12 ++-- pkg/workflow/unified_prompt_step.go | 4 +- pkg/workflow/workflow_builder.go | 14 ++--- pkg/workflow/workflow_data.go | 2 +- 20 files changed, 309 insertions(+), 58 deletions(-) create mode 100644 pkg/workflow/dynamic_checkout_config.go create mode 100644 pkg/workflow/dynamic_checkout_context_validation.go create mode 100644 pkg/workflow/dynamic_checkout_context_validation_test.go diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index 82afc5cd7a6..dd5d781913b 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -26,6 +26,19 @@ function parseDynamicCheckouts(value = process.env.GH_AW_DYNAMIC_CHECKOUTS || "" return entries; } +function parseAllowedRepos(value = process.env.GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS || "") { + let parsed; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`dynamic checkout allowed-repos must resolve to a JSON array: ${getErrorMessage(error)}`, { cause: error }); + } + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some(repository => typeof repository !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository))) { + throw new Error("dynamic checkout allowed-repos must resolve to a non-empty array of owner/repo names"); + } + return new Set(parsed.map(repository => repository.toLowerCase())); +} + function normalizeCheckout(entry, workspace) { for (const field of Object.keys(entry)) { if (!supportedFields.has(field)) { @@ -61,9 +74,13 @@ function normalizeCheckout(entry, workspace) { if (entry.wiki === true) { repository += ".wiki"; } + const ref = String(entry.ref || "").trim(); + if (ref.startsWith("-")) { + throw new Error("dynamic checkout ref must not start with '-'"); + } return { repository, - ref: String(entry.ref || "").trim(), + ref, path: checkoutPath, token: String(entry["github-token"] || entry.token || ""), fetchDepth, @@ -73,6 +90,12 @@ function normalizeCheckout(entry, workspace) { }; } +function assertSafeSparsePatterns(patterns) { + if (patterns.some(pattern => pattern.startsWith("-"))) { + throw new Error("dynamic checkout sparse-checkout patterns must not start with '-'"); + } +} + async function defaultRunGit(args, options = {}) { const result = await exec.getExecOutput("git", args, { silent: true, ...options }); if (result.exitCode !== 0) { @@ -131,6 +154,7 @@ async function checkoutRepository(checkout, options = {}) { } const authArgs = credentialArgs(serverURL, token, options.maskSecret); + const worktreeOptions = { env: { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" } }; const cloneArgs = [...authArgs, "clone", "--no-tags"]; if (checkout.fetchDepth > 0) { cloneArgs.push("--depth", String(checkout.fetchDepth)); @@ -138,15 +162,15 @@ async function checkoutRepository(checkout, options = {}) { cloneArgs.push(`${serverURL}/${checkout.repository}.git`, checkoutTarget); core.info(`Checking out ${checkout.repository} into ${checkout.path}`); - await runGit(cloneArgs); + await runGit(cloneArgs, worktreeOptions); 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); + fetchArgs.push("origin", "--", checkout.ref); await runGit(fetchArgs); - await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"]); + await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"], worktreeOptions); } if (checkout.sparseCheckout.trim()) { @@ -154,14 +178,15 @@ async function checkoutRepository(checkout, options = {}) { .split(/\r?\n/) .map(pattern => pattern.trim()) .filter(Boolean); - await runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", ...patterns]); + assertSafeSparsePatterns(patterns); + await runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", "--", ...patterns], worktreeOptions); } 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); + await runGit(args, worktreeOptions); } if (checkout.lfs) { await runGit([...authArgs, "-C", checkoutTarget, "lfs", "pull"]); @@ -221,6 +246,12 @@ async function main(options = {}) { } const checkouts = parseDynamicCheckouts(options.value); const normalized = checkouts.map(entry => normalizeCheckout(entry, workspace)); + const allowedRepos = parseAllowedRepos(options.allowedRepos); + for (const checkout of normalized) { + if (!allowedRepos.has(checkout.repository.toLowerCase())) { + throw new Error(`dynamic checkout repository '${checkout.repository}' is not in allowed-repos`); + } + } if (new Set(normalized.map(entry => entry.path.toLowerCase())).size !== normalized.length) { throw new Error("dynamic checkout paths must be unique"); } @@ -246,6 +277,7 @@ module.exports = { checkoutRepository, main, normalizeCheckout, + parseAllowedRepos, parseDynamicCheckouts, writeManifest, }; diff --git a/actions/setup/js/dynamic_checkouts.test.cjs b/actions/setup/js/dynamic_checkouts.test.cjs index 45445f8e334..1dee9f6e53e 100644 --- a/actions/setup/js/dynamic_checkouts.test.cjs +++ b/actions/setup/js/dynamic_checkouts.test.cjs @@ -5,7 +5,7 @@ import fs from "fs"; import os from "os"; import path from "path"; -const { checkoutRepository, normalizeCheckout, parseDynamicCheckouts, writeManifest } = require("./dynamic_checkouts.cjs"); +const { checkoutRepository, normalizeCheckout, parseAllowedRepos, parseDynamicCheckouts, writeManifest } = require("./dynamic_checkouts.cjs"); describe("parseDynamicCheckouts", () => { it("accepts one object or an array", () => { @@ -13,6 +13,17 @@ describe("parseDynamicCheckouts", () => { expect(parseDynamicCheckouts('[{"repository":"owner/a"},{"repository":"owner/b"}]')).toHaveLength(2); }); + describe("parseAllowedRepos", () => { + it("accepts a non-empty repository allowlist", () => { + expect(parseAllowedRepos('["owner/repo"]')).toEqual(new Set(["owner/repo"])); + }); + + it("rejects a missing or malformed allowlist", () => { + expect(() => parseAllowedRepos("")).toThrow("must resolve"); + expect(() => parseAllowedRepos('["not-a-repository"]')).toThrow("owner/repo"); + }); + }); + it("rejects scalar results", () => { expect(() => parseDynamicCheckouts('"owner/repo"')).toThrow("checkout object or an array"); }); @@ -29,6 +40,7 @@ describe("normalizeCheckout", () => { 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"); + expect(() => normalizeCheckout({ repository: "owner/repo", ref: "--upload-pack=evil" }, "/workspace")).toThrow("ref must not start"); }); }); @@ -77,6 +89,46 @@ describe("checkoutRepository", () => { ).rejects.toThrow("traverses a symbolic link"); expect(fs.existsSync(path.join(outside, "nested"))).toBe(false); }); + + it("terminates Git options and disables LFS smudging until lfs pull", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); + const target = path.join(workspace, "repo"); + const calls = []; + const runGit = async (args, options) => { + calls.push({ args, options }); + if (args.includes("clone")) { + fs.mkdirSync(path.join(target, ".git"), { recursive: true }); + } + return ""; + }; + const checkout = normalizeCheckout({ repository: "owner/repo", ref: "main", "sparse-checkout": "src\nREADME.md", submodules: true }, workspace); + + await checkoutRepository(checkout, { workspace, runGit, maskSecret: () => {} }); + + expect(calls.find(call => call.args.includes("fetch")).args.slice(-3)).toEqual(["origin", "--", "main"]); + expect(calls.find(call => call.args.includes("sparse-checkout")).args).toContain("--"); + for (const call of calls.filter(call => call.args.includes("clone") || call.args.includes("checkout") || call.args.includes("sparse-checkout") || call.args.includes("submodule"))) { + expect(call.options.env.GIT_LFS_SKIP_SMUDGE).toBe("1"); + } + }); + + it("rejects option-like sparse checkout patterns", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); + const target = path.join(workspace, "repo"); + const checkout = normalizeCheckout({ repository: "owner/repo", "sparse-checkout": "--stdin" }, workspace); + await expect( + checkoutRepository(checkout, { + workspace, + maskSecret: () => {}, + runGit: async args => { + if (args.includes("clone")) { + fs.mkdirSync(path.join(target, ".git"), { recursive: true }); + } + return ""; + }, + }) + ).rejects.toThrow("patterns must not start"); + }); }); describe("writeManifest", () => { diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index d38477ca488..4d5eaaa12f6 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -33,11 +33,15 @@ checkout: ### Dynamic Checkout Sets -Use a GitHub Actions expression when the repositories are only known at runtime. The -expression must resolve to one checkout object or an array of checkout objects: +Use a dynamic checkout declaration when repositories are only known at runtime. The +expression must resolve to one checkout object or an array of checkout objects. To +prevent untrusted input from selecting arbitrary repositories, `allowed-repos` is +required and may itself be a GitHub Actions expression resolving to an array: ```yaml wrap -checkout: ${{ fromJSON(inputs.checkouts) }} +checkout: + dynamic: ${{ fromJSON(inputs.checkouts) }} + allowed-repos: ${{ fromJSON(inputs.allowed-repos) }} ``` Dynamic entries are checked out in addition to the default workflow repository. Each @@ -60,12 +64,19 @@ reference it through `env.NAME` instead: ```yaml wrap env: CHECKOUT_TOKEN: ${{ secrets.MY_TOKEN }} -checkout: ${{ fromJSON(format('[{"repository":"{0}","github-token":"{1}"}]', inputs.repo, env.CHECKOUT_TOKEN)) }} +checkout: + dynamic: ${{ fromJSON(format('[{"repository":"{0}","github-token":"{1}"}]', inputs.repo, env.CHECKOUT_TOKEN)) }} + allowed-repos: + - owner/repository ``` Compilation fails (or warns, in non-strict mode) if a dynamic checkout expression references `secrets.*` directly. +Dynamic checkout expressions are evaluated in both the agent and `safe_outputs` jobs. +They cannot reference agent-job step outputs (`steps.*`); use workflow inputs, +`github.*`, `vars.*`, or top-level `env.*` values instead. + You can also use `checkout:` to check out additional repositories alongside the main repository: ```yaml wrap diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3cb10c2801e..aa582629291 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13281,9 +13281,24 @@ "description": "Set to false to disable the default checkout step. The agent job will not check out any repository (dev-mode checkouts are unaffected)." }, { - "type": "string", - "pattern": "^\\s*\\$\\{\\{[\\s\\S]+\\}\\}\\s*$", - "description": "A GitHub Actions expression that resolves at runtime to one checkout object or an array of checkout objects. Dynamic entries are additional checkouts and must identify a repository." + "type": "object", + "required": ["dynamic", "allowed-repos"], + "properties": { + "dynamic": { + "type": "string", + "pattern": "^\\s*\\$\\{\\{[\\s\\S]+\\}\\}\\s*$", + "description": "GitHub Actions expression resolving to one checkout object or an array of checkout objects." + }, + "allowed-repos": { + "oneOf": [ + { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" } }, + { "type": "string", "pattern": "^\\s*\\$\\{\\{[\\s\\S]+\\}\\}\\s*$" } + ], + "description": "Repositories permitted for dynamic checkout, or an expression resolving to their array." + } + }, + "additionalProperties": false, + "description": "Runtime checkout configuration with a required repository allowlist." } ] }, diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 3b9b99c340a..183e9ddf8ab 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -94,7 +94,7 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { //n condition, )...) steps = append(steps, injectStepCondition( - c.generateDynamicCheckoutSteps(data.CheckoutExpressions, prCheckoutToken, true), + c.generateDynamicCheckoutSteps(data.DynamicCheckouts, prCheckoutToken, true), condition, )...) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index d42a0d6fec2..68a0cb8c244 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -24,18 +24,11 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath } } - // Check for secrets serialization expressions FIRST — before the general allowlist — - // to provide a specific, actionable error/warning message. - // In strict mode this returns an error that stops further validation. - // In non-strict mode it emits a warning and continues. if err := c.validateSecretsSerializationExpressions(workflowData); err != nil { return formatCompilerError(markdownPath, "error", err.Error(), err) } - // Dynamic checkout expressions must not embed secrets directly: the resolved - // expression is serialized once into the GH_AW_DYNAMIC_CHECKOUTS runtime JSON - // payload, so secrets used there would not be statically listed as env vars. - if err := c.validateDynamicCheckoutSecretsUsage(workflowData); err != nil { + if err := c.validateDynamicCheckoutExpressions(workflowData); err != nil { return formatCompilerError(markdownPath, "error", err.Error(), err) } @@ -48,6 +41,7 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath if !c.effectiveStrictMode(workflowData.RawFrontmatter) { markdownForAllowlist = neutralizeSecretsSerializationExpressions(markdownForAllowlist) } + if err := validateExpressionSafety(markdownForAllowlist); err != nil { return formatCompilerError(markdownPath, "error", err.Error(), err) } @@ -85,6 +79,13 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath return nil } +func (c *Compiler) validateDynamicCheckoutExpressions(workflowData *WorkflowData) error { + if err := c.validateDynamicCheckoutSecretsUsage(workflowData); err != nil { + return err + } + return c.validateDynamicCheckoutContexts(workflowData) +} + func runtimeImportValidationMarkdown(workflowData *WorkflowData) string { if workflowData == nil { return "" diff --git a/pkg/workflow/compiler_yaml_checkout.go b/pkg/workflow/compiler_yaml_checkout.go index afe2787c350..23ba34ddd39 100644 --- a/pkg/workflow/compiler_yaml_checkout.go +++ b/pkg/workflow/compiler_yaml_checkout.go @@ -66,7 +66,7 @@ func (c *Compiler) generateInitialAndCheckoutSteps(yaml *strings.Builder, data * for _, line := range additionalLines { yaml.WriteString(line) } - for _, step := range c.generateDynamicCheckoutSteps(data.CheckoutExpressions, "", false) { + for _, step := range c.generateDynamicCheckoutSteps(data.DynamicCheckouts, "", false) { yaml.WriteString(step) } diff --git a/pkg/workflow/dynamic_checkout.go b/pkg/workflow/dynamic_checkout.go index 7bd2a8749e0..18e5b9225af 100644 --- a/pkg/workflow/dynamic_checkout.go +++ b/pkg/workflow/dynamic_checkout.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "fmt" "strings" ) @@ -8,14 +9,27 @@ import ( // generateDynamicCheckoutSteps emits one runtime checkout step per expression-valued // checkout declaration. GitHub Actions cannot expand an expression into a variable // number of steps, so the bundled script performs the additional checkouts with git. -func (c *Compiler) generateDynamicCheckoutSteps(expressions []string, overrideToken string, persistCredentials bool) []string { +func (c *Compiler) generateDynamicCheckoutSteps(checkouts []DynamicCheckoutConfig, overrideToken string, persistCredentials bool) []string { var steps []string - for index, expression := range expressions { + for index, checkout := range checkouts { var step strings.Builder fmt.Fprintf(&step, " - name: Checkout dynamic repositories (%d)\n", index+1) fmt.Fprintf(&step, " uses: %s\n", c.getActionPin("actions/github-script")) step.WriteString(" env:\n") - fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUTS: %s\n", wrapExpressionWithToJSON(expression)) + fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUTS: %s\n", wrapExpressionWithToJSON(checkout.Expression)) + allowedReposExpression := "" + if len(checkout.AllowedRepos) == 1 { + for _, allowedRepoExpression := range checkout.AllowedRepos { + if isExpression(allowedRepoExpression) { + allowedReposExpression = allowedRepoExpression + } + } + } + if allowedReposExpression != "" { + fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS: %s\n", wrapExpressionWithToJSON(allowedReposExpression)) + } else if allowedReposJSON, err := json.Marshal(checkout.AllowedRepos); err == nil { + writeYAMLEnv(&step, " ", "GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS", string(allowedReposJSON)) + } step.WriteString(" GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\n") if overrideToken != "" { fmt.Fprintf(&step, " GH_AW_DYNAMIC_CHECKOUT_TOKEN: %s\n", overrideToken) @@ -32,8 +46,8 @@ func (c *Compiler) generateDynamicCheckoutSteps(expressions []string, overrideTo return steps } -func buildDynamicCheckoutsPromptContent(expressions []string) string { - if len(expressions) == 0 { +func buildDynamicCheckoutsPromptContent(checkouts []DynamicCheckoutConfig) string { + if len(checkouts) == 0 { return "" } return "- **dynamic checkouts**: Additional repositories were selected and checked out at runtime. " + diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go new file mode 100644 index 00000000000..a8b60a4d08d --- /dev/null +++ b/pkg/workflow/dynamic_checkout_config.go @@ -0,0 +1,61 @@ +package workflow + +import ( + "errors" + "strings" +) + +type DynamicCheckoutConfig struct { + Expression string + AllowedRepos []string +} + +func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) { + raw, ok := value.(map[string]any) + if !ok { + return DynamicCheckoutConfig{}, false, nil + } + expression, hasExpression := raw["dynamic"].(string) + if !hasExpression || !isExpression(expression) { + return DynamicCheckoutConfig{}, false, nil + } + if len(raw) != 2 { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") + } + allowed, ok := raw["allowed-repos"] + if !ok { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout requires allowed-repos") + } + allowedRepos, err := parseStringArrayOrExpression(allowed) + if err != nil || len(allowedRepos) == 0 { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout allowed-repos must be a non-empty array or GitHub Actions expression") + } + return DynamicCheckoutConfig{Expression: strings.TrimSpace(expression), AllowedRepos: allowedRepos}, true, nil +} + +func parseStringArrayOrExpression(value any) ([]string, error) { + if expression, ok := value.(string); ok && isExpression(expression) { + return []string{strings.TrimSpace(expression)}, nil + } + values, ok := value.([]any) + if !ok { + return nil, errors.New("expected array") + } + result := make([]string, 0, len(values)) + for _, value := range values { + repository, ok := value.(string) + if !ok || !strings.Contains(repository, "/") { + return nil, errors.New("expected repository names") + } + result = append(result, repository) + } + return result, nil +} + +func dynamicCheckoutExpressions(checkouts []DynamicCheckoutConfig) []string { + expressions := make([]string, 0, len(checkouts)) + for _, checkout := range checkouts { + expressions = append(expressions, checkout.Expression) + } + return expressions +} diff --git a/pkg/workflow/dynamic_checkout_context_validation.go b/pkg/workflow/dynamic_checkout_context_validation.go new file mode 100644 index 00000000000..6300f838d92 --- /dev/null +++ b/pkg/workflow/dynamic_checkout_context_validation.go @@ -0,0 +1,19 @@ +package workflow + +import ( + "errors" + "regexp" +) + +var dynamicCheckoutStepsContextPattern = regexp.MustCompile(`\bsteps\s*\.`) + +// validateDynamicCheckoutContexts rejects agent-job step references because dynamic +// checkouts are also evaluated in the safe_outputs job. +func (c *Compiler) validateDynamicCheckoutContexts(workflowData *WorkflowData) error { + for _, expression := range dynamicCheckoutExpressions(workflowData.DynamicCheckouts) { + if dynamicCheckoutStepsContextPattern.MatchString(maskQuotedExpressionLiterals(expression)) { + return errors.New("dynamic checkout expressions cannot reference steps.* because they are also evaluated in the safe_outputs job; use a workflow input, vars.*, github.*, or top-level env.* instead") + } + } + return nil +} diff --git a/pkg/workflow/dynamic_checkout_context_validation_test.go b/pkg/workflow/dynamic_checkout_context_validation_test.go new file mode 100644 index 00000000000..2cbb6379d98 --- /dev/null +++ b/pkg/workflow/dynamic_checkout_context_validation_test.go @@ -0,0 +1,21 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateDynamicCheckoutContexts(t *testing.T) { + compiler := NewCompiler() + err := compiler.validateDynamicCheckoutContexts(&WorkflowData{ + DynamicCheckouts: []DynamicCheckoutConfig{{Expression: "${{ fromJSON(steps.select.outputs.checkouts) }}"}}, + }) + require.ErrorContains(t, err, "cannot reference steps.*") + + err = compiler.validateDynamicCheckoutContexts(&WorkflowData{ + DynamicCheckouts: []DynamicCheckoutConfig{{Expression: "${{ fromJSON(inputs.checkouts) }}"}}, + }) + assert.NoError(t, err) +} diff --git a/pkg/workflow/dynamic_checkout_secrets_validation.go b/pkg/workflow/dynamic_checkout_secrets_validation.go index 5965ee43ab2..edaa9dc5ef3 100644 --- a/pkg/workflow/dynamic_checkout_secrets_validation.go +++ b/pkg/workflow/dynamic_checkout_secrets_validation.go @@ -75,7 +75,7 @@ func findDynamicCheckoutSecretsExpressions(expressions []string) []string { // In strict mode this returns an error; in non-strict mode it emits a warning to // stderr and increments the compiler warning count. func (c *Compiler) validateDynamicCheckoutSecretsUsage(workflowData *WorkflowData) error { - found := findDynamicCheckoutSecretsExpressions(workflowData.CheckoutExpressions) + found := findDynamicCheckoutSecretsExpressions(dynamicCheckoutExpressions(workflowData.DynamicCheckouts)) if len(found) == 0 { dynamicCheckoutSecretsLog.Printf("No dynamic checkout secrets usage found") return nil diff --git a/pkg/workflow/dynamic_checkout_secrets_validation_test.go b/pkg/workflow/dynamic_checkout_secrets_validation_test.go index 7062507875c..38281b3d49e 100644 --- a/pkg/workflow/dynamic_checkout_secrets_validation_test.go +++ b/pkg/workflow/dynamic_checkout_secrets_validation_test.go @@ -135,9 +135,13 @@ func TestValidateDynamicCheckoutSecretsUsage(t *testing.T) { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler() compiler.strictMode = tt.strictMode + checkouts := make([]DynamicCheckoutConfig, 0, len(tt.expressions)) + for _, expression := range tt.expressions { + checkouts = append(checkouts, DynamicCheckoutConfig{Expression: expression, AllowedRepos: []string{"owner/repo"}}) + } workflowData := &WorkflowData{ - CheckoutExpressions: tt.expressions, - RawFrontmatter: tt.rawFrontmatter, + DynamicCheckouts: checkouts, + RawFrontmatter: tt.rawFrontmatter, } warningsBefore := compiler.GetWarningCount() diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 041a50461f9..74bd6dcaedf 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -8,23 +8,39 @@ import ( ) func TestParseFrontmatterConfigDynamicCheckout(t *testing.T) { - expression := "${{ fromJSON(inputs.checkouts) }}" config, err := ParseFrontmatterConfig(map[string]any{ - "name": "dynamic-checkout", - "engine": "copilot", - "checkout": expression, + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "dynamic": "${{ fromJSON(inputs.checkouts) }}", + "allowed-repos": []any{"owner/repo"}, + }, }) require.NoError(t, err) - assert.Equal(t, []string{expression}, config.CheckoutExpressions) + assert.Equal(t, []DynamicCheckoutConfig{{Expression: "${{ fromJSON(inputs.checkouts) }}", AllowedRepos: []string{"owner/repo"}}}, config.DynamicCheckouts) assert.Empty(t, config.CheckoutConfigs) assert.False(t, config.CheckoutDisabled) } +func TestParseFrontmatterConfigDynamicCheckoutTrimsExpression(t *testing.T) { + config, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "dynamic": " ${{ fromJSON(inputs.checkouts) }} ", + "allowed-repos": "${{ fromJSON(inputs.allowed_repos) }}", + }, + }) + + require.NoError(t, err) + assert.Equal(t, []DynamicCheckoutConfig{{Expression: "${{ fromJSON(inputs.checkouts) }}", AllowedRepos: []string{"${{ fromJSON(inputs.allowed_repos) }}"}}}, config.DynamicCheckouts) +} + func TestGenerateDynamicCheckoutSteps(t *testing.T) { compiler := NewCompiler() steps := compiler.generateDynamicCheckoutSteps( - []string{"${{ fromJSON(inputs.checkouts) }}"}, + []DynamicCheckoutConfig{{Expression: "${{ fromJSON(inputs.checkouts) }}", AllowedRepos: []string{"owner/repo"}}}, "${{ secrets.PUSH_TOKEN }}", true, ) @@ -32,6 +48,7 @@ func TestGenerateDynamicCheckoutSteps(t *testing.T) { require.Len(t, steps, 1) assert.Contains(t, steps[0], "name: Checkout dynamic repositories (1)") assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUTS: ${{ toJSON(fromJSON(inputs.checkouts)) }}") + assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS: \"[\\\"owner/repo\\\"]\"") assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUT_TOKEN: ${{ secrets.PUSH_TOKEN }}") assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS: true") assert.Contains(t, steps[0], "dynamic_checkouts.cjs") @@ -39,7 +56,7 @@ func TestGenerateDynamicCheckoutSteps(t *testing.T) { func TestGenerateDynamicCheckoutStepsPreservesToJSON(t *testing.T) { compiler := NewCompiler() - steps := compiler.generateDynamicCheckoutSteps([]string{"${{ toJSON(inputs.checkouts) }}"}, "", false) + steps := compiler.generateDynamicCheckoutSteps([]DynamicCheckoutConfig{{Expression: "${{ toJSON(inputs.checkouts) }}", AllowedRepos: []string{"${{ inputs.allowed_repos }}"}}}, "", false) require.Len(t, steps, 1) assert.Contains(t, steps[0], "GH_AW_DYNAMIC_CHECKOUTS: ${{ toJSON(inputs.checkouts) }}") @@ -47,7 +64,7 @@ func TestGenerateDynamicCheckoutStepsPreservesToJSON(t *testing.T) { } func TestBuildDynamicCheckoutsPromptContent(t *testing.T) { - content := buildDynamicCheckoutsPromptContent([]string{"${{ inputs.checkouts }}"}) + content := buildDynamicCheckoutsPromptContent([]DynamicCheckoutConfig{{Expression: "${{ inputs.checkouts }}"}}) assert.Contains(t, content, "selected and checked out at runtime") assert.Contains(t, content, "checkout-manifest.json") diff --git a/pkg/workflow/expression_patterns.go b/pkg/workflow/expression_patterns.go index 4d0f3679d26..50ce1e5b1b9 100644 --- a/pkg/workflow/expression_patterns.go +++ b/pkg/workflow/expression_patterns.go @@ -93,6 +93,7 @@ func containsExpression(s string) bool { // isExpression reports whether the entire string s is a GitHub Actions expression. func isExpression(s string) bool { + s = strings.TrimSpace(s) result := strings.HasPrefix(s, "${{") && strings.HasSuffix(s, "}}") if result { expressionPatternsLog.Printf("isExpression: entire value is an expression (length %d)", len(s)) diff --git a/pkg/workflow/frontmatter_parsing.go b/pkg/workflow/frontmatter_parsing.go index 8c4bf0647f9..b85d847efae 100644 --- a/pkg/workflow/frontmatter_parsing.go +++ b/pkg/workflow/frontmatter_parsing.go @@ -97,8 +97,11 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err config.CheckoutDisabled = true config.CheckoutExplicitlyDisabled = true frontmatterTypesLog.Print("Checkout disabled via checkout: false") - } else if checkoutExpression, ok := config.Checkout.(string); ok && isExpression(checkoutExpression) { - config.CheckoutExpressions = []string{checkoutExpression} + } else if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(config.Checkout); ok { + if err != nil { + return nil, err + } + config.DynamicCheckouts = []DynamicCheckoutConfig{dynamicCheckout} frontmatterTypesLog.Print("Parsed expression-valued checkout configuration") } else { checkoutConfigs, err := ParseCheckoutConfigs(config.Checkout) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index ae601075c2b..5c29bb32719 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -462,12 +462,12 @@ type FrontmatterConfig struct { // Controls how actions/checkout is invoked. // Can be a single CheckoutConfig object or an array of CheckoutConfig objects. // Set to false to disable the default checkout step entirely. - Checkout any `json:"checkout,omitempty"` // Raw value (object, array, or false) - CheckoutConfigs []*CheckoutConfig `json:"-"` // Parsed checkout configs (not in JSON) - CheckoutExpressions []string `json:"-"` // Runtime expressions resolving to checkout config objects or arrays - CheckoutDisabled bool `json:"-"` // true when checkout: false is set in frontmatter - CheckoutExplicitlyDisabled bool `json:"-"` // true only when checkout: false is explicitly written by the user in frontmatter - CheckoutSkipDefault bool `json:"-"` // true when permissions.contents: none skips only the default workflow-repository checkout + Checkout any `json:"checkout,omitempty"` // Raw value (object, array, or false) + CheckoutConfigs []*CheckoutConfig `json:"-"` // Parsed checkout configs (not in JSON) + DynamicCheckouts []DynamicCheckoutConfig `json:"-"` // Runtime checkout expressions and their repository allowlists + CheckoutDisabled bool `json:"-"` // true when checkout: false is set in frontmatter + CheckoutExplicitlyDisabled bool `json:"-"` // true only when checkout: false is explicitly written by the user in frontmatter + CheckoutSkipDefault bool `json:"-"` // true when permissions.contents: none skips only the default workflow-repository checkout // Model is the top-level LLM model default. An engine.model value overrides it // for that engine instance. diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 992a347a2cd..b854621e56c 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -172,9 +172,9 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection { / if data.TrialMode && data.TrialLogicalRepo != "" { combinedPromptText = applyTrialLogicalRepoToGitHubContext(combinedPromptText, data.TrialLogicalRepo) } - checkoutsContent := buildCheckoutsPromptContent(data.CheckoutConfigs) + buildDynamicCheckoutsPromptContent(data.CheckoutExpressions) + checkoutsContent := buildCheckoutsPromptContent(data.CheckoutConfigs) + buildDynamicCheckoutsPromptContent(data.DynamicCheckouts) if checkoutsContent != "" { - unifiedPromptLog.Printf("Injecting checkout context into GitHub context (%d static, %d dynamic)", len(data.CheckoutConfigs), len(data.CheckoutExpressions)) + unifiedPromptLog.Printf("Injecting checkout context into GitHub context (%d static, %d dynamic)", len(data.CheckoutConfigs), len(data.DynamicCheckouts)) const closeTag = "" if idx := strings.LastIndex(combinedPromptText, closeTag); idx >= 0 { combinedPromptText = combinedPromptText[:idx] + checkoutsContent + combinedPromptText[idx:] diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index b491bb22688..4a4946136ab 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -113,7 +113,7 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work // (e.g. due to unrecognised tool config shapes like bash: ["*"]). if toolsResult.parsedFrontmatter != nil { workflowData.CheckoutConfigs = toolsResult.parsedFrontmatter.CheckoutConfigs - workflowData.CheckoutExpressions = toolsResult.parsedFrontmatter.CheckoutExpressions + workflowData.DynamicCheckouts = toolsResult.parsedFrontmatter.DynamicCheckouts workflowData.CheckoutDisabled = toolsResult.parsedFrontmatter.CheckoutDisabled workflowData.CheckoutExplicitlyDisabled = toolsResult.parsedFrontmatter.CheckoutExplicitlyDisabled workflowData.CheckoutSkipDefault = toolsResult.parsedFrontmatter.CheckoutSkipDefault @@ -122,8 +122,8 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work if checkoutValue, ok := rawCheckout.(bool); ok && !checkoutValue { workflowData.CheckoutDisabled = true workflowData.CheckoutExplicitlyDisabled = true - } else if checkoutExpression, ok := rawCheckout.(string); ok && isExpression(checkoutExpression) { - workflowData.CheckoutExpressions = append(workflowData.CheckoutExpressions, checkoutExpression) + } else if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(rawCheckout); ok && err == nil { + workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) } else if configs, err := ParseCheckoutConfigs(rawCheckout); err == nil { workflowData.CheckoutConfigs = configs } @@ -150,8 +150,8 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work workflowBuilderLog.Printf("Failed to unmarshal imported checkout JSON: %v", err) continue } - if checkoutExpression, ok := raw.(string); ok && isExpression(checkoutExpression) { - workflowData.CheckoutExpressions = append(workflowData.CheckoutExpressions, checkoutExpression) + if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(raw); ok && err == nil { + workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) continue } importedConfigs, err := ParseCheckoutConfigs(raw) @@ -167,7 +167,7 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work // checkout: entries (own repo, imports) are configured, since that leaves the // agent with no working-directory checkout at all (effectively equivalent to // checkout: false, but without the explicit intent that flag signals). - if workflowData.CheckoutSkipDefault && !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.CheckoutExpressions) == 0 { + if workflowData.CheckoutSkipDefault && !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.DynamicCheckouts) == 0 { warningMsg := "permissions.contents: none skips the default workflow-repository checkout, " + "but no other checkout: entries are configured; the agent job will have no repository " + "checked out. Add a target checkout: entry, or set checkout: false to make the intent explicit." @@ -190,7 +190,7 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work // suppresses the checkout_pr_branch.cjs step regardless of checkout configuration. workflowData.IsPullRequestTarget = true - if !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.CheckoutExpressions) == 0 { + if !workflowData.CheckoutDisabled && len(workflowData.CheckoutConfigs) == 0 && len(workflowData.DynamicCheckouts) == 0 { if _, checkoutExplicitlySet := result.Frontmatter["checkout"]; !checkoutExplicitlySet { workflowBuilderLog.Print("Auto-disabling checkout for pull_request_target workflow") workflowData.CheckoutDisabled = true diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index 413d1af5b92..58f5c3e9e14 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -180,7 +180,7 @@ type WorkflowData struct { HasExplicitGitHubTool bool // true if tools.github was explicitly configured in frontmatter InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter - CheckoutExpressions []string // GitHub Actions expressions resolving to dynamic checkout settings + DynamicCheckouts []DynamicCheckoutConfig // Runtime checkout expressions and their repository allowlists CheckoutDisabled bool // true when checkout: false is set in frontmatter, or auto-disabled for pull_request_target CheckoutExplicitlyDisabled bool // true only when checkout: false is explicitly set in frontmatter (not auto-disabled) CheckoutSkipDefault bool // true when permissions.contents: none skips only the default workflow-repository checkout From a40339dadf5814ad0a7e36bf969faf1c5e135b43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:57:17 +0000 Subject: [PATCH 05/26] Clarify dynamic checkout allowlist errors Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 6 +++--- pkg/workflow/dynamic_checkout_test.go | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index a8b60a4d08d..7591a02000d 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -19,13 +19,13 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) if !hasExpression || !isExpression(expression) { return DynamicCheckoutConfig{}, false, nil } - if len(raw) != 2 { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") - } allowed, ok := raw["allowed-repos"] if !ok { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout requires allowed-repos") } + if len(raw) != 2 { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") + } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout allowed-repos must be a non-empty array or GitHub Actions expression") diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 74bd6dcaedf..74815983baf 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -37,6 +37,18 @@ func TestParseFrontmatterConfigDynamicCheckoutTrimsExpression(t *testing.T) { assert.Equal(t, []DynamicCheckoutConfig{{Expression: "${{ fromJSON(inputs.checkouts) }}", AllowedRepos: []string{"${{ fromJSON(inputs.allowed_repos) }}"}}}, config.DynamicCheckouts) } +func TestParseFrontmatterConfigDynamicCheckoutRequiresAllowedRepos(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "dynamic": "${{ fromJSON(inputs.checkouts) }}", + }, + }) + + require.ErrorContains(t, err, "requires allowed-repos") +} + func TestGenerateDynamicCheckoutSteps(t *testing.T) { compiler := NewCompiler() steps := compiler.generateDynamicCheckoutSteps( From 801b499bc28d46f7be14c764284131de2fe43f21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:03:35 +0000 Subject: [PATCH 06/26] Validate deferred dynamic checkout errors Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/compiler_validators.go | 3 +++ pkg/workflow/workflow_builder.go | 16 ++++++++++++---- pkg/workflow/workflow_data.go | 1 + 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 68a0cb8c244..f2f66804034 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -80,6 +80,9 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath } func (c *Compiler) validateDynamicCheckoutExpressions(workflowData *WorkflowData) error { + for _, err := range workflowData.DynamicCheckoutErrors { + return err + } if err := c.validateDynamicCheckoutSecretsUsage(workflowData); err != nil { return err } diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 4a4946136ab..09ffe0f5b9a 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -122,8 +122,12 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work if checkoutValue, ok := rawCheckout.(bool); ok && !checkoutValue { workflowData.CheckoutDisabled = true workflowData.CheckoutExplicitlyDisabled = true - } else if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(rawCheckout); ok && err == nil { - workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) + } else if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(rawCheckout); ok { + if err != nil { + workflowData.DynamicCheckoutErrors = append(workflowData.DynamicCheckoutErrors, err) + } else { + workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) + } } else if configs, err := ParseCheckoutConfigs(rawCheckout); err == nil { workflowData.CheckoutConfigs = configs } @@ -150,8 +154,12 @@ func (c *Compiler) buildInitialWorkflowData( //nolint:largefunc // Existing work workflowBuilderLog.Printf("Failed to unmarshal imported checkout JSON: %v", err) continue } - if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(raw); ok && err == nil { - workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) + if dynamicCheckout, ok, err := parseDynamicCheckoutConfig(raw); ok { + if err != nil { + workflowData.DynamicCheckoutErrors = append(workflowData.DynamicCheckoutErrors, err) + } else { + workflowData.DynamicCheckouts = append(workflowData.DynamicCheckouts, dynamicCheckout) + } continue } importedConfigs, err := ParseCheckoutConfigs(raw) diff --git a/pkg/workflow/workflow_data.go b/pkg/workflow/workflow_data.go index 58f5c3e9e14..12adac281d5 100644 --- a/pkg/workflow/workflow_data.go +++ b/pkg/workflow/workflow_data.go @@ -181,6 +181,7 @@ type WorkflowData struct { InlinedImports bool // if true, inline all imports at compile time (from inlined-imports frontmatter field) CheckoutConfigs []*CheckoutConfig // user-configured checkout settings from frontmatter DynamicCheckouts []DynamicCheckoutConfig // Runtime checkout expressions and their repository allowlists + DynamicCheckoutErrors []error // Deferred parsing errors from fallback and imported checkout handling CheckoutDisabled bool // true when checkout: false is set in frontmatter, or auto-disabled for pull_request_target CheckoutExplicitlyDisabled bool // true only when checkout: false is explicitly set in frontmatter (not auto-disabled) CheckoutSkipDefault bool // true when permissions.contents: none skips only the default workflow-repository checkout From 53fad2474a242c152d8d8b2052f40fc4af32ceff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:07:42 +0000 Subject: [PATCH 07/26] Polish dynamic checkout validation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/compiler_validators.go | 4 ++-- pkg/workflow/dynamic_checkout_config.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index f2f66804034..0fad9ff7cdd 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -80,8 +80,8 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath } func (c *Compiler) validateDynamicCheckoutExpressions(workflowData *WorkflowData) error { - for _, err := range workflowData.DynamicCheckoutErrors { - return err + if len(workflowData.DynamicCheckoutErrors) > 0 { + return errors.Join(workflowData.DynamicCheckoutErrors...) } if err := c.validateDynamicCheckoutSecretsUsage(workflowData); err != nil { return err diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index 7591a02000d..d354a4340a8 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -42,8 +42,8 @@ func parseStringArrayOrExpression(value any) ([]string, error) { return nil, errors.New("expected array") } result := make([]string, 0, len(values)) - for _, value := range values { - repository, ok := value.(string) + for _, item := range values { + repository, ok := item.(string) if !ok || !strings.Contains(repository, "/") { return nil, errors.New("expected repository names") } From 55c159a8c8b47446fb301d86c77f5e11ffdfb485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:10:05 +0000 Subject: [PATCH 08/26] Allow dynamic wiki checkout lists Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/dynamic_checkouts.cjs | 4 +++- pkg/workflow/compiler_validators.go | 1 + pkg/workflow/dynamic_checkout_config.go | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index dd5d781913b..1b71f12536d 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -87,6 +87,7 @@ function normalizeCheckout(entry, workspace) { sparseCheckout: String(entry["sparse-checkout"] || ""), submodules: entry.submodules, lfs: entry.lfs === true, + wiki: entry.wiki === true, }; } @@ -248,7 +249,8 @@ async function main(options = {}) { const normalized = checkouts.map(entry => normalizeCheckout(entry, workspace)); const allowedRepos = parseAllowedRepos(options.allowedRepos); for (const checkout of normalized) { - if (!allowedRepos.has(checkout.repository.toLowerCase())) { + const allowedRepository = checkout.wiki ? checkout.repository.slice(0, -".wiki".length) : checkout.repository; + if (!allowedRepos.has(allowedRepository.toLowerCase())) { throw new Error(`dynamic checkout repository '${checkout.repository}' is not in allowed-repos`); } } diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 0fad9ff7cdd..aa447c2a86f 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -80,6 +80,7 @@ func (c *Compiler) validateExpressions(workflowData *WorkflowData, markdownPath } func (c *Compiler) validateDynamicCheckoutExpressions(workflowData *WorkflowData) error { + // Validate deferred parsing, secret serialization, and cross-job expression contexts. if len(workflowData.DynamicCheckoutErrors) > 0 { return errors.Join(workflowData.DynamicCheckoutErrors...) } diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index d354a4340a8..209eea84369 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -23,6 +23,11 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) if !ok { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout requires allowed-repos") } + for key := range raw { + if key != "dynamic" && key != "allowed-repos" { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") + } + } if len(raw) != 2 { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") } From 5e57e8dac0028e9049a41607b8a90fc5727b6e02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:26:02 +0000 Subject: [PATCH 09/26] Harden dynamic checkout docs and paths Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../workflows/agentic-token-optimizer.lock.yml | 2 +- .../daily-harness-experiment-proposer.lock.yml | 2 +- .github/workflows/smoke-ci.lock.yml | 2 +- actions/setup/js/dynamic_checkouts.cjs | 10 +++++++++- actions/setup/js/dynamic_checkouts.test.cjs | 16 ++++++++++++++++ docs/src/content/docs/reference/checkout.md | 9 +++++++-- 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/agentic-token-optimizer.lock.yml b/.github/workflows/agentic-token-optimizer.lock.yml index 250849b3679..42bde54bc86 100644 --- a/.github/workflows/agentic-token-optimizer.lock.yml +++ b/.github/workflows/agentic-token-optimizer.lock.yml @@ -1879,7 +1879,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) && needs.agent.result != 'skipped' + if: always() && (!cancelled()) runs-on: ubuntu-slim permissions: contents: write diff --git a/.github/workflows/daily-harness-experiment-proposer.lock.yml b/.github/workflows/daily-harness-experiment-proposer.lock.yml index 03f717d0b01..57dde49feed 100644 --- a/.github/workflows/daily-harness-experiment-proposer.lock.yml +++ b/.github/workflows/daily-harness-experiment-proposer.lock.yml @@ -1995,7 +1995,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) && needs.agent.result != 'skipped' + if: always() && (!cancelled()) runs-on: ubuntu-slim permissions: contents: write diff --git a/.github/workflows/smoke-ci.lock.yml b/.github/workflows/smoke-ci.lock.yml index a7fb2ca6958..74216975ec3 100644 --- a/.github/workflows/smoke-ci.lock.yml +++ b/.github/workflows/smoke-ci.lock.yml @@ -1800,7 +1800,7 @@ jobs: - activation - agent - safe_outputs - if: always() && (!cancelled()) && needs.agent.result != 'skipped' + if: always() && (!cancelled()) runs-on: ubuntu-slim permissions: contents: write diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index 1b71f12536d..c041c92b8fb 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -127,8 +127,16 @@ async function checkoutRepository(checkout, options = {}) { try { workspaceReal = fs.realpathSync(workspace); checkoutTarget = path.join(workspaceReal, checkout.path); - if (fs.existsSync(checkoutTarget)) { + try { + const targetStats = fs.lstatSync(checkoutTarget); + if (targetStats.isSymbolicLink()) { + throw new Error(`dynamic checkout path is a symbolic link: ${checkout.path}`); + } throw new Error(`dynamic checkout path already exists: ${checkout.path}`); + } catch (error) { + if (!error || error.code !== "ENOENT") { + throw error; + } } let parent = workspaceReal; diff --git a/actions/setup/js/dynamic_checkouts.test.cjs b/actions/setup/js/dynamic_checkouts.test.cjs index 1dee9f6e53e..6fda1968810 100644 --- a/actions/setup/js/dynamic_checkouts.test.cjs +++ b/actions/setup/js/dynamic_checkouts.test.cjs @@ -90,6 +90,22 @@ describe("checkoutRepository", () => { expect(fs.existsSync(path.join(outside, "nested"))).toBe(false); }); + it("rejects a symlinked checkout path before clone", 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(path.join(outside, "repo"), path.join(workspace, "repo")); + const checkout = normalizeCheckout({ repository: "owner/repo" }, workspace); + + await expect( + checkoutRepository(checkout, { + workspace, + runGit: async () => "", + maskSecret: () => {}, + }) + ).rejects.toThrow("path is a symbolic link"); + expect(fs.existsSync(path.join(outside, "repo"))).toBe(false); + }); + it("terminates Git options and disables LFS smudging until lfs pull", async () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); const target = path.join(workspace, "repo"); diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 4d5eaaa12f6..459f4ad67c4 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -41,7 +41,8 @@ required and may itself be a GitHub Actions expression resolving to an array: ```yaml wrap checkout: dynamic: ${{ fromJSON(inputs.checkouts) }} - allowed-repos: ${{ fromJSON(inputs.allowed-repos) }} + # Use trusted configuration for this allowlist, not caller-controlled input. + allowed-repos: ${{ fromJSON(vars.ALLOWED_DYNAMIC_CHECKOUT_REPOS) }} ``` Dynamic entries are checked out in addition to the default workflow repository. Each @@ -65,11 +66,15 @@ reference it through `env.NAME` instead: env: CHECKOUT_TOKEN: ${{ secrets.MY_TOKEN }} checkout: - dynamic: ${{ fromJSON(format('[{"repository":"{0}","github-token":"{1}"}]', inputs.repo, env.CHECKOUT_TOKEN)) }} + dynamic: ${{ fromJSON(inputs.checkouts) }} allowed-repos: - owner/repository ``` +The `inputs.checkouts` value should already be valid JSON for one checkout object +or an array of checkout objects. Do not build that JSON by concatenating or +formatting caller-controlled strings. + Compilation fails (or warns, in non-strict mode) if a dynamic checkout expression references `secrets.*` directly. From e90d8f6320e95300936d0255f90861e8520f4482 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:31:39 +0000 Subject: [PATCH 10/26] Polish dynamic checkout path validation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../agentic-token-optimizer.lock.yml | 2 +- actions/setup/js/dynamic_checkouts.cjs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/agentic-token-optimizer.lock.yml b/.github/workflows/agentic-token-optimizer.lock.yml index 42bde54bc86..250849b3679 100644 --- a/.github/workflows/agentic-token-optimizer.lock.yml +++ b/.github/workflows/agentic-token-optimizer.lock.yml @@ -1879,7 +1879,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) + if: always() && (!cancelled()) && needs.agent.result != 'skipped' runs-on: ubuntu-slim permissions: contents: write diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index c041c92b8fb..de83085ce5c 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -115,6 +115,17 @@ function credentialArgs(serverURL, token, maskSecret = value => core.setSecret(v return ["-c", `http.${serverURL}/.extraheader=AUTHORIZATION: basic ${encoded}`]; } +function lstatIfExists(target) { + try { + return fs.lstatSync(target); + } catch (error) { + if (error && error.code === "ENOENT") { + return null; + } + throw error; + } +} + 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(/\/+$/, ""); @@ -127,16 +138,12 @@ async function checkoutRepository(checkout, options = {}) { try { workspaceReal = fs.realpathSync(workspace); checkoutTarget = path.join(workspaceReal, checkout.path); - try { - const targetStats = fs.lstatSync(checkoutTarget); + const targetStats = lstatIfExists(checkoutTarget); + if (targetStats) { if (targetStats.isSymbolicLink()) { throw new Error(`dynamic checkout path is a symbolic link: ${checkout.path}`); } throw new Error(`dynamic checkout path already exists: ${checkout.path}`); - } catch (error) { - if (!error || error.code !== "ENOENT") { - throw error; - } } let parent = workspaceReal; From 706293845a9286df7adfa2d9fdca72216a1af53a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:52:22 +0000 Subject: [PATCH 11/26] Prepare dynamic checkout review follow-up Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/agentic-token-optimizer.lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic-token-optimizer.lock.yml b/.github/workflows/agentic-token-optimizer.lock.yml index 250849b3679..42bde54bc86 100644 --- a/.github/workflows/agentic-token-optimizer.lock.yml +++ b/.github/workflows/agentic-token-optimizer.lock.yml @@ -1879,7 +1879,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) && needs.agent.result != 'skipped' + if: always() && (!cancelled()) runs-on: ubuntu-slim permissions: contents: write From 1a90bef374eb1f0bc918e58be9feb4e0589832b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:52:40 +0000 Subject: [PATCH 12/26] Revert unrelated generated workflow change Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/agentic-token-optimizer.lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic-token-optimizer.lock.yml b/.github/workflows/agentic-token-optimizer.lock.yml index 42bde54bc86..250849b3679 100644 --- a/.github/workflows/agentic-token-optimizer.lock.yml +++ b/.github/workflows/agentic-token-optimizer.lock.yml @@ -1879,7 +1879,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) + if: always() && (!cancelled()) && needs.agent.result != 'skipped' runs-on: ubuntu-slim permissions: contents: write From 2e9f6b734d98411017741c8196c993a043503f35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:01:25 +0000 Subject: [PATCH 13/26] Regenerate workflow locks after checkout changes Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/agentic-token-optimizer.lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agentic-token-optimizer.lock.yml b/.github/workflows/agentic-token-optimizer.lock.yml index 250849b3679..42bde54bc86 100644 --- a/.github/workflows/agentic-token-optimizer.lock.yml +++ b/.github/workflows/agentic-token-optimizer.lock.yml @@ -1879,7 +1879,7 @@ jobs: - agent - evals - safe_outputs - if: always() && (!cancelled()) && needs.agent.result != 'skipped' + if: always() && (!cancelled()) runs-on: ubuntu-slim permissions: contents: write From 81577d70bc08f6c0d0561cd2c02a7ecc92764b22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:50:26 +0000 Subject: [PATCH 14/26] Document dynamic checkout runtime modules Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/dynamic_checkouts.cjs | 5 +++++ pkg/workflow/dynamic_checkout.go | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index de83085ce5c..d5fcb7145af 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -1,6 +1,11 @@ // @ts-check /// +// Runtime support for expression-valued checkout declarations. The compiler +// passes resolved checkout JSON and an allowed-repos list through environment +// variables; this module validates them, performs the extra git checkouts, and +// updates the checkout manifest consumed by agent and safe-output jobs. + require("./shim.cjs"); const fs = require("fs"); diff --git a/pkg/workflow/dynamic_checkout.go b/pkg/workflow/dynamic_checkout.go index 18e5b9225af..6509471158a 100644 --- a/pkg/workflow/dynamic_checkout.go +++ b/pkg/workflow/dynamic_checkout.go @@ -6,6 +6,10 @@ import ( "strings" ) +// Dynamic checkout compilation bridges expression-valued checkout declarations +// to runtime git operations and records metadata for agent guidance and safe +// output jobs through the checkout manifest. + // generateDynamicCheckoutSteps emits one runtime checkout step per expression-valued // checkout declaration. GitHub Actions cannot expand an expression into a variable // number of steps, so the bundled script performs the additional checkouts with git. From 3a3800ecb3876c5017851156a5fc04547f545265 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:05:39 +0000 Subject: [PATCH 15/26] docs: add dynamic checkout sets to spec and review skill Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../checkout-credential-review/SKILL.md | 6 ++ .../specs/checkout-behavior-specification.md | 65 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/.github/skills/checkout-credential-review/SKILL.md b/.github/skills/checkout-credential-review/SKILL.md index 6d53f167184..6d867225cf2 100644 --- a/.github/skills/checkout-credential-review/SKILL.md +++ b/.github/skills/checkout-credential-review/SKILL.md @@ -18,6 +18,8 @@ Two important contexts deliberately run with **no git credentials**: - The **safe-outputs MCP server** and its handlers (`generate_git_bundle.cjs`, `generate_git_patch.cjs`, `create_pull_request.cjs`). Errors in these paths explicitly say "the safe-outputs MCP server has no credentials for private repositories" — fetch/push will fail for private repos. - The **agent runtime** after `actions/checkout`. The agent prompt in [actions/setup/md/safe_outputs_push_to_pr_branch.md](../../../actions/setup/md/safe_outputs_push_to_pr_branch.md) explicitly tells the model not to attempt `git fetch`, `git pull`, `git push`, or any other authenticated git operation, and to report unavailable branches rather than try to fetch them. +Expression-valued `checkout: { dynamic: ..., allowed-repos: ... }` entries are checked out at runtime by [actions/setup/js/dynamic_checkouts.cjs](../../../actions/setup/js/dynamic_checkouts.cjs) rather than `actions/checkout`. The compiler resolves the expression once per job and passes it, plus the resolved `allowed-repos` allowlist, through `GH_AW_DYNAMIC_CHECKOUTS` / `GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS`. Each dynamic checkout uses a command-level token (`GH_AW_DYNAMIC_CHECKOUT_TOKEN`, falling back to `GH_TOKEN`) injected only into that git invocation; the runtime does not persist git credentials afterward unless the caller explicitly asks it to keep them (`GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS=true`), which only the `safe_outputs` job's PR/push checkout path does. + ## Review checklist When you see a new `git`, `gh`, `execFileSync('git'…)`, or compiled `run:` block: @@ -27,6 +29,7 @@ When you see a new `git`, `gh`, `execFileSync('git'…)`, or compiled `run:` blo 3. **Which job/context emits it?** Agent job and safe-outputs MCP server both run without git credentials by design. Any remote git operation there must be wrapped in `try/catch`, fail soft, and surface a clear "no credentials" error rather than a raw git stderr. 4. **Sparse / shallow / monorepo concerns.** Avoid emitting steps that deepen (`git fetch --unshallow`, `--deepen=N`) or widen (`git fetch origin '+refs/heads/*'`) a sparse or shallow checkout of a large monorepo — these need credentials *and* can pull hundreds of MB. Prefer expanding `fetch:` / `fetch-depth:` / `sparse-checkout:` at compile time so it happens during `actions/checkout` with its internal token, never later. 5. **`gh` is REST, not git.** `gh api …` uses whatever `GH_TOKEN` is in the step's env — it does **not** automatically inherit per-checkout PATs. For cross-org private repos, either thread the right token in or accept the call will 404 and handle it. +6. **Dynamic checkout expressions.** A `checkout.dynamic` expression MUST NOT reference `secrets.*` directly — its resolved value lands in the single `GH_AW_DYNAMIC_CHECKOUTS` JSON payload rather than a statically declared env var, hiding the secret from static analysis; the fix is a top-level `env:` entry referenced via `env.NAME`. It also MUST NOT reference `steps.*`, since the same expression is re-evaluated independently in the agent job and the `safe_outputs` job. Any new field or code path added to `dynamic_checkouts.cjs` must keep passing `ref`/sparse-checkout patterns to `git` after a `--` terminator, keep `GIT_LFS_SKIP_SMUDGE=1` on non-LFS operations, and keep validating that checkout paths cannot escape the workspace via `..`, absolute paths, or symlinks. ## Related @@ -34,3 +37,6 @@ When you see a new `git`, `gh`, `execFileSync('git'…)`, or compiled `run:` blo - [docs/sparseness.md](../../../docs/sparseness.md) — sparse/blobless credential lifecycle - [pkg/workflow/checkout_step_generator.go](../../../pkg/workflow/checkout_step_generator.go) — token wiring per checkout - [actions/setup/md/safe_outputs_push_to_pr_branch.md](../../../actions/setup/md/safe_outputs_push_to_pr_branch.md) — agent-facing guidance +- [actions/setup/js/dynamic_checkouts.cjs](../../../actions/setup/js/dynamic_checkouts.cjs) — dynamic checkout runtime credential/path/git-arg handling +- [pkg/workflow/dynamic_checkout_context_validation.go](../../../pkg/workflow/dynamic_checkout_context_validation.go) and [pkg/workflow/dynamic_checkout_secrets_validation.go](../../../pkg/workflow/dynamic_checkout_secrets_validation.go) — compile-time `steps.*`/`secrets.*` rejection +- [docs/src/content/docs/specs/checkout-behavior-specification.md](../../../docs/src/content/docs/specs/checkout-behavior-specification.md) — §3.6 Dynamic Checkout Sets normative requirements diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index f2ec4c9f372..274353e1593 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -7,9 +7,9 @@ sidebar: # Checkout Behavior Specification -**Version**: 1.2.0
+**Version**: 1.3.0
**Status**: Working Draft -**Publication Date**: 2026-09-23
+**Publication Date**: 2026-09-25
**Editor**: GitHub Agentic Workflows Team **This Version**: [checkout-behavior-specification](/gh-aw/specs/checkout-behavior-specification/) **Latest Published Version**: This document @@ -18,7 +18,7 @@ sidebar: ## Abstract -This specification defines normative checkout behavior in GitHub Agentic Workflows for activation, agent, and `safe_outputs` jobs. It specifies credential and token precedence, `github-token` and `github-app` resolution, trial mode behavior, side-repo targeting, sparse/shallow/fetch semantics, symlink handling during sparse activation checkout, submodule cleanup semantics, and checkout-manifest behavior used by safe output handlers. +This specification defines normative checkout behavior in GitHub Agentic Workflows for activation, agent, and `safe_outputs` jobs. It specifies credential and token precedence, `github-token` and `github-app` resolution, trial mode behavior, side-repo targeting, sparse/shallow/fetch semantics, symlink handling during sparse activation checkout, submodule cleanup semantics, expression-valued dynamic checkout sets, and checkout-manifest behavior used by safe output handlers. ## Status of This Document @@ -132,6 +132,40 @@ The fallback is an agent-influenced trust boundary: the agent can write anywhere See also Threat T7 and requirements RCR1–RCR7 in the [Safe Outputs MCP Gateway Specification](/gh-aw/specs/safe-outputs-specification/). +### 3.6 Dynamic Checkout Sets + +A `checkout:` entry MAY be expression-valued instead of a static object or array, using a `dynamic`/`allowed-repos` object form: + +```yaml +checkout: + dynamic: ${{ fromJSON(inputs.checkouts) }} + allowed-repos: ${{ fromJSON(vars.ALLOWED_DYNAMIC_CHECKOUT_REPOS) }} +``` + +`dynamic` MUST be a GitHub Actions expression (`${{ ... }}`) that resolves at runtime to one checkout object or an array of checkout objects. `allowed-repos` is REQUIRED and MUST be either a non-empty static array of `owner/repo` strings or an expression resolving to such an array. The object form MUST NOT contain any field other than `dynamic` and `allowed-repos`. + +**Compile-time requirements:** + +- The compiler MUST reject a dynamic checkout declaration missing `allowed-repos`. +- The compiler MUST reject a dynamic checkout expression that references `steps.*`, because the same expression is re-evaluated independently in both the agent job and the `safe_outputs` job (see §3.3); an agent-job-local step output would resolve differently — or not at all — in `safe_outputs`. +- The compiler MUST reject (strict mode) or warn (non-strict mode) when a dynamic checkout expression references `secrets.*` directly (dot or bracket notation), because the resolved expression is serialized once into the single `GH_AW_DYNAMIC_CHECKOUTS` runtime JSON payload rather than a statically declared environment variable, hiding the secret from static analysis. Secrets needed by the expression MUST instead be declared in the workflow's top-level `env:` section and referenced via `env.NAME`. +- GitHub App authentication, `current`, and additional `fetch` patterns are NOT supported on dynamic checkout entries; those remain available only on statically declared `checkout:` entries. + +**Runtime requirements** (`actions/setup/js/dynamic_checkouts.cjs`): + +- The runtime MUST parse the resolved `GH_AW_DYNAMIC_CHECKOUTS` payload as a single checkout object or an array of checkout objects, rejecting any other JSON shape. +- Each entry MUST be validated against a fixed field allowlist (`repository`, `ref`, `path`, `github-token`/`token`, `fetch-depth`, `sparse-checkout`, `submodules`, `lfs`, `wiki`); an unsupported field MUST fail the step. +- `repository` MUST match `owner/repo` syntax; `path` MUST be a non-empty relative path that does not escape the workspace (no absolute paths, no `..` segments). +- Every checkout's effective repository (its `.wiki` suffix stripped for the comparison when `wiki: true`) MUST be present in the resolved `allowed-repos` set (case-insensitive); a repository outside that set MUST fail the step before any checkout is attempted. +- Checkout paths across all dynamic entries in a single declaration MUST be unique (case-insensitive); duplicates MUST fail the step. +- Before cloning, the runtime MUST reject a checkout path whose final component is an existing file, directory, or symbolic link (including a dangling symlink), and MUST reject a path whose parent segments traverse a symbolic link or resolve outside the workspace root. +- `ref` values and `sparse-checkout` patterns MUST be passed to `git` after an option-terminator (`--`) so that a value beginning with `-` cannot be parsed as a git option. +- Clone, checkout, sparse-checkout, and submodule operations MUST run with `GIT_LFS_SKIP_SMUDGE=1`; LFS objects MUST be fetched only when `lfs: true`, via an explicit `git lfs pull` step. +- Agent-job dynamic checkouts MUST use ephemeral, command-level credentials (`persist-credentials: false`-equivalent behavior): the runtime MUST NOT leave a git credential configured after checkout completes unless the caller explicitly requests credential retention (used only for the `safe_outputs` job's PR/push checkout path). +- Each successfully checked-out dynamic entry MUST be merged into the same checkout-manifest file used by static cross-repo checkouts (see §3.4), recording at least `repository`, `path`, and resolved `default_branch`. + +**Agent guidance**: when a workflow declares any dynamic checkout, the agent's system prompt MUST include guidance stating that additional repositories were selected and checked out at runtime, and that the agent should inspect the checkout manifest and workspace directories to locate them. + --- ## 4. Authentication and Token Resolution @@ -284,6 +318,11 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme - **T-CHK-014**: Checkout-manifest path resolution MUST reject paths that are absolute (e.g., `/etc/passwd`) or escape the workspace root (e.g., `../../sensitive`); rejected paths MUST produce an error and MUST NOT be used for checkout or file lookup - **T-CHK-015**: `push_to_pull_request_branch` uses side-repo checkout from `GH_AW_TARGET_REPO_SLUG` only when it differs from `GITHUB_REPOSITORY`; emits debug log and ignores it when they match - **T-CHK-016**: Workspace git-scan fallback reads `remote.origin.url` with a per-invocation `safe.directory` override (process environment unchanged), and ignores scanned repositories whose remote host is neither `GITHUB_SERVER_URL`'s host nor `github.com` +- **T-CHK-017**: Dynamic checkout compilation requires `allowed-repos`, rejects expressions referencing `steps.*`, and rejects/warns on expressions referencing `secrets.*` directly (strict vs. non-strict mode) +- **T-CHK-018**: Dynamic checkout runtime rejects repositories not present in the resolved `allowed-repos` set, rejects duplicate checkout paths, and rejects unsupported entry fields +- **T-CHK-019**: Dynamic checkout runtime rejects checkout paths that are absolute, escape the workspace, or resolve through a symbolic link (including the checkout target itself, whether pre-existing or a dangling symlink) +- **T-CHK-020**: Dynamic checkout runtime passes `ref` and sparse-checkout patterns to `git` after an option-terminator (`--`), and disables Git LFS smudging except in the explicit `lfs: true` pull step +- **T-CHK-021**: Dynamic checkout entries are merged into the same checkout-manifest file as static cross-repo checkouts, and agent-job dynamic checkouts leave no persisted git credential after checkout completes ### 7.2 Compliance Checklist @@ -300,6 +339,11 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme | Checkout-manifest path-escape rejection | T-CHK-014 | C2 | Required | | `push_to_pull_request_branch` side-repo cwd resolution | T-CHK-015 | C2 | Required | | Workspace git-scan fallback trust scoping and host constraint | T-CHK-016 | C2 | Required | +| Dynamic checkout compile-time validation (`allowed-repos`, `steps.*`, `secrets.*`) | T-CHK-017 | C1 | Required | +| Dynamic checkout runtime allowlist, duplicate-path, and field validation | T-CHK-018 | C2 | Required | +| Dynamic checkout path/symlink workspace-escape rejection | T-CHK-019 | C2 | Required | +| Dynamic checkout git argument hardening and LFS smudge suppression | T-CHK-020 | C2 | Required | +| Dynamic checkout manifest merge and credential lifecycle | T-CHK-021 | C1/C2 | Required | ### 7.3 Safeguards @@ -313,6 +357,10 @@ The following MUST-level norms govern credential and token safety during checkou 4. **Scan trust scoping**: The workspace git-scan fallback MUST NOT grant process-wide git ownership trust to scanned directories, and MUST NOT bind a scanned directory to an `owner/repo` slug when its remote host is neither the host of `GITHUB_SERVER_URL` nor `github.com` (see §3.5 and T-CHK-016). +5. **Dynamic checkout allowlist enforcement**: A dynamic checkout entry MUST NOT be cloned unless its repository is present in the resolved `allowed-repos` set. The check MUST occur before any git operation runs for that entry (see §3.6 and T-CHK-018). + +6. **Dynamic checkout path safety**: A dynamic checkout path that is absolute, escapes the workspace root, or resolves through a symbolic link (pre-existing target or traversed parent, including dangling symlinks) MUST be rejected before cloning (see §3.6 and T-CHK-019). + --- ## 8. References @@ -332,6 +380,11 @@ The following MUST-level norms govern credential and token safety during checkou - `actions/setup/js/find_repo_checkout.cjs` - `actions/setup/sh/configure_git_credentials.sh` - `actions/setup/sh/clean_git_credentials.sh` +- `pkg/workflow/dynamic_checkout.go` +- `pkg/workflow/dynamic_checkout_config.go` +- `pkg/workflow/dynamic_checkout_context_validation.go` +- `pkg/workflow/dynamic_checkout_secrets_validation.go` +- `actions/setup/js/dynamic_checkouts.cjs` ### Informative References @@ -343,6 +396,12 @@ The following MUST-level norms govern credential and token safety during checkou ## 9. Change Log +### Version 1.3.0 (Working Draft) + +- Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.dynamic` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. +- Added T-CHK-017 through T-CHK-021 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. +- Added the dynamic checkout implementation files to the §8 Normative References. + ### Version 1.2.0 (Working Draft) - Added §3.5: workspace git-scan fallback requirements covering manifest precedence, scan confinement, per-invocation `safe.directory` scoping, read-only discovery, remote host constraint, deferred durable trust, and non-disclosing failure messages. From fab70bef238f668301f2712df93c4ed2dbac62ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:02:32 +0000 Subject: [PATCH 16/26] Rename dynamic checkout field to repos Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../checkout-credential-review/SKILL.md | 4 +-- docs/src/content/docs/reference/checkout.md | 4 +-- .../specs/checkout-behavior-specification.md | 8 ++--- pkg/parser/schemas/main_workflow_schema.json | 4 +-- pkg/workflow/dynamic_checkout_config.go | 19 ++++++++--- .../dynamic_checkout_secrets_validation.go | 6 ++-- pkg/workflow/dynamic_checkout_test.go | 32 +++++++++++++++++-- 7 files changed, 56 insertions(+), 21 deletions(-) diff --git a/.github/skills/checkout-credential-review/SKILL.md b/.github/skills/checkout-credential-review/SKILL.md index 6d867225cf2..f19e2452221 100644 --- a/.github/skills/checkout-credential-review/SKILL.md +++ b/.github/skills/checkout-credential-review/SKILL.md @@ -18,7 +18,7 @@ Two important contexts deliberately run with **no git credentials**: - The **safe-outputs MCP server** and its handlers (`generate_git_bundle.cjs`, `generate_git_patch.cjs`, `create_pull_request.cjs`). Errors in these paths explicitly say "the safe-outputs MCP server has no credentials for private repositories" — fetch/push will fail for private repos. - The **agent runtime** after `actions/checkout`. The agent prompt in [actions/setup/md/safe_outputs_push_to_pr_branch.md](../../../actions/setup/md/safe_outputs_push_to_pr_branch.md) explicitly tells the model not to attempt `git fetch`, `git pull`, `git push`, or any other authenticated git operation, and to report unavailable branches rather than try to fetch them. -Expression-valued `checkout: { dynamic: ..., allowed-repos: ... }` entries are checked out at runtime by [actions/setup/js/dynamic_checkouts.cjs](../../../actions/setup/js/dynamic_checkouts.cjs) rather than `actions/checkout`. The compiler resolves the expression once per job and passes it, plus the resolved `allowed-repos` allowlist, through `GH_AW_DYNAMIC_CHECKOUTS` / `GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS`. Each dynamic checkout uses a command-level token (`GH_AW_DYNAMIC_CHECKOUT_TOKEN`, falling back to `GH_TOKEN`) injected only into that git invocation; the runtime does not persist git credentials afterward unless the caller explicitly asks it to keep them (`GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS=true`), which only the `safe_outputs` job's PR/push checkout path does. +Expression-valued `checkout: { repos: ..., allowed-repos: ... }` entries are checked out at runtime by [actions/setup/js/dynamic_checkouts.cjs](../../../actions/setup/js/dynamic_checkouts.cjs) rather than `actions/checkout`. The compiler resolves the expression once per job and passes it, plus the resolved `allowed-repos` allowlist, through `GH_AW_DYNAMIC_CHECKOUTS` / `GH_AW_DYNAMIC_CHECKOUT_ALLOWED_REPOS`. Each dynamic checkout uses a command-level token (`GH_AW_DYNAMIC_CHECKOUT_TOKEN`, falling back to `GH_TOKEN`) injected only into that git invocation; the runtime does not persist git credentials afterward unless the caller explicitly asks it to keep them (`GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS=true`), which only the `safe_outputs` job's PR/push checkout path does. ## Review checklist @@ -29,7 +29,7 @@ When you see a new `git`, `gh`, `execFileSync('git'…)`, or compiled `run:` blo 3. **Which job/context emits it?** Agent job and safe-outputs MCP server both run without git credentials by design. Any remote git operation there must be wrapped in `try/catch`, fail soft, and surface a clear "no credentials" error rather than a raw git stderr. 4. **Sparse / shallow / monorepo concerns.** Avoid emitting steps that deepen (`git fetch --unshallow`, `--deepen=N`) or widen (`git fetch origin '+refs/heads/*'`) a sparse or shallow checkout of a large monorepo — these need credentials *and* can pull hundreds of MB. Prefer expanding `fetch:` / `fetch-depth:` / `sparse-checkout:` at compile time so it happens during `actions/checkout` with its internal token, never later. 5. **`gh` is REST, not git.** `gh api …` uses whatever `GH_TOKEN` is in the step's env — it does **not** automatically inherit per-checkout PATs. For cross-org private repos, either thread the right token in or accept the call will 404 and handle it. -6. **Dynamic checkout expressions.** A `checkout.dynamic` expression MUST NOT reference `secrets.*` directly — its resolved value lands in the single `GH_AW_DYNAMIC_CHECKOUTS` JSON payload rather than a statically declared env var, hiding the secret from static analysis; the fix is a top-level `env:` entry referenced via `env.NAME`. It also MUST NOT reference `steps.*`, since the same expression is re-evaluated independently in the agent job and the `safe_outputs` job. Any new field or code path added to `dynamic_checkouts.cjs` must keep passing `ref`/sparse-checkout patterns to `git` after a `--` terminator, keep `GIT_LFS_SKIP_SMUDGE=1` on non-LFS operations, and keep validating that checkout paths cannot escape the workspace via `..`, absolute paths, or symlinks. +6. **Dynamic checkout expressions.** A `checkout.repos` expression MUST NOT reference `secrets.*` directly — its resolved value lands in the single `GH_AW_DYNAMIC_CHECKOUTS` JSON payload rather than a statically declared env var, hiding the secret from static analysis; the fix is a top-level `env:` entry referenced via `env.NAME`. It also MUST NOT reference `steps.*`, since the same expression is re-evaluated independently in the agent job and the `safe_outputs` job. `allowed-repos` is mandatory and must come from trusted configuration rather than caller-controlled input. Any new field or code path added to `dynamic_checkouts.cjs` must keep passing `ref`/sparse-checkout patterns to `git` after a `--` terminator, keep `GIT_LFS_SKIP_SMUDGE=1` on non-LFS operations, and keep validating that checkout paths cannot escape the workspace via `..`, absolute paths, or symlinks. ## Related diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 459f4ad67c4..b14b5c65977 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -40,7 +40,7 @@ required and may itself be a GitHub Actions expression resolving to an array: ```yaml wrap checkout: - dynamic: ${{ fromJSON(inputs.checkouts) }} + repos: ${{ fromJSON(inputs.checkouts) }} # Use trusted configuration for this allowlist, not caller-controlled input. allowed-repos: ${{ fromJSON(vars.ALLOWED_DYNAMIC_CHECKOUT_REPOS) }} ``` @@ -66,7 +66,7 @@ reference it through `env.NAME` instead: env: CHECKOUT_TOKEN: ${{ secrets.MY_TOKEN }} checkout: - dynamic: ${{ fromJSON(inputs.checkouts) }} + repos: ${{ fromJSON(inputs.checkouts) }} allowed-repos: - owner/repository ``` diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index 274353e1593..6010aff39ab 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -134,15 +134,15 @@ See also Threat T7 and requirements RCR1–RCR7 in the [Safe Outputs MCP Gateway ### 3.6 Dynamic Checkout Sets -A `checkout:` entry MAY be expression-valued instead of a static object or array, using a `dynamic`/`allowed-repos` object form: +A `checkout:` entry MAY be expression-valued instead of a static object or array, using a `repos`/`allowed-repos` object form: ```yaml checkout: - dynamic: ${{ fromJSON(inputs.checkouts) }} + repos: ${{ fromJSON(inputs.checkouts) }} allowed-repos: ${{ fromJSON(vars.ALLOWED_DYNAMIC_CHECKOUT_REPOS) }} ``` -`dynamic` MUST be a GitHub Actions expression (`${{ ... }}`) that resolves at runtime to one checkout object or an array of checkout objects. `allowed-repos` is REQUIRED and MUST be either a non-empty static array of `owner/repo` strings or an expression resolving to such an array. The object form MUST NOT contain any field other than `dynamic` and `allowed-repos`. +`repos` MUST be a GitHub Actions expression (`${{ ... }}`) that resolves at runtime to one checkout object or an array of checkout objects. `allowed-repos` is REQUIRED and MUST be either a non-empty static array of `owner/repo` strings or an expression resolving to such an array. The object form MUST NOT contain any field other than `repos` and `allowed-repos`. **Compile-time requirements:** @@ -398,7 +398,7 @@ The following MUST-level norms govern credential and token safety during checkou ### Version 1.3.0 (Working Draft) -- Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.dynamic` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. +- Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.repos` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. - Added T-CHK-017 through T-CHK-021 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. - Added the dynamic checkout implementation files to the §8 Normative References. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index aa582629291..7037a0abb3b 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13282,9 +13282,9 @@ }, { "type": "object", - "required": ["dynamic", "allowed-repos"], + "required": ["repos", "allowed-repos"], "properties": { - "dynamic": { + "repos": { "type": "string", "pattern": "^\\s*\\$\\{\\{[\\s\\S]+\\}\\}\\s*$", "description": "GitHub Actions expression resolving to one checkout object or an array of checkout objects." diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index 209eea84369..dbde678b2cb 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -15,21 +15,30 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) if !ok { return DynamicCheckoutConfig{}, false, nil } - expression, hasExpression := raw["dynamic"].(string) - if !hasExpression || !isExpression(expression) { + if _, hasLegacyDynamic := raw["dynamic"]; hasLegacyDynamic { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout uses checkout.repos; checkout.dynamic is not supported") + } + expression, hasExpression := raw["repos"].(string) + if !hasExpression { + if _, hasRepos := raw["repos"]; hasRepos { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout repos must be a GitHub Actions expression") + } return DynamicCheckoutConfig{}, false, nil } + if !isExpression(expression) { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout repos must be a GitHub Actions expression") + } allowed, ok := raw["allowed-repos"] if !ok { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout requires allowed-repos") } for key := range raw { - if key != "dynamic" && key != "allowed-repos" { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") + if key != "repos" && key != "allowed-repos" { + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports repos and allowed-repos fields") } } if len(raw) != 2 { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports dynamic and allowed-repos fields") + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports repos and allowed-repos fields") } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { diff --git a/pkg/workflow/dynamic_checkout_secrets_validation.go b/pkg/workflow/dynamic_checkout_secrets_validation.go index edaa9dc5ef3..da8eef179df 100644 --- a/pkg/workflow/dynamic_checkout_secrets_validation.go +++ b/pkg/workflow/dynamic_checkout_secrets_validation.go @@ -3,9 +3,9 @@ // // # Dynamic Checkout Secrets Validation // -// A dynamic checkout expression (e.g. checkout: ${{ fromJSON(inputs.checkouts) }}) -// is serialized once and passed to the runtime checkout script as a single JSON -// payload (GH_AW_DYNAMIC_CHECKOUTS). If the expression itself references +// A dynamic checkout expression (checkout.repos) is serialized once and passed +// to the runtime checkout script as a single JSON payload +// (GH_AW_DYNAMIC_CHECKOUTS). If the expression itself references // secrets.* (for example to embed a per-repository github-token value), the // resolved secret value is written into that JSON payload rather than being // assigned to its own statically declared environment variable. This makes the diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 74815983baf..5e6cfc2d535 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -12,7 +12,7 @@ func TestParseFrontmatterConfigDynamicCheckout(t *testing.T) { "name": "dynamic-checkout", "engine": "copilot", "checkout": map[string]any{ - "dynamic": "${{ fromJSON(inputs.checkouts) }}", + "repos": "${{ fromJSON(inputs.checkouts) }}", "allowed-repos": []any{"owner/repo"}, }, }) @@ -28,7 +28,7 @@ func TestParseFrontmatterConfigDynamicCheckoutTrimsExpression(t *testing.T) { "name": "dynamic-checkout", "engine": "copilot", "checkout": map[string]any{ - "dynamic": " ${{ fromJSON(inputs.checkouts) }} ", + "repos": " ${{ fromJSON(inputs.checkouts) }} ", "allowed-repos": "${{ fromJSON(inputs.allowed_repos) }}", }, }) @@ -42,13 +42,39 @@ func TestParseFrontmatterConfigDynamicCheckoutRequiresAllowedRepos(t *testing.T) "name": "dynamic-checkout", "engine": "copilot", "checkout": map[string]any{ - "dynamic": "${{ fromJSON(inputs.checkouts) }}", + "repos": "${{ fromJSON(inputs.checkouts) }}", }, }) require.ErrorContains(t, err, "requires allowed-repos") } +func TestParseFrontmatterConfigDynamicCheckoutRejectsLegacyDynamicField(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "dynamic": "${{ fromJSON(inputs.checkouts) }}", + "allowed-repos": []any{"owner/repo"}, + }, + }) + + require.ErrorContains(t, err, "checkout.repos") +} + +func TestParseFrontmatterConfigDynamicCheckoutRequiresReposExpression(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "repos": "owner/repo", + "allowed-repos": []any{"owner/repo"}, + }, + }) + + require.ErrorContains(t, err, "repos must be a GitHub Actions expression") +} + func TestGenerateDynamicCheckoutSteps(t *testing.T) { compiler := NewCompiler() steps := compiler.generateDynamicCheckoutSteps( From e517a966c0343ac9906234e4b4461ccbfd1a0021 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:08:54 +0000 Subject: [PATCH 17/26] Polish dynamic checkout repos validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 5 +---- pkg/workflow/dynamic_checkout_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index dbde678b2cb..e3d919ffea1 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -21,7 +21,7 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) expression, hasExpression := raw["repos"].(string) if !hasExpression { if _, hasRepos := raw["repos"]; hasRepos { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout repos must be a GitHub Actions expression") + return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout repos must be a string containing a GitHub Actions expression") } return DynamicCheckoutConfig{}, false, nil } @@ -37,9 +37,6 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports repos and allowed-repos fields") } } - if len(raw) != 2 { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports repos and allowed-repos fields") - } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout allowed-repos must be a non-empty array or GitHub Actions expression") diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 5e6cfc2d535..d309a04b022 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -75,6 +75,19 @@ func TestParseFrontmatterConfigDynamicCheckoutRequiresReposExpression(t *testing require.ErrorContains(t, err, "repos must be a GitHub Actions expression") } +func TestParseFrontmatterConfigDynamicCheckoutRequiresReposString(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "repos": []any{"owner/repo"}, + "allowed-repos": []any{"owner/repo"}, + }, + }) + + require.ErrorContains(t, err, "repos must be a string containing a GitHub Actions expression") +} + func TestGenerateDynamicCheckoutSteps(t *testing.T) { compiler := NewCompiler() steps := compiler.generateDynamicCheckoutSteps( From e5648f175d438147b087e071a2547f0d7aa7047d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:10:56 +0000 Subject: [PATCH 18/26] Document dynamic checkout field migration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/specs/checkout-behavior-specification.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index 6010aff39ab..66b4c5d4152 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -399,6 +399,7 @@ The following MUST-level norms govern credential and token safety during checkou ### Version 1.3.0 (Working Draft) - Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.repos` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. +- Renamed the dynamic checkout expression field from `checkout.dynamic` to `checkout.repos`; `checkout.dynamic` is rejected with a migration error. - Added T-CHK-017 through T-CHK-021 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. - Added the dynamic checkout implementation files to the §8 Normative References. From 05ba9a8a3f33e24661fe098e78ab109c4da45e93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:15:59 +0000 Subject: [PATCH 19/26] Clarify dynamic checkout migration error Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index e3d919ffea1..36f3d2331c4 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -16,7 +16,7 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) return DynamicCheckoutConfig{}, false, nil } if _, hasLegacyDynamic := raw["dynamic"]; hasLegacyDynamic { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout uses checkout.repos; checkout.dynamic is not supported") + return DynamicCheckoutConfig{}, true, errors.New("checkout.dynamic is no longer supported; rename it to checkout.repos") } expression, hasExpression := raw["repos"].(string) if !hasExpression { From 553d2a1d94d5de92c9aa14a615aafd6482b7838e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:21:05 +0000 Subject: [PATCH 20/26] Name unsupported dynamic checkout fields Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 3 ++- pkg/workflow/dynamic_checkout_test.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index 36f3d2331c4..fb9f2fa6d5f 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -2,6 +2,7 @@ package workflow import ( "errors" + "fmt" "strings" ) @@ -34,7 +35,7 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) } for key := range raw { if key != "repos" && key != "allowed-repos" { - return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout only supports repos and allowed-repos fields") + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field %q is not supported; only repos and allowed-repos are allowed", key) } } allowedRepos, err := parseStringArrayOrExpression(allowed) diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index d309a04b022..23783f5907f 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -88,6 +88,20 @@ func TestParseFrontmatterConfigDynamicCheckoutRequiresReposString(t *testing.T) require.ErrorContains(t, err, "repos must be a string containing a GitHub Actions expression") } +func TestParseFrontmatterConfigDynamicCheckoutRejectsUnsupportedField(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "repos": "${{ fromJSON(inputs.checkouts) }}", + "allowed-repos": []any{"owner/repo"}, + "fetch-depth": 1, + }, + }) + + require.ErrorContains(t, err, `field "fetch-depth" is not supported`) +} + func TestGenerateDynamicCheckoutSteps(t *testing.T) { compiler := NewCompiler() steps := compiler.generateDynamicCheckoutSteps( From 3ce28a7ad38265875470864388029f70316a09e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:26:12 +0000 Subject: [PATCH 21/26] Stabilize dynamic checkout field errors Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 8 +++++++- pkg/workflow/dynamic_checkout_test.go | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index fb9f2fa6d5f..bb1c8baaa2b 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -3,6 +3,7 @@ package workflow import ( "errors" "fmt" + "sort" "strings" ) @@ -33,11 +34,16 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) if !ok { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout requires allowed-repos") } + var unsupportedFields []string for key := range raw { if key != "repos" && key != "allowed-repos" { - return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field %q is not supported; only repos and allowed-repos are allowed", key) + unsupportedFields = append(unsupportedFields, key) } } + if len(unsupportedFields) > 0 { + sort.Strings(unsupportedFields) + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field(s) %q are not supported; only repos and allowed-repos are allowed", strings.Join(unsupportedFields, ", ")) + } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout allowed-repos must be a non-empty array or GitHub Actions expression") diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 23783f5907f..61eae64dfea 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -96,10 +96,11 @@ func TestParseFrontmatterConfigDynamicCheckoutRejectsUnsupportedField(t *testing "repos": "${{ fromJSON(inputs.checkouts) }}", "allowed-repos": []any{"owner/repo"}, "fetch-depth": 1, + "path": "repo", }, }) - require.ErrorContains(t, err, `field "fetch-depth" is not supported`) + require.ErrorContains(t, err, `field(s) "fetch-depth, path" are not supported`) } func TestGenerateDynamicCheckoutSteps(t *testing.T) { From f38643fc998af34aefa792713d6eb5be3cd4fa85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:31:18 +0000 Subject: [PATCH 22/26] Clarify dynamic checkout field list errors Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 6 +++++- pkg/workflow/dynamic_checkout_test.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index bb1c8baaa2b..7b0f82fdc1a 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -42,7 +42,11 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) } if len(unsupportedFields) > 0 { sort.Strings(unsupportedFields) - return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field(s) %q are not supported; only repos and allowed-repos are allowed", strings.Join(unsupportedFields, ", ")) + quotedFields := make([]string, 0, len(unsupportedFields)) + for _, field := range unsupportedFields { + quotedFields = append(quotedFields, fmt.Sprintf("%q", field)) + } + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field(s) %s are not supported; only repos and allowed-repos are allowed", strings.Join(quotedFields, ", ")) } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 61eae64dfea..485260ce6f0 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -100,7 +100,7 @@ func TestParseFrontmatterConfigDynamicCheckoutRejectsUnsupportedField(t *testing }, }) - require.ErrorContains(t, err, `field(s) "fetch-depth, path" are not supported`) + require.ErrorContains(t, err, `field(s) "fetch-depth", "path" are not supported`) } func TestGenerateDynamicCheckoutSteps(t *testing.T) { From b832d3df5e2f2e19b8b2362eb2cd9927ed465d8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:34:45 +0000 Subject: [PATCH 23/26] Refine dynamic checkout diagnostics Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/dynamic_checkout_config.go | 16 +++++++++++-- pkg/workflow/dynamic_checkout_test.go | 30 ++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index 7b0f82fdc1a..c648d7bb894 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -17,7 +17,7 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) if !ok { return DynamicCheckoutConfig{}, false, nil } - if _, hasLegacyDynamic := raw["dynamic"]; hasLegacyDynamic { + if legacyDynamic, hasLegacyDynamic := raw["dynamic"]; hasLegacyDynamic && legacyDynamicLooksLikeCheckout(legacyDynamic, raw) { return DynamicCheckoutConfig{}, true, errors.New("checkout.dynamic is no longer supported; rename it to checkout.repos") } expression, hasExpression := raw["repos"].(string) @@ -46,15 +46,27 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) for _, field := range unsupportedFields { quotedFields = append(quotedFields, fmt.Sprintf("%q", field)) } - return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field(s) %s are not supported; only repos and allowed-repos are allowed", strings.Join(quotedFields, ", ")) + if len(quotedFields) == 1 { + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field %s is not supported; only repos and allowed-repos are allowed", quotedFields[0]) + } + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout fields %s are not supported; only repos and allowed-repos are allowed", strings.Join(quotedFields, ", ")) } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { return DynamicCheckoutConfig{}, true, errors.New("dynamic checkout allowed-repos must be a non-empty array or GitHub Actions expression") } + return DynamicCheckoutConfig{Expression: strings.TrimSpace(expression), AllowedRepos: allowedRepos}, true, nil } +func legacyDynamicLooksLikeCheckout(value any, raw map[string]any) bool { + if _, hasAllowedRepos := raw["allowed-repos"]; hasAllowedRepos { + return true + } + expression, ok := value.(string) + return ok && isExpression(expression) +} + func parseStringArrayOrExpression(value any) ([]string, error) { if expression, ok := value.(string); ok && isExpression(expression) { return []string{strings.TrimSpace(expression)}, nil diff --git a/pkg/workflow/dynamic_checkout_test.go b/pkg/workflow/dynamic_checkout_test.go index 485260ce6f0..9fa3a92db3a 100644 --- a/pkg/workflow/dynamic_checkout_test.go +++ b/pkg/workflow/dynamic_checkout_test.go @@ -62,6 +62,34 @@ func TestParseFrontmatterConfigDynamicCheckoutRejectsLegacyDynamicField(t *testi require.ErrorContains(t, err, "checkout.repos") } +func TestParseFrontmatterConfigDynamicCheckoutRejectsLegacyDynamicExpression(t *testing.T) { + _, err := ParseFrontmatterConfig(map[string]any{ + "name": "dynamic-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "dynamic": "${{ fromJSON(inputs.checkouts) }}", + }, + }) + + require.ErrorContains(t, err, "checkout.dynamic is no longer supported") +} + +func TestParseFrontmatterConfigDynamicCheckoutIgnoresStaticDynamicKey(t *testing.T) { + config, err := ParseFrontmatterConfig(map[string]any{ + "name": "static-checkout", + "engine": "copilot", + "checkout": map[string]any{ + "repository": "owner/repo", + "dynamic": false, + }, + }) + + require.NoError(t, err) + require.Empty(t, config.DynamicCheckouts) + require.Len(t, config.CheckoutConfigs, 1) + assert.Equal(t, "owner/repo", config.CheckoutConfigs[0].Repository) +} + func TestParseFrontmatterConfigDynamicCheckoutRequiresReposExpression(t *testing.T) { _, err := ParseFrontmatterConfig(map[string]any{ "name": "dynamic-checkout", @@ -100,7 +128,7 @@ func TestParseFrontmatterConfigDynamicCheckoutRejectsUnsupportedField(t *testing }, }) - require.ErrorContains(t, err, `field(s) "fetch-depth", "path" are not supported`) + require.ErrorContains(t, err, `fields "fetch-depth", "path" are not supported`) } func TestGenerateDynamicCheckoutSteps(t *testing.T) { From fd68ce67958e052697af38594e37c1c164730de9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:13:08 +0000 Subject: [PATCH 24/26] Clarify static vs dynamic checkout syntax Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/checkout.md | 36 +++++++++++++++---- .../specs/checkout-behavior-specification.md | 7 ++-- pkg/parser/schemas/main_workflow_schema.json | 4 +-- pkg/workflow/dynamic_checkout_config.go | 5 +-- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index b14b5c65977..95c9e698f47 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -5,11 +5,32 @@ sidebar: order: 852 --- -The `checkout:` frontmatter field controls how `actions/checkout` is invoked in the agent job. Configure custom checkout settings, check out multiple repositories, or disable checkout entirely. +The `checkout:` frontmatter field controls how repositories are checked out for the agent job. Use the static syntax when every repository is known from the workflow file, and use the dynamic syntax only when the checkout set must be selected at runtime. By default, the agent checks out the repository where the workflow is running with a shallow fetch (`fetch-depth: 1`). If triggered by a `pull_request` event, it also checks out the PR head ref. For `pull_request_target` events, checkout of the PR head branch is **disabled by default** — the head branch may be deleted (merged/closed PRs) or inaccessible (fork PRs), causing the step to hard-fail. For most workflows, this default checkout is sufficient and no `checkout:` configuration is necessary. -Use `checkout:` when you need to check out additional branches, check out multiple repositories, or to disable checkout entirely for workflows that don't need to access code or can access code dynamically through the GitHub Tools. +Use `checkout:` when you need to check out additional branches, check out multiple repositories, select repositories dynamically, or disable checkout entirely for workflows that don't need a local workspace. + +## Static and Dynamic Syntax + +Static checkout syntax is the existing object or array form. The compiler sees the repository list while compiling the workflow and emits `actions/checkout` steps. Static entries support the complete checkout field set, including GitHub App authentication, `current`, and additional `fetch` patterns. + +```yaml wrap +checkout: + - repository: owner/known-repo + path: known-repo +``` + +Dynamic checkout syntax uses a wrapper object with `repos` and `allowed-repos`. The `repos` value is a GitHub Actions expression that resolves at runtime to one checkout object or an array of checkout objects. The `allowed-repos` value is the trusted allowlist that constrains those runtime results. + +```yaml wrap +checkout: + repos: ${{ fromJSON(inputs.checkouts) }} + allowed-repos: + - owner/allowed-repo +``` + +Do not use a top-level expression such as `checkout: ${{ fromJSON(...) }}` for dynamic checkout. Do not use the old `checkout.dynamic` field. Use `checkout.repos` so the compiler can distinguish the dynamic runtime expression from statically declared checkout entries. ## Custom Checkout Settings @@ -34,9 +55,10 @@ checkout: ### Dynamic Checkout Sets Use a dynamic checkout declaration when repositories are only known at runtime. The -expression must resolve to one checkout object or an array of checkout objects. To -prevent untrusted input from selecting arbitrary repositories, `allowed-repos` is -required and may itself be a GitHub Actions expression resolving to an array: +`checkout.repos` expression must resolve to one checkout object or an array of +checkout objects. To prevent untrusted input from selecting arbitrary repositories, +`allowed-repos` is required and may itself be a GitHub Actions expression resolving +to an array: ```yaml wrap checkout: @@ -53,7 +75,9 @@ support `repository`, `ref`, `path`, `github-token` (or `token`), `fetch-depth`, The runtime validates repository names, prevents paths from escaping the workspace, 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. +statically declared checkout entries. Agents should treat static checkouts as known from +the workflow source and dynamic checkouts as runtime-selected repositories discovered +from the workspace and checkout manifest. The resolved expression is serialized once into a single runtime JSON payload (`GH_AW_DYNAMIC_CHECKOUTS`), so it must not reference `secrets.*` directly — doing so diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index 66b4c5d4152..67bcc840c55 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -18,7 +18,7 @@ sidebar: ## Abstract -This specification defines normative checkout behavior in GitHub Agentic Workflows for activation, agent, and `safe_outputs` jobs. It specifies credential and token precedence, `github-token` and `github-app` resolution, trial mode behavior, side-repo targeting, sparse/shallow/fetch semantics, symlink handling during sparse activation checkout, submodule cleanup semantics, expression-valued dynamic checkout sets, and checkout-manifest behavior used by safe output handlers. +This specification defines normative checkout behavior in GitHub Agentic Workflows for activation, agent, and `safe_outputs` jobs. It specifies credential and token precedence, `github-token` and `github-app` resolution, trial mode behavior, side-repo targeting, sparse/shallow/fetch semantics, symlink handling during sparse activation checkout, submodule cleanup semantics, object-form dynamic checkout sets, and checkout-manifest behavior used by safe output handlers. ## Status of This Document @@ -134,7 +134,7 @@ See also Threat T7 and requirements RCR1–RCR7 in the [Safe Outputs MCP Gateway ### 3.6 Dynamic Checkout Sets -A `checkout:` entry MAY be expression-valued instead of a static object or array, using a `repos`/`allowed-repos` object form: +A `checkout:` declaration MAY select repositories dynamically at runtime using a `repos`/`allowed-repos` object form. Static checkout declarations remain the direct object or array form; a top-level `checkout: ${{ ... }}` expression is not a valid dynamic checkout declaration. ```yaml checkout: @@ -142,7 +142,7 @@ checkout: allowed-repos: ${{ fromJSON(vars.ALLOWED_DYNAMIC_CHECKOUT_REPOS) }} ``` -`repos` MUST be a GitHub Actions expression (`${{ ... }}`) that resolves at runtime to one checkout object or an array of checkout objects. `allowed-repos` is REQUIRED and MUST be either a non-empty static array of `owner/repo` strings or an expression resolving to such an array. The object form MUST NOT contain any field other than `repos` and `allowed-repos`. +`repos` MUST be a GitHub Actions expression (`${{ ... }}`) that resolves at runtime to one checkout object or an array of checkout objects. `allowed-repos` is REQUIRED and MUST be either a non-empty static array of `owner/repo` strings or an expression resolving to such an array. The object form MUST NOT contain any field other than `repos` and `allowed-repos`, so agents and validators can distinguish runtime-selected repositories from statically declared checkout entries. **Compile-time requirements:** @@ -150,6 +150,7 @@ checkout: - The compiler MUST reject a dynamic checkout expression that references `steps.*`, because the same expression is re-evaluated independently in both the agent job and the `safe_outputs` job (see §3.3); an agent-job-local step output would resolve differently — or not at all — in `safe_outputs`. - The compiler MUST reject (strict mode) or warn (non-strict mode) when a dynamic checkout expression references `secrets.*` directly (dot or bracket notation), because the resolved expression is serialized once into the single `GH_AW_DYNAMIC_CHECKOUTS` runtime JSON payload rather than a statically declared environment variable, hiding the secret from static analysis. Secrets needed by the expression MUST instead be declared in the workflow's top-level `env:` section and referenced via `env.NAME`. - GitHub App authentication, `current`, and additional `fetch` patterns are NOT supported on dynamic checkout entries; those remain available only on statically declared `checkout:` entries. +- Static checkout entries are compile-time repository declarations. Dynamic checkout entries are runtime data produced by `checkout.repos`; agents MUST use the workspace and checkout manifest to discover the repositories that were selected at runtime. **Runtime requirements** (`actions/setup/js/dynamic_checkouts.cjs`): diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b4a32fbea86..4cd261435c3 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -13310,7 +13310,7 @@ "additionalProperties": false }, "checkout": { - "description": "Checkout configuration for the agent job. Controls how repositories are checked out. Can be a single checkout configuration, an array for multiple checkouts, a GitHub Actions expression resolving to either shape, or false to disable the default checkout step entirely (dev-mode checkouts are unaffected).", + "description": "Checkout configuration for the agent job. Controls how repositories are checked out. Static checkout uses a single checkout configuration or an array of checkout configurations. Dynamic checkout uses an object with repos and allowed-repos. Set to false to disable the default checkout step entirely (dev-mode checkouts are unaffected).", "oneOf": [ { "$ref": "#/$defs/checkoutConfig", @@ -13346,7 +13346,7 @@ } }, "additionalProperties": false, - "description": "Runtime checkout configuration with a required repository allowlist." + "description": "Dynamic runtime checkout configuration with a repos expression and a required trusted repository allowlist." } ] }, diff --git a/pkg/workflow/dynamic_checkout_config.go b/pkg/workflow/dynamic_checkout_config.go index c648d7bb894..8778a60f003 100644 --- a/pkg/workflow/dynamic_checkout_config.go +++ b/pkg/workflow/dynamic_checkout_config.go @@ -46,10 +46,11 @@ func parseDynamicCheckoutConfig(value any) (DynamicCheckoutConfig, bool, error) for _, field := range unsupportedFields { quotedFields = append(quotedFields, fmt.Sprintf("%q", field)) } + fields := strings.Join(quotedFields, ", ") if len(quotedFields) == 1 { - return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field %s is not supported; only repos and allowed-repos are allowed", quotedFields[0]) + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout field %s is not supported; only repos and allowed-repos are allowed", fields) } - return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout fields %s are not supported; only repos and allowed-repos are allowed", strings.Join(quotedFields, ", ")) + return DynamicCheckoutConfig{}, true, fmt.Errorf("dynamic checkout fields %s are not supported; only repos and allowed-repos are allowed", fields) } allowedRepos, err := parseStringArrayOrExpression(allowed) if err != nil || len(allowedRepos) == 0 { From bfae6b19eb33ac741d89cbb5304030aa47a1a9a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:18:55 +0000 Subject: [PATCH 25/26] Resolve checkout documentation merge conflicts Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- docs/src/content/docs/reference/checkout.md | 8 +--- .../specs/checkout-behavior-specification.md | 37 ++++++------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 97cb1f6cdab..9e7da0c87b4 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -9,7 +9,8 @@ The `checkout:` frontmatter field controls how repositories are checked out for By default, the agent checks out the repository where the workflow is running with a shallow fetch (`fetch-depth: 1`). If triggered by a `pull_request` event, it also checks out the PR head ref. For `pull_request_target` events, checkout of the PR head branch is **disabled by default** — the head branch may be deleted (merged/closed PRs) or inaccessible (fork PRs), causing the step to hard-fail. For most workflows, this default checkout is sufficient and no `checkout:` configuration is necessary. -<<<<<<< HEAD +Before the agent job, activation separately checks out workflow configuration to load runtime imports and skills. On `pull_request`, `pull_request_review`, and `pull_request_review_comment` events, this sparse checkout uses the PR **base SHA**, not the PR head; on other events it retains the normal activation ref. For reusable `workflow_call` workflows, it uses the resolved callee SHA when there is no active PR event, and skips cross-repository checkout if authentication may fall back to the caller's repository-scoped `GITHUB_TOKEN`. The `checkout:` options below describe the agent job and do not override this activation checkout. + Use `checkout:` when you need to check out additional branches, check out multiple repositories, select repositories dynamically, or disable checkout entirely for workflows that don't need a local workspace. ## Static and Dynamic Syntax @@ -32,11 +33,6 @@ checkout: ``` Do not use a top-level expression such as `checkout: ${{ fromJSON(...) }}` for dynamic checkout. Do not use the old `checkout.dynamic` field. Use `checkout.repos` so the compiler can distinguish the dynamic runtime expression from statically declared checkout entries. -======= -Before the agent job, activation separately checks out workflow configuration to load runtime imports and skills. On `pull_request`, `pull_request_review`, and `pull_request_review_comment` events, this sparse checkout uses the PR **base SHA**, not the PR head; on other events it retains the normal activation ref. For reusable `workflow_call` workflows, it uses the resolved callee SHA when there is no active PR event, and skips cross-repository checkout if authentication may fall back to the caller's repository-scoped `GITHUB_TOKEN`. The `checkout:` options below describe the agent job and do not override this activation checkout. - -Use `checkout:` when you need to check out additional branches, check out multiple repositories, or to disable checkout entirely for workflows that don't need to access code or can access code dynamically through the GitHub Tools. ->>>>>>> origin/main ## Custom Checkout Settings diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index a636d163310..fd1297885e7 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -9,11 +9,7 @@ sidebar: **Version**: 1.3.0
**Status**: Working Draft -<<<<<<< HEAD -**Publication Date**: 2026-09-25
-======= **Publication Date**: 2026-09-26
->>>>>>> origin/main **Editor**: GitHub Agentic Workflows Team **This Version**: [checkout-behavior-specification](/gh-aw/specs/checkout-behavior-specification/) **Latest Published Version**: This document @@ -327,15 +323,12 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme - **T-CHK-014**: Checkout-manifest path resolution MUST reject paths that are absolute (e.g., `/etc/passwd`) or escape the workspace root (e.g., `../../sensitive`); rejected paths MUST produce an error and MUST NOT be used for checkout or file lookup - **T-CHK-015**: `push_to_pull_request_branch` uses side-repo checkout from `GH_AW_TARGET_REPO_SLUG` only when it differs from `GITHUB_REPOSITORY`; emits debug log and ignores it when they match - **T-CHK-016**: Workspace git-scan fallback reads `remote.origin.url` with a per-invocation `safe.directory` override (process environment unchanged), and ignores scanned repositories whose remote host is neither `GITHUB_SERVER_URL`'s host nor `github.com` -<<<<<<< HEAD -- **T-CHK-017**: Dynamic checkout compilation requires `allowed-repos`, rejects expressions referencing `steps.*`, and rejects/warns on expressions referencing `secrets.*` directly (strict vs. non-strict mode) -- **T-CHK-018**: Dynamic checkout runtime rejects repositories not present in the resolved `allowed-repos` set, rejects duplicate checkout paths, and rejects unsupported entry fields -- **T-CHK-019**: Dynamic checkout runtime rejects checkout paths that are absolute, escape the workspace, or resolve through a symbolic link (including the checkout target itself, whether pre-existing or a dangling symlink) -- **T-CHK-020**: Dynamic checkout runtime passes `ref` and sparse-checkout patterns to `git` after an option-terminator (`--`), and disables Git LFS smudging except in the explicit `lfs: true` pull step -- **T-CHK-021**: Dynamic checkout entries are merged into the same checkout-manifest file as static cross-repo checkouts, and agent-job dynamic checkouts leave no persisted git credential after checkout completes -======= - **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 ->>>>>>> origin/main +- **T-CHK-018**: Dynamic checkout compilation requires `allowed-repos`, rejects expressions referencing `steps.*`, and rejects/warns on expressions referencing `secrets.*` directly (strict vs. non-strict mode) +- **T-CHK-019**: Dynamic checkout runtime rejects repositories not present in the resolved `allowed-repos` set, rejects duplicate checkout paths, and rejects unsupported entry fields +- **T-CHK-020**: Dynamic checkout runtime rejects checkout paths that are absolute, escape the workspace, or resolve through a symbolic link (including the checkout target itself, whether pre-existing or a dangling symlink) +- **T-CHK-021**: Dynamic checkout runtime passes `ref` and sparse-checkout patterns to `git` after an option-terminator (`--`), and disables Git LFS smudging except in the explicit `lfs: true` pull step +- **T-CHK-022**: Dynamic checkout entries are merged into the same checkout-manifest file as static cross-repo checkouts, and agent-job dynamic checkouts leave no persisted git credential after checkout completes ### 7.2 Compliance Checklist @@ -352,15 +345,12 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme | Checkout-manifest path-escape rejection | T-CHK-014 | C2 | Required | | `push_to_pull_request_branch` side-repo cwd resolution | T-CHK-015 | C2 | Required | | Workspace git-scan fallback trust scoping and host constraint | T-CHK-016 | C2 | Required | -<<<<<<< HEAD -| Dynamic checkout compile-time validation (`allowed-repos`, `steps.*`, `secrets.*`) | T-CHK-017 | C1 | Required | -| Dynamic checkout runtime allowlist, duplicate-path, and field validation | T-CHK-018 | C2 | Required | -| Dynamic checkout path/symlink workspace-escape rejection | T-CHK-019 | C2 | Required | -| Dynamic checkout git argument hardening and LFS smudge suppression | T-CHK-020 | C2 | Required | -| Dynamic checkout manifest merge and credential lifecycle | T-CHK-021 | C1/C2 | Required | -======= | Activation checkout ref and fallback provenance | T-CHK-017 | C1 | Required | ->>>>>>> origin/main +| Dynamic checkout compile-time validation (`allowed-repos`, `steps.*`, `secrets.*`) | T-CHK-018 | C1 | Required | +| Dynamic checkout runtime allowlist, duplicate-path, and field validation | T-CHK-019 | C2 | Required | +| Dynamic checkout path/symlink workspace-escape rejection | T-CHK-020 | C2 | Required | +| Dynamic checkout git argument hardening and LFS smudge suppression | T-CHK-021 | C2 | Required | +| Dynamic checkout manifest merge and credential lifecycle | T-CHK-022 | C1/C2 | Required | ### 7.3 Safeguards @@ -415,14 +405,11 @@ The following MUST-level norms govern credential and token safety during checkou ### Version 1.3.0 (Working Draft) -<<<<<<< HEAD +- Specified activation checkout base-SHA pinning, event and payload guards, and same-repository token fallback; added T-CHK-017. - Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.repos` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. - Renamed the dynamic checkout expression field from `checkout.dynamic` to `checkout.repos`; `checkout.dynamic` is rejected with a migration error. -- Added T-CHK-017 through T-CHK-021 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. +- Added T-CHK-018 through T-CHK-022 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. - Added the dynamic checkout implementation files to the §8 Normative References. -======= -- Specified activation checkout base-SHA pinning, event and payload guards, and same-repository token fallback; added T-CHK-017. ->>>>>>> origin/main ### Version 1.2.0 (Working Draft) From 2b83bd4cbb90a35e8dcfb2010f76b54ba8fe34c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:57:39 +0000 Subject: [PATCH 26/26] Harden dynamic checkout ref, repository, sparse-checkout, and server URL validation Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/dynamic_checkouts.cjs | 59 ++++++++++++++++--- actions/setup/js/dynamic_checkouts.test.cjs | 36 +++++++++++ docs/src/content/docs/reference/checkout.md | 5 +- .../specs/checkout-behavior-specification.md | 14 +++-- 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/dynamic_checkouts.cjs b/actions/setup/js/dynamic_checkouts.cjs index d5fcb7145af..d31da39cc34 100644 --- a/actions/setup/js/dynamic_checkouts.cjs +++ b/actions/setup/js/dynamic_checkouts.cjs @@ -14,6 +14,35 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const supportedFields = new Set(["repository", "ref", "path", "github-token", "token", "fetch-depth", "sparse-checkout", "submodules", "lfs", "wiki"]); +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +// Rejects owner/repo values whose segments are relative path components, which +// would otherwise traverse out of the server URL when building the clone URL. +function isSafeRepository(repository) { + if (typeof repository !== "string" || !repositoryPattern.test(repository)) { + return false; + } + return repository.split("/").every(segment => segment !== "." && segment !== ".."); +} + +// Git refs are passed to `git fetch origin -- `; `--` blocks option +// injection, so this only needs to keep the value to a conservative subset of +// the characters git itself accepts in a refname or object id. +function assertSafeRef(ref) { + if (!ref) { + return; + } + if (ref.startsWith("-")) { + throw new Error("dynamic checkout ref must not start with '-'"); + } + if (!/^[A-Za-z0-9._\-/+]+$/.test(ref)) { + throw new Error(`dynamic checkout ref contains unsupported characters: '${ref}'`); + } + if (ref.includes("..") || ref.startsWith("/") || ref.endsWith("/") || ref.endsWith(".lock") || ref.endsWith(".")) { + throw new Error(`dynamic checkout ref is not a valid git ref: '${ref}'`); + } +} + function parseDynamicCheckouts(value = process.env.GH_AW_DYNAMIC_CHECKOUTS || "") { if (!value.trim()) { return []; @@ -38,7 +67,7 @@ function parseAllowedRepos(value = process.env.GH_AW_DYNAMIC_CHECKOUT_ALLOWED_RE } catch (error) { throw new Error(`dynamic checkout allowed-repos must resolve to a JSON array: ${getErrorMessage(error)}`, { cause: error }); } - if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some(repository => typeof repository !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository))) { + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some(repository => !isSafeRepository(repository))) { throw new Error("dynamic checkout allowed-repos must resolve to a non-empty array of owner/repo names"); } return new Set(parsed.map(repository => repository.toLowerCase())); @@ -52,7 +81,7 @@ function normalizeCheckout(entry, workspace) { } let repository = String(entry.repository || "").trim(); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + if (!isSafeRepository(repository)) { throw new Error(`dynamic checkout repository must use owner/repo format, got '${repository}'`); } if (entry.lfs !== undefined && typeof entry.lfs !== "boolean") { @@ -80,9 +109,7 @@ function normalizeCheckout(entry, workspace) { repository += ".wiki"; } const ref = String(entry.ref || "").trim(); - if (ref.startsWith("-")) { - throw new Error("dynamic checkout ref must not start with '-'"); - } + assertSafeRef(ref); return { repository, ref, @@ -97,9 +124,23 @@ function normalizeCheckout(entry, workspace) { } function assertSafeSparsePatterns(patterns) { - if (patterns.some(pattern => pattern.startsWith("-"))) { - throw new Error("dynamic checkout sparse-checkout patterns must not start with '-'"); + for (const pattern of patterns) { + if (pattern.startsWith("-")) { + throw new Error("dynamic checkout sparse-checkout patterns must not start with '-'"); + } + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(pattern)) { + throw new Error("dynamic checkout sparse-checkout patterns must not contain control characters"); + } + } +} + +function normalizeServerURL(value) { + const serverURL = String(value || "https://github.com").replace(/\/+$/, ""); + if (!/^https?:\/\/[A-Za-z0-9._-]+(:\d+)?(\/[A-Za-z0-9._~-]+)*$/.test(serverURL)) { + throw new Error(`dynamic checkout server URL is not supported: '${serverURL}'`); } + return serverURL; } async function defaultRunGit(args, options = {}) { @@ -133,7 +174,7 @@ function lstatIfExists(target) { 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 serverURL = normalizeServerURL(options.serverURL || process.env.GITHUB_SERVER_URL || "https://github.com"); const token = checkout.token || options.overrideToken || process.env.GH_TOKEN || ""; const persistCredentials = options.persistCredentials === true; const runGit = options.runGit || defaultRunGit; @@ -180,7 +221,7 @@ async function checkoutRepository(checkout, options = {}) { if (checkout.fetchDepth > 0) { cloneArgs.push("--depth", String(checkout.fetchDepth)); } - cloneArgs.push(`${serverURL}/${checkout.repository}.git`, checkoutTarget); + cloneArgs.push("--", `${serverURL}/${checkout.repository}.git`, checkoutTarget); core.info(`Checking out ${checkout.repository} into ${checkout.path}`); await runGit(cloneArgs, worktreeOptions); diff --git a/actions/setup/js/dynamic_checkouts.test.cjs b/actions/setup/js/dynamic_checkouts.test.cjs index 6fda1968810..2a0fe8c543d 100644 --- a/actions/setup/js/dynamic_checkouts.test.cjs +++ b/actions/setup/js/dynamic_checkouts.test.cjs @@ -42,6 +42,19 @@ describe("normalizeCheckout", () => { expect(() => normalizeCheckout({ repository: "owner/repo", current: true }, "/workspace")).toThrow("field 'current' is not supported"); expect(() => normalizeCheckout({ repository: "owner/repo", ref: "--upload-pack=evil" }, "/workspace")).toThrow("ref must not start"); }); + + it("rejects relative path segments in the repository name", () => { + expect(() => normalizeCheckout({ repository: "../.." }, "/workspace")).toThrow("owner/repo format"); + expect(() => normalizeCheckout({ repository: "owner/.." }, "/workspace")).toThrow("owner/repo format"); + expect(() => parseAllowedRepos('["../.."]')).toThrow("owner/repo"); + }); + + it("rejects refs that are not plain git refs", () => { + expect(() => normalizeCheckout({ repository: "owner/repo", ref: "main;rm -rf /" }, "/workspace")).toThrow("unsupported characters"); + expect(() => normalizeCheckout({ repository: "owner/repo", ref: "refs/heads/../evil" }, "/workspace")).toThrow("not a valid git ref"); + expect(() => normalizeCheckout({ repository: "owner/repo", ref: "refs/heads/main.lock" }, "/workspace")).toThrow("not a valid git ref"); + expect(normalizeCheckout({ repository: "owner/repo", ref: "refs/heads/main" }, "/workspace").ref).toBe("refs/heads/main"); + }); }); describe("checkoutRepository", () => { @@ -145,6 +158,29 @@ describe("checkoutRepository", () => { }) ).rejects.toThrow("patterns must not start"); }); + + it("rejects control characters in sparse checkout patterns and unsupported server URLs", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); + const target = path.join(workspace, "repo"); + const runGit = async args => { + if (args.includes("clone")) { + fs.mkdirSync(path.join(target, ".git"), { recursive: true }); + } + return ""; + }; + const checkout = normalizeCheckout({ repository: "owner/repo", "sparse-checkout": "src\u0000evil" }, workspace); + await expect(checkoutRepository(checkout, { workspace, runGit, maskSecret: () => {} })).rejects.toThrow("control characters"); + + const other = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); + await expect( + checkoutRepository(normalizeCheckout({ repository: "owner/repo" }, other), { + workspace: other, + runGit, + serverURL: "https://github.com/evil?x=1", + maskSecret: () => {}, + }) + ).rejects.toThrow("server URL is not supported"); + }); }); describe("writeManifest", () => { diff --git a/docs/src/content/docs/reference/checkout.md b/docs/src/content/docs/reference/checkout.md index 9e7da0c87b4..9c6a12edace 100644 --- a/docs/src/content/docs/reference/checkout.md +++ b/docs/src/content/docs/reference/checkout.md @@ -75,7 +75,10 @@ support `repository`, `ref`, `path`, `github-token` (or `token`), `fetch-depth`, `sparse-checkout`, `submodules`, `lfs`, and `wiki`. The runtime validates repository names, prevents paths from escaping the workspace, -enforces unique paths, and removes checkout credentials before the agent starts. GitHub +enforces unique paths, and removes checkout credentials before the agent starts. `ref` +values must be plain git refs or object ids (letters, digits, `.`, `_`, `-`, `/`, `+`), +and `sparse-checkout` patterns may not start with `-` or contain control characters, so +runtime values cannot inject additional git options. GitHub App authentication, `current`, and additional `fetch` patterns remain available only in statically declared checkout entries. Agents should treat static checkouts as known from the workflow source and dynamic checkouts as runtime-selected repositories discovered diff --git a/docs/src/content/docs/specs/checkout-behavior-specification.md b/docs/src/content/docs/specs/checkout-behavior-specification.md index fd1297885e7..4c2d56ecf45 100644 --- a/docs/src/content/docs/specs/checkout-behavior-specification.md +++ b/docs/src/content/docs/specs/checkout-behavior-specification.md @@ -160,11 +160,12 @@ checkout: - The runtime MUST parse the resolved `GH_AW_DYNAMIC_CHECKOUTS` payload as a single checkout object or an array of checkout objects, rejecting any other JSON shape. - Each entry MUST be validated against a fixed field allowlist (`repository`, `ref`, `path`, `github-token`/`token`, `fetch-depth`, `sparse-checkout`, `submodules`, `lfs`, `wiki`); an unsupported field MUST fail the step. -- `repository` MUST match `owner/repo` syntax; `path` MUST be a non-empty relative path that does not escape the workspace (no absolute paths, no `..` segments). +- `repository` MUST match `owner/repo` syntax and MUST NOT use `.` or `..` as either segment, so that the clone URL cannot traverse outside the server URL; `path` MUST be a non-empty relative path that does not escape the workspace (no absolute paths, no `..` segments). +- `ref` MUST be a plain git ref or object id (only `A-Z`, `a-z`, `0-9`, `.`, `_`, `-`, `/`, `+`), MUST NOT start with `-`, and MUST NOT contain `..`, start or end with `/`, or end with `.` or `.lock`; `sparse-checkout` patterns MUST NOT contain control characters. The clone server URL MUST be an `http(s)` origin with an optional simple path. - Every checkout's effective repository (its `.wiki` suffix stripped for the comparison when `wiki: true`) MUST be present in the resolved `allowed-repos` set (case-insensitive); a repository outside that set MUST fail the step before any checkout is attempted. - Checkout paths across all dynamic entries in a single declaration MUST be unique (case-insensitive); duplicates MUST fail the step. - Before cloning, the runtime MUST reject a checkout path whose final component is an existing file, directory, or symbolic link (including a dangling symlink), and MUST reject a path whose parent segments traverse a symbolic link or resolve outside the workspace root. -- `ref` values and `sparse-checkout` patterns MUST be passed to `git` after an option-terminator (`--`) so that a value beginning with `-` cannot be parsed as a git option. +- `ref` values, `sparse-checkout` patterns, and clone positional arguments MUST be passed to `git` after an option-terminator (`--`) so that a value beginning with `-` cannot be parsed as a git option. - Clone, checkout, sparse-checkout, and submodule operations MUST run with `GIT_LFS_SKIP_SMUDGE=1`; LFS objects MUST be fetched only when `lfs: true`, via an explicit `git lfs pull` step. - Agent-job dynamic checkouts MUST use ephemeral, command-level credentials (`persist-credentials: false`-equivalent behavior): the runtime MUST NOT leave a git credential configured after checkout completes unless the caller explicitly requests credential retention (used only for the `safe_outputs` job's PR/push checkout path). - Each successfully checked-out dynamic entry MUST be merged into the same checkout-manifest file used by static cross-repo checkouts (see §3.4), recording at least `repository`, `path`, and resolved `default_branch`. @@ -329,6 +330,7 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme - **T-CHK-020**: Dynamic checkout runtime rejects checkout paths that are absolute, escape the workspace, or resolve through a symbolic link (including the checkout target itself, whether pre-existing or a dangling symlink) - **T-CHK-021**: Dynamic checkout runtime passes `ref` and sparse-checkout patterns to `git` after an option-terminator (`--`), and disables Git LFS smudging except in the explicit `lfs: true` pull step - **T-CHK-022**: Dynamic checkout entries are merged into the same checkout-manifest file as static cross-repo checkouts, and agent-job dynamic checkouts leave no persisted git credential after checkout completes +- **T-CHK-023**: Dynamic checkout runtime rejects `.`/`..` repository segments, refs that are not plain git refs, sparse-checkout patterns containing control characters, and unsupported clone server URLs ### 7.2 Compliance Checklist @@ -351,6 +353,7 @@ When `GH_AW_TARGET_REPO_SLUG` is set but equals `GITHUB_REPOSITORY`, the impleme | Dynamic checkout path/symlink workspace-escape rejection | T-CHK-020 | C2 | Required | | Dynamic checkout git argument hardening and LFS smudge suppression | T-CHK-021 | C2 | Required | | Dynamic checkout manifest merge and credential lifecycle | T-CHK-022 | C1/C2 | Required | +| Dynamic checkout repository, ref, sparse-pattern, and server URL validation | T-CHK-023 | C2 | Required | ### 7.3 Safeguards @@ -366,7 +369,9 @@ The following MUST-level norms govern credential and token safety during checkou 5. **Dynamic checkout allowlist enforcement**: A dynamic checkout entry MUST NOT be cloned unless its repository is present in the resolved `allowed-repos` set. The check MUST occur before any git operation runs for that entry (see §3.6 and T-CHK-018). -6. **Dynamic checkout path safety**: A dynamic checkout path that is absolute, escapes the workspace root, or resolves through a symbolic link (pre-existing target or traversed parent, including dangling symlinks) MUST be rejected before cloning (see §3.6 and T-CHK-019). +6. **Dynamic checkout value validation**: Dynamic `repository`, `ref`, `sparse-checkout`, and server URL values MUST be validated against conservative character sets before reaching `git`, in addition to the option-terminator hardening, so that runtime-supplied values cannot alter git's command interpretation or clone target (see §3.6 and T-CHK-023). + +7. **Dynamic checkout path safety**: A dynamic checkout path that is absolute, escapes the workspace root, or resolves through a symbolic link (pre-existing target or traversed parent, including dangling symlinks) MUST be rejected before cloning (see §3.6 and T-CHK-019). --- @@ -408,7 +413,8 @@ The following MUST-level norms govern credential and token safety during checkou - Specified activation checkout base-SHA pinning, event and payload guards, and same-repository token fallback; added T-CHK-017. - Added §3.6: Dynamic Checkout Sets requirements covering expression-valued `checkout.repos` parsing, required `allowed-repos` enforcement, compile-time rejection of `steps.*` and `secrets.*` references, runtime field/path/symlink/uniqueness validation, git argument hardening, LFS smudge suppression, ephemeral agent-job credentials, and checkout-manifest merge. - Renamed the dynamic checkout expression field from `checkout.dynamic` to `checkout.repos`; `checkout.dynamic` is rejected with a migration error. -- Added T-CHK-018 through T-CHK-022 to §7.1 and the §7.2 compliance checklist, and two dynamic-checkout safeguards to §7.3. +- Added dynamic checkout value validation for repository segments, git refs, sparse-checkout patterns, and the clone server URL; added T-CHK-023 and a third dynamic-checkout safeguard. +- Added T-CHK-018 through T-CHK-023 to §7.1 and the §7.2 compliance checklist, and three dynamic-checkout safeguards to §7.3. - Added the dynamic checkout implementation files to the §8 Normative References. ### Version 1.2.0 (Working Draft)