Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-find-repo-checkout-manifest-first.md

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

22 changes: 22 additions & 0 deletions actions/setup/js/checkout_manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,29 @@ function _resetCache() {
cached = null;
}

/**
* Return all checkout manifest entries as a Map keyed by lowercase repo slug.
* Each value has the shape { repository, path, default_branch }.
*
* @returns {Map<string, { repository: string, path: string, default_branch: string }>}
*/
function loadAllCheckouts() {
const manifest = loadManifest();
const map = new Map();
for (const [key, entry] of Object.entries(manifest)) {
if (!entry || typeof entry !== "object") continue;
const slug = key.trim().toLowerCase();
if (!slug) continue;
const repository = typeof entry.repository === "string" ? entry.repository : slug;
const entryPath = typeof entry.path === "string" ? entry.path : "";
const defaultBranch = typeof entry.default_branch === "string" ? entry.default_branch : "";
map.set(slug, { repository, path: entryPath, default_branch: defaultBranch });
}
Comment thread
dsyme marked this conversation as resolved.
Comment thread
dsyme marked this conversation as resolved.
return map;
}

module.exports = {
lookupCheckout,
loadAllCheckouts,
_resetCache,
};
63 changes: 62 additions & 1 deletion actions/setup/js/find_repo_checkout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const fs = require("fs");
const path = require("path");
const { execGitSync } = require("./git_helpers.cjs");
const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs");
const { lookupCheckout, loadAllCheckouts } = require("./checkout_manifest.cjs");

/**
* Debug logging helper - logs to stderr when DEBUG env var matches
Expand Down Expand Up @@ -122,6 +123,31 @@ function getRemoteOriginUrl(repoPath) {
}
}

/**
* Resolve a workspace-relative path from the checkout manifest into an
* absolute path under the workspace. The manifest stores an empty string
* to represent the workspace root. Returns null when the manifest path is
* absolute or escapes the workspace root via `..` traversal, so a malformed
* or tampered manifest cannot redirect lookups outside of $GITHUB_WORKSPACE.
* @param {string} workspaceRoot
* @param {string} relPath
* @returns {string|null}
*/
Comment thread
dsyme marked this conversation as resolved.
function resolveManifestPath(workspaceRoot, relPath) {
if (!relPath || relPath === ".") {
return workspaceRoot;
}
Comment thread
dsyme marked this conversation as resolved.
if (path.isAbsolute(relPath)) {
return null;
}
const wsResolved = path.resolve(workspaceRoot);
const resolved = path.resolve(wsResolved, relPath);
if (resolved !== wsResolved && !resolved.startsWith(wsResolved + path.sep)) {
return null;
}
return resolved;
}
Comment thread
dsyme marked this conversation as resolved.

/**
* Find the checkout directory for a given repo slug
* Searches the workspace for git repos and matches by remote URL
Expand Down Expand Up @@ -155,6 +181,28 @@ function findRepoCheckout(repoSlug, workspaceRoot, options = {}) {
}
}

// First, consult the checkout manifest written by the compiler-emitted
// "Build checkout manifest for safe-outputs handlers" step. This is the
// authoritative source for cross-repo checkout paths and does not depend
// on `git config --get remote.origin.url`, which a later "Configure Git
// credentials" step may have overwritten to point at the workflow repo.
// The manifest is written before `actions/checkout` runs, so fall back to
// the git scan when the resolved path is unsafe or does not exist on disk
// (failed checkout, workspace wiped, manifest stale).
const manifestEntry = lookupCheckout(targetSlug);
if (manifestEntry) {
const resolved = resolveManifestPath(ws, manifestEntry.path);
if (resolved && fs.existsSync(resolved)) {
debugLog(`Found manifest entry for ${targetSlug}: ${resolved}`);
return {
success: true,
path: resolved,
repoSlug: targetSlug,
};
}
debugLog(`Manifest entry for ${targetSlug} unusable (path: ${manifestEntry.path}), falling back to git scan`);
}

// Find all git directories in the workspace
const gitDirs = findGitDirectories(ws);
debugLog(`Found ${gitDirs.length} git directories: ${gitDirs.join(", ")}`);
Expand Down Expand Up @@ -211,14 +259,27 @@ function buildRepoCheckoutMap(workspaceRoot) {
const ws = workspaceRoot || process.env.GITHUB_WORKSPACE || process.cwd();
const map = new Map();

// Seed from the checkout manifest first so cross-repo entries survive even
// when a later "Configure Git credentials" step has rewritten remote.origin.url.
// Only seed entries whose resolved path is safe (inside the workspace) and
// actually exists on disk, mirroring the guarantee that the git-scan branch
// provides for every entry it produces.
const manifest = loadAllCheckouts();
for (const [slug, entry] of manifest) {
const resolved = resolveManifestPath(ws, entry.path);
if (resolved && fs.existsSync(resolved)) {
map.set(slug, resolved);
}
}

const gitDirs = findGitDirectories(ws);

for (const repoPath of gitDirs) {
const remoteUrl = getRemoteOriginUrl(repoPath);
if (!remoteUrl) continue;

const slug = extractRepoSlugFromUrl(remoteUrl);
if (slug) {
if (slug && !map.has(slug)) {
map.set(slug, repoPath);
}
}
Expand Down
Loading
Loading