Repository navigation
Support dynamic checkout sets from GitHub Actions expressions #63241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
4e22b94
22e30b8
faa0782
3c1e797
0250e45
a40339d
801b499
faa71e2
53fad24
55c159a
5e57e8d
e90d8f6
7062938
1a90bef
2e9f6b7
b948232
81577d7
2b17a5e
3a3800e
fab70be
e517a96
e5648f1
05ba9a8
553d2a1
3ce28a7
f38643f
b832d3d
88c6d55
fd68ce6
e82a8d7
5fbbffb
b13bdf4
de9f545
bfae6b1
2b83bd4
c580198
a3202ef
1247780
1ce527c
b5015e2
c7e114f
6c95d54
80e0b93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| // @ts-check | ||
| /// <reference types="@actions/github-script" /> | ||
|
|
||
| require("./shim.cjs"); | ||
|
|
||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
|
|
||
| const supportedFields = new Set(["repository", "ref", "path", "github-token", "token", "fetch-depth", "sparse-checkout", "submodules", "lfs", "wiki"]); | ||
|
|
||
| function parseDynamicCheckouts(value = process.env.GH_AW_DYNAMIC_CHECKOUTS || "") { | ||
| if (!value.trim()) { | ||
| return []; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(value); | ||
| } catch (error) { | ||
| throw new Error(`checkout expression must resolve to a JSON object or array: ${getErrorMessage(error)}`, { cause: error }); | ||
| } | ||
| const entries = Array.isArray(parsed) ? parsed : [parsed]; | ||
| if (entries.some(entry => !entry || typeof entry !== "object" || Array.isArray(entry))) { | ||
| throw new Error("checkout expression must resolve to a checkout object or an array of checkout objects"); | ||
| } | ||
| return entries; | ||
| } | ||
|
|
||
| function normalizeCheckout(entry, workspace) { | ||
| for (const field of Object.keys(entry)) { | ||
| if (!supportedFields.has(field)) { | ||
| throw new Error(`dynamic checkout field '${field}' is not supported`); | ||
| } | ||
| } | ||
|
|
||
| let repository = String(entry.repository || "").trim(); | ||
| if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { | ||
| throw new Error(`dynamic checkout repository must use owner/repo format, got '${repository}'`); | ||
| } | ||
| if (entry.lfs !== undefined && typeof entry.lfs !== "boolean") { | ||
| throw new Error("dynamic checkout lfs must be a boolean"); | ||
| } | ||
| if (entry.wiki !== undefined && typeof entry.wiki !== "boolean") { | ||
| throw new Error("dynamic checkout wiki must be a boolean"); | ||
| } | ||
|
|
||
| let checkoutPath = entry.path === undefined ? repository.split("/")[1] : String(entry.path).trim(); | ||
| checkoutPath = checkoutPath.replaceAll("\\", "/").replace(/^\.\//, ""); | ||
| if (!checkoutPath || path.isAbsolute(checkoutPath) || checkoutPath.split("/").includes("..")) { | ||
| throw new Error(`dynamic checkout path must be a non-empty relative path under the workspace, got '${checkoutPath}'`); | ||
| } | ||
|
|
||
| const fetchDepth = entry["fetch-depth"] === undefined ? 1 : Number(entry["fetch-depth"]); | ||
| if (!Number.isInteger(fetchDepth) || fetchDepth < 0) { | ||
| throw new Error("dynamic checkout fetch-depth must be a non-negative integer"); | ||
| } | ||
| if (entry.submodules !== undefined && !["true", "false", "recursive", true, false].includes(entry.submodules)) { | ||
| throw new Error("dynamic checkout submodules must be true, false, or recursive"); | ||
| } | ||
|
|
||
| if (entry.wiki === true) { | ||
| repository += ".wiki"; | ||
| } | ||
| return { | ||
| repository, | ||
| ref: String(entry.ref || "").trim(), | ||
| path: checkoutPath, | ||
| token: String(entry["github-token"] || entry.token || ""), | ||
| fetchDepth, | ||
| sparseCheckout: String(entry["sparse-checkout"] || ""), | ||
| submodules: entry.submodules, | ||
| lfs: entry.lfs === true, | ||
| }; | ||
| } | ||
|
|
||
| async function defaultRunGit(args, options = {}) { | ||
| const result = await exec.getExecOutput("git", args, { silent: true, ...options }); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error("git command failed while preparing a dynamic checkout"); | ||
| } | ||
| return result.stdout.trim(); | ||
| } | ||
|
|
||
| function credentialArgs(serverURL, token, maskSecret = value => core.setSecret(value)) { | ||
| if (!token) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L85: stdlib: manual key-validation loop with Set membership.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change for this style-only simplification: the current explicit loop keeps the unsupported field name available for the precise validation error. |
||
| return []; | ||
| } | ||
| maskSecret(token); | ||
| const encoded = Buffer.from(`x-access-token:${token}`).toString("base64"); | ||
| maskSecret(encoded); | ||
| return ["-c", `http.${serverURL}/.extraheader=AUTHORIZATION: basic ${encoded}`]; | ||
| } | ||
|
|
||
| async function checkoutRepository(checkout, options = {}) { | ||
| const workspace = options.workspace || process.env.GITHUB_WORKSPACE || ""; | ||
| const serverURL = (options.serverURL || process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, ""); | ||
| const token = checkout.token || options.overrideToken || process.env.GH_TOKEN || ""; | ||
| const persistCredentials = options.persistCredentials === true; | ||
| const runGit = options.runGit || defaultRunGit; | ||
|
|
||
| let workspaceReal; | ||
| let checkoutTarget; | ||
| try { | ||
| workspaceReal = fs.realpathSync(workspace); | ||
| checkoutTarget = path.join(workspaceReal, checkout.path); | ||
| if (fs.existsSync(checkoutTarget)) { | ||
| throw new Error(`dynamic checkout path already exists: ${checkout.path}`); | ||
| } | ||
|
|
||
| let parent = workspaceReal; | ||
| const parentSegments = path | ||
| .dirname(checkout.path) | ||
| .split(path.sep) | ||
| .filter(segment => segment && segment !== "."); | ||
| for (const segment of parentSegments) { | ||
| const candidate = path.join(parent, segment); | ||
| if (fs.existsSync(candidate)) { | ||
| if (fs.lstatSync(candidate).isSymbolicLink()) { | ||
| throw new Error(`dynamic checkout path traverses a symbolic link: ${checkout.path}`); | ||
| } | ||
| } else { | ||
| fs.mkdirSync(candidate); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L123: shrink: duplicated token alias handling (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change for this style-only simplification: the current normalization keeps the token aliases close to the returned checkout shape without changing behavior. |
||
| parent = fs.realpathSync(candidate); | ||
| if (parent !== workspaceReal && !parent.startsWith(workspaceReal + path.sep)) { | ||
| throw new Error(`dynamic checkout path escapes the workspace: ${checkout.path}`); | ||
| } | ||
| } | ||
|
Comment on lines
+186
to
+213
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } catch (error) { | ||
| throw new Error(`failed to prepare dynamic checkout path '${checkout.path}': ${getErrorMessage(error)}`, { cause: error }); | ||
| } | ||
|
|
||
| const authArgs = credentialArgs(serverURL, token, options.maskSecret); | ||
| const cloneArgs = [...authArgs, "clone", "--no-tags"]; | ||
| if (checkout.fetchDepth > 0) { | ||
| cloneArgs.push("--depth", String(checkout.fetchDepth)); | ||
| } | ||
| cloneArgs.push(`${serverURL}/${checkout.repository}.git`, checkoutTarget); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] 💡 Suggested fixPass const gitEnv = checkout.lfs ? undefined : { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" };
await runGit(cloneArgs, { env: gitEnv });@copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already addressed in 801b499: clone, checkout, sparse-checkout, and submodule worktree updates now run with |
||
|
|
||
| core.info(`Checking out ${checkout.repository} into ${checkout.path}`); | ||
| await runGit(cloneArgs); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dynamic checkout clone, checkout, sparse checkout, and submodule operations now disable LFS smudging; only the explicit LFS branch pulls objects. Addressed in 801b499.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified addressed in |
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] 💡 Suggested fixfetchArgs.push("origin", "--", checkout.ref);Add a regression test with a ref like @copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current branch: |
||
| await runGit(fetchArgs); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current branch: dynamic checkout refs are rejected when they start with |
||
| await runGit(["-C", checkoutTarget, "checkout", "--force", "FETCH_HEAD"]); | ||
|
Copilot marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| if (checkout.sparseCheckout.trim()) { | ||
| const patterns = checkout.sparseCheckout | ||
| .split(/\r?\n/) | ||
| .map(pattern => pattern.trim()) | ||
| .filter(Boolean); | ||
| await runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", ...patterns]); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sparse-checkout patterns are now validated and passed after Git's option terminator. Addressed in 801b499.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verified addressed in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current branch: dynamic checkout refs are validated against leading
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] Same argument-injection class as the 💡 Suggested fixawait runGit(["-C", checkoutTarget, "sparse-checkout", "set", "--no-cone", "--", ...patterns]);@copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current branch: sparse-checkout patterns are validated to reject leading
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current branch: sparse-checkout patterns beginning with |
||
| } | ||
| if (checkout.submodules === true || checkout.submodules === "true" || checkout.submodules === "recursive") { | ||
| const args = [...authArgs, "-C", checkoutTarget, "submodule", "update", "--init"]; | ||
| if (checkout.submodules === "recursive") { | ||
| args.push("--recursive"); | ||
| } | ||
| await runGit(args); | ||
| } | ||
| if (checkout.lfs) { | ||
| await runGit([...authArgs, "-C", checkoutTarget, "lfs", "pull"]); | ||
| } | ||
|
|
||
| const credentialKey = `http.${serverURL}/.extraheader`; | ||
| if (persistCredentials && token) { | ||
| const encoded = Buffer.from(`x-access-token:${token}`).toString("base64"); | ||
| await runGit(["-C", checkoutTarget, "config", credentialKey, `AUTHORIZATION: basic ${encoded}`]); | ||
| } else { | ||
| try { | ||
| await runGit(["-C", checkoutTarget, "config", "--unset-all", credentialKey]); | ||
| } catch (error) { | ||
| core.debug(`No persisted credential needed removal: ${getErrorMessage(error)}`); | ||
| } | ||
| } | ||
|
|
||
| let defaultBranch = ""; | ||
| try { | ||
| defaultBranch = (await runGit(["-C", checkoutTarget, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).replace(/^origin\//, ""); | ||
| } catch (error) { | ||
| core.debug(`Could not resolve dynamic checkout default branch: ${getErrorMessage(error)}`); | ||
| } | ||
| return { | ||
| repository: checkout.repository, | ||
| path: checkout.path, | ||
| default_branch: defaultBranch, | ||
| }; | ||
| } | ||
|
|
||
| function writeManifest(entries, runnerTemp = process.env.RUNNER_TEMP || "") { | ||
| if (!runnerTemp) { | ||
| throw new Error("RUNNER_TEMP is required for dynamic checkouts"); | ||
| } | ||
| const manifestDir = path.join(runnerTemp, "gh-aw", "safeoutputs"); | ||
| const manifestPath = path.join(manifestDir, "checkout-manifest.json"); | ||
| let manifest = {}; | ||
| try { | ||
| fs.mkdirSync(manifestDir, { recursive: true }); | ||
| if (fs.existsSync(manifestPath)) { | ||
| manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); | ||
| } | ||
| for (const entry of entries) { | ||
| manifest[entry.repository.toLowerCase()] = entry; | ||
| } | ||
| fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); | ||
| } catch (error) { | ||
| throw new Error(`failed to write dynamic checkout manifest: ${getErrorMessage(error)}`, { cause: error }); | ||
| } | ||
| return manifestPath; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. L215: yagni: tri-branch submodule mode check (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No change for this style-only simplification: the current explicit submodule handling keeps the accepted |
||
|
|
||
| async function main(options = {}) { | ||
| const workspace = options.workspace || process.env.GITHUB_WORKSPACE || ""; | ||
| if (!workspace) { | ||
| throw new Error("GITHUB_WORKSPACE is required for dynamic checkouts"); | ||
| } | ||
| const checkouts = parseDynamicCheckouts(options.value); | ||
| const normalized = checkouts.map(entry => normalizeCheckout(entry, workspace)); | ||
| if (new Set(normalized.map(entry => entry.path.toLowerCase())).size !== normalized.length) { | ||
| throw new Error("dynamic checkout paths must be unique"); | ||
| } | ||
|
|
||
| const persistCredentials = options.persistCredentials ?? process.env.GH_AW_DYNAMIC_CHECKOUT_PERSIST_CREDENTIALS === "true"; | ||
| const overrideToken = options.overrideToken ?? process.env.GH_AW_DYNAMIC_CHECKOUT_TOKEN ?? ""; | ||
| const manifestEntries = []; | ||
| for (const checkout of normalized) { | ||
| manifestEntries.push( | ||
| await checkoutRepository(checkout, { | ||
| ...options, | ||
| workspace, | ||
| persistCredentials, | ||
| overrideToken, | ||
| }) | ||
| ); | ||
| } | ||
| writeManifest(manifestEntries, options.runnerTemp); | ||
| return manifestEntries; | ||
| } | ||
|
|
||
| module.exports = { | ||
| checkoutRepository, | ||
| main, | ||
| normalizeCheckout, | ||
| parseDynamicCheckouts, | ||
| writeManifest, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| // @ts-check | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
|
|
||
| const { checkoutRepository, normalizeCheckout, parseDynamicCheckouts, writeManifest } = require("./dynamic_checkouts.cjs"); | ||
|
|
||
| describe("parseDynamicCheckouts", () => { | ||
| it("accepts one object or an array", () => { | ||
| expect(parseDynamicCheckouts('{"repository":"owner/repo"}')).toHaveLength(1); | ||
| expect(parseDynamicCheckouts('[{"repository":"owner/a"},{"repository":"owner/b"}]')).toHaveLength(2); | ||
| }); | ||
|
|
||
| it("rejects scalar results", () => { | ||
| expect(() => parseDynamicCheckouts('"owner/repo"')).toThrow("checkout object or an array"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("normalizeCheckout", () => { | ||
| it("derives a safe path and defaults to a shallow checkout", () => { | ||
| const checkout = normalizeCheckout({ repository: "owner/repo" }, "/workspace"); | ||
| expect(checkout.path).toBe("repo"); | ||
| expect(checkout.fetchDepth).toBe(1); | ||
| }); | ||
|
|
||
| it("rejects traversal and unsupported fields", () => { | ||
| expect(() => normalizeCheckout({ repository: "owner/repo", path: "../repo" }, "/workspace")).toThrow("relative path"); | ||
| expect(() => normalizeCheckout({ repository: "owner/repo", fetch: ["main"] }, "/workspace")).toThrow("field 'fetch' is not supported"); | ||
| expect(() => normalizeCheckout({ repository: "owner/repo", current: true }, "/workspace")).toThrow("field 'current' is not supported"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("checkoutRepository", () => { | ||
| it("uses an ephemeral credential and leaves no credential when persistence is disabled", async () => { | ||
| const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-")); | ||
| const "repo"); | ||
| const calls = []; | ||
| const runGit = async args => { | ||
| calls.push(args); | ||
| if (args.includes("clone")) { | ||
| fs.mkdirSync(path.join(target, ".git"), { recursive: true }); | ||
| } | ||
| if (args.includes("symbolic-ref")) { | ||
| return "origin/main"; | ||
| } | ||
| return ""; | ||
| }; | ||
|
|
||
| const checkout = normalizeCheckout({ repository: "owner/repo", "github-token": "secret" }, workspace); | ||
| const result = await checkoutRepository(checkout, { | ||
| workspace, | ||
| runGit, | ||
| serverURL: "https://github.com", | ||
| overrideToken: "fallback-token", | ||
| maskSecret: () => {}, | ||
| }); | ||
|
|
||
| expect(result.default_branch).toBe("main"); | ||
| expect(calls[0]).toContain("http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46c2VjcmV0"); | ||
| expect(calls.some(args => args.includes("--unset-all"))).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects a symlinked parent before creating directories outside the workspace", async () => { | ||
| const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-workspace-")); | ||
| const outside = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-checkout-outside-")); | ||
| fs.symlinkSync(outside, path.join(workspace, "linked")); | ||
| const checkout = normalizeCheckout({ repository: "owner/repo", path: "linked/nested/repo" }, workspace); | ||
|
|
||
| await expect( | ||
| checkoutRepository(checkout, { | ||
| workspace, | ||
| runGit: async () => "", | ||
| maskSecret: () => {}, | ||
| }) | ||
| ).rejects.toThrow("traverses a symbolic link"); | ||
| expect(fs.existsSync(path.join(outside, "nested"))).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("writeManifest", () => { | ||
| it("merges dynamic entries into an existing manifest", () => { | ||
| const runnerTemp = fs.mkdtempSync(path.join(os.tmpdir(), "dynamic-manifest-")); | ||
| const manifestDir = path.join(runnerTemp, "gh-aw", "safeoutputs"); | ||
| fs.mkdirSync(manifestDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(manifestDir, "checkout-manifest.json"), JSON.stringify({ "owner/static": { repository: "owner/static", path: "static" } })); | ||
|
|
||
| const manifestPath = writeManifest([{ repository: "owner/dynamic", path: "dynamic", default_branch: "main" }], runnerTemp); | ||
| const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); | ||
| expect(manifest).toHaveProperty("owner/static"); | ||
| expect(manifest).toHaveProperty("owner/dynamic"); | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Good coverage of the happy path, symlink escape, and manifest merge — but there's no test exercising the actual command arrays passed to 💡 Suggested testit("does not let a ref starting with '-' be treated as a git option", async () => {
const calls = [];
const runGit = async args => { calls.push(args); return ""; };
const checkout = normalizeCheckout({ repository: "owner/repo", ref: "--upload-pack=evil" }, workspace);
await checkoutRepository(checkout, { workspace, runGit, maskSecret: () => {} });
const fetchCall = calls.find(a => a.includes("fetch"));
expect(fetchCall.slice(fetchCall.indexOf("origin")+1)).toEqual(["--", "--upload-pack=evil"]);
});@copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already addressed in 801b499 and preserved in the current tests: |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Smoke test review comment #2: Nice addition of
dynamic_checkouts.cjs! Adding a brief header comment explaining the module contract (inputs/outputs) would help future maintainers understand the dynamic checkout resolution flow.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a module contract header in
actions/setup/js/dynamic_checkouts.cjsdescribing the environment inputs, validation/runtime checkout work, and checkout-manifest output. Addressed in81577d7.