From c31b7292b98f6e884a99da879931792ca21a53c8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 02:20:49 +0000
Subject: [PATCH 1/6] Initial plan
From 5198141d6bf2c75072d87c90a3a578834bf1fc1f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 02:30:01 +0000
Subject: [PATCH 2/6] Initial plan
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../workflows/daily-model-inventory.lock.yml | 25 ++++++++++---------
1 file changed, 13 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/daily-model-inventory.lock.yml b/.github/workflows/daily-model-inventory.lock.yml
index 582b2fc4ffe..5ada45ec8f9 100644
--- a/.github/workflows/daily-model-inventory.lock.yml
+++ b/.github/workflows/daily-model-inventory.lock.yml
@@ -203,21 +203,21 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_4beb7ac5e590862b_EOF'
+ cat << 'GH_AW_PROMPT_04c8251975e742fc_EOF'
- GH_AW_PROMPT_4beb7ac5e590862b_EOF
+ GH_AW_PROMPT_04c8251975e742fc_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/playwright_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_4beb7ac5e590862b_EOF'
+ cat << 'GH_AW_PROMPT_04c8251975e742fc_EOF'
Tools: create_issue, missing_tool, missing_data, noop
- GH_AW_PROMPT_4beb7ac5e590862b_EOF
+ GH_AW_PROMPT_04c8251975e742fc_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_4beb7ac5e590862b_EOF'
+ cat << 'GH_AW_PROMPT_04c8251975e742fc_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -246,15 +246,15 @@ jobs:
{{/if}}
- GH_AW_PROMPT_4beb7ac5e590862b_EOF
+ GH_AW_PROMPT_04c8251975e742fc_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_4beb7ac5e590862b_EOF'
+ cat << 'GH_AW_PROMPT_04c8251975e742fc_EOF'
{{#runtime-import .github/workflows/shared/otel.md}}
{{#runtime-import .github/workflows/shared/observability-otlp.md}}
{{#runtime-import .github/workflows/shared/noop-reminder.md}}
{{#runtime-import .github/workflows/daily-model-inventory.md}}
- GH_AW_PROMPT_4beb7ac5e590862b_EOF
+ GH_AW_PROMPT_04c8251975e742fc_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -498,9 +498,9 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_23b064ae0499db90_EOF'
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_a31ecd2b69f3aaa1_EOF'
{"create_issue":{"close_older_issues":true,"expires":168,"labels":["automation","models"],"max":1,"title_prefix":"[model-inventory] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_23b064ae0499db90_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_a31ecd2b69f3aaa1_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -710,7 +710,7 @@ jobs:
mkdir -p /home/runner/.copilot
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_ef719422760c1562_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+ cat << GH_AW_MCP_CONFIG_971187abc08ce778_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
@@ -756,7 +756,7 @@ jobs:
}
}
}
- GH_AW_MCP_CONFIG_ef719422760c1562_EOF
+ GH_AW_MCP_CONFIG_971187abc08ce778_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -1752,3 +1752,4 @@ jobs:
/tmp/gh-aw/safe-output-items.jsonl
/tmp/gh-aw/temporary-id-map.json
if-no-files-found: ignore
+
From 6d8a739716d3566d02bbea0dfe8bd49c7feff8ec Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 02:37:37 +0000
Subject: [PATCH 3/6] fix: emit targeted GH_AW_GITHUB_TOKEN guidance on
cross-repo private callee auth failure
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../setup/js/check_workflow_timestamp_api.cjs | 58 +++++++-
.../js/check_workflow_timestamp_api.test.cjs | 130 ++++++++++++++++++
2 files changed, 184 insertions(+), 4 deletions(-)
diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs
index fd0cc1d118c..8dc0acb11b3 100644
--- a/actions/setup/js/check_workflow_timestamp_api.cjs
+++ b/actions/setup/js/check_workflow_timestamp_api.cjs
@@ -241,12 +241,51 @@ async function main() {
}
}
+ // Track whether a cross-repo API auth failure was the root cause of the hash check failure.
+ // Used to surface targeted remediation guidance instead of the generic "run gh aw compile" message.
+ // Set inside compareFrontmatterHashes() when an HTTP 401/403/404 is returned for a cross-repo fetch.
+ let crossRepoAuthFailure = null;
+
// Primary: compare frontmatter hashes using the GitHub API.
// Falls back to local filesystem if the API is inaccessible.
async function compareFrontmatterHashes() {
try {
- // Fetch lock file content to extract stored hash
- const lockFileContent = await getFileContent(github, owner, repo, lockFilePath, ref);
+ // Fetch lock file content to extract stored hash.
+ // Call the API directly (instead of via getFileContent) so we can capture the HTTP status
+ // code for cross-repo auth failure detection: the caller's GITHUB_TOKEN is repo-scoped and
+ // cannot read from a private callee repo, returning 404/401/403.
+ let lockFileContent = null;
+ try {
+ const response = await github.rest.repos.getContent({
+ owner,
+ repo,
+ path: lockFilePath,
+ ref,
+ });
+ if (Array.isArray(response.data)) {
+ core.info(`Path ${lockFilePath} is a directory, not a file`);
+ } else if (response.data && response.data.type === "file") {
+ if (response.data.encoding === "base64" && response.data.content) {
+ lockFileContent = Buffer.from(response.data.content, "base64").toString("utf8");
+ } else {
+ lockFileContent = response.data.content || null;
+ }
+ } else if (response.data && response.data.type) {
+ core.info(`Path ${lockFilePath} is not a file (type: ${response.data.type})`);
+ }
+ } catch (fetchErr) {
+ const status = fetchErr.status;
+ core.info(`Could not fetch content for ${lockFilePath}: ${getErrorMessage(fetchErr)}`);
+ // When the callee is a private repo, the caller's GITHUB_TOKEN (which is scoped to the
+ // caller repo) will receive a 404/401/403 from the callee's Contents API. Record this so
+ // that the final error message can give actionable remediation guidance instead of
+ // directing the user to re-run `gh aw compile`.
+ if (workflowRepo !== currentRepo && (status === 401 || status === 403 || status === 404)) {
+ crossRepoAuthFailure = { status, repo: workflowRepo };
+ core.info(`Cross-repo API access failed (HTTP ${status}): GITHUB_TOKEN is scoped to the caller repo and cannot read from '${workflowRepo}'. Configure GH_AW_GITHUB_TOKEN with read access to '${workflowRepo}' to resolve this.`);
+ }
+ }
+
if (!lockFileContent) {
core.info("Unable to fetch lock file content for hash comparison via API, trying local filesystem fallback");
return await compareFrontmatterHashesFromLocalFiles();
@@ -340,7 +379,18 @@ async function main() {
core.warning("Could not compare frontmatter hashes - assuming lock file is outdated");
await recomputeHashWithDebugLogging();
- const warningMessage = `Lock file '${lockFilePath}' is outdated or unverifiable! Could not verify frontmatter hash for '${workflowMdPath}'. Run 'gh aw compile' to regenerate the lock file.`;
+ let warningMessage;
+ let actionRequiredText;
+
+ if (crossRepoAuthFailure) {
+ // The root cause is an auth gap, not a stale lock file. Direct the user to fix the token,
+ // not to re-run `gh aw compile` (which would not resolve the issue).
+ warningMessage = `Lock file '${lockFilePath}' could not be verified: GITHUB_TOKEN cannot access the callee repo '${crossRepoAuthFailure.repo}' (HTTP ${crossRepoAuthFailure.status}). Configure GH_AW_GITHUB_TOKEN with read access to '${crossRepoAuthFailure.repo}'.`;
+ actionRequiredText = `**Root cause:** \`GITHUB_TOKEN\` is scoped to the caller repo and cannot read from the private callee repo \`${crossRepoAuthFailure.repo}\`.\n\n**Action Required:** Configure \`GH_AW_GITHUB_TOKEN\` with \`contents: read\` access to \`${crossRepoAuthFailure.repo}\`.\n\n`;
+ } else {
+ warningMessage = `Lock file '${lockFilePath}' is outdated or unverifiable! Could not verify frontmatter hash for '${workflowMdPath}'. Run 'gh aw compile' to regenerate the lock file.`;
+ actionRequiredText = "**Action Required:** Run `gh aw compile` to regenerate the lock file.\n\n";
+ }
let summary = core.summary
.addRaw("### ⚠️ Workflow Lock File Warning\n\n")
@@ -348,7 +398,7 @@ async function main() {
.addRaw("**Files:**\n")
.addRaw(`- Source: \`${workflowMdPath}\`\n`)
.addRaw(`- Lock: \`${lockFilePath}\`\n\n`)
- .addRaw("**Action Required:** Run `gh aw compile` to regenerate the lock file.\n\n");
+ .addRaw(actionRequiredText);
await summary.write();
diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs
index 6f78f2570e1..ba939ac3ed8 100644
--- a/actions/setup/js/check_workflow_timestamp_api.test.cjs
+++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs
@@ -794,6 +794,136 @@ engine: copilot
});
});
+ describe("cross-repo private callee auth failure guidance", () => {
+ // Regression test for the private-callee lockdown failure described in the issue:
+ // When a caller workflow_call dispatches against a private callee repo, the caller's
+ // GITHUB_TOKEN is repo-scoped and cannot read from the callee's Contents API,
+ // resulting in a 404/401/403. The system should surface actionable guidance pointing
+ // to GH_AW_GITHUB_TOKEN instead of the generic "run gh aw compile" message.
+
+ beforeEach(() => {
+ process.env.GH_AW_WORKFLOW_FILE = "worker-fix.lock.yml";
+ process.env.GITHUB_REPOSITORY = "gominimal/min-ctl";
+ process.env.GITHUB_WORKFLOW_REF = "gominimal/min-aw/.github/workflows/worker-fix.lock.yml@v0.6.3";
+ });
+
+ it("should emit GH_AW_GITHUB_TOKEN guidance when cross-repo fetch returns HTTP 404", async () => {
+ const error = new Error("Not Found");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo API access failed (HTTP 404)"));
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_TOKEN"));
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("gominimal/min-aw"));
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Configure GH_AW_GITHUB_TOKEN with read access to 'gominimal/min-aw'"));
+ // Must NOT direct the user to run `gh aw compile` — that doesn't fix an auth gap
+ expect(mockCore.setFailed).not.toHaveBeenCalledWith(expect.stringContaining("gh aw compile"));
+ });
+
+ it("should emit GH_AW_GITHUB_TOKEN guidance when cross-repo fetch returns HTTP 401", async () => {
+ const error = new Error("Unauthorized");
+ error.status = 401;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo API access failed (HTTP 401)"));
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_TOKEN"));
+ expect(mockCore.setFailed).not.toHaveBeenCalledWith(expect.stringContaining("gh aw compile"));
+ });
+
+ it("should emit GH_AW_GITHUB_TOKEN guidance when cross-repo fetch returns HTTP 403", async () => {
+ const error = new Error("Forbidden");
+ error.status = 403;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo API access failed (HTTP 403)"));
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_TOKEN"));
+ expect(mockCore.setFailed).not.toHaveBeenCalledWith(expect.stringContaining("gh aw compile"));
+ });
+
+ it("should include the callee repo name in the cross-repo auth guidance summary", async () => {
+ const error = new Error("Not Found");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ // Summary must explain the root cause (auth gap, not stale lock) and direct to GH_AW_GITHUB_TOKEN
+ expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("GITHUB_TOKEN"));
+ expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("GH_AW_GITHUB_TOKEN"));
+ expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("gominimal/min-aw"));
+ // Must NOT show the generic "gh aw compile" action
+ const summaryArgs = mockCore.summary.addRaw.mock.calls.flat();
+ expect(summaryArgs.some(a => a.includes("gh aw compile"))).toBe(false);
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ });
+
+ it("should still set stale_lock_file_failed output on cross-repo auth failure", async () => {
+ const error = new Error("Not Found");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("stale_lock_file_failed", "true");
+ });
+
+ it("should NOT emit cross-repo auth guidance for a generic API error without HTTP status", async () => {
+ // Generic network/permission error without a numeric status (existing test behaviour)
+ mockGithub.rest.repos.getContent.mockRejectedValue(new Error("Resource not accessible by integration"));
+
+ await main();
+
+ expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Cross-repo API access failed"));
+ // Falls back to the generic "outdated or unverifiable" message
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated"));
+ });
+
+ it("should NOT emit cross-repo auth guidance for same-repo HTTP 404", async () => {
+ // Same-repo scenario: source == current repo, so no cross-repo auth guidance expected
+ process.env.GITHUB_REPOSITORY = "gominimal/min-aw";
+ const error = new Error("Not Found");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ await main();
+
+ expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Cross-repo API access failed"));
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated"));
+ });
+
+ it("should succeed via local filesystem fallback even when cross-repo API returns 404", async () => {
+ const copilotHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3";
+ const error = new Error("Not Found");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValue(error);
+
+ // Provide local files so the filesystem fallback can verify the hash
+ const tmpDir = require("fs").mkdtempSync(require("path").join(require("os").tmpdir(), "gh-aw-auth-test-"));
+ const workflowsDir = require("path").join(tmpDir, ".github", "workflows");
+ require("fs").mkdirSync(workflowsDir, { recursive: true });
+ require("fs").writeFileSync(require("path").join(workflowsDir, "worker-fix.lock.yml"), `# frontmatter-hash: ${copilotHash}\nname: Test\n`);
+ require("fs").writeFileSync(require("path").join(workflowsDir, "worker-fix.md"), "---\nengine: copilot\n---\n# Test");
+ process.env.GITHUB_WORKSPACE = tmpDir;
+
+ try {
+ await main();
+
+ // Local fallback succeeds — no failure despite the API 404
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("✅ Lock file is up to date"));
+ } finally {
+ require("fs").rmSync(tmpDir, { recursive: true, force: true });
+ delete process.env.GITHUB_WORKSPACE;
+ }
+ });
+ });
+
describe("manual GH_AW_CONTEXT_WORKFLOW_REF fallback override", () => {
// Regression test for https://github.com/github/gh-aw/issues/23935
// In reusable workflow contexts, both GITHUB_WORKFLOW_REF and
From 8d9229f90eb59c476a3840562adc219d820dbbed Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 03:49:06 +0000
Subject: [PATCH 4/6] refactor: return crossRepoAuthFailure from
compareFrontmatterHashes instead of outer variable
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../setup/js/check_workflow_timestamp_api.cjs | 19 +++++++++----------
1 file changed, 9 insertions(+), 10 deletions(-)
diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs
index 8dc0acb11b3..6336e3e0052 100644
--- a/actions/setup/js/check_workflow_timestamp_api.cjs
+++ b/actions/setup/js/check_workflow_timestamp_api.cjs
@@ -241,13 +241,11 @@ async function main() {
}
}
- // Track whether a cross-repo API auth failure was the root cause of the hash check failure.
- // Used to surface targeted remediation guidance instead of the generic "run gh aw compile" message.
- // Set inside compareFrontmatterHashes() when an HTTP 401/403/404 is returned for a cross-repo fetch.
- let crossRepoAuthFailure = null;
-
// Primary: compare frontmatter hashes using the GitHub API.
// Falls back to local filesystem if the API is inaccessible.
+ // Returns { result, crossRepoAuthFailure } where:
+ // result — { match, storedHash, recomputedHash } | null
+ // crossRepoAuthFailure — { status, repo } when a cross-repo 401/403/404 was the root cause, else null
async function compareFrontmatterHashes() {
try {
// Fetch lock file content to extract stored hash.
@@ -255,6 +253,7 @@ async function main() {
// code for cross-repo auth failure detection: the caller's GITHUB_TOKEN is repo-scoped and
// cannot read from a private callee repo, returning 404/401/403.
let lockFileContent = null;
+ let crossRepoAuthFailure = null;
try {
const response = await github.rest.repos.getContent({
owner,
@@ -288,13 +287,13 @@ async function main() {
if (!lockFileContent) {
core.info("Unable to fetch lock file content for hash comparison via API, trying local filesystem fallback");
- return await compareFrontmatterHashesFromLocalFiles();
+ return { result: await compareFrontmatterHashesFromLocalFiles(), crossRepoAuthFailure };
}
const storedHash = extractHashFromLockFile(lockFileContent);
if (!storedHash) {
core.info("No frontmatter hash found in lock file");
- return null;
+ return { result: null, crossRepoAuthFailure: null };
}
// Compute hash using pure JavaScript implementation
@@ -310,13 +309,13 @@ async function main() {
core.info(` Recomputed hash: ${recomputedHash}`);
core.info(` Status: ${match ? "✅ Hashes match" : "⚠️ Hashes differ"}`);
- return { match, storedHash, recomputedHash };
+ return { result: { match, storedHash, recomputedHash }, crossRepoAuthFailure: null };
} catch (error) {
const errorMessage = getErrorMessage(error);
core.info(`Could not compute frontmatter hash via API: ${errorMessage}`);
// Fall back to local filesystem when API is unavailable
// (e.g., cross-org reusable workflow where caller token lacks source repo access)
- return await compareFrontmatterHashesFromLocalFiles();
+ return { result: await compareFrontmatterHashesFromLocalFiles(), crossRepoAuthFailure: null };
}
}
@@ -372,7 +371,7 @@ async function main() {
core.info("═══ End of debug hash recomputation ═══");
}
- const hashComparison = await compareFrontmatterHashes();
+ const { result: hashComparison, crossRepoAuthFailure } = await compareFrontmatterHashes();
if (!hashComparison) {
// Could not compute hash - run verbose pass for debugging then fail
From d31c54419368dbb1cff44f210af573fff2d2b4bc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 16 May 2026 05:38:11 +0000
Subject: [PATCH 5/6] Add changeset
---
.changeset/patch-cross-repo-private-callee-auth-guidance.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/patch-cross-repo-private-callee-auth-guidance.md
diff --git a/.changeset/patch-cross-repo-private-callee-auth-guidance.md b/.changeset/patch-cross-repo-private-callee-auth-guidance.md
new file mode 100644
index 00000000000..dcd42e55697
--- /dev/null
+++ b/.changeset/patch-cross-repo-private-callee-auth-guidance.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": patch
+---
+
+Surface actionable `GH_AW_GITHUB_TOKEN` guidance when a private cross-repo callee cannot be read by the caller's `GITHUB_TOKEN`.
From 0936ee4d3265265829fc8f2edb1e873370806f7b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 16 May 2026 13:53:44 +0000
Subject: [PATCH 6/6] refactor: expose errorStatus in getFileContent result,
remove inlined API call
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.../setup/js/check_workflow_timestamp_api.cjs | 47 +++++--------------
actions/setup/js/github_api_helpers.cjs | 12 ++---
actions/setup/js/github_api_helpers.test.cjs | 37 +++++++++++----
3 files changed, 46 insertions(+), 50 deletions(-)
diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs
index 6336e3e0052..07d0db5f85b 100644
--- a/actions/setup/js/check_workflow_timestamp_api.cjs
+++ b/actions/setup/js/check_workflow_timestamp_api.cjs
@@ -249,40 +249,19 @@ async function main() {
async function compareFrontmatterHashes() {
try {
// Fetch lock file content to extract stored hash.
- // Call the API directly (instead of via getFileContent) so we can capture the HTTP status
- // code for cross-repo auth failure detection: the caller's GITHUB_TOKEN is repo-scoped and
- // cannot read from a private callee repo, returning 404/401/403.
- let lockFileContent = null;
+ // errorStatus is populated when the API call fails so we can detect cross-repo auth
+ // failures: the caller's GITHUB_TOKEN is repo-scoped and cannot read from a private
+ // callee repo, returning 404/401/403.
+ const { content: lockFileContent, errorStatus } = await getFileContent(github, owner, repo, lockFilePath, ref);
+
+ // When the callee is a private repo, the caller's GITHUB_TOKEN (which is scoped to the
+ // caller repo) will receive a 404/401/403 from the callee's Contents API. Record this so
+ // that the final error message can give actionable remediation guidance instead of
+ // directing the user to re-run `gh aw compile`.
let crossRepoAuthFailure = null;
- try {
- const response = await github.rest.repos.getContent({
- owner,
- repo,
- path: lockFilePath,
- ref,
- });
- if (Array.isArray(response.data)) {
- core.info(`Path ${lockFilePath} is a directory, not a file`);
- } else if (response.data && response.data.type === "file") {
- if (response.data.encoding === "base64" && response.data.content) {
- lockFileContent = Buffer.from(response.data.content, "base64").toString("utf8");
- } else {
- lockFileContent = response.data.content || null;
- }
- } else if (response.data && response.data.type) {
- core.info(`Path ${lockFilePath} is not a file (type: ${response.data.type})`);
- }
- } catch (fetchErr) {
- const status = fetchErr.status;
- core.info(`Could not fetch content for ${lockFilePath}: ${getErrorMessage(fetchErr)}`);
- // When the callee is a private repo, the caller's GITHUB_TOKEN (which is scoped to the
- // caller repo) will receive a 404/401/403 from the callee's Contents API. Record this so
- // that the final error message can give actionable remediation guidance instead of
- // directing the user to re-run `gh aw compile`.
- if (workflowRepo !== currentRepo && (status === 401 || status === 403 || status === 404)) {
- crossRepoAuthFailure = { status, repo: workflowRepo };
- core.info(`Cross-repo API access failed (HTTP ${status}): GITHUB_TOKEN is scoped to the caller repo and cannot read from '${workflowRepo}'. Configure GH_AW_GITHUB_TOKEN with read access to '${workflowRepo}' to resolve this.`);
- }
+ if (!lockFileContent && workflowRepo !== currentRepo && (errorStatus === 401 || errorStatus === 403 || errorStatus === 404)) {
+ crossRepoAuthFailure = { status: errorStatus, repo: workflowRepo };
+ core.info(`Cross-repo API access failed (HTTP ${errorStatus}): GITHUB_TOKEN is scoped to the caller repo and cannot read from '${workflowRepo}'. Configure GH_AW_GITHUB_TOKEN with read access to '${workflowRepo}' to resolve this.`);
}
if (!lockFileContent) {
@@ -334,7 +313,7 @@ async function main() {
// Try API first (same strategy as compareFrontmatterHashes)
let fileReader;
try {
- const testContent = await getFileContent(github, owner, repo, workflowMdPath, ref);
+ const { content: testContent } = await getFileContent(github, owner, repo, workflowMdPath, ref);
if (testContent) {
fileReader = createGitHubFileReader(github, owner, repo, ref);
core.info(" Using GitHub API file reader for debug pass");
diff --git a/actions/setup/js/github_api_helpers.cjs b/actions/setup/js/github_api_helpers.cjs
index 0383b01081a..af324c5ea3e 100644
--- a/actions/setup/js/github_api_helpers.cjs
+++ b/actions/setup/js/github_api_helpers.cjs
@@ -64,7 +64,7 @@ function logGraphQLError(error, operation, hints = {}) {
* @param {string} repo - Repository name
* @param {string} path - File path within the repository
* @param {string} ref - Git reference (branch, tag, or commit SHA)
- * @returns {Promise} File content as string, or null if not found/error
+ * @returns {Promise<{content: string|null, errorStatus: number|null}>} File content and HTTP error status (if any)
*/
async function getFileContent(github, owner, repo, path, ref) {
try {
@@ -78,25 +78,25 @@ async function getFileContent(github, owner, repo, path, ref) {
// Handle case where response is an array (directory listing)
if (Array.isArray(response.data)) {
core.info(`Path ${path} is a directory, not a file`);
- return null;
+ return { content: null, errorStatus: null };
}
// Check if this is a file (not a symlink or submodule)
if (response.data.type !== "file") {
core.info(`Path ${path} is not a file (type: ${response.data.type})`);
- return null;
+ return { content: null, errorStatus: null };
}
// Decode base64 content
if (response.data.encoding === "base64" && response.data.content) {
- return Buffer.from(response.data.content, "base64").toString("utf8");
+ return { content: Buffer.from(response.data.content, "base64").toString("utf8"), errorStatus: null };
}
- return response.data.content || null;
+ return { content: response.data.content || null, errorStatus: null };
} catch (error) {
const errorMessage = getErrorMessage(error);
core.info(`Could not fetch content for ${path}: ${errorMessage}`);
- return null;
+ return { content: null, errorStatus: error.status ?? null };
}
}
diff --git a/actions/setup/js/github_api_helpers.test.cjs b/actions/setup/js/github_api_helpers.test.cjs
index e6e62b109af..ac1f571f3af 100644
--- a/actions/setup/js/github_api_helpers.test.cjs
+++ b/actions/setup/js/github_api_helpers.test.cjs
@@ -45,7 +45,8 @@ describe("github_api_helpers.cjs", () => {
const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");
- expect(result).toBe(fileContent);
+ expect(result.content).toBe(fileContent);
+ expect(result.errorStatus).toBeNull();
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
@@ -66,10 +67,11 @@ describe("github_api_helpers.cjs", () => {
const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");
- expect(result).toBe(fileContent);
+ expect(result.content).toBe(fileContent);
+ expect(result.errorStatus).toBeNull();
});
- it("should return null for directory paths", async () => {
+ it("should return null content for directory paths", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: [
{ name: "file1.txt", type: "file" },
@@ -79,11 +81,12 @@ describe("github_api_helpers.cjs", () => {
const result = await getFileContent(mockGithub, "owner", "repo", "directory", "main");
- expect(result).toBeNull();
+ expect(result.content).toBeNull();
+ expect(result.errorStatus).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is a directory"));
});
- it("should return null for non-file types", async () => {
+ it("should return null content for non-file types", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
type: "symlink",
@@ -94,19 +97,32 @@ describe("github_api_helpers.cjs", () => {
const result = await getFileContent(mockGithub, "owner", "repo", "symlink.txt", "main");
- expect(result).toBeNull();
+ expect(result.content).toBeNull();
+ expect(result.errorStatus).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("is not a file"));
});
- it("should handle API errors gracefully", async () => {
- mockGithub.rest.repos.getContent.mockRejectedValueOnce(new Error("API error"));
+ it("should handle API errors gracefully and return errorStatus", async () => {
+ const error = new Error("API error");
+ error.status = 404;
+ mockGithub.rest.repos.getContent.mockRejectedValueOnce(error);
const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");
- expect(result).toBeNull();
+ expect(result.content).toBeNull();
+ expect(result.errorStatus).toBe(404);
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Could not fetch content"));
});
+ it("should return null errorStatus when error has no status", async () => {
+ mockGithub.rest.repos.getContent.mockRejectedValueOnce(new Error("API error"));
+
+ const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");
+
+ expect(result.content).toBeNull();
+ expect(result.errorStatus).toBeNull();
+ });
+
it("should handle missing content field", async () => {
mockGithub.rest.repos.getContent.mockResolvedValueOnce({
data: {
@@ -118,7 +134,8 @@ describe("github_api_helpers.cjs", () => {
const result = await getFileContent(mockGithub, "owner", "repo", "file.txt", "main");
- expect(result).toBeNull();
+ expect(result.content).toBeNull();
+ expect(result.errorStatus).toBeNull();
});
});