diff --git a/setup/action.yml b/setup/action.yml index bf9b64e..d4aaa59 100644 --- a/setup/action.yml +++ b/setup/action.yml @@ -7,7 +7,7 @@ inputs: description: 'Destination directory for activation files (default: ${RUNNER_TEMP}/gh-aw/actions)' required: false safe-output-artifact-client: - description: 'Install @actions/artifact so upload_artifact.cjs can upload GitHub Actions artifacts via REST API directly' + description: 'Deprecated no-op (artifact support is now built into setup action scripts)' required: false default: 'false' job-name: diff --git a/setup/index.js b/setup/index.js index 7a37e77..678ae89 100644 --- a/setup/index.js +++ b/setup/index.js @@ -12,7 +12,6 @@ const setupStartMs = Date.now(); // runner versions preserve the original hyphen form. getActionInput() handles // both forms automatically. const safeOutputCustomTokens = getActionInput("SAFE_OUTPUT_CUSTOM_TOKENS") || "false"; -const safeOutputArtifactClient = getActionInput("SAFE_OUTPUT_ARTIFACT_CLIENT") || "false"; const inputTraceId = getActionInput("TRACE_ID"); const inputParentSpanId = getActionInput("PARENT_SPAN_ID"); const inputJobName = getActionInput("JOB_NAME"); @@ -22,7 +21,6 @@ const result = spawnSync(path.join(__dirname, "setup.sh"), [], { stdio: "inherit", env: Object.assign({}, process.env, { INPUT_SAFE_OUTPUT_CUSTOM_TOKENS: safeOutputCustomTokens, - INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: safeOutputArtifactClient, INPUT_TRACE_ID: inputTraceId, INPUT_PARENT_SPAN_ID: inputParentSpanId, INPUT_JOB_NAME: inputJobName, diff --git a/setup/js/artifact_client.cjs b/setup/js/artifact_client.cjs new file mode 100644 index 0000000..1b46333 --- /dev/null +++ b/setup/js/artifact_client.cjs @@ -0,0 +1,385 @@ +// @ts-check +/// + +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { Readable } = require("stream"); +const { pipeline } = require("stream/promises"); +const { spawnSync } = require("child_process"); + +const DEFAULT_RETRY_ATTEMPTS = 5; +const RETRY_DELAY_MS = 5000; +const RESULTS_SCOPE_PREFIX = "Actions.Results:"; +const TWIRP_ARTIFACT_SERVICE = "github.actions.results.api.v1.ArtifactService"; +const MAX_ARTIFACTS = 1000; +const PAGE_SIZE = 100; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function decodeJWTPayload(token) { + const parts = String(token || "").split("."); + if (parts.length < 2 || !parts[1]) { + throw new Error("failed to decode ACTIONS_RUNTIME_TOKEN payload"); + } + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = payload + "=".repeat((4 - (payload.length % 4 || 4)) % 4); + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); +} + +function getBackendIdsFromRuntimeToken() { + const token = process.env.ACTIONS_RUNTIME_TOKEN || ""; + if (!token) { + throw new Error("ACTIONS_RUNTIME_TOKEN is required for artifact upload"); + } + const payload = decodeJWTPayload(token); + const scope = String(payload?.scp || ""); + for (const part of scope.split(" ")) { + if (!part.startsWith(RESULTS_SCOPE_PREFIX)) continue; + const ids = part.split(":"); + if (ids.length !== 3 || !ids[1] || !ids[2]) { + break; + } + return { + workflowRunBackendId: ids[1], + workflowJobRunBackendId: ids[2], + }; + } + throw new Error("failed to parse Actions.Results backend IDs from ACTIONS_RUNTIME_TOKEN"); +} + +function getResultsServiceOrigin() { + const url = process.env.ACTIONS_RESULTS_URL || ""; + if (!url) { + throw new Error("ACTIONS_RESULTS_URL is required for artifact upload"); + } + return new URL(url).origin; +} + +async function twirpRequest(method, body) { + const runtimeToken = process.env.ACTIONS_RUNTIME_TOKEN || ""; + if (!runtimeToken) { + throw new Error("ACTIONS_RUNTIME_TOKEN is required for artifact upload"); + } + const url = new URL(`/twirp/${TWIRP_ARTIFACT_SERVICE}/${method}`, getResultsServiceOrigin()).toString(); + + let lastError; + for (let attempt = 1; attempt <= DEFAULT_RETRY_ATTEMPTS; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: "Bearer " + runtimeToken, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (response.ok) { + return await response.json(); + } + + const responseBody = await response.text(); + const retryable = response.status >= 500 || response.status === 429; + if (!retryable || attempt === DEFAULT_RETRY_ATTEMPTS) { + throw new Error(`artifact twirp ${method} failed (${response.status}): ${responseBody || response.statusText}`); + } + await sleep(RETRY_DELAY_MS); + } catch (error) { + lastError = error; + if (attempt === DEFAULT_RETRY_ATTEMPTS) { + break; + } + await sleep(RETRY_DELAY_MS); + } + } + + throw lastError || new Error(`artifact twirp ${method} failed`); +} + +function artifactListFilterLatest(artifacts) { + const sorted = [...artifacts].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)); + const seen = new Set(); + /** @type {any[]} */ + const latest = []; + for (const artifact of sorted) { + if (seen.has(artifact.name)) continue; + seen.add(artifact.name); + latest.push(artifact); + } + return latest; +} + +function parseFilenameFromContentDisposition(contentDisposition) { + if (!contentDisposition) return "artifact"; + const filenameStar = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;\r\n]*)/i); + const filenamePlain = contentDisposition.match(/(? hash.update(chunk)); + await pipeline(nodeStream, fs.createWriteStream(filePath)); + return hash.digest("hex"); +} + +function ensureZipAvailable() { + const result = spawnSync("zip", ["-v"], { stdio: "ignore" }); + if (result.status !== 0) { + throw new Error("zip command is required to upload artifacts (for example: apt-get install zip)"); + } +} + +function ensureUnzipAvailable() { + const result = spawnSync("unzip", ["-v"], { stdio: "ignore" }); + if (result.status !== 0) { + throw new Error("unzip command is required to download artifacts (for example: apt-get install unzip)"); + } +} + +function createZipFromFiles(files, rootDirectory, outputPath) { + ensureZipAvailable(); + const relativeFiles = files.map(file => path.relative(rootDirectory, file)); + const invalid = relativeFiles.find(rel => !rel || rel.startsWith("..") || path.isAbsolute(rel)); + if (invalid) { + throw new Error(`all upload artifact files must be under rootDirectory (invalid path: ${invalid})`); + } + const result = spawnSync("zip", ["-q", "-r", outputPath, ...relativeFiles], { + cwd: rootDirectory, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`zip command failed: ${result.stderr || result.stdout || "unknown error"}`); + } +} + +async function uploadFileToSignedURL(filePath, signedUploadURL, contentType) { + const stats = fs.statSync(filePath); + const response = await fetch(signedUploadURL, { + method: "PUT", + headers: { + "Content-Type": contentType, + "Content-Length": String(stats.size), + "x-ms-blob-type": "BlockBlob", + }, + body: fs.createReadStream(filePath), + duplex: "half", + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`artifact blob upload failed (${response.status}): ${body || response.statusText}`); + } + return stats.size; +} + +async function hashFile(filePath) { + const hash = crypto.createHash("sha256"); + const nodeStream = fs.createReadStream(filePath); + await pipeline(nodeStream, async function* (source) { + for await (const chunk of source) { + hash.update(chunk); + } + }); + return hash.digest("hex"); +} + +function formatRetentionTimestamp(retentionDays) { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) { + return ""; + } + return new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000).toISOString(); +} + +class DefaultArtifactClient { + async listArtifacts(options = {}) { + const findBy = options.findBy; + if (!findBy?.token || !findBy?.repositoryOwner || !findBy?.repositoryName || !findBy?.workflowRunId) { + throw new Error("listArtifacts requires findBy.token, findBy.repositoryOwner, findBy.repositoryName, and findBy.workflowRunId"); + } + + const serverUrl = process.env.GITHUB_API_URL || "https://api.github.com"; + /** @type {Array<{id:number,name:string,size:number,createdAt?:Date,digest?:string}>} */ + const artifacts = []; + + let page = 1; + const maxPages = Math.ceil(MAX_ARTIFACTS / PAGE_SIZE); + for (; page <= maxPages; page++) { + const url = new URL(`/repos/${findBy.repositoryOwner}/${findBy.repositoryName}/actions/runs/${findBy.workflowRunId}/artifacts`, serverUrl); + url.searchParams.set("per_page", String(PAGE_SIZE)); + url.searchParams.set("page", String(page)); + const response = await fetch(url.toString(), { + headers: { + Authorization: "Bearer " + findBy.token, + Accept: "application/vnd.github+json", + "User-Agent": "gh-aw-artifact-client", + }, + }); + if (!response.ok) { + throw new Error(`failed to list artifacts (${response.status}): ${await response.text()}`); + } + /** @type {any} */ + const payload = await response.json(); + const pageArtifacts = Array.isArray(payload?.artifacts) ? payload.artifacts : []; + for (const item of pageArtifacts) { + artifacts.push({ + id: Number(item.id), + name: String(item.name || ""), + size: Number(item.size_in_bytes || 0), + createdAt: item.created_at ? new Date(item.created_at) : undefined, + digest: typeof item.digest === "string" ? item.digest : undefined, + }); + } + if (pageArtifacts.length < PAGE_SIZE) { + break; + } + } + + return { + artifacts: options.latest ? artifactListFilterLatest(artifacts) : artifacts, + }; + } + + async downloadArtifact(artifactId, options = {}) { + const findBy = options.findBy; + if (!findBy?.token || !findBy?.repositoryOwner || !findBy?.repositoryName) { + throw new Error("downloadArtifact requires findBy.token, findBy.repositoryOwner, and findBy.repositoryName"); + } + + const destination = options.path || process.env.GITHUB_WORKSPACE || process.cwd(); + fs.mkdirSync(destination, { recursive: true }); + + const apiUrl = new URL(`/repos/${findBy.repositoryOwner}/${findBy.repositoryName}/actions/artifacts/${artifactId}/zip`, process.env.GITHUB_API_URL || "https://api.github.com"); + const redirectResponse = await fetch(apiUrl.toString(), { + headers: { + Authorization: "Bearer " + findBy.token, + Accept: "application/vnd.github+json", + "User-Agent": "gh-aw-artifact-client", + }, + redirect: "manual", + }); + if (![301, 302, 303, 307, 308].includes(redirectResponse.status)) { + throw new Error(`unable to download artifact: unexpected status ${redirectResponse.status}`); + } + const location = redirectResponse.headers.get("location"); + if (!location) { + throw new Error("unable to download artifact: missing redirect location"); + } + + const blobResponse = await fetch(location); + if (!blobResponse.ok) { + throw new Error(`artifact blob download failed (${blobResponse.status})`); + } + + let digest; + const contentType = blobResponse.headers.get("content-type") || ""; + const zipLike = isZipResponse(location, contentType); + if (zipLike && !options.skipDecompress) { + ensureUnzipAvailable(); + const tempZip = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-download-")), "artifact.zip"); + digest = await streamToFile(blobResponse, tempZip); + const unzipResult = spawnSync("unzip", ["-q", tempZip, "-d", destination], { encoding: "utf8" }); + if (unzipResult.status !== 0) { + throw new Error(`unzip failed: ${unzipResult.stderr || unzipResult.stdout || "unknown error"}`); + } + } else { + const fileName = parseFilenameFromContentDisposition(blobResponse.headers.get("content-disposition") || ""); + const outputPath = path.join(destination, fileName); + digest = await streamToFile(blobResponse, outputPath); + } + + const computed = digest ? `sha256:${digest}` : ""; + return { + downloadPath: destination, + digestMismatch: !!(computed && options.expectedHash && options.expectedHash !== computed), + }; + } + + async uploadArtifact(name, files, rootDirectory, options = {}) { + if (!Array.isArray(files) || files.length === 0) { + throw new Error("uploadArtifact requires at least one file"); + } + + let artifactName = String(name || "").trim(); + let uploadPath = ""; + let contentType = "application/zip"; + + if (options.skipArchive) { + if (files.length !== 1) { + throw new Error("skipArchive option is only supported when uploading a single file"); + } + uploadPath = files[0]; + contentType = "application/octet-stream"; + } else { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-artifact-upload-")); + uploadPath = path.join(tmpDir, `${artifactName || "artifact"}.zip`); + createZipFromFiles(files, rootDirectory, uploadPath); + } + + const { workflowRunBackendId, workflowJobRunBackendId } = getBackendIdsFromRuntimeToken(); + const createRequest = { + workflow_run_backend_id: workflowRunBackendId, + workflow_job_run_backend_id: workflowJobRunBackendId, + name: artifactName, + version: 7, + mime_type: { value: contentType }, + }; + const expiresAt = formatRetentionTimestamp(options.retentionDays); + if (expiresAt) { + createRequest.expires_at = expiresAt; + } + + /** @type {any} */ + const createResponse = await twirpRequest("CreateArtifact", createRequest); + if (!createResponse?.ok || !createResponse?.signed_upload_url) { + throw new Error("CreateArtifact returned an invalid response"); + } + + const uploadSize = await uploadFileToSignedURL(uploadPath, createResponse.signed_upload_url, contentType); + const sha256 = await hashFile(uploadPath); + + const finalizeRequest = { + workflow_run_backend_id: workflowRunBackendId, + workflow_job_run_backend_id: workflowJobRunBackendId, + name: artifactName, + size: String(uploadSize), + hash: { value: `sha256:${sha256}` }, + }; + /** @type {any} */ + const finalizeResponse = await twirpRequest("FinalizeArtifact", finalizeRequest); + if (!finalizeResponse?.ok) { + throw new Error("FinalizeArtifact returned an invalid response"); + } + + return { + id: Number(finalizeResponse.artifact_id || 0) || undefined, + size: uploadSize, + digest: sha256, + }; + } +} + +module.exports = { + DefaultArtifactClient, +}; diff --git a/setup/js/awf_reflect.cjs b/setup/js/awf_reflect.cjs index b64ed72..7060ca4 100644 --- a/setup/js/awf_reflect.cjs +++ b/setup/js/awf_reflect.cjs @@ -19,7 +19,7 @@ require("./shim.cjs"); const fs = require("fs"); const path = require("path"); -const { withRetry } = require("./error_recovery.cjs"); +const { withRetry, sleep } = require("./error_recovery.cjs"); // AWF API proxy management endpoint for discovering configured LLM providers and available models. // The api-proxy sidecar exposes /reflect on its management port (port 10000) inside the AWF @@ -38,6 +38,9 @@ const AWF_MODELS_URL_MAX_ATTEMPTS = 5; const AWF_MODELS_URL_RETRY_BASE_MS = 250; // Cap for exponential backoff delay between retries. const AWF_MODELS_URL_RETRY_MAX_MS = 2000; +// Delay before the first models_url probe when using GitHub OIDC auth with the local api-proxy. +// This reduces startup-race 503s while the proxy completes OIDC token exchange. +const AWF_MODELS_URL_OIDC_INITIAL_DELAY_MS_DEFAULT = 5000; // Gemini model name prefix stripped from model IDs in the Gemini models API response. // Example: { name: "models/gemini-1.5-pro" } → "gemini-1.5-pro" const GEMINI_MODEL_NAME_PREFIX = "models/"; @@ -93,6 +96,22 @@ function extractModelIds(json) { * @returns {Promise} */ async function fetchModelsFromUrl(modelsUrl, timeoutMs, logger) { + let isInitialProbeDelayed = false; + try { + const modelsHost = new URL(modelsUrl).hostname.toLowerCase(); + isInitialProbeDelayed = process.env.AWF_AUTH_TYPE === "github-oidc" && modelsHost === "api-proxy"; + } catch { + // Ignore invalid URL parsing and proceed without startup delay. + } + if (isInitialProbeDelayed) { + const configuredDelay = Number.parseInt(process.env.AWF_MODELS_URL_OIDC_INITIAL_DELAY_MS || "", 10); + const initialProbeDelay = Number.isFinite(configuredDelay) && configuredDelay >= 0 ? configuredDelay : AWF_MODELS_URL_OIDC_INITIAL_DELAY_MS_DEFAULT; + if (initialProbeDelay > 0) { + logger(`awf-reflect: delaying initial models probe for ${modelsUrl} by ${initialProbeDelay}ms (AWF_AUTH_TYPE=github-oidc)`); + await sleep(initialProbeDelay); + } + } + let attemptCounter = 0; const retryConfig = { maxRetries: AWF_MODELS_URL_MAX_ATTEMPTS - 1, diff --git a/setup/js/check_daily_aic_workflow_guardrail.cjs b/setup/js/check_daily_aic_workflow_guardrail.cjs index a61a533..6896ffa 100644 --- a/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); +const { DefaultArtifactClient } = require("./artifact_client.cjs"); const { calculateDailyAICStats, findJSONLFiles, formatAICCredits, sumAICFromUsageJSONLFiles } = require("./daily_aic_workflow_helpers.cjs"); const { parsePositiveCompactNumber } = require("./numeric_limits.cjs"); @@ -19,10 +20,9 @@ const ESTIMATED_API_OPERATIONS_PER_RUN = 2; const INTEGER_FORMATTER = new Intl.NumberFormat("en-US"); /** - * @returns {Promise} + * @returns {Promise} */ async function getArtifactClient() { - const { DefaultArtifactClient } = await import("@actions/artifact"); return new DefaultArtifactClient(); } @@ -55,6 +55,13 @@ function logDailyGuardrail(message, details) { core.info(formatDailyGuardrailLogMessage(message, details)); } +/** + * Event types that indicate a user-initiated slash command trigger. + * When aw_context.event_type is one of these, the workflow was triggered by a user + * typing a slash command in a comment, and the daily guardrail should not be skipped. + */ +const SLASH_COMMAND_EVENT_TYPES = ["issue_comment", "pull_request_review_comment", "discussion_comment"]; + /** * @returns {boolean} */ @@ -62,8 +69,24 @@ function shouldSkipDailyAICGuardrail() { const eventName = process.env.GITHUB_EVENT_NAME || ""; const isWorkflowCall = eventName === "workflow_call"; const isRepositoryDispatch = eventName === "repository_dispatch"; - const hasDispatchContext = (process.env.GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT || "").trim() !== ""; - return isWorkflowCall || isRepositoryDispatch || (eventName === "workflow_dispatch" && hasDispatchContext); + const rawContext = (process.env.GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT || "").trim(); + const hasDispatchContext = rawContext !== ""; + if (!(isWorkflowCall || isRepositoryDispatch || (eventName === "workflow_dispatch" && hasDispatchContext))) { + return false; + } + if (eventName === "workflow_dispatch" && hasDispatchContext) { + try { + const awContext = JSON.parse(rawContext); + const isLabelCommand = typeof awContext.trigger_label === "string" && awContext.trigger_label.trim() !== ""; + const isSlashCommand = SLASH_COMMAND_EVENT_TYPES.includes(awContext.event_type); + if (isLabelCommand || isSlashCommand) { + return false; + } + } catch { + // Malformed aw_context: fall through and skip as before. + } + } + return true; } /** @@ -78,7 +101,7 @@ function matchesGuardrailArtifactName(artifactName) { } /** - * @param {import("@actions/artifact").DefaultArtifactClient} artifactClient + * @param {{ listArtifacts: Function, downloadArtifact: Function }} artifactClient * @param {number} runId * @param {string} token * @param {string} owner @@ -285,16 +308,15 @@ async function appendDailyAICSummary(workflowName, actorLogin, threshold, counte * * Error handling: all GitHub API interactions after the initial guard checks are wrapped * in a top-level try-catch. Any unexpected error (network failure, permission error, etc.) - * is logged as a warning and the function returns cleanly with `daily_effective_workflow_exceeded` - * left at its default value of `"false"`. This design ensures the step never fails the - * activation job — a guardrail error results in a safe bypass (agent allowed to run) rather - * than a confusing workflow failure that blocks the agent entirely. + * is logged as a warning and the function returns cleanly with `daily_ai_credits_exceeded` + * left at its default value of `"false"` (safe bypass). When the guardrail is actually exceeded, + * the step marks the job as failed after setting outputs so downstream conclusion handling can + * still run and produce failure issues. */ async function main() { - core.setOutput("daily_effective_workflow_exceeded", "false"); - core.setOutput("daily_effective_workflow_total_effective_tokens", ""); - core.setOutput("daily_effective_workflow_total_ai_credits", ""); - core.setOutput("daily_effective_workflow_threshold", ""); + core.setOutput("daily_ai_credits_exceeded", "false"); + core.setOutput("daily_ai_credits_total_effective_tokens", ""); + core.setOutput("daily_ai_credits_threshold", ""); const threshold = parsePositiveCompactNumber(process.env.GH_AW_MAX_DAILY_AI_CREDITS); if (threshold <= 0) { return; @@ -312,7 +334,7 @@ async function main() { // Wrap all GitHub API interactions in a top-level try-catch so that transient API // errors, permission failures, or unexpected exceptions never fail the activation - // job step. A failure here would leave `daily_effective_workflow_exceeded` at its + // job step. A failure here would leave `daily_ai_credits_exceeded` at its // default "false" value, which is the safe fallback: the agent is allowed to run // and the guardrail is effectively bypassed for this invocation rather than causing // a confusing workflow failure. @@ -410,7 +432,7 @@ async function main() { truncatedByRateLimit, }); - const artifactClient = await getArtifactClient(); + const artifactClient = await module.exports.getArtifactClient(); let totalAIC = 0; /** @type {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number}>} */ const countedRuns = []; @@ -420,7 +442,7 @@ async function main() { break; } try { - const runAIC = await getRunAIC(artifactClient, run.id, token, owner, repo); + const runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo); if (runAIC <= 0) { logDailyGuardrail("Skipping run without AIC usage artifact data", { runId: run.id, @@ -449,9 +471,8 @@ async function main() { } } - core.setOutput("daily_effective_workflow_total_effective_tokens", String(totalAIC)); - core.setOutput("daily_effective_workflow_total_ai_credits", String(totalAIC)); - core.setOutput("daily_effective_workflow_threshold", String(threshold)); + core.setOutput("daily_ai_credits_total_effective_tokens", String(totalAIC)); + core.setOutput("daily_ai_credits_threshold", String(threshold)); /** @type {{candidateRunsCount:number,inspectedRunsCount:number,truncatedByRateLimit:boolean}} */ const summaryMeta = { @@ -490,19 +511,26 @@ async function main() { return; } - core.setOutput("daily_effective_workflow_exceeded", "true"); - await appendDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, rateLimit, summaryMeta); + core.setOutput("daily_ai_credits_exceeded", "true"); + try { + await appendDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, rateLimit, summaryMeta); + } catch (summaryError) { + core.warning(`Failed to write daily AIC summary: ${getErrorMessage(summaryError)}`); + } core.warning(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`); + core.setFailed(`Daily workflow AIC guardrail exceeded for ${workflowName}: ${totalAIC}/${threshold}.`); } catch (error) { - // Treat any unexpected error as a non-blocking skip so the step never fails the - // activation job. The output stays at the default "false", allowing the agent to - // run. The guardrail is effectively bypassed for this invocation. + // Treat unexpected guardrail execution errors as non-blocking skips so transient + // API/runtime issues do not fail activation. The output stays at the default "false", + // allowing the agent to run. Legitimate threshold exceedance still fails via setFailed. core.warning(`Daily workflow AI Credits guardrail encountered an unexpected error and will be skipped: ${getErrorMessage(error)}`); } } module.exports = { main, + getArtifactClient, + getRunAIC, shouldSkipDailyAICGuardrail, matchesGuardrailArtifactName, findJSONLFiles, diff --git a/setup/js/generate_aw_info.cjs b/setup/js/generate_aw_info.cjs index 49e9b4d..ba091d3 100644 --- a/setup/js/generate_aw_info.cjs +++ b/setup/js/generate_aw_info.cjs @@ -2,7 +2,6 @@ /// const fs = require("fs"); -const path = require("path"); const { TMP_GH_AW_PATH } = require("./constants.cjs"); const { generateWorkflowOverview } = require("./generate_workflow_overview.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); @@ -160,7 +159,6 @@ async function main(core, ctx) { // Write to /tmp/gh-aw directory to avoid inclusion in PR fs.mkdirSync(TMP_GH_AW_PATH, { recursive: true }); - writeMergedModelMultipliers(core, tokenWeights); writeMergedModelsJSON(core); const tmpPath = TMP_GH_AW_PATH + "/aw_info.json"; fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); @@ -192,49 +190,6 @@ async function main(core, ctx) { } } - /** - * Load and merge model multiplier data, then persist it to /tmp/gh-aw for downstream jobs. - * Base data is read from the setup action directory; custom workflow overrides (if any) - * are merged on top. - * @param {typeof import('@actions/core')} core - * @param {Record | null} tokenWeights - */ - function writeMergedModelMultipliers(core, tokenWeights) { - const builtInPath = path.join(__dirname, "model_multipliers.json"); - /** @type {Record} */ - let builtIn = {}; - if (fs.existsSync(builtInPath)) { - try { - const parsed = JSON.parse(fs.readFileSync(builtInPath, "utf8")); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - builtIn = parsed; - } else { - core.warning(`Built-in model_multipliers.json is not a JSON object: ${builtInPath}`); - } - } catch (error) { - core.warning(`Failed to parse built-in model_multipliers.json: ${String(error)}`); - } - } else { - core.warning(`Built-in model_multipliers.json not found at ${builtInPath}`); - } - - const builtInTokenClassWeights = getPlainObjectOrEmpty(builtIn.token_class_weights); - const builtInMultipliers = getPlainObjectOrEmpty(builtIn.multipliers); - - const customTokenClassWeights = getPlainObjectOrEmpty(tokenWeights?.token_class_weights); - const customMultipliers = getPlainObjectOrEmpty(tokenWeights?.multipliers); - - const merged = { - ...builtIn, - token_class_weights: { ...builtInTokenClassWeights, ...customTokenClassWeights }, - multipliers: { ...builtInMultipliers, ...customMultipliers }, - }; - - const mergedPath = `${TMP_GH_AW_PATH}/model_multipliers.json`; - fs.writeFileSync(mergedPath, JSON.stringify(merged, null, 2)); - core.info(`Generated merged model multipliers at: ${mergedPath}`); - } - core.info("Generated aw_info.json at: " + tmpPath); core.info(JSON.stringify(awInfo, null, 2)); @@ -247,22 +202,3 @@ async function main(core, ctx) { } module.exports = { main }; - -/** - * @param {unknown} value - * @returns {Record} - */ -function getPlainObjectOrEmpty(value) { - if (isPlainObject(value)) { - return value; - } - return {}; -} - -/** - * @param {unknown} value - * @returns {value is Record} - */ -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 47e5038..e9eb3dd 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -200,7 +200,7 @@ function buildFailureMatchCategories(options) { if (options.hasAppTokenMintingFailed) categories.push("app_token_minting_failed"); if (options.hasLockdownCheckFailed) categories.push("lockdown_check_failed"); if (options.hasStaleLockFileFailed) categories.push("stale_lock_file_failed"); - if (options.hasDailyAICExceeded) categories.push("daily_effective_workflow_exceeded"); + if (options.hasDailyAICExceeded) categories.push("daily_ai_credits_exceeded"); if (options.agentConclusion === "failure" && !options.isTimedOut) { categories.push("agent_failure"); @@ -231,7 +231,7 @@ function buildFailureMatchCategories(options) { */ function buildFailureIssueTitle(options) { const { workflowName } = options; - if (options.hasDailyAICExceeded) return `[aw] ${workflowName} exceeded daily effective workflow budget`; + if (options.hasDailyAICExceeded) return `[aw] ${workflowName} exceeded daily AI credits budget`; if (options.maxAICreditsExceeded) return `[aw] ${workflowName} exceeded max AI credits`; if (options.aiCreditsRateLimitError) return `[aw] ${workflowName} hit AI credits rate limit`; if (options.hasAppTokenMintingFailed) return `[aw] ${workflowName} failed to mint GitHub App token`; @@ -1869,6 +1869,33 @@ function buildAssignCopilotFailureContext(hasAssignCopilotFailures, assignCopilo return "\n" + renderTemplateFromFile(templatePath, { issues: issueList }); } +/** + * Build the secret verification failure context for the agent failure issue/comment. + * For the Copilot engine, adds a suggestion to use `permissions.copilot-requests: write` + * to enable Copilot inference through the org without a personal access token. + * @param {string} secretVerificationResult - The secret verification result ("failed" or other) + * @param {string} engineId - The engine ID (e.g. "copilot") + * @returns {string} Formatted context string, or empty string if verification did not fail + */ +function buildSecretVerificationContext(secretVerificationResult, engineId) { + if (secretVerificationResult !== "failed") { + return ""; + } + + let context = + buildWarningAlertLine("Secret Verification Failed", "The workflow's secret validation step failed. Please check that the required secrets are configured in your repository settings.") + + "\nFor more information on configuring tokens, see: https://github.github.com/gh-aw/reference/engines/\n"; + + if ((engineId || "").toLowerCase() === "copilot") { + context += + "\n**Alternative**: If your organization has a Copilot subscription, you can avoid the need for a personal access token by adding a top-level `permissions` block to your workflow file. This enables Copilot inference through the org using the built-in GitHub Actions token.\n" + + "\n```yaml\npermissions:\n copilot-requests: write\n```\n" + + "\nSee: https://github.github.com/gh-aw/reference/engines/#github-copilot-default\n"; + } + + return context; +} + /** * Check whether agent-stdio.log contains a terminal_reason: "completed" result entry, * indicating the agent finished its task successfully despite a non-zero job exit code. @@ -2376,9 +2403,9 @@ async function main() { // stored in the compiled .lock.yml no longer matches the source .md file. // The agent is skipped in this case; the conclusion job runs to surface remediation guidance. const hasStaleLockFileFailed = process.env.GH_AW_STALE_LOCK_FILE_FAILED === "true"; - const hasDailyAICExceeded = process.env.GH_AW_DAILY_EFFECTIVE_WORKFLOW_EXCEEDED === "true"; - const dailyAICTotal = process.env.GH_AW_DAILY_EFFECTIVE_WORKFLOW_TOTAL_EFFECTIVE_TOKENS || ""; - const dailyAICThreshold = process.env.GH_AW_DAILY_EFFECTIVE_WORKFLOW_THRESHOLD || ""; + const hasDailyAICExceeded = process.env.GH_AW_DAILY_AI_CREDITS_EXCEEDED === "true"; + const dailyAICTotal = process.env.GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS || ""; + const dailyAICThreshold = process.env.GH_AW_DAILY_AI_CREDITS_THRESHOLD || ""; // Cache-memory availability flag — set when cache-memory is configured for the workflow. // Used to detect cache-miss misconfigurations reported by the agent. const cacheMemoryEnabled = process.env.GH_AW_CACHE_MEMORY_ENABLED === "true"; @@ -2870,11 +2897,7 @@ async function main() { workflow_source: workflowSource, workflow_source_url: workflowSourceURL, secret_verification_failed: String(secretVerificationResult === "failed"), - secret_verification_context: - secretVerificationResult === "failed" - ? buildWarningAlertLine("Secret Verification Failed", "The workflow's secret validation step failed. Please check that the required secrets are configured in your repository settings.") + - "\nFor more information on configuring tokens, see: https://github.github.com/gh-aw/reference/engines/\n" - : "", + secret_verification_context: buildSecretVerificationContext(secretVerificationResult, engineId), credential_auth_error_context: credentialAuthErrorContext, assignment_errors_context: assignmentErrorsContext, assign_copilot_failure_context: assignCopilotFailureContext, @@ -2899,7 +2922,7 @@ async function main() { app_token_minting_failed_context: appTokenMintingFailedContext, lockdown_check_failed_context: lockdownCheckFailedContext, stale_lock_file_failed_context: staleLockFileFailedContext, - daily_effective_workflow_exceeded_context: dailyAICExceededContext, + daily_ai_credits_exceeded_context: dailyAICExceededContext, }; // Render the comment template @@ -3099,11 +3122,7 @@ async function main() { branch: currentBranch, pull_request_info: pullRequest ? ` \n**Pull Request:** [#${pullRequest.number}](${pullRequest.html_url})` : "", secret_verification_failed: String(secretVerificationResult === "failed"), - secret_verification_context: - secretVerificationResult === "failed" - ? buildWarningAlertLine("Secret Verification Failed", "The workflow's secret validation step failed. Please check that the required secrets are configured in your repository settings.") + - "\nFor more information on configuring tokens, see: https://github.github.com/gh-aw/reference/engines/\n" - : "", + secret_verification_context: buildSecretVerificationContext(secretVerificationResult, engineId), credential_auth_error_context: credentialAuthErrorContext, assignment_errors_context: assignmentErrorsContext, assign_copilot_failure_context: assignCopilotFailureContext, @@ -3128,7 +3147,7 @@ async function main() { app_token_minting_failed_context: appTokenMintingFailedContext, lockdown_check_failed_context: lockdownCheckFailedContext, stale_lock_file_failed_context: staleLockFileFailedContext, - daily_effective_workflow_exceeded_context: dailyAICExceededContext, + daily_ai_credits_exceeded_context: dailyAICExceededContext, }; // Render the issue template @@ -3240,6 +3259,7 @@ module.exports = { hasAgentTerminalReasonCompleted, detectAndHandleFailureCascade, findRecentFailureIssues, + buildSecretVerificationContext, CASCADE_WINDOW_MINUTES, CASCADE_WINDOW_MS, CASCADE_THRESHOLD, diff --git a/setup/js/merge_awf_model_multipliers.cjs b/setup/js/merge_awf_model_multipliers.cjs deleted file mode 100644 index d7c471e..0000000 --- a/setup/js/merge_awf_model_multipliers.cjs +++ /dev/null @@ -1,212 +0,0 @@ -// @ts-check -/// - -const fs = require("fs"); -const path = require("path"); -const { TMP_GH_AW_PATH } = require("./constants.cjs"); - -const DEFAULT_MODEL_MULTIPLIERS_PATH = `${TMP_GH_AW_PATH}/model_multipliers.json`; - -/** - * @param {unknown} value - * @returns {value is Record} - */ -function isPlainObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -/** - * @param {Record} rawMultipliers - * @returns {Record} - */ -function normalizeMultipliers(rawMultipliers) { - /** @type {Record} */ - const normalized = {}; - for (const [key, value] of Object.entries(rawMultipliers)) { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - normalized[key] = value; - } - } - return normalized; -} - -/** - * @param {unknown} rawModels - * @returns {Array<{alias: string, targets: string[]}>} - */ -function normalizeAliasRows(rawModels) { - if (!isPlainObject(rawModels)) { - return []; - } - - /** @type {Array<{alias: string, targets: string[]}>} */ - const rows = []; - for (const [alias, rawTargets] of Object.entries(rawModels)) { - if (!Array.isArray(rawTargets)) { - continue; - } - const trimmedTargets = rawTargets.map(target => (typeof target === "string" ? target.trim() : "")); - const targets = trimmedTargets.filter(target => target !== ""); - if (targets.length === 0) { - continue; - } - rows.push({ alias, targets }); - } - - rows.sort((a, b) => { - // Sort the default alias ("") last for readability in step-summary tables. - // We substitute it with U+ffff only for comparison; it is a noncharacter and - // reliably sorts after typical printable alias strings in localeCompare. - const left = a.alias || "\uffff"; - const right = b.alias || "\uffff"; - return left.localeCompare(right); - }); - return rows; -} - -/** - * @param {string} value - * @returns {string} - */ -function escapeTableValue(value) { - return value.replace(/\|/g, "\\|"); -} - -/** - * @param {Array<{alias: string, targets: string[]}>} aliasRows - * @returns {string} - */ -function renderModelAliasSummary(aliasRows) { - const lines = []; - lines.push("
"); - lines.push(`AWF model aliases (${aliasRows.length})`); - lines.push(""); - lines.push("| Alias | Resolution order |"); - lines.push("|-------|------------------|"); - for (const row of aliasRows) { - const aliasLabel = row.alias === "" ? "(default)" : `\`${escapeTableValue(row.alias)}\``; - const resolutionOrder = row.targets.map(target => `\`${escapeTableValue(target)}\``).join(" → "); - lines.push(`| ${aliasLabel} | ${resolutionOrder} |`); - } - lines.push(""); - lines.push("
"); - lines.push(""); - return lines.join("\n"); -} - -/** - * @param {Array<{alias: string, targets: string[]}>} aliasRows - * @param {(message: string) => void} warn - */ -function writeAliasSummary(aliasRows, warn) { - if (aliasRows.length === 0) { - return; - } - - const summaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!summaryPath) { - return; - } - - try { - fs.appendFileSync(summaryPath, renderModelAliasSummary(aliasRows), "utf8"); - } catch (error) { - warn(`warning: failed to write AWF model alias summary: ${String(error)}`); - } -} - -/** - * @param {object} options - * @param {string} options.configPath - * @param {string} options.multipliersPath - * @param {(message: string) => void} options.warn - */ -function mergeModelMultipliers({ configPath, multipliersPath, warn }) { - if (!fs.existsSync(configPath) || !fs.existsSync(multipliersPath)) { - return; - } - - /** @type {Record | null} */ - let multipliersDoc = null; - try { - const parsed = JSON.parse(fs.readFileSync(multipliersPath, "utf8")); - if (isPlainObject(parsed)) { - multipliersDoc = parsed; - } - } catch (error) { - warn(`warning: failed to parse model multipliers file: ${String(error)}`); - return; - } - if (!multipliersDoc || !isPlainObject(multipliersDoc.multipliers)) { - return; - } - - const normalized = normalizeMultipliers(multipliersDoc.multipliers); - - /** @type {Record | null} */ - let configDoc = null; - try { - const parsed = JSON.parse(fs.readFileSync(configPath, "utf8")); - if (isPlainObject(parsed)) { - configDoc = parsed; - } - } catch (error) { - warn(`warning: failed to parse awf-config.json before model multiplier merge: ${String(error)}`); - return; - } - if (!configDoc) { - return; - } - - const apiProxy = isPlainObject(configDoc.apiProxy) ? configDoc.apiProxy : {}; - const aliasRows = normalizeAliasRows(apiProxy.models); - if (Object.keys(normalized).length > 0) { - apiProxy.modelMultipliers = normalized; - } else { - delete apiProxy.modelMultipliers; - } - configDoc.apiProxy = apiProxy; - - fs.writeFileSync(configPath, JSON.stringify(configDoc), "utf8"); - writeAliasSummary(aliasRows, warn); -} - -/** - * @param {object} [options] - * @param {string} [options.runnerTemp] - * @param {string} [options.configPath] - * @param {string} [options.multipliersPath] - * @param {(message: string) => void} [options.warn] - */ -function main(options = {}) { - const runnerTemp = options.runnerTemp ?? process.env.RUNNER_TEMP; - if (!runnerTemp) { - throw new Error("RUNNER_TEMP is required"); - } - - const configPath = options.configPath ?? path.join(runnerTemp, "gh-aw", "awf-config.json"); - const multipliersPath = options.multipliersPath ?? process.env.GH_AW_MODEL_MULTIPLIERS_PATH ?? DEFAULT_MODEL_MULTIPLIERS_PATH; - const warn = options.warn ?? (message => process.stderr.write(`${message}\n`)); - - mergeModelMultipliers({ configPath, multipliersPath, warn }); -} - -if (require.main === module) { - try { - main(); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); - } -} - -module.exports = { - DEFAULT_MODEL_MULTIPLIERS_PATH, - isPlainObject, - normalizeMultipliers, - normalizeAliasRows, - renderModelAliasSummary, - writeAliasSummary, - mergeModelMultipliers, - main, -}; diff --git a/setup/js/messages_footer.cjs b/setup/js/messages_footer.cjs index 7267768..b5c78cf 100644 --- a/setup/js/messages_footer.cjs +++ b/setup/js/messages_footer.cjs @@ -181,6 +181,7 @@ function getFooterMessage(ctx) { aiCreditsFormatted = explicitContextAIC ? formatAIC(explicitContextAIC) : undefined; aiCreditsSuffix = aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : ""; } + const aiCreditsSuffixForTemplate = `${aiCreditsSuffix}${envAmbientContextSuffix}`; // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ @@ -189,7 +190,7 @@ function getFooterMessage(ctx) { historyLink, agenticWorkflowUrl, aiCreditsFormatted, - aiCreditsSuffix, + aiCreditsSuffix: aiCreditsSuffixForTemplate, ambientContext, ambientContextFormatted: envAmbientContextFormatted, ambientContextSuffix: envAmbientContextSuffix, @@ -354,6 +355,7 @@ function getFooterAgentFailureIssueMessage(ctx) { const aiCredits = hasExplicitContextAIC ? explicitContextAIC : envAIC; const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted; const aiCreditsSuffix = hasExplicitContextAIC ? (aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : "") : envAICSuffix; + const aiCreditsSuffixForTemplate = `${aiCreditsSuffix}${ambientContextSuffix}`; // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ @@ -362,7 +364,7 @@ function getFooterAgentFailureIssueMessage(ctx) { agenticWorkflowUrl, aiCredits, aiCreditsFormatted, - aiCreditsSuffix, + aiCreditsSuffix: aiCreditsSuffixForTemplate, agentAiCredits, agentAiCreditsFormatted, agentAiCreditsSuffix, @@ -382,10 +384,10 @@ function getFooterAgentFailureIssueMessage(ctx) { // Default footer template with link to workflow run let defaultFooter = "> Generated from [{workflow_name}]({run_url})"; if (aiCredits) { - defaultFooter += "{ai_credits_suffix}"; + defaultFooter += aiCreditsSuffix; } if (ambientContext) { - defaultFooter += "{ambient_context_suffix}"; + defaultFooter += ambientContextSuffix; } // Append history link when available if (ctx.historyUrl) { @@ -422,11 +424,13 @@ function getFooterAgentFailureCommentMessage(ctx) { threatDetectionAiCreditsFormatted, threatDetectionAiCreditsSuffix, } = getAICFromEnv(); + const { ambientContext, ambientContextFormatted, ambientContextSuffix } = getAmbientContextFromEnv(); const hasExplicitContextAIC = ctx.aiCredits !== undefined && ctx.aiCredits !== null; const explicitContextAIC = parseExplicitContextAIC(ctx.aiCredits); const aiCredits = hasExplicitContextAIC ? explicitContextAIC : envAIC; const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted; const aiCreditsSuffix = hasExplicitContextAIC ? (aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : "") : envAICSuffix; + const aiCreditsSuffixForTemplate = `${aiCreditsSuffix}${ambientContextSuffix}`; // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ @@ -435,13 +439,16 @@ function getFooterAgentFailureCommentMessage(ctx) { agenticWorkflowUrl, aiCredits, aiCreditsFormatted, - aiCreditsSuffix, + aiCreditsSuffix: aiCreditsSuffixForTemplate, agentAiCredits, agentAiCreditsFormatted, agentAiCreditsSuffix, threatDetectionAiCredits, threatDetectionAiCreditsFormatted, threatDetectionAiCreditsSuffix, + ambientContext, + ambientContextFormatted, + ambientContextSuffix, }); // Use custom agent failure comment footer if configured, otherwise use default footer @@ -452,7 +459,10 @@ function getFooterAgentFailureCommentMessage(ctx) { // Default footer template with link to workflow run let defaultFooter = "> Generated from [{workflow_name}]({run_url})"; if (aiCredits) { - defaultFooter += "{ai_credits_suffix}"; + defaultFooter += aiCreditsSuffix; + } + if (ambientContext) { + defaultFooter += ambientContextSuffix; } // Append history link when available if (ctx.historyUrl) { diff --git a/setup/js/model_multipliers.json b/setup/js/model_multipliers.json deleted file mode 100644 index 7842ba1..0000000 --- a/setup/js/model_multipliers.json +++ /dev/null @@ -1,161 +0,0 @@ -{ - "version": "1", - "description": "Effective Tokens (ET) computation data per the gh-aw Effective Tokens Specification v0.2.0. Token class weights are applied first to normalize across token classes, then the per-model multiplier scales the result relative to the reference model. The registry keeps complete model history; entries are removed only by explicit manual deletion.", - "reference_model": "claude-sonnet-4.5", - "token_class_weights": { - "input": 1.0, - "cached_input": 0.1, - "output": 4.0, - "reasoning": 4.0, - "cache_write": 1.0 - }, - "multipliers": { - "claude-haiku-4-5": 0.33, - "claude-haiku-4.5": 0.33, - "claude-haiku-4-5-20251001": 0.33, - "claude-3-5-haiku": 0.1, - "claude-3-haiku": 0.1, - "claude-sonnet-4": 1.0, - "claude-sonnet-4-20250514": 1.0, - "claude-sonnet-4-5": 6.0, - "claude-sonnet-4.5": 6.0, - "claude-sonnet-4-5-20250929": 6.0, - "claude-sonnet-4-6": 9.0, - "claude-sonnet-4.6": 9.0, - "claude-3-5-sonnet": 1.0, - "claude-3-7-sonnet": 1.0, - "claude-3-sonnet": 1.0, - "claude-opus-4": 5.0, - "claude-opus-4-20250514": 5.0, - "claude-opus-4-1": 5.0, - "claude-opus-4-1-20250805": 5.0, - "claude-opus-4-5": 15.0, - "claude-opus-4-5-20251101": 15.0, - "claude-opus-4-6": 27.0, - "claude-opus-4-7": 27.0, - "claude-opus-4-8": 27.0, - "claude-opus-4.5": 15.0, - "claude-opus-4.6": 27.0, - "claude-opus-4.6-fast": 27.0, - "claude-opus-4.7": 27.0, - "claude-opus-4.8": 27.0, - "claude-3-5-opus": 5.0, - "claude-3-opus": 5.0, - "gpt-4o-2024-05-13": 0.33, - "gpt-4o-2024-08-06": 0.33, - "gpt-4o-2024-11-20": 0.33, - "gpt-4o-mini-2024-07-18": 0.33, - "gpt-4.1-2025-04-14": 1.0, - "gpt-41-copilot": 1.0, - "gpt-4.1-mini": 1.0, - "gpt-4.1-nano": 1.0, - "gpt-4-turbo": 1.0, - "gpt-4": 1.0, - "gpt-4-0613": 1.0, - "gpt-4-o-preview": 1.0, - "gpt-3.5-turbo": 0.0, - "gpt-3.5-turbo-0613": 0.0, - "gpt-5": 1.0, - "gpt-5-2025-08-07": 1.0, - "gpt-5-search-api": 1.0, - "gpt-5-search-api-2025-10-14": 1.0, - "gpt-5-chat-latest": 1.0, - "gpt-5-mini": 0.33, - "gpt-5-mini-2025-08-07": 0.33, - "gpt-5-nano": 0.05, - "gpt-5-nano-2025-08-07": 0.05, - "gpt-5-pro": 2.0, - "gpt-5-pro-2025-10-06": 2.0, - "gpt-5.1": 3.0, - "gpt-5.1-2025-11-13": 3.0, - "gpt-5.1-chat-latest": 3.0, - "gpt-5-codex": 1.0, - "gpt-5.1-codex": 3.0, - "gpt-5.1-codex-mini": 0.33, - "gpt-5.1-codex-max": 3.0, - "gpt-5.1-codex-max-customsummarizer": 3.0, - "gpt-5.2": 3.0, - "gpt-5.2-2025-12-11": 3.0, - "gpt-5.2-chat-latest": 3.0, - "gpt-5.2-codex": 3.0, - "gpt-5.2-pro": 3.0, - "gpt-5.2-pro-2025-12-11": 3.0, - "gpt-5.3-chat-latest": 3.0, - "gpt-5.3-codex": 6.0, - "gpt-5.3-codex-api-preview": 6.0, - "gpt-5.3-codex-api-preview-preambles": 6.0, - "gpt-5.4": 6.0, - "gpt-5.4-2026-03-05": 6.0, - "gpt-5.4-mini": 6.0, - "gpt-5.4-mini-2026-03-17": 6.0, - "gpt-5.4-nano-2026-03-17": 6.0, - "gpt-5.4-pro": 6.0, - "gpt-5.4-pro-2026-03-05": 6.0, - "gpt-5.5": 57.0, - "gpt-5.5-2026-04-23": 57.0, - "gpt-5.5-pro": 2.0, - "gpt-5.5-pro-2026-04-23": 2.0, - "o1": 3.0, - "o1-2024-12-17": 3.0, - "o1-mini": 0.5, - "o1-pro": 10.0, - "o1-pro-2025-03-19": 10.0, - "o3": 3.0, - "o3-2025-04-16": 3.0, - "o3-mini": 0.5, - "o3-mini-2025-01-31": 0.5, - "o3-pro": 10.0, - "o3-pro-2025-06-10": 10.0, - "o3-deep-research": 3.0, - "o3-deep-research-2025-06-26": 3.0, - "o4-mini": 0.5, - "o4-mini-2025-04-16": 0.5, - "o4-mini-deep-research": 0.5, - "o4-mini-deep-research-2025-06-26": 0.5, - "gemini-2.5-pro": 1.0, - "gemini-2.5-pro-preview-tts": 1.0, - "gemini-2.5-flash": 0.2, - "gemini-2.5-flash-native-audio-latest": 0.2, - "gemini-2.5-flash-native-audio-preview-09-2025": 0.2, - "gemini-2.5-flash-native-audio-preview-12-2025": 0.2, - "gemini-2.5-flash-preview-tts": 0.2, - "gemini-2.5-flash-image": 0.2, - "gemini-2.5-flash-lite": 0.1, - "gemini-2.0-flash": 0.1, - "gemini-2.0-flash-001": 0.1, - "gemini-2.0-flash-lite": 0.1, - "gemini-2.0-flash-lite-001": 0.1, - "gemini-1.5-pro": 1.0, - "gemini-1.5-flash": 0.1, - "gemini-flash-latest": 0.2, - "gemini-flash-lite-latest": 0.1, - "gemini-pro-latest": 1.0, - "gemini-3-flash-preview": 0.33, - "gemini-3-pro-preview": 6.0, - "gemini-3-pro-image": 6.0, - "gemini-3-pro-image-preview": 6.0, - "gemini-3.1-pro-preview": 6.0, - "gemini-3.1-pro-preview-customtools": 6.0, - "gemini-3.1-flash-live-preview": 0.1, - "gemini-3.1-flash-lite": 0.1, - "gemini-3.1-flash-lite-preview": 0.1, - "gemini-3.1-flash-image": 0.33, - "gemini-3.1-flash-image-preview": 0.33, - "gemini-3.1-flash-tts-preview": 0.1, - "gemini-3.5-flash": 14.0, - "antigravity-preview-05-2026": 1.0, - "nano-banana-pro-preview": 0.2, - "gemini-2.5-computer-use-preview": 0.2, - "gemini-2.5-computer-use-preview-10-2025": 0.2, - "gemini-robotics-er-1.5-preview": 0.2, - "gemini-robotics-er-1.6-preview": 0.2, - "deep-research-max-preview-04-2026": 1.0, - "deep-research-preview-04-2026": 1.0, - "deep-research-pro-preview-12-2025": 1.0, - "MAI-Code-1-Flash": 0.33, - "gemma-4-26b-a4b-it": 0.1, - "gemma-4-31b-it": 0.2, - "grok-code-fast-1": 0.33, - "raptor-mini": 0.33 - } -} diff --git a/setup/js/package.json b/setup/js/package.json index 978b6c4..be9040c 100644 --- a/setup/js/package.json +++ b/setup/js/package.json @@ -1,6 +1,5 @@ { "devDependencies": { - "@actions/artifact": "^6.0.0", "@actions/core": "^3.0.1", "@actions/exec": "^3.0.0", "@actions/github": "^9.1.1", diff --git a/setup/js/upload_artifact.cjs b/setup/js/upload_artifact.cjs index 56b01f3..d521024 100644 --- a/setup/js/upload_artifact.cjs +++ b/setup/js/upload_artifact.cjs @@ -5,7 +5,7 @@ * upload_artifact handler * * Validates artifact upload requests emitted by the model via the upload_artifact safe output - * tool, then uploads the approved files directly via the @actions/artifact REST API client. + * tool, then uploads the approved files directly via the internal artifact client. * * Files can be pre-staged in /tmp/gh-aw/safeoutputs/upload-artifacts/ or referenced by their * original path. When a requested path is not found in the staging directory the handler @@ -34,6 +34,7 @@ const fs = require("fs"); const path = require("path"); +const { DefaultArtifactClient } = require("./artifact_client.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { ERR_VALIDATION } = require("./error_codes.cjs"); @@ -421,16 +422,14 @@ function deriveArtifactName(request, slotIndex) { } /** - * Create or return the @actions/artifact DefaultArtifactClient. + * Create or return the internal DefaultArtifactClient. * global.__createArtifactClient can be set in tests to inject a mock client factory. - * Uses dynamic import() because @actions/artifact v2+ is an ES module. * @returns {Promise<{ uploadArtifact: (name: string, files: string[], rootDir: string, opts: object) => Promise<{id?: number, size?: number}> }>} */ async function getArtifactClient() { if (typeof global.__createArtifactClient === "function") { return global.__createArtifactClient(); } - const { DefaultArtifactClient } = await import("@actions/artifact"); return new DefaultArtifactClient(); } @@ -532,7 +531,7 @@ async function main(config = {}) { let artifactUrl = ""; if (!isStaged) { - // Upload files directly via @actions/artifact REST API. + // Upload files directly via the internal artifact client. const absoluteFiles = files.map(f => path.join(STAGING_DIR, f)); const client = await getArtifactClient(); try { diff --git a/setup/md/agent_failure_comment.md b/setup/md/agent_failure_comment.md index 38ed013..46ba4fb 100644 --- a/setup/md/agent_failure_comment.md +++ b/setup/md/agent_failure_comment.md @@ -1,3 +1,3 @@ Agent job [{run_id}]({run_url}) failed. -{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} +{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} diff --git a/setup/md/agent_failure_issue.md b/setup/md/agent_failure_issue.md index 345c742..a2162cf 100644 --- a/setup/md/agent_failure_issue.md +++ b/setup/md/agent_failure_issue.md @@ -4,7 +4,7 @@ **Branch:** {branch} **Run:** {run_url}{pull_request_info} -{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_effective_workflow_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} +{secret_verification_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{assignment_errors_context}{assign_copilot_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} ### Action Required diff --git a/setup/md/daily_workflow_aic_exceeded.md b/setup/md/daily_workflow_aic_exceeded.md index 325ccb9..00f5972 100644 --- a/setup/md/daily_workflow_aic_exceeded.md +++ b/setup/md/daily_workflow_aic_exceeded.md @@ -40,7 +40,7 @@ Commit and push the updated `.lock.yml` file. The `max-daily-ai-credits` frontmatter option sets a per-workflow spending cap measured in *AI Credits* across the 24-hour window before the current run. The cap is scoped to the repository and workflow — it aggregates usage across all runs of this workflow regardless of who triggered them. -When the aggregated AI Credits usage across all completed runs of this workflow in the last 24 hours exceeds the threshold, the activation job sets the `daily_effective_workflow_exceeded` output to `true` and the agent job is skipped for that run. The conclusion job still runs and creates this report. +When the aggregated AI Credits usage across all completed runs of this workflow in the last 24 hours exceeds the threshold, the activation job sets the `daily_ai_credits_exceeded` output to `true` and the agent job is skipped for that run. The conclusion job still runs and creates this report. The guardrail is evaluated at activation time, not retrospectively, so a single very large run that pushes usage over the threshold only blocks *subsequent* runs in the same window — it does not cancel a run that is already in progress. diff --git a/setup/setup.sh b/setup/setup.sh index 8e16b27..e13caa6 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -84,9 +84,6 @@ DESTINATION="${INPUT_DESTINATION:-${GH_AW_ROOT}/actions}" # Get safe-output-custom-tokens flag from input (default: false) SAFE_OUTPUT_CUSTOM_TOKENS_ENABLED="${INPUT_SAFE_OUTPUT_CUSTOM_TOKENS:-false}" -# Get safe-output-artifact-client flag from input (default: false) -SAFE_OUTPUT_ARTIFACT_CLIENT_ENABLED="${INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT:-false}" - debug_log "Copying activation files to ${DESTINATION}" debug_log "Safe-output custom tokens support: ${SAFE_OUTPUT_CUSTOM_TOKENS_ENABLED}" @@ -396,38 +393,6 @@ fi echo "Successfully copied ${SAFE_OUTPUTS_COUNT} safe-outputs files to ${SAFE_OUTPUTS_DEST}" -# Install @actions/artifact package if upload-artifact safe output is configured. -# upload_artifact.cjs uses DefaultArtifactClient to upload via Actions REST API directly. -if [ "${SAFE_OUTPUT_ARTIFACT_CLIENT_ENABLED}" = "true" ]; then - echo "Artifact client enabled - installing @actions/artifact package in ${DESTINATION}..." - cd "${DESTINATION}" - - # Check if npm is available - if ! command -v npm &> /dev/null; then - echo "::error::npm is not available. Cannot install @actions/artifact package." - exit 1 - fi - - # Create a minimal package.json if it doesn't exist - if [ ! -f "package.json" ]; then - echo '{"private": true}' > package.json - fi - - # Install @actions/artifact package - npm install --ignore-scripts --no-save --loglevel=error @actions/artifact@^6.0.0 2>&1 | grep -v "npm WARN" || true - if [ -d "node_modules/@actions/artifact" ]; then - echo "✓ Successfully installed @actions/artifact package" - else - echo "::error::Failed to install @actions/artifact package" - exit 1 - fi - - # Return to original directory - cd - > /dev/null -else - debug_log "Artifact client not enabled - skipping @actions/artifact installation" -fi - # Send OTLP job setup span when configured (non-fatal). # Delegates to action_setup_otlp.cjs (same file used by actions/setup/index.js) # to keep dev/release and script mode behavior in sync.