From b30b273a226f154496659d77be54e520489d9dc2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:22:57 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.79.5 --- setup/js/add_comment.cjs | 7 +- setup/js/apply_samples.cjs | 183 +++++++++++++++++++++++++++-- setup/js/comment_memory.cjs | 4 + setup/js/get_current_branch.cjs | 13 +- setup/js/safe_outputs_handlers.cjs | 55 ++++++--- setup/js/safe_outputs_tools.json | 6 +- setup/js/send_otlp_span.cjs | 10 +- setup/js/validate_secrets.cjs | 28 ++++- 8 files changed, 262 insertions(+), 44 deletions(-) diff --git a/setup/js/add_comment.cjs b/setup/js/add_comment.cjs index bfc9cbc6..c03eddcc 100644 --- a/setup/js/add_comment.cjs +++ b/setup/js/add_comment.cjs @@ -773,12 +773,9 @@ async function main(config = {}) { // accepted for compatibility with forwarded/legacy payload variants. const explicitCommentIdRaw = message.comment_id ?? message.commentId ?? message["comment-id"]; const rawTarget = message.target; - const allowedTargets = ["status"]; + const allowedTargets = ["status", "issue", "discussion"]; if (rawTarget !== undefined && !allowedTargets.includes(rawTarget)) { - return { - success: false, - error: `target must be one of: [${allowedTargets.join(", ")}]`, - }; + core.warning(`Ignoring unrecognized message-level target value "${rawTarget}": only "status", "issue", or "discussion" are supported. Proceeding without comment reuse.`); } const isStatusCommentTarget = rawTarget === "status"; const statusCommentIdRaw = process.env.GH_AW_COMMENT_ID || ""; diff --git a/setup/js/apply_samples.cjs b/setup/js/apply_samples.cjs index 28aa5f25..68274141 100644 --- a/setup/js/apply_samples.cjs +++ b/setup/js/apply_samples.cjs @@ -114,20 +114,176 @@ function ensureGitIdentity(cwd) { } } +/** + * Read and parse the GitHub Actions event payload from GITHUB_EVENT_PATH. + * Returns null when the file is unset, missing, or not parseable. + * @returns {Record|null} + */ +function readEventPayload() { + const p = process.env.GITHUB_EVENT_PATH; + if (!p) return null; + try { + return JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + return null; + } +} + +/** + * Resolve the best token for a `owner/repo` API call. + * + * Multi-checkout workflows often need different tokens for different + * repositories (e.g. a workflow runs in `owner/automation` but reaches into + * `owner/product` via a separate `checkout:` entry that supplies its own + * `github-token:` or `github-app:`). The compiler emits that mapping as + * `GH_AW_REPO_TOKENS` (a JSON object keyed by `owner/repo`); this helper + * looks the requested slug up there and falls back to GITHUB_TOKEN / + * GH_TOKEN. + * + * @param {string} owner + * @param {string} repo + * @returns {string|undefined} + */ +function selectTokenForRepo(owner, repo) { + const slug = `${owner}/${repo}`; + const raw = process.env.GH_AW_REPO_TOKENS; + if (raw && raw.trim()) { + try { + const map = JSON.parse(raw); + if (map && typeof map === "object" && typeof map[slug] === "string" && map[slug].trim()) { + return map[slug].trim(); + } + } catch (err) { + core.warning(`apply_samples: GH_AW_REPO_TOKENS is not valid JSON, ignoring: ${getErrorMessage(err)}`); + } + } + return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || undefined; +} + +/** + * Fetch a pull request via the REST API and return its head ref. Uses the + * per-repo token from GH_AW_REPO_TOKENS when present, falling back to + * GITHUB_TOKEN; falls back to anonymous (works for public repositories) when + * no token is available. Returns null on any failure so the caller can decide + * how to recover. + * @param {{owner: string, repo: string, pullNumber: number}} args + * @returns {Promise} + */ +async function fetchPullRequestHeadRef({ owner, repo, pullNumber }) { + const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; + const url = `${apiUrl}/repos/${owner}/${repo}/pulls/${pullNumber}`; + /** @type {Record} */ + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "gh-aw-apply-samples", + }; + const token = selectTokenForRepo(owner, repo); + if (token) headers["Authorization"] = `Bearer ${token}`; + try { + const resp = await fetch(url, { headers }); + if (!resp.ok) { + core.warning(`apply_samples: GET ${url} returned HTTP ${resp.status}`); + return null; + } + const body = await resp.json(); + // @ts-ignore - REST API response shape is well-defined; trust GitHub PR endpoint contract + const ref = body && body.head && typeof body.head.ref === "string" ? body.head.ref : null; + return ref || null; + } catch (err) { + core.warning(`apply_samples: failed to fetch PR ${owner}/${repo}#${pullNumber}: ${getErrorMessage(err)}`); + return null; + } +} + +/** + * Derive the pull request head ref for the triggering event. + * + * Resolution order: + * 1. `pull_request.head.ref` from the event payload (pull_request and + * pull_request_target events). + * 2. PR API lookup using `issue.number` when the payload describes an + * issue_comment on a pull request. + * 3. PR API lookup using an explicit `pull_request_number` argument on the + * sample (covers workflow_dispatch driven by the agent). + * + * @param {SampleEntry} entry + * @returns {Promise} + */ +async function derivePrHeadRef(entry) { + const payload = readEventPayload(); + + // 1. pull_request* events expose the head ref directly. + const directRef = payload?.pull_request?.head?.ref; + if (typeof directRef === "string" && directRef.trim()) { + return directRef.trim(); + } + + // Determine the target repo for any API lookups. Prefer the entry's repo if + // the sample sets one (cross-repo workflows), otherwise fall back to + // GITHUB_REPOSITORY. + const repoSlug = (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) || process.env.GITHUB_REPOSITORY || ""; + const [owner, repo] = repoSlug.split("/"); + if (!owner || !repo) return null; + + // 2. issue_comment / pull_request_review_comment with a PR-linked issue. + if (payload?.issue?.pull_request && typeof payload.issue.number === "number") { + const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: payload.issue.number }); + if (ref) return ref; + } + + // 3. Explicit pull_request_number on the sample arguments. + const argNumber = Number(entry.arguments.pull_request_number); + if (Number.isFinite(argNumber) && argNumber > 0) { + const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber }); + if (ref) return ref; + } + + return null; +} + /** * Pre-stage a branch + patch for samples whose tool reads the workspace diff. - * Mutates `entry.arguments.branch` to the actual checked-out branch. + * + * - For `create_pull_request`, the sample creates a brand-new branch, so we + * accept the sample's declared `branch` or synthesize `gh-aw-sample-`. + * - For `push_to_pull_request_branch`, the destination is the triggering PR's + * head branch — we derive it from event/PR context and refuse to invent a + * synthetic branch (which would never exist on origin and would break the + * MCP server's incremental-patch generation, per issue #37835). + * * @param {SampleEntry} entry * @param {number} index * @param {string} workspace + * @returns {Promise} */ -function preStagePatch(entry, index, workspace) { +async function preStagePatch(entry, index, workspace) { const patch = entry.sidecars && entry.sidecars.patch; if (typeof patch !== "string" || !patch.trim()) { return; } - const branch = typeof entry.arguments.branch === "string" && entry.arguments.branch.trim() ? entry.arguments.branch.trim() : `gh-aw-sample-${index + 1}`; - entry.arguments.branch = branch; + + let branch; + if (entry.tool === "push_to_pull_request_branch") { + // Source ref MUST match the PR's head ref so that + // `git diff origin/..` in the MCP server resolves the + // correct baseline. Synthetic `gh-aw-sample-N` names produce + // `fatal: couldn't find remote ref` failures (issue #37835). + branch = await derivePrHeadRef(entry); + if (!branch) { + throw new Error( + `apply_samples: cannot derive pull-request head branch for sample[${index}] (tool=${entry.tool}). ` + + `Trigger the workflow from a pull_request event, or set arguments.pull_request_number on the sample entry, ` + + `or provide GITHUB_TOKEN so the PR can be fetched.` + ); + } + // The agent input no longer carries `branch`; ensure stray sample-supplied + // values do not leak through to the MCP tools/call payload. + delete entry.arguments.branch; + } else { + branch = typeof entry.arguments.branch === "string" && entry.arguments.branch.trim() ? entry.arguments.branch.trim() : `gh-aw-sample-${index + 1}`; + entry.arguments.branch = branch; + } ensureGitIdentity(workspace); @@ -269,11 +425,12 @@ async function main() { const logPath = process.env.GH_AW_AGENT_STDIO_LOG || ""; // Pre-stage branches/patches. - samples.forEach((sample, i) => { + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; if (PATCH_SIDECAR_TOOLS.has(sample.tool)) { - preStagePatch(sample, i, workspace); + await preStagePatch(sample, i, workspace); } - }); + } if (samples.length === 0) { core.info("apply_samples: nothing to replay; exiting cleanly."); @@ -375,4 +532,14 @@ if (require.main === module) { }); } -module.exports = { main, loadSamples, preStagePatch, resolveMcpServerPath, sendJsonRpc }; +module.exports = { + main, + loadSamples, + preStagePatch, + resolveMcpServerPath, + selectTokenForRepo, + sendJsonRpc, + // Exported for unit testing of the 3-tier PR head ref resolution logic. + derivePrHeadRef, + fetchPullRequestHeadRef, +}; diff --git a/setup/js/comment_memory.cjs b/setup/js/comment_memory.cjs index c3455508..1feda311 100644 --- a/setup/js/comment_memory.cjs +++ b/setup/js/comment_memory.cjs @@ -160,6 +160,10 @@ async function main(config = {}) { }); if (!targetResult.success) { core.warning(`comment_memory: target resolution failed: ${targetResult.error}`); + if (!targetResult.shouldFail) { + // No triggering context (e.g. schedule/workflow_dispatch run) — skip rather than fail + return { success: false, skipped: true, error: targetResult.error }; + } return { success: false, error: targetResult.error }; } core.info(`comment_memory: resolved target item_number=${targetResult.number}`); diff --git a/setup/js/get_current_branch.cjs b/setup/js/get_current_branch.cjs index f77f9d0a..dc643590 100644 --- a/setup/js/get_current_branch.cjs +++ b/setup/js/get_current_branch.cjs @@ -20,7 +20,14 @@ function getCurrentBranch(customCwd) { cwd: cwd, stdio: ["pipe", "pipe", "pipe"], }).trim(); - return branch; + // "HEAD" means the repo is in a detached-HEAD state (common with the + // default actions/checkout behaviour). It is not a valid branch name; + // fall through to the GITHUB_HEAD_REF / GITHUB_REF_NAME env-var + // fallbacks so callers get a real ref rather than the literal "HEAD", + // which would later produce a misleading "remote ref not present" error. + if (branch && branch !== "HEAD") { + return branch; + } } catch (error) { // Ignore error and try fallback } @@ -39,7 +46,9 @@ function getCurrentBranch(customCwd) { return ghRefName; } - throw new Error(`${ERR_CONFIG}: Failed to determine current branch: git command failed and no GitHub environment variables available`); + throw new Error( + `${ERR_CONFIG}: Failed to determine current branch: git command returned a detached-HEAD state and no GitHub environment variables (GITHUB_HEAD_REF / GITHUB_REF_NAME) are available. Ensure the workflow checks out the pull request's head ref before calling this step.` + ); } module.exports = { diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index 0f397b8a..bafa4f25 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -797,14 +797,20 @@ function createHandlers(server, appendSafeOutput, config = {}) { /** * Handler for push_to_pull_request_branch tool * Spec cross-reference: Safe Output Outcome Evaluation §17 (`push_to_pull_request_branch`). - * Resolves the current branch if branch is not provided or is the base branch - * Generates git patch for the changes + * The agent does NOT supply a branch. The source branch is derived from the + * current working checkout (the agent must already be on the PR head ref to + * have committed onto it). The destination branch is independently derived + * by the apply-time push handler from pulls.get(pull_number).head.ref. * * Note: Fork PR detection is handled by push_to_pull_request_branch.cjs handler * which fetches the PR and calls detectForkPR() with full PR data. */ const pushToPullRequestBranchHandler = async args => { - const entry = { ...args, type: "push_to_pull_request_branch" }; + // Defensive strip: the input schema no longer declares a `branch` property, + // but an older or non-conforming client could still attempt to pass one. + // Drop it so the agent cannot override the derived source branch. + const { branch: _agentBranch, ...sanitizedArgs } = args || {}; + const entry = { ...sanitizedArgs, type: "push_to_pull_request_branch" }; // Resolve target repo configuration and validate the target repo early // This is needed before getBaseBranch to ensure we resolve the base branch @@ -913,25 +919,36 @@ function createHandlers(server, appendSafeOutput, config = {}) { // like issue_comment on PRs targeting non-default branches. entry.base_branch = baseBranch; - // If branch is not provided, is empty, or equals the base branch, use the current branch from git - // This handles cases where the agent incorrectly passes the base branch instead of the working branch - if (!entry.branch || entry.branch.trim() === "" || entry.branch === baseBranch) { + // The agent never supplies a branch; the validator already strips it from + // args. Derive it from the current checkout: the working tree must be on + // the PR head ref because that's what the agent committed onto. The + // apply-time push job independently re-derives the destination from + // pulls.get(pull_number), so this branch name is used only as the source + // ref for the incremental diff against origin/. + try { const detectedBranch = getCurrentBranch(repoCwd); - - if (entry.branch === baseBranch) { - server.debug(`Branch equals base branch (${baseBranch}), detecting actual working branch: ${detectedBranch}`); - } else { - server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`); - } - + server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`); entry.branch = detectedBranch; + } catch (branchErr) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + result: "error", + error: `Failed to determine source branch for push_to_pull_request_branch: ${getErrorMessage(branchErr)}. The working tree must be on the pull request's head ref before this tool is called.`, + }), + }, + ], + isError: true, + }; } - // Reject if branch still equals base_branch after detection. - // This means the base branch was incorrectly resolved (e.g., resolved to the - // feature branch itself due to a confused event context). Writing a safe output - // in this state would cause a cryptic git exit-1 in the safe_outputs job when - // it tries to fetch a non-existent remote ref. + // Reject if the detected branch equals base_branch. This means the workspace + // is checked out on the PR's base (e.g. main) rather than the PR's head ref, + // so there is nothing to push. Writing a safe output in this state would + // cause a cryptic git exit-1 in the safe_outputs job when it tries to fetch + // a non-existent remote ref. if (entry.branch === entry.base_branch) { return { content: [ @@ -939,7 +956,7 @@ function createHandlers(server, appendSafeOutput, config = {}) { type: "text", text: JSON.stringify({ result: "error", - error: `Branch '${entry.branch}' equals base_branch '${entry.base_branch}'. Cannot push to a pull request branch that targets itself. Ensure 'branch' is your feature branch and that the base branch resolves to the target (e.g., 'main' or 'master').`, + error: `Detected branch '${entry.branch}' equals base_branch '${entry.base_branch}'. The workspace is checked out on the base branch, not the pull request's head branch — there is nothing to push. Check out the PR's head ref and commit your changes there before calling push_to_pull_request_branch.`, }), }, ], diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 169bca5e..8746d177 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -952,15 +952,11 @@ }, { "name": "push_to_pull_request_branch", - "description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite.", + "description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. The destination branch is always derived from the pull request's head ref \u2014 you do not specify it. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite.", "inputSchema": { "type": "object", "required": ["message"], "properties": { - "branch": { - "type": "string", - "description": "Branch name to push changes from. If omitted, uses the current working branch. Only specify if you need to push from a different branch." - }, "message": { "type": "string", "description": "Commit message describing the changes. Follow repository commit message conventions (e.g., conventional commits). This field is named message, NOT commit_message.", diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index eb18ebc8..04ec3f1a 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -2069,13 +2069,15 @@ async function sendJobConclusionSpan(spanName, options = {}) { // Ranked AIC sources: env var → non-zero file → engine metrics → file (may be zero). // When the firewall proxy writes ai_credits=0 to agent_usage.json, the engine result // event (from agent-stdio.log) is tried next so its non-zero value is not lost. - // The final fallback to aiCreditsFromFile (zero) makes observability gaps visible - // (zero != no-data) and lets the Sentry EAP schema infer the attribute as numeric - // so sum()/avg()/percentile() aggregations work without manual schema configuration. + // For jobs that own token usage (agent, detection, engine), gh-aw.aic is ALWAYS + // emitted as a numeric attribute — defaulting to 0 when no data is available. + // This guarantees Sentry EAP infers the field as numeric (not string) so that + // sum()/avg()/percentile() aggregations work without manual schema configuration, + // and Tempo indexes it so { span."gh-aw.aic" > 0 } is queryable immediately. const aiCreditsFromEnv = normalizeNonNegativeNumber(process.env.GH_AW_AIC); const aiCreditsFromFile = agentUsage.ai_credits; const aiCreditsFromMetrics = runtimeMetrics.tokenUsage?.ai_credits; - const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile))) : undefined; + const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile ?? 0))) : undefined; if (typeof aiCredits === "number") { attributes.push(buildAttr("gh-aw.aic", aiCredits)); } diff --git a/setup/js/validate_secrets.cjs b/setup/js/validate_secrets.cjs index d9e0bb42..f4e27de1 100644 --- a/setup/js/validate_secrets.cjs +++ b/setup/js/validate_secrets.cjs @@ -244,6 +244,30 @@ async function testGitHubGraphQLAPI(token, owner, repo) { } } +/** + * Test Copilot token availability, accounting for copilot org billing mode. + * + * When all repository workflows use `copilot-requests: write`, the built-in + * GITHUB_TOKEN is used for Copilot authentication and no separate + * COPILOT_GITHUB_TOKEN secret is required. In that case the maintenance + * workflow sets GH_AW_COPILOT_ORG_BILLING="true" and validation is skipped + * rather than flagging the missing token as unconfigured. + * + * @param {string | undefined} token - Value of GH_AW_COPILOT_TOKEN + * @param {boolean} orgBilling - Whether copilot org billing mode is active (GH_AW_COPILOT_ORG_BILLING="true") + * @returns {Promise<{status: string, message: string, details?: any}>} + */ +async function testCopilotToken(token, orgBilling) { + if (!token && orgBilling) { + return { + status: Status.SKIPPED, + message: "Copilot org billing mode — GITHUB_TOKEN is used for Copilot authentication; COPILOT_GITHUB_TOKEN is not required", + details: { note: "copilot-requests: write is set in the workflow permissions, so the built-in GITHUB_TOKEN handles Copilot authentication" }, + }; + } + return testCopilotCLI(token); +} + /** * Test Copilot CLI availability * @param {string | undefined} token @@ -665,7 +689,8 @@ async function main() { // Test GH_AW_COPILOT_TOKEN core.info("Testing GH_AW_COPILOT_TOKEN..."); const copilotToken = process.env.GH_AW_COPILOT_TOKEN; - const copilotResult = await testCopilotCLI(copilotToken); + const copilotOrgBilling = process.env.GH_AW_COPILOT_ORG_BILLING === "true"; + const copilotResult = await testCopilotToken(copilotToken, copilotOrgBilling); results.push({ secret: "GH_AW_COPILOT_TOKEN", test: "Copilot CLI Availability", @@ -756,6 +781,7 @@ module.exports = { testGitHubRESTAPI, testGitHubGraphQLAPI, testCopilotCLI, + testCopilotToken, testAnthropicAPI, testOpenAIAPI, testBraveSearchAPI,