Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.

20 changes: 20 additions & 0 deletions actions/setup/js/checkout_manifest.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,27 @@ 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 repository = typeof entry.repository === "string" ? entry.repository : key;
const entryPath = typeof entry.path === "string" ? entry.path : "";
const defaultBranch = typeof entry.default_branch === "string" ? entry.default_branch : "";
map.set(key, { repository, path: entryPath, default_branch: defaultBranch });
}
Comment thread
dsyme marked this conversation as resolved.
Resolved
Comment thread
dsyme marked this conversation as resolved.
Resolved
return map;
}

module.exports = {
lookupCheckout,
loadAllCheckouts,
_resetCache,
};
44 changes: 43 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,24 @@ 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.
* @param {string} workspaceRoot
* @param {string} relPath
* @returns {string}
*/
Comment thread
dsyme marked this conversation as resolved.
Resolved
function resolveManifestPath(workspaceRoot, relPath) {
if (!relPath || relPath === "" || relPath === ".") {
return workspaceRoot;
}
Comment thread
dsyme marked this conversation as resolved.
Resolved
if (path.isAbsolute(relPath)) {
return relPath;
}
return path.join(workspaceRoot, relPath);
}
Comment thread
dsyme marked this conversation as resolved.
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 +174,22 @@ 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.
const manifestEntry = lookupCheckout(targetSlug);
if (manifestEntry) {
const resolved = resolveManifestPath(ws, manifestEntry.path);
debugLog(`Found manifest entry for ${targetSlug}: ${resolved}`);
return {
success: true,
path: resolved,
Comment thread
dsyme marked this conversation as resolved.
Resolved
repoSlug: targetSlug,
};
}

// 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 +246,21 @@ 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.
const manifest = loadAllCheckouts();
for (const [slug, entry] of manifest) {
map.set(slug, resolveManifestPath(ws, entry.path));
}

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
135 changes: 135 additions & 0 deletions actions/setup/js/find_repo_checkout.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require("fs");
const path = require("path");
const { extractRepoSlugFromUrl, normalizeRepoSlug, findGitDirectories, findRepoCheckout, buildRepoCheckoutMap } = require("./find_repo_checkout.cjs");
const { getPatchPathForBranchInRepo, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch } = require("./generate_git_patch.cjs");
const { _resetCache: resetCheckoutManifestCache } = require("./checkout_manifest.cjs");

describe("find_repo_checkout", () => {
describe("extractRepoSlugFromUrl", () => {
Expand Down Expand Up @@ -168,6 +169,140 @@ describe("find_repo_checkout", () => {
});
});

describe("checkout manifest fallback (issue #37545)", () => {
let workspaceDir;
let manifestPath;
let originalManifestEnv;

beforeEach(() => {
originalManifestEnv = process.env.GH_AW_CHECKOUT_MANIFEST;
workspaceDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "test-manifest-"));
manifestPath = path.join(workspaceDir, "checkout-manifest.json");
process.env.GH_AW_CHECKOUT_MANIFEST = manifestPath;
resetCheckoutManifestCache();
});

afterEach(() => {
if (originalManifestEnv === undefined) {
delete process.env.GH_AW_CHECKOUT_MANIFEST;
} else {
process.env.GH_AW_CHECKOUT_MANIFEST = originalManifestEnv;
}
resetCheckoutManifestCache();
try {
fs.rmSync(workspaceDir, { recursive: true, force: true });
} catch {
// ignore
}
});

it("findRepoCheckout returns the manifest path even when remote.origin.url has been clobbered", () => {
// Reproduces the bug from #37545: the workflow checked out githubnext/gh-aw-side-repo
// at the workspace root, but a later "Configure Git credentials" step rewrote
// origin to point at githubnext/gh-aw-test. The manifest is the source of truth.
fs.writeFileSync(
manifestPath,
JSON.stringify({
"githubnext/gh-aw-side-repo": {
repository: "githubnext/gh-aw-side-repo",
path: "",
default_branch: "main",
},
})
);

const result = findRepoCheckout("githubnext/gh-aw-side-repo", workspaceDir);

expect(result.success).toBe(true);
expect(result.path).toBe(workspaceDir);
expect(result.repoSlug).toBe("githubnext/gh-aw-side-repo");
});

it("findRepoCheckout resolves manifest entries with a non-empty path to a subdirectory", () => {
fs.writeFileSync(
manifestPath,
JSON.stringify({
"owner/sub-repo": {
repository: "owner/sub-repo",
path: "repos/sub-repo",
default_branch: "main",
},
})
);

const result = findRepoCheckout("owner/sub-repo", workspaceDir);

expect(result.success).toBe(true);
expect(result.path).toBe(path.join(workspaceDir, "repos/sub-repo"));
});

it("findRepoCheckout is case-insensitive on the repo slug", () => {
fs.writeFileSync(
manifestPath,
JSON.stringify({
"githubnext/gh-aw-side-repo": {
repository: "githubnext/gh-aw-side-repo",
path: "",
default_branch: "main",
},
})
);

const result = findRepoCheckout("GithubNext/gh-aw-side-repo", workspaceDir);

expect(result.success).toBe(true);
expect(result.path).toBe(workspaceDir);
});

it("findRepoCheckout still applies allowedRepos validation when the manifest matches", () => {
fs.writeFileSync(
manifestPath,
JSON.stringify({
"owner/blocked": { repository: "owner/blocked", path: "blocked", default_branch: "main" },
})
);

const result = findRepoCheckout("owner/blocked", workspaceDir, {
allowedRepos: ["owner/allowed"],
});

expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});

it("findRepoCheckout falls back to the git-remote scan when the manifest has no entry", () => {
fs.writeFileSync(manifestPath, JSON.stringify({}));
Comment thread
dsyme marked this conversation as resolved.
Resolved

const result = findRepoCheckout("owner/missing", workspaceDir);

expect(result.success).toBe(false);
expect(result.error).toContain("not found in workspace");
});

it("buildRepoCheckoutMap seeds from manifest entries", () => {
fs.writeFileSync(
manifestPath,
JSON.stringify({
"githubnext/gh-aw-side-repo": {
repository: "githubnext/gh-aw-side-repo",
path: "",
default_branch: "main",
},
"owner/sub-repo": {
repository: "owner/sub-repo",
path: "repos/sub-repo",
default_branch: "main",
},
})
);

const map = buildRepoCheckoutMap(workspaceDir);

expect(map.get("githubnext/gh-aw-side-repo")).toBe(workspaceDir);
expect(map.get("owner/sub-repo")).toBe(path.join(workspaceDir, "repos/sub-repo"));
Comment thread
dsyme marked this conversation as resolved.
Resolved
});
});

describe("buildRepoCheckoutMap", () => {
let testDir;

Expand Down
Loading