From cda6d5b404fd65f4186e11ac70fb62a5da864643 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:07:23 +0000 Subject: [PATCH 01/13] Initial plan From ead2d9e6742145bdee05b7fb719dc09160875f7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:30:33 +0000 Subject: [PATCH 02/13] Add memory custom validation hooks Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/memory_custom_validation.cjs | 215 ++++++++++++++++++ .../js/memory_custom_validation.test.cjs | 87 +++++++ actions/setup/js/push_repo_memory.cjs | 85 ++++--- actions/setup/js/safe_outputs_handlers.cjs | 85 ++++++- .../setup/js/safe_outputs_handlers.test.cjs | 128 ++++++++++- .../content/docs/reference/cache-memory.md | 13 ++ .../src/content/docs/reference/repo-memory.md | 15 +- pkg/parser/schemas/main_workflow_schema.json | 76 +++++++ pkg/workflow/cache.go | 157 +++++++++---- pkg/workflow/cache_memory_syntax_test.go | 55 +++++ pkg/workflow/compiler_github_actions_steps.go | 25 -- pkg/workflow/memory_validation_config.go | 96 ++++++++ pkg/workflow/repo_memory.go | 81 +++++-- pkg/workflow/repo_memory_test.go | 39 ++++ .../safe_outputs_config_generation.go | 14 +- 15 files changed, 1032 insertions(+), 139 deletions(-) create mode 100644 actions/setup/js/memory_custom_validation.cjs create mode 100644 actions/setup/js/memory_custom_validation.test.cjs create mode 100644 pkg/workflow/memory_validation_config.go diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs new file mode 100644 index 00000000000..af5e0a265f4 --- /dev/null +++ b/actions/setup/js/memory_custom_validation.cjs @@ -0,0 +1,215 @@ +// @ts-check + +const childProcess = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { getErrorMessage } = require("./error_helpers.cjs"); + +const DEFAULT_VALIDATION_TIMEOUT_SECONDS = 30; +const MAX_VALIDATION_OUTPUT_BYTES = 12 * 1024; + +/** + * @param {string} value + */ +function sanitizeID(value) { + return String(value || "default").replace(/[^A-Za-z0-9_.-]/g, "_"); +} + +/** + * @param {string} kind + * @param {string} memoryId + */ +function getValidationMarkerPath(kind, memoryId) { + return path.join(os.tmpdir(), "gh-aw", "memory-validation", `${sanitizeID(kind)}-${sanitizeID(memoryId)}.ok`); +} + +/** + * @param {string} kind + * @param {string} memoryId + */ +function clearValidationMarker(kind, memoryId) { + fs.rmSync(getValidationMarkerPath(kind, memoryId), { force: true }); +} + +/** + * @param {string} kind + * @param {string} memoryId + */ +function writeValidationMarker(kind, memoryId) { + const markerPath = getValidationMarkerPath(kind, memoryId); + fs.mkdirSync(path.dirname(markerPath), { recursive: true }); + fs.writeFileSync(markerPath, "ok\n", "utf8"); + return markerPath; +} + +/** + * @param {Buffer | string | undefined | null} output + */ +function boundedOutput(output) { + const text = Buffer.isBuffer(output) ? output.toString("utf8") : String(output || ""); + if (Buffer.byteLength(text, "utf8") <= MAX_VALIDATION_OUTPUT_BYTES) { + return text; + } + return text.slice(0, MAX_VALIDATION_OUTPUT_BYTES) + "\n[output truncated]"; +} + +/** + * @param {string} dirPath + * @param {number} maxFileSize + */ +function formatJSONFiles(dirPath, maxFileSize) { + if (!fs.existsSync(dirPath)) { + return []; + } + /** @type {string[]} */ + const formattedFiles = []; + + /** + * @param {string} currentDir + */ + function visit(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== ".git") { + visit(fullPath); + } + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + const raw = fs.readFileSync(fullPath, "utf8"); + if (!raw.trim()) { + continue; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (_error) { + continue; + } + const formatted = JSON.stringify(parsed, null, 2) + "\n"; + if (raw === formatted) { + continue; + } + const formattedSize = Buffer.byteLength(formatted, "utf8"); + if (formattedSize > maxFileSize) { + throw new Error(`Formatted JSON exceeds max file size: ${path.relative(dirPath, fullPath)} (${formattedSize} bytes > ${maxFileSize} bytes)`); + } + fs.writeFileSync(fullPath, formatted, "utf8"); + formattedFiles.push(path.relative(dirPath, fullPath).replace(/\\/g, "/")); + } + } + + visit(dirPath); + return formattedFiles; +} + +/** + * @param {Record} sourceEnv + */ +function sanitizedValidationEnv(sourceEnv) { + const keep = ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "RUNNER_TEMP", "GITHUB_WORKSPACE", "CI"]; + /** @type {Record} */ + const env = {}; + for (const key of keep) { + const value = sourceEnv[key]; + if (value !== undefined) { + env[key] = value; + } + } + return env; +} + +/** + * @param {{ + * script?: string, + * scriptBase64?: string, + * memoryDir: string, + * memoryId?: string, + * kind: "repo" | "cache", + * timeoutSeconds?: number, + * }} options + */ +function runCustomMemoryValidation(options) { + let script = options.script || ""; + if (!script && options.scriptBase64) { + script = Buffer.from(options.scriptBase64, "base64").toString("utf8"); + } + if (!script.trim()) { + return { + ok: false, + exitCode: null, + timedOut: false, + stdout: "", + stderr: "validation.script is configured but empty or missing", + }; + } + + const rawTimeoutSeconds = options.timeoutSeconds; + const timeoutSeconds = typeof rawTimeoutSeconds === "number" && Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 ? Math.floor(rawTimeoutSeconds) : DEFAULT_VALIDATION_TIMEOUT_SECONDS; + const timeoutMs = timeoutSeconds * 1000; + const memoryId = options.memoryId || "default"; + const validationDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-memory-validation-")); + const scriptPath = path.join(validationDir, "validator.cjs"); + const wrapper = `"use strict"; +const fs = require("fs"); +const path = require("path"); +const memoryRoot = ${JSON.stringify(options.memoryDir)}; +const memoryDir = memoryRoot; +const memoryId = ${JSON.stringify(memoryId)}; +const memoryKind = ${JSON.stringify(options.kind)}; +process.env.GH_AW_MEMORY_ROOT = memoryRoot; +process.env.GH_AW_MEMORY_DIR = memoryRoot; +process.env.GH_AW_MEMORY_ID = memoryId; +process.env.GH_AW_MEMORY_KIND = memoryKind; +(async () => { + return await (async () => { +${script} + })(); +})() + .then(result => { + if (result === false) { + console.error("validation.script returned false"); + process.exit(1); + } + }) + .catch(error => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); + }); +`; + fs.writeFileSync(scriptPath, wrapper, { encoding: "utf8", mode: 0o600 }); + try { + const result = childProcess.spawnSync(process.execPath, [scriptPath], { + cwd: options.memoryDir, + encoding: "utf8", + env: sanitizedValidationEnv(process.env), + timeout: timeoutMs, + maxBuffer: MAX_VALIDATION_OUTPUT_BYTES * 2, + windowsHide: true, + }); + return { + ok: result.status === 0 && !result.error, + exitCode: result.status, + timedOut: Boolean(result.error && /** @type {NodeJS.ErrnoException} */ result.error.code === "ETIMEDOUT"), + stdout: boundedOutput(result.stdout), + stderr: boundedOutput(result.stderr || (result.error ? getErrorMessage(result.error) : "")), + }; + } finally { + fs.rmSync(validationDir, { recursive: true, force: true }); + } +} + +module.exports = { + DEFAULT_VALIDATION_TIMEOUT_SECONDS, + clearValidationMarker, + formatJSONFiles, + getValidationMarkerPath, + runCustomMemoryValidation, + writeValidationMarker, +}; diff --git a/actions/setup/js/memory_custom_validation.test.cjs b/actions/setup/js/memory_custom_validation.test.cjs new file mode 100644 index 00000000000..c28b5e10ef4 --- /dev/null +++ b/actions/setup/js/memory_custom_validation.test.cjs @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { clearValidationMarker, formatJSONFiles, getValidationMarkerPath, runCustomMemoryValidation, writeValidationMarker } from "./memory_custom_validation.cjs"; + +describe("memory_custom_validation", () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-memory-validation-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + clearValidationMarker("repo", "default"); + }); + + it("runs a successful validator with memory globals", () => { + fs.writeFileSync(path.join(tempDir, "state.json"), JSON.stringify({ ok: true })); + const result = runCustomMemoryValidation({ + script: ` + const state = JSON.parse(fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8")); + if (!state.ok || memoryKind !== "repo" || memoryId !== "default") throw new Error("bad context"); + console.log("domain ok"); + `, + memoryDir: tempDir, + memoryId: "default", + kind: "repo", + timeoutSeconds: 5, + }); + + expect(result.ok).toBe(true); + expect(result.stdout).toContain("domain ok"); + }); + + it("reports a nonzero validator separately from stdout", () => { + const result = runCustomMemoryValidation({ + script: ` + console.log("generic-looking stdout"); + console.error("domain schema failed"); + return false; + `, + memoryDir: tempDir, + memoryId: "default", + kind: "cache", + timeoutSeconds: 5, + }); + + expect(result.ok).toBe(false); + expect(result.stdout).toContain("generic-looking stdout"); + expect(result.stderr).toContain("domain schema failed"); + }); + + it("times out long-running validators", () => { + const result = runCustomMemoryValidation({ + script: "while (true) {}", + memoryDir: tempDir, + memoryId: "default", + kind: "repo", + timeoutSeconds: 1, + }); + + expect(result.ok).toBe(false); + expect(result.timedOut).toBe(true); + }); + + it("formats JSON before validation can inspect it", () => { + const file = path.join(tempDir, "state.json"); + fs.writeFileSync(file, '{"b":2,"a":1}'); + + const formatted = formatJSONFiles(tempDir, 1024); + + expect(formatted).toEqual(["state.json"]); + expect(fs.readFileSync(file, "utf8")).toBe('{\n "b": 2,\n "a": 1\n}\n'); + }); + + it("writes and clears validation markers", () => { + const marker = writeValidationMarker("repo", "default"); + expect(marker).toBe(getValidationMarkerPath("repo", "default")); + expect(fs.existsSync(marker)).toBe(true); + + clearValidationMarker("repo", "default"); + + expect(fs.existsSync(marker)).toBe(false); + }); +}); diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 01e48ba7d94..2d894b1aa53 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -9,6 +9,7 @@ const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { getGitAuthEnv } = require("./git_auth_helpers.cjs"); const { execGitSync } = require("./git_helpers.cjs"); const { getStagedPatchDiffSizeBytes } = require("./git_patch_utils.cjs"); +const { formatJSONFiles, runCustomMemoryValidation } = require("./memory_custom_validation.cjs"); const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); const { pushSignedCommits } = require("./push_signed_commits.cjs"); @@ -51,6 +52,8 @@ async function main() { const maxPatchSize = parseInt(process.env.MAX_PATCH_SIZE || "10240", 10); const fileGlobFilter = process.env.FILE_GLOB_FILTER || ""; const formatJSON = process.env.FORMAT_JSON === "true"; + const validationScriptBase64 = process.env.VALIDATION_SCRIPT_B64 || ""; + const validationTimeoutSeconds = parseInt(process.env.VALIDATION_TIMEOUT_SECONDS || "30", 10); // Parse allowed extensions with error handling let allowedExtensions = [".json", ".jsonl", ".txt", ".md", ".csv"]; @@ -446,59 +449,49 @@ async function main() { if (formatJSON) { core.info("FORMAT_JSON is enabled: formatting .json files as human-readable..."); - /** - * Recursively find and format all .json files under a directory - * @param {string} dirPath - Directory to scan - */ - function formatJSONFilesInDir(dirPath) { - let entries; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch (err) { - throw new Error(`Failed to read directory ${dirPath}: ${getErrorMessage(err)}`, { cause: err }); - } - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - if (entry.name !== ".git") { - formatJSONFilesInDir(fullPath); - } - } else if (entry.isFile() && entry.name.endsWith(".json")) { - try { - const raw = fs.readFileSync(fullPath, "utf8"); - if (!raw.trim()) { - continue; - } - const parsed = JSON.parse(raw); - const formatted = JSON.stringify(parsed, null, 2) + "\n"; - if (raw !== formatted) { - const formattedSize = Buffer.byteLength(formatted, "utf8"); - if (formattedSize > maxFileSize) { - const sizeError = new Error(`Formatted JSON exceeds MAX_FILE_SIZE: ${path.relative(destMemoryPath, fullPath)} (${formattedSize} bytes > ${maxFileSize} bytes)`); - sizeError.name = "FormatJSONSizeLimitError"; - throw sizeError; - } - fs.writeFileSync(fullPath, formatted, "utf8"); - core.info(`Formatted JSON: ${path.relative(destMemoryPath, fullPath)}`); - } - } catch (/** @type {any} */ error) { - if (error?.name === "FormatJSONSizeLimitError") { - throw error; - } - core.warning(`Skipping JSON formatting for ${path.relative(destMemoryPath, fullPath)}: ${getErrorMessage(error)}`); - } - } - } - } - try { - formatJSONFilesInDir(destMemoryPath); + const formattedFiles = formatJSONFiles(destMemoryPath, maxFileSize); + for (const formattedFile of formattedFiles) { + core.info(`Formatted JSON: ${formattedFile}`); + } } catch (error) { core.setFailed(`Failed to format JSON files: ${getErrorMessage(error)}`); return; } } + if (validationScriptBase64) { + core.info("Running custom repo-memory validation before commit..."); + const customValidation = runCustomMemoryValidation({ + scriptBase64: validationScriptBase64, + memoryDir: destMemoryPath, + memoryId, + kind: "repo", + timeoutSeconds: validationTimeoutSeconds, + }); + if (!customValidation.ok) { + const reason = customValidation.timedOut ? `timed out after ${validationTimeoutSeconds} second(s)` : `exited with code ${customValidation.exitCode}`; + const errorMessage = `Custom repo-memory validation failed for '${memoryId}': ${reason}.`; + if (customValidation.stdout) { + core.info(`Custom repo-memory validation stdout:\n${customValidation.stdout}`); + } + if (customValidation.stderr) { + core.error(`Custom repo-memory validation stderr:\n${customValidation.stderr}`); + } + core.setOutput("validation_failed", "true"); + core.setOutput("validation_error", errorMessage); + core.setFailed(errorMessage); + return; + } + if (customValidation.stdout) { + core.info(`Custom repo-memory validation stdout:\n${customValidation.stdout}`); + } + if (customValidation.stderr) { + core.info(`Custom repo-memory validation stderr:\n${customValidation.stderr}`); + } + core.info("Custom repo-memory validation passed."); + } + // Build literal pathspecs from the relative paths of files to copy. // The :(literal) magic prefix tells Git to treat each entry as a plain string, // preventing glob expansion or pathspec-magic interpretation (e.g. :(top), diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 73ea7006a87..1138ede6878 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -31,6 +31,7 @@ const { resolveInvocationContext } = require("./invocation_context_helpers.cjs") const { lstatGuard } = require("./symlink_guard.cjs"); const { validateValueAgainstSchema } = require("./mcp_scripts_validation.cjs"); const { resolveDataSchema } = require("./data_schema_normalizer.cjs"); +const { clearValidationMarker, formatJSONFiles, runCustomMemoryValidation, writeValidationMarker } = require("./memory_custom_validation.cjs"); /** PR event names used for target:triggering context validation across all safe-output handlers. */ const PR_EVENT_NAMES = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); @@ -1663,6 +1664,9 @@ function createHandlers(server, appendSafeOutput, config = {}) { const maxFileSize = memoryConf.max_file_size || 10240; const maxPatchSize = memoryConf.max_patch_size || 10240; const maxFileCount = memoryConf.max_file_count || 100; + const validationConfig = memoryConf.validation || null; + const validationScript = validationConfig && typeof validationConfig.script === "string" ? validationConfig.script : ""; + const validationTimeoutSeconds = validationConfig && Number.isFinite(validationConfig.timeout) ? validationConfig.timeout : undefined; // The effective limit is max_patch_size × 1.2, matching the push gate in push_repo_memory.cjs. // This catches cases where total memory content is close to or exceeds the push diff limit. const effectiveMaxPatchSize = Math.floor(maxPatchSize * 1.2); @@ -1678,6 +1682,30 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } + clearValidationMarker("repo", memoryId); + + if (memoryConf.format_json === true) { + try { + const formattedFiles = formatJSONFiles(memoryDir, maxFileSize); + if (formattedFiles.length > 0) { + core.info(`Formatted ${formattedFiles.length} repo-memory JSON file(s) before validation: ${formattedFiles.join(", ")}`); + } + } catch (/** @type {any} */ error) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + result: "error", + error: `Failed to format repo-memory JSON before validation: ${getErrorMessage(error)}`, + }), + }, + ], + isError: true, + }; + } + } + // Recursively scan all files in the memory directory /** @type {Array<{relativePath: string, size: number}>} */ const files = []; @@ -1816,13 +1844,68 @@ function createHandlers(server, appendSafeOutput, config = {}) { }; } + /** @type {ReturnType | null} */ + let customValidation = null; + if (validationConfig) { + customValidation = runCustomMemoryValidation({ + script: validationScript, + memoryDir, + memoryId, + kind: "repo", + timeoutSeconds: validationTimeoutSeconds, + }); + if (!customValidation.ok) { + const reason = customValidation.timedOut ? `timed out after ${validationTimeoutSeconds || 30} second(s)` : `exited with code ${customValidation.exitCode}`; + return { + content: [ + { + type: "text", + text: JSON.stringify({ + result: "error", + error: `Custom repo-memory validation failed for '${memoryId}': ${reason}.`, + storage_validation: { + result: "success", + message: `Storage validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ${patchSizeKb} KB patch diff (${patchSizeBytes} bytes).`, + }, + custom_validation: { + result: "error", + stdout: customValidation.stdout, + stderr: customValidation.stderr, + }, + }), + }, + ], + isError: true, + }; + } + } + + const markerPath = writeValidationMarker("repo", memoryId); return { content: [ { type: "text", text: JSON.stringify({ result: "success", - message: `Memory validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ` + `${patchSizeKb} KB patch diff (${patchSizeBytes} bytes) (limit: ${effectiveMaxKb} KB / ${effectiveMaxPatchSize} bytes).`, + message: + `Storage validation passed: ${files.length} file(s), ${totalSizeKb} KB total content, ` + + `${patchSizeKb} KB patch diff (${patchSizeBytes} bytes) (limit: ${effectiveMaxKb} KB / ${effectiveMaxPatchSize} bytes).` + + (customValidation ? " Custom domain validation passed." : ""), + storage_validation: { + result: "success", + files: files.length, + total_size_kb: totalSizeKb, + patch_size_bytes: patchSizeBytes, + effective_patch_limit_bytes: effectiveMaxPatchSize, + }, + custom_validation: customValidation + ? { + result: "success", + stdout: customValidation.stdout, + stderr: customValidation.stderr, + } + : undefined, + validation_marker: markerPath, }), }, ], diff --git a/actions/setup/js/safe_outputs_handlers.test.cjs b/actions/setup/js/safe_outputs_handlers.test.cjs index 3fbb1f2b926..619d7bd0b1c 100644 --- a/actions/setup/js/safe_outputs_handlers.test.cjs +++ b/actions/setup/js/safe_outputs_handlers.test.cjs @@ -2816,7 +2816,7 @@ describe("safe_outputs_handlers", () => { const result = h.pushRepoMemoryHandler({ memory_id: "default" }); const data = JSON.parse(result.content[0].text); expect(data.result).toBe("success"); - expect(data.message).toContain("validation passed"); + expect(data.message).toContain("Storage validation passed"); }); it("should return error when a file exceeds max_file_size", () => { @@ -2906,7 +2906,131 @@ describe("safe_outputs_handlers", () => { const result = h.pushRepoMemoryHandler({ memory_id: "default" }); const data = JSON.parse(result.content[0].text); expect(data.result).toBe("success"); - expect(data.message).toContain("validation passed"); + expect(data.message).toContain("Storage validation passed"); + }); + + it("should run custom validation and distinguish it from storage validation", () => { + const h = makeHandlersWithMemory({ + validation: { + script: ` + const state = JSON.parse(fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8")); + if (state.digest.length !== 16) throw new Error("digest must be 16 chars"); + console.log("domain validator passed"); + `, + timeout: 5, + }, + }); + fs.mkdirSync(memoryDir, { recursive: true }); + initGitRepo(memoryDir); + fs.writeFileSync(path.join(memoryDir, "state.json"), JSON.stringify({ digest: "1234567890abcdef" })); + + const result = h.pushRepoMemoryHandler({ memory_id: "default" }); + const data = JSON.parse(result.content[0].text); + + expect(data.result).toBe("success"); + expect(data.message).toContain("Storage validation passed"); + expect(data.message).toContain("Custom domain validation passed"); + expect(data.storage_validation.result).toBe("success"); + expect(data.custom_validation.result).toBe("success"); + expect(data.custom_validation.stdout).toContain("domain validator passed"); + }); + + it("should reject generically valid content that fails custom validation", () => { + const h = makeHandlersWithMemory({ + validation: { + script: ` + const state = JSON.parse(fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8")); + if (state.digest.length !== 16) throw new Error("digest must be 16 chars"); + `, + timeout: 5, + }, + }); + fs.mkdirSync(memoryDir, { recursive: true }); + initGitRepo(memoryDir); + fs.writeFileSync(path.join(memoryDir, "state.json"), JSON.stringify({ digest: "a".repeat(64) })); + + const result = h.pushRepoMemoryHandler({ memory_id: "default" }); + const data = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(data.result).toBe("error"); + expect(data.storage_validation.result).toBe("success"); + expect(data.custom_validation.result).toBe("error"); + expect(data.custom_validation.stderr).toContain("digest must be 16 chars"); + }); + + it("should fail when validation is configured without a validator script", () => { + const h = makeHandlersWithMemory({ validation: {} }); + fs.mkdirSync(memoryDir, { recursive: true }); + initGitRepo(memoryDir); + fs.writeFileSync(path.join(memoryDir, "state.json"), "{}"); + + const result = h.pushRepoMemoryHandler({ memory_id: "default" }); + const data = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(data.custom_validation.stderr).toContain("empty or missing"); + }); + + it("should apply custom validation to the selected memory_id only", () => { + const otherDir = `${memoryDir}-other`; + const h = createHandlers(mockServer, mockAppendSafeOutput, { + push_repo_memory: { + memories: [ + { + id: "default", + dir: memoryDir, + max_file_size: 1024, + max_patch_size: 2048, + max_file_count: 5, + validation: { script: "throw new Error('default validator should not run')", timeout: 5 }, + }, + { + id: "session", + dir: otherDir, + max_file_size: 1024, + max_patch_size: 2048, + max_file_count: 5, + validation: { script: "console.log(memoryId)", timeout: 5 }, + }, + ], + }, + }); + fs.mkdirSync(otherDir, { recursive: true }); + initGitRepo(otherDir); + fs.writeFileSync(path.join(otherDir, "state.json"), "{}"); + + try { + const result = h.pushRepoMemoryHandler({ memory_id: "session" }); + const data = JSON.parse(result.content[0].text); + + expect(data.result).toBe("success"); + expect(data.custom_validation.stdout).toContain("session"); + } finally { + fs.rmSync(otherDir, { recursive: true, force: true }); + } + }); + + it("should run custom validation after format-json normalization", () => { + const h = makeHandlersWithMemory({ + format_json: true, + validation: { + script: ` + const raw = fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8"); + if (!raw.includes("\\n \\"digest\\"")) throw new Error("expected formatted JSON"); + `, + timeout: 5, + }, + }); + fs.mkdirSync(memoryDir, { recursive: true }); + initGitRepo(memoryDir); + fs.writeFileSync(path.join(memoryDir, "state.json"), '{"digest":"1234567890abcdef"}'); + + const result = h.pushRepoMemoryHandler({ memory_id: "default" }); + const data = JSON.parse(result.content[0].text); + + expect(data.result).toBe("success"); + expect(fs.readFileSync(path.join(memoryDir, "state.json"), "utf8")).toContain('\n "digest"'); }); }); diff --git a/docs/src/content/docs/reference/cache-memory.md b/docs/src/content/docs/reference/cache-memory.md index 614ab4ef3be..8862783105a 100644 --- a/docs/src/content/docs/reference/cache-memory.md +++ b/docs/src/content/docs/reference/cache-memory.md @@ -27,6 +27,11 @@ tools: key: custom-memory-${{ github.repository_owner }} retention-days: 30 # 1-90 days, extends access beyond cache expiration allowed-extensions: [".json", ".txt", ".md"] # Restrict file types (default: empty/all files allowed) + validation: + timeout: 30 + script: | + const index = JSON.parse(fs.readFileSync(path.join(memoryRoot, "index.json"), "utf8")); + if (!Array.isArray(index.entries)) throw new Error("index.json entries must be an array"); --- ``` @@ -49,6 +54,14 @@ If files with disallowed extensions are found, the workflow will report validati When a cache is restored for agent execution, gh-aw also strips execute bits from restored working-tree files and removes disallowed file types before the agent can read them. See [ADR-26587](https://github.com/github/gh-aw/blob/main/docs/adr/26587-pre-agent-cache-memory-working-tree-sanitization.md) for the pre-agent sanitization contract behind `allowed-extensions`. +### Custom validation + +Use `validation.script` for domain-specific constraints such as schema checks, cross-file uniqueness, or timestamp policies. The script is a JavaScript body executed with Node.js over the complete configured cache-memory directory after agent execution and before the cache is saved. When threat detection is enabled, the validator also runs again in the `update_cache_memory` job before `actions/cache/save`. + +Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"cache"`). The working directory is the cache root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). + +Throw an exception, return `false`, time out, or exit nonzero to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output. + ## Multiple Configurations ```aw wrap diff --git a/docs/src/content/docs/reference/repo-memory.md b/docs/src/content/docs/reference/repo-memory.md index f5e35c9468e..c099655206c 100644 --- a/docs/src/content/docs/reference/repo-memory.md +++ b/docs/src/content/docs/reference/repo-memory.md @@ -35,10 +35,15 @@ tools: create-orphan: true # default allowed-extensions: [".json", ".txt", ".md"] # Restrict file types (default: empty/all files allowed) format-json: true # Pretty-print .json files (default: false) + validation: + timeout: 30 + script: | + const data = JSON.parse(fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8")); + if (!Array.isArray(data.items)) throw new Error("state.json must contain an items array"); --- ``` -`branch-prefix` changes the default `memory` prefix and must be 4-32 alphanumeric, hyphen, or underscore characters; it cannot be `copilot`. `allowed-extensions` limits which file types can be stored, `format-json: true` pretty-prints `.json` files before commit, and `max-patch-size` caps the total diff size for one push (default 10KB, max 1MB) to prevent oversized updates. +`branch-prefix` changes the default `memory` prefix and must be 4-32 alphanumeric, hyphen, or underscore characters; it cannot be `copilot`. `allowed-extensions` limits which file types can be stored, `format-json: true` pretty-prints `.json` files before commit, `validation.script` runs a custom JavaScript domain validator before persistence, and `max-patch-size` caps the total diff size for one push (default 10KB, max 1MB) to prevent oversized updates. **File Glob Matching Rules**: @@ -70,6 +75,14 @@ Mounts at `/tmp/gh-aw/repo-memory-{id}/` during workflow execution. The required Branches auto-create as orphans by default, or clone with `--depth 1`. After validating `file-glob`, `max-file-size`, and `max-file-count`, gh-aw auto-commits and pushes when changes are present and threat detection passes. +### Custom validation + +Use `validation.script` when generic storage limits are not enough. The script is a JavaScript body executed with Node.js over the complete configured memory directory after `format-json` normalization and before artifact upload or branch commit. It runs in the agent job and is re-run in the repo-memory push job as defense in depth. + +Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"repo"`). The working directory is the memory root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). + +Throw an exception, return `false`, time out, or exit nonzero to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output so agents can distinguish domain-schema validation from size/count checks. + Commits use the [GitHub GraphQL `createCommitOnBranch` mutation](https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch), so they are automatically **Verified** with GitHub's GPG key and satisfy rulesets that require signed commits. :::note[Signed-commit fallback limitation] diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 4bfa6b05ed0..1465dda18d9 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4595,6 +4595,25 @@ "type": "string" }, "description": "List of allowed file extensions (e.g., [\".json\", \".txt\"]). Default: [\".json\", \".jsonl\", \".txt\", \".md\", \".csv\"]" + }, + "validation": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript validator body that runs over the complete cache-memory directory before persistence. Throw, return false, or exit nonzero to reject the update." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + } + }, + "required": ["script"], + "additionalProperties": false, + "description": "Custom domain validation hook for this cache-memory entry" } }, "additionalProperties": false, @@ -4648,6 +4667,25 @@ "type": "string" }, "description": "List of allowed file extensions (e.g., [\".json\", \".txt\"]). Default: [\".json\", \".jsonl\", \".txt\", \".md\", \".csv\"]" + }, + "validation": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript validator body that runs over the complete cache-memory directory before persistence. Throw, return false, or exit nonzero to reject the update." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + } + }, + "required": ["script"], + "additionalProperties": false, + "description": "Custom domain validation hook for this cache-memory entry" } }, "required": ["id", "key"], @@ -4904,6 +4942,25 @@ "format-json": { "type": "boolean", "description": "When true, all .json files are pretty-printed (2-space indent) before being committed, making them human-readable in the repository (default: false)" + }, + "validation": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript validator body that runs over the complete repo-memory directory after optional JSON formatting and before persistence. Throw, return false, or exit nonzero to reject the update." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + } + }, + "required": ["script"], + "additionalProperties": false, + "description": "Custom domain validation hook for this repo-memory entry" } }, "additionalProperties": false, @@ -4999,6 +5056,25 @@ "format-json": { "type": "boolean", "description": "When true, all .json files are pretty-printed (2-space indent) before being committed, making them human-readable in the repository (default: false)" + }, + "validation": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "JavaScript validator body that runs over the complete repo-memory directory after optional JSON formatting and before persistence. Throw, return false, or exit nonzero to reject the update." + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "default": 30, + "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + } + }, + "required": ["script"], + "additionalProperties": false, + "description": "Custom domain validation hook for this repo-memory entry" } }, "additionalProperties": false diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 1fc9e9d00ed..108e0145538 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -43,6 +43,7 @@ func cacheMemoryDirFor(cacheID string) string { if cacheID == "default" || cacheID == "" { return defaultCacheMemoryDir } + if !isValidCacheID(cacheID) { // This should never happen: parseCacheMemoryEntry validates IDs at parse time. // Panic here to surface a clear programming error rather than silently producing @@ -52,6 +53,14 @@ func cacheMemoryDirFor(cacheID string) string { return cacheMemoryDirPrefix + cacheID } +func cacheMemoryValidationStepID(cacheID string) string { + return strings.ReplaceAll("validate_cache_memory_"+cacheID, "-", "_") +} + +func cacheHasValidationStep(cache CacheMemoryEntry) bool { + return len(cache.AllowedExtensions) > 0 || cache.Validation != nil +} + // validCacheMemoryScopes defines the allowed values for cache-memory scope var validCacheMemoryScopes = []string{"workflow", "repo"} @@ -100,13 +109,14 @@ type CacheMemoryConfig struct { // CacheMemoryEntry represents a single cache-memory configuration type CacheMemoryEntry struct { - ID string `yaml:"id"` // cache identifier (required for array notation) - Key string `yaml:"key,omitempty"` // custom cache key - Description string `yaml:"description,omitempty"` // optional description for this cache - RetentionDays *int `yaml:"retention-days,omitempty"` // retention days for upload-artifact action - RestoreOnly bool `yaml:"restore-only,omitempty"` // if true, only restore cache without saving - Scope string `yaml:"scope,omitempty"` // scope for restore keys: "workflow" (default) or "repo" - AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` // allowed file extensions (default: [".json", ".jsonl", ".txt", ".md", ".csv"]) + ID string `yaml:"id"` // cache identifier (required for array notation) + Key string `yaml:"key,omitempty"` // custom cache key + Description string `yaml:"description,omitempty"` // optional description for this cache + RetentionDays *int `yaml:"retention-days,omitempty"` // retention days for upload-artifact action + RestoreOnly bool `yaml:"restore-only,omitempty"` // if true, only restore cache without saving + Scope string `yaml:"scope,omitempty"` // scope for restore keys: "workflow" (default) or "repo" + AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` // allowed file extensions (default: [".json", ".jsonl", ".txt", ".md", ".csv"]) + Validation *MemoryValidationConfig `yaml:"validation,omitempty"` // optional custom JavaScript validation hook } // generateDefaultCacheKey generates a default cache key for a given cache ID. @@ -141,6 +151,11 @@ func parseCacheMemoryEntry(cacheMap map[string]any, defaultID string) (CacheMemo if err := parseCacheMemoryAllowedExtensions(cacheMap, &entry); err != nil { return entry, err } + validation, err := parseMemoryValidationConfig(cacheMap, "tools.cache-memory.validation") + if err != nil { + return entry, err + } + entry.Validation = validation applyDefaultAllowedExtensions(&entry) cacheLog.Printf("Parsed cache-memory entry: id=%s, scope=%s, restore-only=%v, retention-days=%v", entry.ID, entry.Scope, entry.RestoreOnly, entry.RetentionDays) return entry, nil @@ -680,34 +695,57 @@ func generateCacheMemoryValidation(builder *strings.Builder, data *WorkflowData) continue } - // Skip validation step if allowed extensions is empty (means all files are allowed) - if len(cache.AllowedExtensions) == 0 { - cacheLog.Printf("Skipping validation step for cache %s (empty allowed-extensions means all files are allowed)", cache.ID) + hasFileTypeValidation := len(cache.AllowedExtensions) > 0 + hasCustomValidation := cache.Validation != nil + if !hasFileTypeValidation && !hasCustomValidation { + cacheLog.Printf("Skipping validation step for cache %s (empty allowed-extensions and no custom validation)", cache.ID) continue } cacheDir := cacheMemoryDirFor(cache.ID) - - // Prepare allowed extensions array for JavaScript allowedExtsJSON, _ := json.Marshal(cache.AllowedExtensions) //nolint:jsonmarshalignoredeerror // marshaling a string slice cannot fail - // Build validation script - var validationScript strings.Builder - validationScript.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") - validationScript.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - validationScript.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") - fmt.Fprintf(&validationScript, " const allowedExtensions = %s;\n", allowedExtsJSON) - fmt.Fprintf(&validationScript, " const result = validateMemoryFiles('%s', 'cache', allowedExtensions);\n", cacheDir) - validationScript.WriteString(" if (!result.valid) {\n") - fmt.Fprintf(&validationScript, " core.setFailed(`File type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`);\n", strings.Join(cache.AllowedExtensions, ", ")) - validationScript.WriteString(" }\n") - - // Generate validation step using helper stepName := "Validate cache-memory file types" if !useBackwardCompatiblePaths { stepName = fmt.Sprintf("Validate cache-memory file types (%s)", cache.ID) } - builder.WriteString(generateInlineGitHubScriptStep(stepName, validationScript.String(), "always()", data)) + if hasCustomValidation { + stepName = strings.Replace(stepName, "file types", "file types and domain content", 1) + } + fmt.Fprintf(builder, " - name: %s\n", stepName) + fmt.Fprintf(builder, " id: %s\n", cacheMemoryValidationStepID(cache.ID)) + builder.WriteString(" if: always()\n") + fmt.Fprintf(builder, " uses: %s\n", getCachedActionPin("actions/github-script", data)) + builder.WriteString(" env:\n") + fmt.Fprintf(builder, " MEMORY_DIR: %s\n", cacheDir) + fmt.Fprintf(builder, " MEMORY_ID: %s\n", cache.ID) + fmt.Fprintf(builder, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) + if cache.Validation != nil { + fmt.Fprintf(builder, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(cache.Validation)) + fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", cache.Validation.Timeout) + } + builder.WriteString(" with:\n") + builder.WriteString(" script: |\n") + builder.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") + builder.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") + builder.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") + builder.WriteString(" const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") + builder.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") + builder.WriteString(" const memoryId = process.env.MEMORY_ID || 'default';\n") + builder.WriteString(" clearValidationMarker('cache', memoryId);\n") + builder.WriteString(" let failed = false;\n") + builder.WriteString(" const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]');\n") + builder.WriteString(" if (allowedExtensions.length > 0) {\n") + builder.WriteString(" const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions);\n") + fmt.Fprintf(builder, " if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`); failed = true; }\n", strings.Join(cache.AllowedExtensions, ", ")) + builder.WriteString(" }\n") + builder.WriteString(" if (process.env.VALIDATION_SCRIPT_B64) {\n") + builder.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") + builder.WriteString(" if (result.stdout) core.info(`Custom cache-memory validation stdout:\\n${result.stdout}`);\n") + builder.WriteString(" if (result.stderr) core.info(`Custom cache-memory validation stderr:\\n${result.stderr}`);\n") + builder.WriteString(" if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; }\n") + builder.WriteString(" }\n") + builder.WriteString(" if (!failed) writeValidationMarker('cache', memoryId);\n") } } @@ -763,7 +801,11 @@ func generateCacheMemoryArtifactUpload(builder *strings.Builder, data *WorkflowD fmt.Fprintf(builder, " - name: Upload cache-memory data as artifact (%s)\n", cache.ID) } fmt.Fprintf(builder, " uses: %s\n", pinAction("actions/upload-artifact")) - builder.WriteString(" if: always()\n") + if cacheHasValidationStep(cache) { + fmt.Fprintf(builder, " if: always() && steps.%s.outcome == 'success'\n", cacheMemoryValidationStepID(cache.ID)) + } else { + builder.WriteString(" if: always()\n") + } builder.WriteString(" with:\n") // Always use the new artifact name and path format, with prefix in workflow_call context if useBackwardCompatiblePaths { @@ -968,28 +1010,45 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection checkStep.WriteString(" fi\n") steps = append(steps, checkStep.String()) - // Skip validation step if allowed extensions is empty (means all files are allowed) - if len(cache.AllowedExtensions) == 0 { - cacheLog.Printf("Skipping validation step for cache %s in update job (empty allowed-extensions means all files are allowed)", cache.ID) + if !cacheHasValidationStep(cache) { + cacheLog.Printf("Skipping validation step for cache %s in update job (empty allowed-extensions and no custom validation)", cache.ID) } else { - // Prepare allowed extensions array for JavaScript allowedExtsJSON, _ := json.Marshal(cache.AllowedExtensions) //nolint:jsonmarshalignoredeerror // marshaling a string slice cannot fail - - // Build validation script - var validationScript strings.Builder - validationScript.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") - validationScript.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - validationScript.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") - fmt.Fprintf(&validationScript, " const allowedExtensions = %s;\n", allowedExtsJSON) - fmt.Fprintf(&validationScript, " const result = validateMemoryFiles('%s', 'cache', allowedExtensions);\n", cacheDir) - validationScript.WriteString(" if (!result.valid) {\n") - fmt.Fprintf(&validationScript, " core.setFailed(`File type validation failed: Found ${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`);\n", strings.Join(cache.AllowedExtensions, ", ")) - validationScript.WriteString(" }\n") - - // Generate validation step using helper with condition to only run if cache has content - stepName := fmt.Sprintf("Validate cache-memory file types (%s)", cache.ID) - condition := fmt.Sprintf("steps.%s.outputs.has_content == 'true'", checkStepID) - steps = append(steps, generateInlineGitHubScriptStep(stepName, validationScript.String(), condition, data)) + var validationStep strings.Builder + fmt.Fprintf(&validationStep, " - name: Validate cache-memory before save (%s)\n", cache.ID) + fmt.Fprintf(&validationStep, " id: %s\n", cacheMemoryValidationStepID(cache.ID)) + fmt.Fprintf(&validationStep, " if: steps.%s.outputs.has_content == 'true'\n", checkStepID) + fmt.Fprintf(&validationStep, " uses: %s\n", getCachedActionPin("actions/github-script", data)) + validationStep.WriteString(" env:\n") + fmt.Fprintf(&validationStep, " MEMORY_DIR: %s\n", cacheDir) + fmt.Fprintf(&validationStep, " MEMORY_ID: %s\n", cache.ID) + fmt.Fprintf(&validationStep, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) + if cache.Validation != nil { + fmt.Fprintf(&validationStep, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(cache.Validation)) + fmt.Fprintf(&validationStep, " VALIDATION_TIMEOUT_SECONDS: %d\n", cache.Validation.Timeout) + } + validationStep.WriteString(" with:\n") + validationStep.WriteString(" script: |\n") + validationStep.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") + validationStep.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") + validationStep.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") + validationStep.WriteString(" const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") + validationStep.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") + validationStep.WriteString(" const memoryId = process.env.MEMORY_ID || 'default';\n") + validationStep.WriteString(" let failed = false;\n") + validationStep.WriteString(" const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]');\n") + validationStep.WriteString(" if (allowedExtensions.length > 0) {\n") + validationStep.WriteString(" const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions);\n") + fmt.Fprintf(&validationStep, " if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`); failed = true; }\n", strings.Join(cache.AllowedExtensions, ", ")) + validationStep.WriteString(" }\n") + validationStep.WriteString(" if (process.env.VALIDATION_SCRIPT_B64) {\n") + validationStep.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") + validationStep.WriteString(" if (result.stdout) core.info(`Custom cache-memory validation stdout:\\n${result.stdout}`);\n") + validationStep.WriteString(" if (result.stderr) core.info(`Custom cache-memory validation stderr:\\n${result.stderr}`);\n") + validationStep.WriteString(" if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; }\n") + validationStep.WriteString(" }\n") + validationStep.WriteString(" if (failed) process.exitCode = 1;\n") + steps = append(steps, validationStep.String()) } // Generate cache key using integrity-aware format (matches generateCacheMemorySteps) @@ -1008,7 +1067,11 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection // Save to cache step - only run if cache has content var saveStep strings.Builder fmt.Fprintf(&saveStep, " - name: Save cache-memory to cache (%s)\n", cache.ID) - fmt.Fprintf(&saveStep, " if: steps.%s.outputs.has_content == 'true'\n", checkStepID) + if cacheHasValidationStep(cache) { + fmt.Fprintf(&saveStep, " if: steps.%s.outputs.has_content == 'true' && steps.%s.outcome == 'success'\n", checkStepID, cacheMemoryValidationStepID(cache.ID)) + } else { + fmt.Fprintf(&saveStep, " if: steps.%s.outputs.has_content == 'true'\n", checkStepID) + } fmt.Fprintf(&saveStep, " uses: %s\n", getActionPin("actions/cache/save")) saveStep.WriteString(" with:\n") fmt.Fprintf(&saveStep, " key: %s\n", cacheKey) diff --git a/pkg/workflow/cache_memory_syntax_test.go b/pkg/workflow/cache_memory_syntax_test.go index 29814b08db6..0dc50320b31 100644 --- a/pkg/workflow/cache_memory_syntax_test.go +++ b/pkg/workflow/cache_memory_syntax_test.go @@ -3,7 +3,11 @@ package workflow import ( + "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCacheMemorySyntaxVariations(t *testing.T) { @@ -183,3 +187,54 @@ func TestCacheMemorySyntaxVariations(t *testing.T) { }) } } + +func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { + compiler := NewCompiler() + config, err := compiler.extractCacheMemoryConfigFromMap(map[string]any{ + "cache-memory": []any{ + map[string]any{ + "id": "default", + "key": "memory-default", + "validation": map[string]any{ + "script": "if (!fs.existsSync(path.join(memoryRoot, 'index.json'))) throw new Error('missing index');", + "timeout": 9, + }, + }, + map[string]any{ + "id": "session", + "key": "memory-session", + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, config) + require.Len(t, config.Caches, 2) + require.NotNil(t, config.Caches[0].Validation) + assert.Equal(t, 9, config.Caches[0].Validation.Timeout) + assert.Nil(t, config.Caches[1].Validation) + + data := &WorkflowData{ + CacheMemoryConfig: config, + SafeOutputs: &SafeOutputsConfig{ThreatDetection: &ThreatDetectionConfig{}}, + } + + var validation strings.Builder + generateCacheMemoryValidation(&validation, data) + validationYAML := validation.String() + assert.Contains(t, validationYAML, "Validate cache-memory file types and domain content") + assert.Contains(t, validationYAML, "VALIDATION_SCRIPT_B64:") + assert.Contains(t, validationYAML, "id: validate_cache_memory_default") + + var upload strings.Builder + generateCacheMemoryArtifactUpload(&upload, data, getActionPin) + uploadYAML := upload.String() + assert.Contains(t, uploadYAML, "steps.validate_cache_memory_default.outcome == 'success'") + + job, err := compiler.buildUpdateCacheMemoryJob(data, true) + require.NoError(t, err) + require.NotNil(t, job) + updateYAML := strings.Join(job.Steps, "\n") + assert.Contains(t, updateYAML, "Validate cache-memory before save (default)") + assert.Contains(t, updateYAML, "VALIDATION_TIMEOUT_SECONDS: 9") + assert.Contains(t, updateYAML, "steps.validate_cache_memory_default.outcome == 'success'") +} diff --git a/pkg/workflow/compiler_github_actions_steps.go b/pkg/workflow/compiler_github_actions_steps.go index 943cb210dcf..cab22e3d464 100644 --- a/pkg/workflow/compiler_github_actions_steps.go +++ b/pkg/workflow/compiler_github_actions_steps.go @@ -30,31 +30,6 @@ func generateGitHubScriptWithRequire(scriptPath string) string { return script.String() } -// generateInlineGitHubScriptStep generates a simple inline github-script step -// for validation or utility operations that don't require artifact downloads. -// -// Parameters: -// - stepName: The name of the step (e.g., "Validate cache-memory file types") -// - script: The JavaScript code to execute (pre-formatted with proper indentation) -// - condition: Optional if condition (e.g., "always()"). Empty string means no condition. -// -// Returns a string containing the complete YAML for the github-script step. -func generateInlineGitHubScriptStep(stepName, script, condition string, data *WorkflowData) string { - compilerGitHubActionsStepsLog.Printf("Generating inline GitHub script step: name=%q, condition=%q", stepName, condition) - var step strings.Builder - - step.WriteString(" - name: " + stepName + "\n") - if condition != "" { - step.WriteString(" if: " + condition + "\n") - } - step.WriteString(" uses: " + getCachedActionPin("actions/github-script", data) + "\n") - step.WriteString(" with:\n") - step.WriteString(" script: |\n") - step.WriteString(script) - - return step.String() -} - // generatePlaceholderSubstitutionStep generates a JavaScript-based step that performs // safe placeholder substitution using the substitute_placeholders script. // This replaces the multiple sed commands with a single JavaScript step. diff --git a/pkg/workflow/memory_validation_config.go b/pkg/workflow/memory_validation_config.go new file mode 100644 index 00000000000..395ace5094d --- /dev/null +++ b/pkg/workflow/memory_validation_config.go @@ -0,0 +1,96 @@ +package workflow + +import ( + "encoding/base64" + "fmt" + "strconv" +) + +const defaultMemoryValidationTimeoutSeconds = 30 + +type MemoryValidationConfig struct { + Script string `yaml:"script,omitempty"` + Timeout int `yaml:"timeout,omitempty"` +} + +func parseMemoryValidationConfig(configMap map[string]any, fieldPath string) (*MemoryValidationConfig, error) { + raw, ok := configMap["validation"] + if !ok { + if script, ok := configMap["validation-script"].(string); ok { + return normalizeMemoryValidationConfig(&MemoryValidationConfig{Script: script}, fieldPath) + } + if script, ok := configMap["custom-validation"].(string); ok { + return normalizeMemoryValidationConfig(&MemoryValidationConfig{Script: script}, fieldPath) + } + return nil, nil + } + + switch value := raw.(type) { + case string: + return normalizeMemoryValidationConfig(&MemoryValidationConfig{Script: value}, fieldPath) + case map[string]any: + config := &MemoryValidationConfig{} + if script, ok := value["script"].(string); ok { + config.Script = script + } + if timeout, exists := value["timeout"]; exists { + parsed, err := parseMemoryValidationTimeout(timeout, fieldPath+".timeout") + if err != nil { + return nil, err + } + config.Timeout = parsed + } + return normalizeMemoryValidationConfig(config, fieldPath) + default: + return nil, fmt.Errorf("%s must be an object with script and optional timeout, or a script string", fieldPath) + } +} + +func parseMemoryValidationTimeout(value any, fieldPath string) (int, error) { + switch v := value.(type) { + case int: + return validateMemoryValidationTimeout(v, fieldPath) + case float64: + return validateMemoryValidationTimeout(int(v), fieldPath) + case uint64: + if v > uint64(^uint(0)>>1) { + return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) + } + return validateMemoryValidationTimeout(int(v), fieldPath) + case string: + parsed, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + } + return validateMemoryValidationTimeout(parsed, fieldPath) + default: + return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + } +} + +func validateMemoryValidationTimeout(timeout int, fieldPath string) (int, error) { + if timeout < 1 || timeout > 300 { + return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) + } + return timeout, nil +} + +func normalizeMemoryValidationConfig(config *MemoryValidationConfig, fieldPath string) (*MemoryValidationConfig, error) { + if config == nil { + return nil, nil + } + if config.Script == "" { + return nil, fmt.Errorf("%s.script must not be empty", fieldPath) + } + if config.Timeout == 0 { + config.Timeout = defaultMemoryValidationTimeoutSeconds + } + return config, nil +} + +func memoryValidationScriptBase64(config *MemoryValidationConfig) string { + if config == nil || config.Script == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(config.Script)) +} diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index 7d2291f0d59..d3cd798ec7b 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -45,18 +45,19 @@ type RepoMemoryConfig struct { // RepoMemoryEntry represents a single repo-memory configuration type RepoMemoryEntry struct { - ID string `yaml:"id"` // memory identifier (required for array notation) - TargetRepo string `yaml:"target-repo,omitempty"` // target repository (default: current repo) - BranchName string `yaml:"branch-name,omitempty"` // branch name (default: memory/{memory-id}) - FileGlob []string `yaml:"file-glob,omitempty"` // file glob patterns for allowed files - MaxFileSize int `yaml:"max-file-size,omitempty"` // maximum size per file in bytes (default: 100KB) - MaxFileCount int `yaml:"max-file-count,omitempty"` // maximum file count per commit (default: 100) - MaxPatchSize int `yaml:"max-patch-size,omitempty"` // maximum total patch size in bytes (default: 10KB, max: 1MB) - Description string `yaml:"description,omitempty"` // optional description for this memory - CreateOrphan bool `yaml:"create-orphan,omitempty"` // create orphaned branch if missing (default: true) - AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` // allowed file extensions (default: [".json", ".jsonl", ".txt", ".md", ".csv"]) - Wiki bool `yaml:"wiki,omitempty"` // use the GitHub Wiki git repository instead of the regular repo - FormatJSON bool `yaml:"format-json,omitempty"` // pretty-print all .json files before committing (default: false) + ID string `yaml:"id"` // memory identifier (required for array notation) + TargetRepo string `yaml:"target-repo,omitempty"` // target repository (default: current repo) + BranchName string `yaml:"branch-name,omitempty"` // branch name (default: memory/{memory-id}) + FileGlob []string `yaml:"file-glob,omitempty"` // file glob patterns for allowed files + MaxFileSize int `yaml:"max-file-size,omitempty"` // maximum size per file in bytes (default: 100KB) + MaxFileCount int `yaml:"max-file-count,omitempty"` // maximum file count per commit (default: 100) + MaxPatchSize int `yaml:"max-patch-size,omitempty"` // maximum total patch size in bytes (default: 10KB, max: 1MB) + Description string `yaml:"description,omitempty"` // optional description for this memory + CreateOrphan bool `yaml:"create-orphan,omitempty"` // create orphaned branch if missing (default: true) + AllowedExtensions []string `yaml:"allowed-extensions,omitempty"` // allowed file extensions (default: [".json", ".jsonl", ".txt", ".md", ".csv"]) + Wiki bool `yaml:"wiki,omitempty"` // use the GitHub Wiki git repository instead of the regular repo + FormatJSON bool `yaml:"format-json,omitempty"` // pretty-print all .json files before committing (default: false) + Validation *MemoryValidationConfig `yaml:"validation,omitempty"` // optional custom JavaScript validation hook } // RepoMemoryToolConfig represents the configuration for repo-memory in tools @@ -202,7 +203,9 @@ func parseRepoMemoryEntry(memoryMap map[string]any, workflowID, branchPrefix str if err := applyRepoMemoryLimits(&entry, memoryMap); err != nil { return RepoMemoryEntry{}, err } - applyRepoMemoryOptionalFields(&entry, memoryMap) + if err := applyRepoMemoryOptionalFields(&entry, memoryMap); err != nil { + return RepoMemoryEntry{}, err + } finalizeRepoMemoryEntry(&entry, explicitBranchName) return entry, nil } @@ -274,7 +277,7 @@ func applyRepoMemoryLimits(entry *RepoMemoryEntry, memoryMap map[string]any) err return nil } -func applyRepoMemoryOptionalFields(entry *RepoMemoryEntry, memoryMap map[string]any) { +func applyRepoMemoryOptionalFields(entry *RepoMemoryEntry, memoryMap map[string]any) error { if description, ok := memoryMap["description"].(string); ok { entry.Description = description } @@ -287,6 +290,12 @@ func applyRepoMemoryOptionalFields(entry *RepoMemoryEntry, memoryMap map[string] if formatJSON, ok := memoryMap["format-json"].(bool); ok { entry.FormatJSON = formatJSON } + validation, err := parseMemoryValidationConfig(memoryMap, "tools.repo-memory.validation") + if err != nil { + return err + } + entry.Validation = validation + return nil } func finalizeRepoMemoryEntry(entry *RepoMemoryEntry, explicitBranchName bool) { @@ -371,9 +380,43 @@ func generateRepoMemoryArtifactUpload(builder *strings.Builder, data *WorkflowDa fmt.Fprintf(builder, " MEMORY_DIR: %s\n", memoryDir) builder.WriteString(" run: bash \"${RUNNER_TEMP}/gh-aw/actions/sanitize_repo_memory_filenames.sh\"\n") + validationStepID := repoMemoryValidationStepID(memory.ID) + if memory.Validation != nil { + fmt.Fprintf(builder, " - name: Validate %s domain content (%s)\n", memoryLabel, memory.ID) + fmt.Fprintf(builder, " id: %s\n", validationStepID) + builder.WriteString(" if: always()\n") + fmt.Fprintf(builder, " uses: %s\n", getActionPin("actions/github-script")) + builder.WriteString(" env:\n") + fmt.Fprintf(builder, " MEMORY_DIR: %s\n", memoryDir) + fmt.Fprintf(builder, " MEMORY_ID: %s\n", memory.ID) + fmt.Fprintf(builder, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(memory.Validation)) + fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", memory.Validation.Timeout) + if memory.FormatJSON { + builder.WriteString(" FORMAT_JSON: 'true'\n") + } + + builder.WriteString(" with:\n") + builder.WriteString(" script: |\n") + builder.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") + builder.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") + builder.WriteString(" const { formatJSONFiles, runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") + builder.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") + builder.WriteString(" if (process.env.FORMAT_JSON === 'true') {\n") + builder.WriteString(" for (const file of formatJSONFiles(memoryDir, 102400000)) core.info(`Formatted JSON before custom validation: ${file}`);\n") + builder.WriteString(" }\n") + builder.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId: process.env.MEMORY_ID, kind: 'repo', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") + builder.WriteString(" if (result.stdout) core.info(`Custom repo-memory validation stdout:\\n${result.stdout}`);\n") + builder.WriteString(" if (result.stderr) core.info(`Custom repo-memory validation stderr:\\n${result.stderr}`);\n") + builder.WriteString(" if (!result.ok) core.setFailed(`Custom repo-memory validation failed for '${process.env.MEMORY_ID}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`);\n") + } + // Step: Upload repo-memory directory as artifact fmt.Fprintf(builder, " - name: Upload %s artifact (%s)\n", memoryLabel, memory.ID) - builder.WriteString(" if: always()\n") + if memory.Validation != nil { + fmt.Fprintf(builder, " if: always() && steps.%s.outcome == 'success'\n", validationStepID) + } else { + builder.WriteString(" if: always()\n") + } fmt.Fprintf(builder, " uses: %s\n", pinAction("actions/upload-artifact")) builder.WriteString(" with:\n") fmt.Fprintf(builder, " name: %srepo-memory-%s\n", prefix, sanitizedID) @@ -383,6 +426,10 @@ func generateRepoMemoryArtifactUpload(builder *strings.Builder, data *WorkflowDa } } +func repoMemoryValidationStepID(memoryID string) string { + return strings.ReplaceAll("validate_repo_memory_"+memoryID, "-", "_") +} + // generateRepoMemorySteps generates git steps for the repo-memory configuration func generateRepoMemorySteps(builder *strings.Builder, data *WorkflowData) { if data.RepoMemoryConfig == nil || len(data.RepoMemoryConfig.Memories) == 0 { @@ -586,6 +633,10 @@ func (c *Compiler) buildSinglePushRepoMemoryStep(data *WorkflowData, memory Repo if memory.FormatJSON { step.WriteString(" FORMAT_JSON: 'true'\n") } + if memory.Validation != nil { + fmt.Fprintf(&step, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(memory.Validation)) + fmt.Fprintf(&step, " VALIDATION_TIMEOUT_SECONDS: %d\n", memory.Validation.Timeout) + } step.WriteString(" with:\n") step.WriteString(" script: |\n") step.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index 9f1e4ef4edf..f9205e46222 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1566,6 +1566,45 @@ func TestRepoMemoryFormatJSONPushStepEnvVar(t *testing.T) { }) } +func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { + toolsMap := map[string]any{ + "repo-memory": map[string]any{ + "branch-name": "memory/notes", + "validation": map[string]any{ + "script": "if (!fs.existsSync(path.join(memoryRoot, 'state.json'))) throw new Error('missing state');", + "timeout": 7, + }, + }, + } + + toolsConfig, err := ParseToolsConfig(toolsMap) + require.NoError(t, err) + + compiler := NewCompiler() + config, err := compiler.extractRepoMemoryConfig(toolsConfig, "") + require.NoError(t, err) + require.NotNil(t, config) + require.Len(t, config.Memories, 1) + require.NotNil(t, config.Memories[0].Validation) + assert.Equal(t, 7, config.Memories[0].Validation.Timeout) + assert.Contains(t, config.Memories[0].Validation.Script, "missing state") + + data := &WorkflowData{RepoMemoryConfig: config} + var upload strings.Builder + generateRepoMemoryArtifactUpload(&upload, data, getActionPin) + uploadYAML := upload.String() + assert.Contains(t, uploadYAML, "Validate repo-memory domain content (default)") + assert.Contains(t, uploadYAML, "VALIDATION_SCRIPT_B64:") + assert.Contains(t, uploadYAML, "steps.validate_repo_memory_default.outcome == 'success'") + + pushJob, err := compiler.buildPushRepoMemoryJob(data, false) + require.NoError(t, err) + require.NotNil(t, pushJob) + pushYAML := strings.Join(pushJob.Steps, "\n") + assert.Contains(t, pushYAML, "VALIDATION_SCRIPT_B64:") + assert.Contains(t, pushYAML, "VALIDATION_TIMEOUT_SECONDS: 7") +} + // TestValidateFileGlobPatterns tests the validateFileGlobPatterns function func TestValidateFileGlobPatterns(t *testing.T) { tests := []struct { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 181b65f17c2..0ddfd9ee662 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -191,13 +191,23 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { if data.RepoMemoryConfig != nil && len(data.RepoMemoryConfig.Memories) > 0 { var memories []map[string]any for _, memory := range data.RepoMemoryConfig.Memories { - memories = append(memories, map[string]any{ + memoryConfig := map[string]any{ "id": memory.ID, "dir": constants.TmpRepoMemoryDir + memory.ID, "max_file_size": memory.MaxFileSize, "max_patch_size": memory.MaxPatchSize, "max_file_count": memory.MaxFileCount, - }) + } + if memory.FormatJSON { + memoryConfig["format_json"] = true + } + if memory.Validation != nil { + memoryConfig["validation"] = map[string]any{ + "script": memory.Validation.Script, + "timeout": memory.Validation.Timeout, + } + } + memories = append(memories, memoryConfig) } safeOutputsConfig["push_repo_memory"] = map[string]any{ "memories": memories, From 4656c47ba49f990f57842ec453c91fe92fcc06e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:33:34 +0000 Subject: [PATCH 03/13] Fix memory validation typecheck Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/memory_custom_validation.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs index af5e0a265f4..2ee7a19bc3d 100644 --- a/actions/setup/js/memory_custom_validation.cjs +++ b/actions/setup/js/memory_custom_validation.cjs @@ -193,10 +193,11 @@ ${script} maxBuffer: MAX_VALIDATION_OUTPUT_BYTES * 2, windowsHide: true, }); + const spawnError = /** @type {NodeJS.ErrnoException | undefined} */ result.error; return { ok: result.status === 0 && !result.error, exitCode: result.status, - timedOut: Boolean(result.error && /** @type {NodeJS.ErrnoException} */ result.error.code === "ETIMEDOUT"), + timedOut: Boolean(spawnError && spawnError.code === "ETIMEDOUT"), stdout: boundedOutput(result.stdout), stderr: boundedOutput(result.stderr || (result.error ? getErrorMessage(result.error) : "")), }; From 0b40cd9d0ecb57be2be4669addba1e0e1c10cd47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:35:34 +0000 Subject: [PATCH 04/13] Stabilize validation timeout detection Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/memory_custom_validation.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs index 2ee7a19bc3d..dd2e01844f4 100644 --- a/actions/setup/js/memory_custom_validation.cjs +++ b/actions/setup/js/memory_custom_validation.cjs @@ -193,11 +193,11 @@ ${script} maxBuffer: MAX_VALIDATION_OUTPUT_BYTES * 2, windowsHide: true, }); - const spawnError = /** @type {NodeJS.ErrnoException | undefined} */ result.error; + const spawnErrorCode = result.error ? Reflect.get(result.error, "code") : undefined; return { ok: result.status === 0 && !result.error, exitCode: result.status, - timedOut: Boolean(spawnError && spawnError.code === "ETIMEDOUT"), + timedOut: spawnErrorCode === "ETIMEDOUT", stdout: boundedOutput(result.stdout), stderr: boundedOutput(result.stderr || (result.error ? getErrorMessage(result.error) : "")), }; From f193b5764a21e9166a425b9b00e4a551e80fef88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:54:52 +0000 Subject: [PATCH 05/13] Plan compiler validation reporting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ai-moderator.lock.yml | 25 +++++++-- .../daily-go-test-parallelizer.lock.yml | 55 +++++++++++++++---- .github/workflows/purelock.lock.yml | 55 +++++++++++++++---- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 4de8298000d..445798f5f25 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -1074,18 +1074,35 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types + id: validate_cache_memory_default if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/cache-memory + MEMORY_ID: default + ALLOWED_EXTENSIONS: '[".json"]' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const allowedExtensions = [".json"]; - const result = validateMemoryFiles('/tmp/gh-aw/cache-memory', 'cache', allowedExtensions); - if (!result.valid) { - core.setFailed(`File type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); + const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); + const memoryDir = process.env.MEMORY_DIR; + const memoryId = process.env.MEMORY_ID || 'default'; + clearValidationMarker('cache', memoryId); + let failed = false; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); + if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } + } + if (process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); + if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); + if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); + if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } } + if (!failed) writeValidationMarker('cache', memoryId); - name: Upload agent artifacts if: always() continue-on-error: true diff --git a/.github/workflows/daily-go-test-parallelizer.lock.yml b/.github/workflows/daily-go-test-parallelizer.lock.yml index df7fd13a987..7b10afc2752 100644 --- a/.github/workflows/daily-go-test-parallelizer.lock.yml +++ b/.github/workflows/daily-go-test-parallelizer.lock.yml @@ -990,18 +990,35 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types + id: validate_cache_memory_default if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/cache-memory + MEMORY_ID: default + ALLOWED_EXTENSIONS: '[".json"]' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const allowedExtensions = [".json"]; - const result = validateMemoryFiles('/tmp/gh-aw/cache-memory', 'cache', allowedExtensions); - if (!result.valid) { - core.setFailed(`File type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); + const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); + const memoryDir = process.env.MEMORY_DIR; + const memoryId = process.env.MEMORY_ID || 'default'; + clearValidationMarker('cache', memoryId); + let failed = false; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); + if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } + } + if (process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); + if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); + if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); + if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } } + if (!failed) writeValidationMarker('cache', memoryId); - name: Check cache-memory git integrity if: always() continue-on-error: true @@ -1010,7 +1027,7 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/check_cache_memory_git_integrity.sh" - name: Upload cache-memory data as artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() + if: always() && steps.validate_cache_memory_default.outcome == 'success' with: name: cache-memory include-hidden-files: true @@ -2175,21 +2192,37 @@ jobs: else echo "has_content=false" >> "$GITHUB_OUTPUT" fi - - name: Validate cache-memory file types (default) + - name: Validate cache-memory before save (default) + id: validate_cache_memory_default if: steps.check_cache_default.outputs.has_content == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/cache-memory + MEMORY_ID: default + ALLOWED_EXTENSIONS: '[".json"]' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const allowedExtensions = [".json"]; - const result = validateMemoryFiles('/tmp/gh-aw/cache-memory', 'cache', allowedExtensions); - if (!result.valid) { - core.setFailed(`File type validation failed: Found ${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); + const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); + const memoryDir = process.env.MEMORY_DIR; + const memoryId = process.env.MEMORY_ID || 'default'; + let failed = false; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); + if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } + } + if (process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); + if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); + if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); + if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } } + if (failed) process.exitCode = 1; - name: Save cache-memory to cache (default) - if: steps.check_cache_default.outputs.has_content == 'true' + if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_default.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} diff --git a/.github/workflows/purelock.lock.yml b/.github/workflows/purelock.lock.yml index e324e523e1e..ca1c81af4e0 100644 --- a/.github/workflows/purelock.lock.yml +++ b/.github/workflows/purelock.lock.yml @@ -1075,18 +1075,35 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types + id: validate_cache_memory_default if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/cache-memory + MEMORY_ID: default + ALLOWED_EXTENSIONS: '[".json"]' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const allowedExtensions = [".json"]; - const result = validateMemoryFiles('/tmp/gh-aw/cache-memory', 'cache', allowedExtensions); - if (!result.valid) { - core.setFailed(`File type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); + const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); + const memoryDir = process.env.MEMORY_DIR; + const memoryId = process.env.MEMORY_ID || 'default'; + clearValidationMarker('cache', memoryId); + let failed = false; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); + if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } + } + if (process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); + if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); + if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); + if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } } + if (!failed) writeValidationMarker('cache', memoryId); - name: Check cache-memory git integrity if: always() continue-on-error: true @@ -1095,7 +1112,7 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/check_cache_memory_git_integrity.sh" - name: Upload cache-memory data as artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() + if: always() && steps.validate_cache_memory_default.outcome == 'success' with: name: cache-memory include-hidden-files: true @@ -2371,21 +2388,37 @@ jobs: else echo "has_content=false" >> "$GITHUB_OUTPUT" fi - - name: Validate cache-memory file types (default) + - name: Validate cache-memory before save (default) + id: validate_cache_memory_default if: steps.check_cache_default.outputs.has_content == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MEMORY_DIR: /tmp/gh-aw/cache-memory + MEMORY_ID: default + ALLOWED_EXTENSIONS: '[".json"]' with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const allowedExtensions = [".json"]; - const result = validateMemoryFiles('/tmp/gh-aw/cache-memory', 'cache', allowedExtensions); - if (!result.valid) { - core.setFailed(`File type validation failed: Found ${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); + const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); + const memoryDir = process.env.MEMORY_DIR; + const memoryId = process.env.MEMORY_ID || 'default'; + let failed = false; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); + if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } + } + if (process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); + if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); + if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); + if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } } + if (failed) process.exitCode = 1; - name: Save cache-memory to cache (default) - if: steps.check_cache_default.outputs.has_content == 'true' + if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_default.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} From 2f496b2d12af4deba9af7454b5d76b7ac341884f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:10:28 +0000 Subject: [PATCH 06/13] Detect memory validation script changes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler.go | 2 +- .../compiler_threat_detection_formal_test.go | 22 ++++---- pkg/workflow/compiler_yaml_header.go | 1 + pkg/workflow/safe_update_enforcement.go | 44 +++++++++++++-- pkg/workflow/safe_update_enforcement_test.go | 44 ++++++++++++--- pkg/workflow/safe_update_manifest.go | 56 ++++++++++++++++--- pkg/workflow/safe_update_manifest_test.go | 21 +++++++ 7 files changed, 158 insertions(+), 32 deletions(-) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index e67e0bf7b41..05a8154adf5 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -560,7 +560,7 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath // file is written and the agent receives the actionable guidance embedded in the warning. if safeUpdateEnabled { currentHasPR, currentHasPRTarget := extractPullRequestEventPresenceFromOnField(workflowData.RawFrontmatter["on"]) - if enforceErr := EnforceSafeUpdate(oldManifest, bodySecrets, bodyActions, workflowData.Redirect, oldHasPR, oldHasPRTarget, currentHasPR, currentHasPRTarget); enforceErr != nil { + if enforceErr := EnforceSafeUpdate(oldManifest, bodySecrets, bodyActions, workflowData.Redirect, oldHasPR, oldHasPRTarget, currentHasPR, currentHasPRTarget, collectMemoryValidationScripts(workflowData)); enforceErr != nil { warningMsg := buildSafeUpdateWarningPrompt(enforceErr.Error()) c.AddSafeUpdateWarning(warningMsg) fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", enforceErr.Error())) diff --git a/pkg/workflow/compiler_threat_detection_formal_test.go b/pkg/workflow/compiler_threat_detection_formal_test.go index e044d35428e..560991561a8 100644 --- a/pkg/workflow/compiler_threat_detection_formal_test.go +++ b/pkg/workflow/compiler_threat_detection_formal_test.go @@ -9,47 +9,47 @@ import ( ) func TestFormal_CTR016_NilManifestSkipsEnforcement(t *testing.T) { - err := EnforceSafeUpdate(nil, []string{"MY_SECRET"}, []string{"evil-org/action@deadbeef # v1"}, "", false, false, false, false) + err := EnforceSafeUpdate(nil, []string{"MY_SECRET"}, []string{"evil-org/action@deadbeef # v1"}, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_EmptyManifestRejectsNewSecret(t *testing.T) { - err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"MY_SECRET"}, nil, "", false, false, false, false) + err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"MY_SECRET"}, nil, "", false, false, false, false, nil) require.Error(t, err) require.ErrorContains(t, err, "MY_SECRET") } func TestFormal_CTR016_GitHubTokenExempt_BareForm(t *testing.T) { - err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"GITHUB_TOKEN"}, nil, "", false, false, false, false) + err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"GITHUB_TOKEN"}, nil, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_GitHubTokenExempt_PrefixedForm(t *testing.T) { - err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"secrets.GITHUB_TOKEN"}, nil, "", false, false, false, false) + err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"secrets.GITHUB_TOKEN"}, nil, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_GhAwInternalSecretExempt(t *testing.T) { - err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"GH_AW_GITHUB_TOKEN"}, nil, "", false, false, false, false) + err := EnforceSafeUpdate(&GHAWManifest{Version: currentGHAWManifestVersion}, []string{"GH_AW_GITHUB_TOKEN"}, nil, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_SecretPrefixNormalization(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Secrets: []string{"MY_SECRET"}} - err := EnforceSafeUpdate(manifest, []string{"secrets.MY_SECRET"}, nil, "", false, false, false, false) + err := EnforceSafeUpdate(manifest, []string{"secrets.MY_SECRET"}, nil, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_NewActionDriftRejected(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Actions: []GHAWManifestAction{{Repo: "actions/checkout", SHA: "abc1234", Version: "v4"}}} - err := EnforceSafeUpdate(manifest, nil, []string{"actions/checkout@abc1234 # v4", "evil-org/steal@deadbeef # v1"}, "", false, false, false, false) + err := EnforceSafeUpdate(manifest, nil, []string{"actions/checkout@abc1234 # v4", "evil-org/steal@deadbeef # v1"}, "", false, false, false, false, nil) require.Error(t, err) require.ErrorContains(t, err, "evil-org/steal") } func TestFormal_CTR016_RemovedActionDriftRejected(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Actions: []GHAWManifestAction{{Repo: "my-org/approved-action", SHA: "abc1234", Version: "v1"}}} - err := EnforceSafeUpdate(manifest, nil, []string{}, "", false, false, false, false) + err := EnforceSafeUpdate(manifest, nil, []string{}, "", false, false, false, false, nil) require.Error(t, err) require.ErrorContains(t, err, "Previously-approved action") require.ErrorContains(t, err, "my-org/approved-action") @@ -57,19 +57,19 @@ func TestFormal_CTR016_RemovedActionDriftRejected(t *testing.T) { func TestFormal_CTR016_KnownActionPinUpdateAllowed(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Actions: []GHAWManifestAction{{Repo: "my-org/action", SHA: "abc1234", Version: "v1"}}} - err := EnforceSafeUpdate(manifest, nil, []string{"my-org/action@def5678 # v2"}, "", false, false, false, false) + err := EnforceSafeUpdate(manifest, nil, []string{"my-org/action@def5678 # v2"}, "", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_RedirectWhitespaceNormalization(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Redirect: "owner/repo/workflows/new.md@main"} - err := EnforceSafeUpdate(manifest, nil, nil, " owner/repo/workflows/new.md@main ", false, false, false, false) + err := EnforceSafeUpdate(manifest, nil, nil, " owner/repo/workflows/new.md@main ", false, false, false, false, nil) require.NoError(t, err) } func TestFormal_CTR016_RedirectChangeRejected(t *testing.T) { manifest := &GHAWManifest{Version: currentGHAWManifestVersion, Redirect: "owner/repo/workflows/old.md@main"} - err := EnforceSafeUpdate(manifest, nil, nil, "owner/repo/workflows/new.md@main", false, false, false, false) + err := EnforceSafeUpdate(manifest, nil, nil, "owner/repo/workflows/new.md@main", false, false, false, false, nil) require.Error(t, err) require.ErrorContains(t, err, "New redirect configured") require.ErrorContains(t, err, "Previously-approved redirect removed") diff --git a/pkg/workflow/compiler_yaml_header.go b/pkg/workflow/compiler_yaml_header.go index 3bf67c9eb88..29c74aa7e5f 100644 --- a/pkg/workflow/compiler_yaml_header.go +++ b/pkg/workflow/compiler_yaml_header.go @@ -65,6 +65,7 @@ func (c *Compiler) generateWorkflowHeader(yaml *strings.Builder, data *WorkflowD // skills detected at compile time so that subsequent compilations can perform safe update // enforcement. manifest := NewGHAWManifest(secrets, actions, data.ActionResolutionFailures, data.DockerImagePins, data.Redirect, data.Skills, data.RawFrontmatter["on"]) + manifest.MemoryValidationScripts = collectMemoryValidationScripts(data) if manifestJSON, err := manifest.ToJSON(); err == nil { fmt.Fprintf(yaml, "# gh-aw-manifest: %s\n", manifestJSON) } else { diff --git a/pkg/workflow/safe_update_enforcement.go b/pkg/workflow/safe_update_enforcement.go index 45819236c97..2bd70f032dd 100644 --- a/pkg/workflow/safe_update_enforcement.go +++ b/pkg/workflow/safe_update_enforcement.go @@ -50,7 +50,7 @@ var ghAwInternalSecrets = map[string]bool{ // e.g. "actions/checkout@abc1234 # v4". // // Returns a structured, actionable error when violations are found. -func EnforceSafeUpdate(manifest *GHAWManifest, secretNames []string, actionRefs []string, currentRedirect string, oldHasPullRequest bool, oldHasPullRequestTarget bool, currentHasPullRequest bool, currentHasPullRequestTarget bool) error { +func EnforceSafeUpdate(manifest *GHAWManifest, secretNames []string, actionRefs []string, currentRedirect string, oldHasPullRequest bool, oldHasPullRequestTarget bool, currentHasPullRequest bool, currentHasPullRequestTarget bool, currentMemoryValidationScripts []GHAWManifestMemoryValidationScript) error { if manifest == nil { // Lock file exists but predates the safe-updates feature (no gh-aw-manifest // section). Skip enforcement so legacy lock files are not flagged on upgrade. @@ -61,9 +61,10 @@ func EnforceSafeUpdate(manifest *GHAWManifest, secretNames []string, actionRefs secretViolations := collectSecretViolations(manifest, secretNames) addedActions, removedActions := collectActionViolations(manifest, actionRefs) addedRedirect, removedRedirect := collectRedirectViolations(manifest, currentRedirect) + memoryValidationScriptChanges := collectMemoryValidationScriptChanges(manifest, currentMemoryValidationScripts) pullRequestTargetEscalation := hasPullRequestTargetEscalation(oldHasPullRequest, oldHasPullRequestTarget, currentHasPullRequest, currentHasPullRequestTarget) - if len(secretViolations) == 0 && len(addedActions) == 0 && len(removedActions) == 0 && addedRedirect == "" && removedRedirect == "" && !pullRequestTargetEscalation { + if len(secretViolations) == 0 && len(addedActions) == 0 && len(removedActions) == 0 && addedRedirect == "" && removedRedirect == "" && len(memoryValidationScriptChanges) == 0 && !pullRequestTargetEscalation { safeUpdateLog.Printf("Safe update check passed (%d secret(s), %d action(s) verified)", len(secretNames), len(actionRefs)) return nil @@ -87,11 +88,15 @@ func EnforceSafeUpdate(manifest *GHAWManifest, secretNames []string, actionRefs if removedRedirect != "" { safeUpdateLog.Printf("Safe update violation: redirect removed: %s", removedRedirect) } + if len(memoryValidationScriptChanges) > 0 { + safeUpdateLog.Printf("Safe update violation: %d memory validation script change(s) detected: %s", + len(memoryValidationScriptChanges), strings.Join(memoryValidationScriptChanges, ", ")) + } if pullRequestTargetEscalation { safeUpdateLog.Print("Safe update violation: pull_request event converted to pull_request_target") } - return buildSafeUpdateError(secretViolations, addedActions, removedActions, addedRedirect, removedRedirect, pullRequestTargetEscalation) + return buildSafeUpdateError(secretViolations, addedActions, removedActions, addedRedirect, removedRedirect, pullRequestTargetEscalation, memoryValidationScriptChanges) } func hasPullRequestTargetEscalation(oldHasPullRequest bool, oldHasPullRequestTarget bool, currentHasPullRequest bool, currentHasPullRequestTarget bool) bool { @@ -265,9 +270,36 @@ func collectRedirectViolations(manifest *GHAWManifest, currentRedirect string) ( return current, knownRedirect } +func collectMemoryValidationScriptChanges(manifest *GHAWManifest, current []GHAWManifestMemoryValidationScript) []string { + previous := make(map[string]string, len(manifest.MemoryValidationScripts)) + for _, script := range manifest.MemoryValidationScripts { + previous[script.Memory] = script.SHA256 + } + currentByMemory := make(map[string]string, len(current)) + for _, script := range current { + currentByMemory[script.Memory] = script.SHA256 + } + var changes []string + for memory, hash := range currentByMemory { + switch previousHash, ok := previous[memory]; { + case !ok: + changes = append(changes, memory+" (added)") + case previousHash != hash: + changes = append(changes, memory+" (modified)") + } + } + for memory := range previous { + if _, ok := currentByMemory[memory]; !ok { + changes = append(changes, memory+" (removed)") + } + } + sort.Strings(changes) + return changes +} + // buildSafeUpdateError creates a clear, structured error message that names the // offending secrets, actions, and redirects and tells the user how to remediate. -func buildSafeUpdateError(secretViolations, addedActions, removedActions []string, addedRedirect, removedRedirect string, hasPullRequestTargetEscalation bool) error { +func buildSafeUpdateError(secretViolations, addedActions, removedActions []string, addedRedirect, removedRedirect string, hasPullRequestTargetEscalation bool, memoryValidationScriptChanges []string) error { var sb strings.Builder sb.WriteString("safe update mode detected unapproved changes\n") @@ -291,6 +323,10 @@ func buildSafeUpdateError(secretViolations, addedActions, removedActions []strin sb.WriteString("\nPreviously-approved redirect removed:\n - ") sb.WriteString(removedRedirect) } + if len(memoryValidationScriptChanges) > 0 { + sb.WriteString("\nMemory validation script changes:\n - ") + sb.WriteString(strings.Join(memoryValidationScriptChanges, "\n - ")) + } if hasPullRequestTargetEscalation { sb.WriteString("\nEvent trigger security escalation:\n - pull_request was converted to pull_request_target") } diff --git a/pkg/workflow/safe_update_enforcement_test.go b/pkg/workflow/safe_update_enforcement_test.go index 20d66c7f66f..65394897c50 100644 --- a/pkg/workflow/safe_update_enforcement_test.go +++ b/pkg/workflow/safe_update_enforcement_test.go @@ -363,7 +363,7 @@ func TestEnforceSafeUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := EnforceSafeUpdate(tt.manifest, tt.secretNames, tt.actionRefs, tt.redirect, tt.oldHasPR, tt.oldHasPRTarget, tt.currentHasPR, tt.currentHasPRTarget) + err := EnforceSafeUpdate(tt.manifest, tt.secretNames, tt.actionRefs, tt.redirect, tt.oldHasPR, tt.oldHasPRTarget, tt.currentHasPR, tt.currentHasPRTarget, nil) if tt.wantErr { require.Error(t, err, "expected safe update enforcement error") for _, msg := range tt.wantErrMsgs { @@ -379,7 +379,7 @@ func TestEnforceSafeUpdate(t *testing.T) { func TestBuildSafeUpdateError(t *testing.T) { t.Run("secrets only", func(t *testing.T) { violations := []string{"NEW_SECRET", "ANOTHER_SECRET"} - err := buildSafeUpdateError(violations, nil, nil, "", "", false) + err := buildSafeUpdateError(violations, nil, nil, "", "", false, nil) require.Error(t, err, "should return an error") msg := err.Error() @@ -390,7 +390,7 @@ func TestBuildSafeUpdateError(t *testing.T) { }) t.Run("added actions only", func(t *testing.T) { - err := buildSafeUpdateError(nil, []string{"evil-org/bad-action"}, nil, "", "", false) + err := buildSafeUpdateError(nil, []string{"evil-org/bad-action"}, nil, "", "", false, nil) require.Error(t, err, "should return an error") msg := err.Error() assert.Contains(t, msg, "evil-org/bad-action", "action in message") @@ -398,7 +398,7 @@ func TestBuildSafeUpdateError(t *testing.T) { }) t.Run("removed actions only", func(t *testing.T) { - err := buildSafeUpdateError(nil, nil, []string{"actions/setup-node"}, "", "", false) + err := buildSafeUpdateError(nil, nil, []string{"actions/setup-node"}, "", "", false, nil) require.Error(t, err, "should return an error") msg := err.Error() assert.Contains(t, msg, "actions/setup-node", "action in message") @@ -406,7 +406,7 @@ func TestBuildSafeUpdateError(t *testing.T) { }) t.Run("added redirect only", func(t *testing.T) { - err := buildSafeUpdateError(nil, nil, nil, "owner/repo/workflows/new.md@main", "", false) + err := buildSafeUpdateError(nil, nil, nil, "owner/repo/workflows/new.md@main", "", false, nil) require.Error(t, err, "should return an error") msg := err.Error() assert.Contains(t, msg, "New redirect configured", "added redirect section header in message") @@ -414,7 +414,7 @@ func TestBuildSafeUpdateError(t *testing.T) { }) t.Run("removed redirect only", func(t *testing.T) { - err := buildSafeUpdateError(nil, nil, nil, "", "owner/repo/workflows/old.md@main", false) + err := buildSafeUpdateError(nil, nil, nil, "", "owner/repo/workflows/old.md@main", false, nil) require.Error(t, err, "should return an error") msg := err.Error() assert.Contains(t, msg, "Previously-approved redirect removed", "removed redirect section header in message") @@ -429,6 +429,7 @@ func TestBuildSafeUpdateError(t *testing.T) { "owner/repo/workflows/new.md@main", "owner/repo/workflows/old.md@main", false, + nil, ) require.Error(t, err, "should return an error") msg := err.Error() @@ -440,7 +441,7 @@ func TestBuildSafeUpdateError(t *testing.T) { }) t.Run("pull_request to pull_request_target escalation", func(t *testing.T) { - err := buildSafeUpdateError(nil, nil, nil, "", "", true) + err := buildSafeUpdateError(nil, nil, nil, "", "", true, nil) require.Error(t, err, "should return an error") msg := err.Error() assert.Contains(t, msg, "Event trigger security escalation", "event escalation section in message") @@ -448,6 +449,35 @@ func TestBuildSafeUpdateError(t *testing.T) { }) } +func TestMemoryValidationScriptChangesRequireSafeUpdateReview(t *testing.T) { + manifest := &GHAWManifest{ + MemoryValidationScripts: []GHAWManifestMemoryValidationScript{ + {Memory: "cache-memory:unchanged", SHA256: "same"}, + {Memory: "repo-memory:modified", SHA256: "before"}, + {Memory: "repo-memory:removed", SHA256: "removed"}, + }, + } + current := []GHAWManifestMemoryValidationScript{ + {Memory: "cache-memory:added", SHA256: "added"}, + {Memory: "cache-memory:unchanged", SHA256: "same"}, + {Memory: "repo-memory:modified", SHA256: "after"}, + } + + changes := collectMemoryValidationScriptChanges(manifest, current) + assert.Equal(t, []string{ + "cache-memory:added (added)", + "repo-memory:modified (modified)", + "repo-memory:removed (removed)", + }, changes) + + err := EnforceSafeUpdate(manifest, nil, nil, "", false, false, false, false, current) + require.Error(t, err) + require.ErrorContains(t, err, "Memory validation script changes") + require.ErrorContains(t, err, "cache-memory:added (added)") + require.ErrorContains(t, err, "repo-memory:modified (modified)") + require.ErrorContains(t, err, "repo-memory:removed (removed)") +} + func TestExtractPullRequestEventPresence(t *testing.T) { t.Run("from on field map", func(t *testing.T) { hasPR, hasPRTarget := extractPullRequestEventPresenceFromOnField(map[string]any{ diff --git a/pkg/workflow/safe_update_manifest.go b/pkg/workflow/safe_update_manifest.go index 9da360e6b3a..25e0f238c06 100644 --- a/pkg/workflow/safe_update_manifest.go +++ b/pkg/workflow/safe_update_manifest.go @@ -1,6 +1,8 @@ package workflow import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "regexp" @@ -44,21 +46,29 @@ type GHAWManifestResolutionFailure struct { ErrorType string `json:"error_type"` } +// GHAWManifestMemoryValidationScript represents a custom memory validation +// script without storing its potentially sensitive source in the lock file. +type GHAWManifestMemoryValidationScript struct { + Memory string `json:"memory"` + SHA256 string `json:"sha256"` +} + // GHAWManifest is the single-line JSON payload embedded as a "# gh-aw-manifest: ..." // comment in generated lock files. It records the secrets, external actions, and // container images that were detected at the time the lock file was last compiled // so that subsequent compilations can detect newly introduced secrets when safe // update mode is enabled. type GHAWManifest struct { - Version int `json:"version"` - Secrets []string `json:"secrets"` - Actions []GHAWManifestAction `json:"actions"` - Skills []string `json:"skills,omitempty"` // frontmatter skill specs (owner/repo@sha or owner/repo/skill/path@sha), sorted - ResolutionFailures []GHAWManifestResolutionFailure `json:"resolution_failures,omitempty"` // unresolved action-ref pinning failures - Containers []GHAWManifestContainer `json:"containers,omitempty"` // container images used, with digest when available - Redirect string `json:"redirect,omitempty"` // frontmatter redirect target for moved workflows - HasPullRequest bool `json:"has_pull_request,omitempty"` // whether on: includes pull_request - HasPullRequestTarget bool `json:"has_pull_request_target,omitempty"` // whether on: includes pull_request_target + Version int `json:"version"` + Secrets []string `json:"secrets"` + Actions []GHAWManifestAction `json:"actions"` + Skills []string `json:"skills,omitempty"` // frontmatter skill specs (owner/repo@sha or owner/repo/skill/path@sha), sorted + ResolutionFailures []GHAWManifestResolutionFailure `json:"resolution_failures,omitempty"` // unresolved action-ref pinning failures + Containers []GHAWManifestContainer `json:"containers,omitempty"` // container images used, with digest when available + Redirect string `json:"redirect,omitempty"` // frontmatter redirect target for moved workflows + HasPullRequest bool `json:"has_pull_request,omitempty"` // whether on: includes pull_request + HasPullRequestTarget bool `json:"has_pull_request_target,omitempty"` // whether on: includes pull_request_target + MemoryValidationScripts []GHAWManifestMemoryValidationScript `json:"memory_validation_scripts,omitempty"` // custom repo/cache memory validation scripts, hashed } // NewGHAWManifest builds a GHAWManifest from the raw secret names, action reference @@ -170,6 +180,34 @@ func detectPullRequestEvents(onField any) (hasPR bool, hasPRTarget bool) { return hasPR, hasPRTarget } +func collectMemoryValidationScripts(data *WorkflowData) []GHAWManifestMemoryValidationScript { + var scripts []GHAWManifestMemoryValidationScript + add := func(kind, id string, validation *MemoryValidationConfig) { + if validation == nil || validation.Script == "" { + return + } + hash := sha256.Sum256([]byte(validation.Script)) + scripts = append(scripts, GHAWManifestMemoryValidationScript{ + Memory: kind + ":" + id, + SHA256: hex.EncodeToString(hash[:]), + }) + } + if data.RepoMemoryConfig != nil { + for _, memory := range data.RepoMemoryConfig.Memories { + add("repo-memory", memory.ID, memory.Validation) + } + } + if data.CacheMemoryConfig != nil { + for _, cache := range data.CacheMemoryConfig.Caches { + add("cache-memory", cache.ID, cache.Validation) + } + } + slices.SortFunc(scripts, func(a, b GHAWManifestMemoryValidationScript) int { + return strings.Compare(a.Memory, b.Memory) + }) + return scripts +} + // normalizeSecretName ensures a secret identifier is stored as a plain name // without the "secrets." prefix (e.g. "GITHUB_TOKEN" not "secrets.GITHUB_TOKEN"). // If the input already carries the "secrets." prefix it is stripped; otherwise diff --git a/pkg/workflow/safe_update_manifest_test.go b/pkg/workflow/safe_update_manifest_test.go index 716efaad14c..ead8a8dbd9c 100644 --- a/pkg/workflow/safe_update_manifest_test.go +++ b/pkg/workflow/safe_update_manifest_test.go @@ -210,6 +210,27 @@ func TestNewGHAWManifest(t *testing.T) { } } +func TestCollectMemoryValidationScripts(t *testing.T) { + data := &WorkflowData{ + RepoMemoryConfig: &RepoMemoryConfig{Memories: []RepoMemoryEntry{ + {ID: "repo", Validation: &MemoryValidationConfig{Script: "repo validation"}}, + }}, + CacheMemoryConfig: &CacheMemoryConfig{Caches: []CacheMemoryEntry{ + {ID: "cache", Validation: &MemoryValidationConfig{Script: "cache validation"}}, + {ID: "unvalidated"}, + }}, + } + + scripts := collectMemoryValidationScripts(data) + + require.Len(t, scripts, 2) + assert.Equal(t, "cache-memory:cache", scripts[0].Memory) + assert.Equal(t, "repo-memory:repo", scripts[1].Memory) + assert.Len(t, scripts[0].SHA256, 64) + assert.Len(t, scripts[1].SHA256, 64) + assert.NotEqual(t, scripts[0].SHA256, scripts[1].SHA256) +} + func TestNewGHAWManifestContainerDigest(t *testing.T) { containers := []GHAWManifestContainer{ { From dfa815cdc35d1e987cdb21441888b134acd00a59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:42:53 +0000 Subject: [PATCH 07/13] Fix memory validation persistence gates Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/ai-moderator.lock.yml | 2 +- .../daily-go-test-parallelizer.lock.yml | 8 +-- .github/workflows/purelock.lock.yml | 8 +-- actions/setup/js/memory_custom_validation.cjs | 59 ++++++++++++++++++- .../js/memory_custom_validation.test.cjs | 16 +++++ .../content/docs/reference/cache-memory.md | 2 +- .../src/content/docs/reference/repo-memory.md | 2 +- pkg/workflow/cache.go | 2 +- pkg/workflow/cache_memory_syntax_test.go | 15 ++++- pkg/workflow/memory_validation_config.go | 11 ++++ pkg/workflow/memory_validation_config_test.go | 22 +++++++ pkg/workflow/repo_memory.go | 2 +- pkg/workflow/repo_memory_test.go | 11 +++- 13 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 pkg/workflow/memory_validation_config_test.go diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 445798f5f25..9f2a9ed87e9 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -1074,7 +1074,7 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types - id: validate_cache_memory_default + id: validate_cache_memory_64656661756c74 if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: diff --git a/.github/workflows/daily-go-test-parallelizer.lock.yml b/.github/workflows/daily-go-test-parallelizer.lock.yml index 7b10afc2752..ff0bce49584 100644 --- a/.github/workflows/daily-go-test-parallelizer.lock.yml +++ b/.github/workflows/daily-go-test-parallelizer.lock.yml @@ -990,7 +990,7 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types - id: validate_cache_memory_default + id: validate_cache_memory_64656661756c74 if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -1027,7 +1027,7 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/check_cache_memory_git_integrity.sh" - name: Upload cache-memory data as artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() && steps.validate_cache_memory_default.outcome == 'success' + if: always() && steps.validate_cache_memory_64656661756c74.outcome == 'success' with: name: cache-memory include-hidden-files: true @@ -2193,7 +2193,7 @@ jobs: echo "has_content=false" >> "$GITHUB_OUTPUT" fi - name: Validate cache-memory before save (default) - id: validate_cache_memory_default + id: validate_cache_memory_64656661756c74 if: steps.check_cache_default.outputs.has_content == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -2222,7 +2222,7 @@ jobs: } if (failed) process.exitCode = 1; - name: Save cache-memory to cache (default) - if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_default.outcome == 'success' + if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_64656661756c74.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} diff --git a/.github/workflows/purelock.lock.yml b/.github/workflows/purelock.lock.yml index ca1c81af4e0..3b6656b4e6a 100644 --- a/.github/workflows/purelock.lock.yml +++ b/.github/workflows/purelock.lock.yml @@ -1075,7 +1075,7 @@ jobs: GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" - name: Validate cache-memory file types - id: validate_cache_memory_default + id: validate_cache_memory_64656661756c74 if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -1112,7 +1112,7 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/check_cache_memory_git_integrity.sh" - name: Upload cache-memory data as artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() && steps.validate_cache_memory_default.outcome == 'success' + if: always() && steps.validate_cache_memory_64656661756c74.outcome == 'success' with: name: cache-memory include-hidden-files: true @@ -2389,7 +2389,7 @@ jobs: echo "has_content=false" >> "$GITHUB_OUTPUT" fi - name: Validate cache-memory before save (default) - id: validate_cache_memory_default + id: validate_cache_memory_64656661756c74 if: steps.check_cache_default.outputs.has_content == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -2418,7 +2418,7 @@ jobs: } if (failed) process.exitCode = 1; - name: Save cache-memory to cache (default) - if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_default.outcome == 'success' + if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_64656661756c74.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ github.run_id }} diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs index dd2e01844f4..36741609eca 100644 --- a/actions/setup/js/memory_custom_validation.cjs +++ b/actions/setup/js/memory_custom_validation.cjs @@ -1,6 +1,7 @@ // @ts-check const childProcess = require("child_process"); +const crypto = require("crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -125,6 +126,38 @@ function sanitizedValidationEnv(sourceEnv) { return env; } +/** + * @param {string} dirPath + */ +function memoryTreeDigest(dirPath) { + const hash = crypto.createHash("sha256"); + + /** + * @param {string} currentDir + */ + function visit(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, "/"); + if (entry.isDirectory()) { + hash.update(`directory\0${relativePath}\0`); + visit(fullPath); + } else if (entry.isFile()) { + hash.update(`file\0${relativePath}\0`); + hash.update(fs.readFileSync(fullPath)); + } else if (entry.isSymbolicLink()) { + hash.update(`symlink\0${relativePath}\0${fs.readlinkSync(fullPath)}\0`); + } else { + hash.update(`other\0${relativePath}\0`); + } + } + } + + visit(dirPath); + return hash.digest("hex"); +} + /** * @param {{ * script?: string, @@ -154,6 +187,18 @@ function runCustomMemoryValidation(options) { const timeoutSeconds = typeof rawTimeoutSeconds === "number" && Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 ? Math.floor(rawTimeoutSeconds) : DEFAULT_VALIDATION_TIMEOUT_SECONDS; const timeoutMs = timeoutSeconds * 1000; const memoryId = options.memoryId || "default"; + let beforeDigest; + try { + beforeDigest = memoryTreeDigest(options.memoryDir); + } catch (error) { + return { + ok: false, + exitCode: null, + timedOut: false, + stdout: "", + stderr: `Unable to snapshot memory before custom validation: ${getErrorMessage(error)}`, + }; + } const validationDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-memory-validation-")); const scriptPath = path.join(validationDir, "validator.cjs"); const wrapper = `"use strict"; @@ -194,12 +239,21 @@ ${script} windowsHide: true, }); const spawnErrorCode = result.error ? Reflect.get(result.error, "code") : undefined; + let memoryChanged = false; + let snapshotError = ""; + try { + memoryChanged = beforeDigest !== memoryTreeDigest(options.memoryDir); + } catch (error) { + snapshotError = `Unable to snapshot memory after custom validation: ${getErrorMessage(error)}`; + } + const stderr = boundedOutput(result.stderr || (result.error ? getErrorMessage(result.error) : "")); + const validationError = memoryChanged ? "Custom validation must not modify memory files" : snapshotError; return { - ok: result.status === 0 && !result.error, + ok: result.status === 0 && !result.error && !memoryChanged && !snapshotError, exitCode: result.status, timedOut: spawnErrorCode === "ETIMEDOUT", stdout: boundedOutput(result.stdout), - stderr: boundedOutput(result.stderr || (result.error ? getErrorMessage(result.error) : "")), + stderr: validationError ? boundedOutput(`${stderr}${stderr ? "\n" : ""}${validationError}`) : stderr, }; } finally { fs.rmSync(validationDir, { recursive: true, force: true }); @@ -211,6 +265,7 @@ module.exports = { clearValidationMarker, formatJSONFiles, getValidationMarkerPath, + memoryTreeDigest, runCustomMemoryValidation, writeValidationMarker, }; diff --git a/actions/setup/js/memory_custom_validation.test.cjs b/actions/setup/js/memory_custom_validation.test.cjs index c28b5e10ef4..25e9db6e056 100644 --- a/actions/setup/js/memory_custom_validation.test.cjs +++ b/actions/setup/js/memory_custom_validation.test.cjs @@ -52,6 +52,22 @@ describe("memory_custom_validation", () => { expect(result.stderr).toContain("domain schema failed"); }); + it("rejects validators that modify memory files", () => { + const statePath = path.join(tempDir, "state.json"); + fs.writeFileSync(statePath, JSON.stringify({ ok: true })); + + const result = runCustomMemoryValidation({ + script: `fs.writeFileSync(path.join(memoryRoot, "state.json"), JSON.stringify({ ok: false }));`, + memoryDir: tempDir, + memoryId: "default", + kind: "cache", + timeoutSeconds: 5, + }); + + expect(result.ok).toBe(false); + expect(result.stderr).toContain("must not modify memory files"); + }); + it("times out long-running validators", () => { const result = runCustomMemoryValidation({ script: "while (true) {}", diff --git a/docs/src/content/docs/reference/cache-memory.md b/docs/src/content/docs/reference/cache-memory.md index 8862783105a..897baf871ef 100644 --- a/docs/src/content/docs/reference/cache-memory.md +++ b/docs/src/content/docs/reference/cache-memory.md @@ -60,7 +60,7 @@ Use `validation.script` for domain-specific constraints such as schema checks, c Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"cache"`). The working directory is the cache root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). -Throw an exception, return `false`, time out, or exit nonzero to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output. +Throw an exception, return `false`, time out, exit nonzero, or modify a memory file to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output. ## Multiple Configurations diff --git a/docs/src/content/docs/reference/repo-memory.md b/docs/src/content/docs/reference/repo-memory.md index c099655206c..f9671da9d55 100644 --- a/docs/src/content/docs/reference/repo-memory.md +++ b/docs/src/content/docs/reference/repo-memory.md @@ -81,7 +81,7 @@ Use `validation.script` when generic storage limits are not enough. The script i Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"repo"`). The working directory is the memory root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). -Throw an exception, return `false`, time out, or exit nonzero to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output so agents can distinguish domain-schema validation from size/count checks. +Throw an exception, return `false`, time out, exit nonzero, or modify a memory file to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output so agents can distinguish domain-schema validation from size/count checks. Commits use the [GitHub GraphQL `createCommitOnBranch` mutation](https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch), so they are automatically **Verified** with GitHub's GPG key and satisfy rulesets that require signed commits. diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 108e0145538..d5ec0bc9c08 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -54,7 +54,7 @@ func cacheMemoryDirFor(cacheID string) string { } func cacheMemoryValidationStepID(cacheID string) string { - return strings.ReplaceAll("validate_cache_memory_"+cacheID, "-", "_") + return memoryValidationStepID("validate_cache_memory", cacheID) } func cacheHasValidationStep(cache CacheMemoryEntry) bool { diff --git a/pkg/workflow/cache_memory_syntax_test.go b/pkg/workflow/cache_memory_syntax_test.go index 0dc50320b31..72ce3ba1685 100644 --- a/pkg/workflow/cache_memory_syntax_test.go +++ b/pkg/workflow/cache_memory_syntax_test.go @@ -223,12 +223,12 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { validationYAML := validation.String() assert.Contains(t, validationYAML, "Validate cache-memory file types and domain content") assert.Contains(t, validationYAML, "VALIDATION_SCRIPT_B64:") - assert.Contains(t, validationYAML, "id: validate_cache_memory_default") + assert.Contains(t, validationYAML, "id: "+cacheMemoryValidationStepID("default")) var upload strings.Builder generateCacheMemoryArtifactUpload(&upload, data, getActionPin) uploadYAML := upload.String() - assert.Contains(t, uploadYAML, "steps.validate_cache_memory_default.outcome == 'success'") + assert.Contains(t, uploadYAML, "steps."+cacheMemoryValidationStepID("default")+".outcome == 'success'") job, err := compiler.buildUpdateCacheMemoryJob(data, true) require.NoError(t, err) @@ -236,5 +236,14 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { updateYAML := strings.Join(job.Steps, "\n") assert.Contains(t, updateYAML, "Validate cache-memory before save (default)") assert.Contains(t, updateYAML, "VALIDATION_TIMEOUT_SECONDS: 9") - assert.Contains(t, updateYAML, "steps.validate_cache_memory_default.outcome == 'success'") + assert.Contains(t, updateYAML, "steps."+cacheMemoryValidationStepID("default")+".outcome == 'success'") +} + +func TestCacheMemoryValidationStepIDsDoNotCollide(t *testing.T) { + hyphenID := cacheMemoryValidationStepID("my-cache") + underscoreID := cacheMemoryValidationStepID("my_cache") + + assert.NotEqual(t, hyphenID, underscoreID) + assert.Equal(t, "validate_cache_memory_6d792d6361636865", hyphenID) + assert.Equal(t, "validate_cache_memory_6d795f6361636865", underscoreID) } diff --git a/pkg/workflow/memory_validation_config.go b/pkg/workflow/memory_validation_config.go index 395ace5094d..b734a0a8740 100644 --- a/pkg/workflow/memory_validation_config.go +++ b/pkg/workflow/memory_validation_config.go @@ -3,6 +3,7 @@ package workflow import ( "encoding/base64" "fmt" + "math" "strconv" ) @@ -51,6 +52,12 @@ func parseMemoryValidationTimeout(value any, fieldPath string) (int, error) { case int: return validateMemoryValidationTimeout(v, fieldPath) case float64: + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + } + if v < 1 || v > 300 { + return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) + } return validateMemoryValidationTimeout(int(v), fieldPath) case uint64: if v > uint64(^uint(0)>>1) { @@ -94,3 +101,7 @@ func memoryValidationScriptBase64(config *MemoryValidationConfig) string { } return base64.StdEncoding.EncodeToString([]byte(config.Script)) } + +func memoryValidationStepID(prefix, memoryID string) string { + return fmt.Sprintf("%s_%x", prefix, memoryID) +} diff --git a/pkg/workflow/memory_validation_config_test.go b/pkg/workflow/memory_validation_config_test.go new file mode 100644 index 00000000000..63ff9be858b --- /dev/null +++ b/pkg/workflow/memory_validation_config_test.go @@ -0,0 +1,22 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMemoryValidationTimeoutRejectsFractionalValues(t *testing.T) { + _, err := parseMemoryValidationTimeout(1.9, "tools.cache-memory.validation.timeout") + + require.Error(t, err) + assert.Contains(t, err.Error(), "must be an integer number of seconds") +} + +func TestParseMemoryValidationTimeoutRejectsOutOfRangeFloat(t *testing.T) { + _, err := parseMemoryValidationTimeout(301.0, "tools.cache-memory.validation.timeout") + + require.Error(t, err) + assert.Contains(t, err.Error(), "must be between 1 and 300 seconds") +} diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index d3cd798ec7b..f11a29e02ff 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -427,7 +427,7 @@ func generateRepoMemoryArtifactUpload(builder *strings.Builder, data *WorkflowDa } func repoMemoryValidationStepID(memoryID string) string { - return strings.ReplaceAll("validate_repo_memory_"+memoryID, "-", "_") + return memoryValidationStepID("validate_repo_memory", memoryID) } // generateRepoMemorySteps generates git steps for the repo-memory configuration diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index f9205e46222..09e10809ac6 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1595,7 +1595,7 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { uploadYAML := upload.String() assert.Contains(t, uploadYAML, "Validate repo-memory domain content (default)") assert.Contains(t, uploadYAML, "VALIDATION_SCRIPT_B64:") - assert.Contains(t, uploadYAML, "steps.validate_repo_memory_default.outcome == 'success'") + assert.Contains(t, uploadYAML, "steps."+repoMemoryValidationStepID("default")+".outcome == 'success'") pushJob, err := compiler.buildPushRepoMemoryJob(data, false) require.NoError(t, err) @@ -1605,6 +1605,15 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { assert.Contains(t, pushYAML, "VALIDATION_TIMEOUT_SECONDS: 7") } +func TestRepoMemoryValidationStepIDsDoNotCollide(t *testing.T) { + hyphenID := repoMemoryValidationStepID("my-memory") + underscoreID := repoMemoryValidationStepID("my_memory") + + assert.NotEqual(t, hyphenID, underscoreID) + assert.Equal(t, "validate_repo_memory_6d792d6d656d6f7279", hyphenID) + assert.Equal(t, "validate_repo_memory_6d795f6d656d6f7279", underscoreID) +} + // TestValidateFileGlobPatterns tests the validateFileGlobPatterns function func TestValidateFileGlobPatterns(t *testing.T) { tests := []struct { From 7dea1f62e3a2cba8db9f29a7fc2ffdb39e3dc7a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:46:40 +0000 Subject: [PATCH 08/13] Merge main and refresh workflow lock Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../workflows/daily-pr-review-cursor.lock.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/daily-pr-review-cursor.lock.yml b/.github/workflows/daily-pr-review-cursor.lock.yml index b4ce938dc77..6ef523643af 100644 --- a/.github/workflows/daily-pr-review-cursor.lock.yml +++ b/.github/workflows/daily-pr-review-cursor.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f51fba46e48ad3e580030bf320ba97a7c033bfb2eed6bb72a787807047d8694a","body_hash":"bf1cf21f4246f5ae31a56495c39972606baec878241331ad7f299696d6230278","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.78","copilot-sdk":"1.0.8"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f51fba46e48ad3e580030bf320ba97a7c033bfb2eed6bb72a787807047d8694a","body_hash":"bf1cf21f4246f5ae31a56495c39972606baec878241331ad7f299696d6230278","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79","copilot-sdk":"1.0.8"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -129,7 +129,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-pr-review-cursor.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Mask OTLP telemetry headers @@ -140,8 +140,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.78" - GH_AW_INFO_AGENT_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" + GH_AW_INFO_AGENT_VERSION: "1.0.79" GH_AW_INFO_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -425,7 +425,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-pr-review-cursor.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Set runtime paths @@ -1078,7 +1078,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-pr-review-cursor.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1342,7 +1342,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-pr-review-cursor.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Download agent output artifact @@ -1645,7 +1645,7 @@ jobs: env: GH_AW_SETUP_WORKFLOW_NAME: "Daily PR Code Quality Review" GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/daily-pr-review-cursor.lock.yml@${{ github.ref }} - GH_AW_INFO_VERSION: "1.0.78" + GH_AW_INFO_VERSION: "1.0.79" GH_AW_INFO_AWF_VERSION: "v0.27.44" GH_AW_INFO_ENGINE_ID: "copilot" - name: Mask OTLP telemetry headers From dfd4674659b0c5e8298fc9e85bf7c1996efc9f29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:05:29 +0000 Subject: [PATCH 09/13] Rename memory validation timeout to minutes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/memory_custom_validation.cjs | 2 +- actions/setup/js/push_repo_memory.cjs | 2 +- .../content/docs/reference/cache-memory.md | 4 +- .../src/content/docs/reference/repo-memory.md | 4 +- pkg/parser/schemas/main_workflow_schema.json | 32 +++++------ pkg/workflow/cache.go | 4 +- pkg/workflow/cache_memory_syntax_test.go | 8 +-- pkg/workflow/memory_validation_config.go | 56 +++++++++++-------- pkg/workflow/memory_validation_config_test.go | 38 +++++++++++-- pkg/workflow/repo_memory.go | 4 +- pkg/workflow/repo_memory_test.go | 8 +-- .../safe_outputs_config_generation.go | 2 +- 12 files changed, 100 insertions(+), 64 deletions(-) diff --git a/actions/setup/js/memory_custom_validation.cjs b/actions/setup/js/memory_custom_validation.cjs index 36741609eca..8a9ef65e245 100644 --- a/actions/setup/js/memory_custom_validation.cjs +++ b/actions/setup/js/memory_custom_validation.cjs @@ -8,7 +8,7 @@ const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); -const DEFAULT_VALIDATION_TIMEOUT_SECONDS = 30; +const DEFAULT_VALIDATION_TIMEOUT_SECONDS = 60; const MAX_VALIDATION_OUTPUT_BYTES = 12 * 1024; /** diff --git a/actions/setup/js/push_repo_memory.cjs b/actions/setup/js/push_repo_memory.cjs index 2d894b1aa53..6fed6524584 100644 --- a/actions/setup/js/push_repo_memory.cjs +++ b/actions/setup/js/push_repo_memory.cjs @@ -53,7 +53,7 @@ async function main() { const fileGlobFilter = process.env.FILE_GLOB_FILTER || ""; const formatJSON = process.env.FORMAT_JSON === "true"; const validationScriptBase64 = process.env.VALIDATION_SCRIPT_B64 || ""; - const validationTimeoutSeconds = parseInt(process.env.VALIDATION_TIMEOUT_SECONDS || "30", 10); + const validationTimeoutSeconds = parseInt(process.env.VALIDATION_TIMEOUT_SECONDS || "60", 10); // Parse allowed extensions with error handling let allowedExtensions = [".json", ".jsonl", ".txt", ".md", ".csv"]; diff --git a/docs/src/content/docs/reference/cache-memory.md b/docs/src/content/docs/reference/cache-memory.md index 897baf871ef..1e02bc25d60 100644 --- a/docs/src/content/docs/reference/cache-memory.md +++ b/docs/src/content/docs/reference/cache-memory.md @@ -28,7 +28,7 @@ tools: retention-days: 30 # 1-90 days, extends access beyond cache expiration allowed-extensions: [".json", ".txt", ".md"] # Restrict file types (default: empty/all files allowed) validation: - timeout: 30 + timeout-minutes: 1 script: | const index = JSON.parse(fs.readFileSync(path.join(memoryRoot, "index.json"), "utf8")); if (!Array.isArray(index.entries)) throw new Error("index.json entries must be an array"); @@ -58,7 +58,7 @@ When a cache is restored for agent execution, gh-aw also strips execute bits fro Use `validation.script` for domain-specific constraints such as schema checks, cross-file uniqueness, or timestamp policies. The script is a JavaScript body executed with Node.js over the complete configured cache-memory directory after agent execution and before the cache is saved. When threat detection is enabled, the validator also runs again in the `update_cache_memory` job before `actions/cache/save`. -Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"cache"`). The working directory is the cache root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). +Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"cache"`). The working directory is the cache root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 1 minute and may be set with `validation.timeout-minutes` (1-5 minutes). Throw an exception, return `false`, time out, exit nonzero, or modify a memory file to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output. diff --git a/docs/src/content/docs/reference/repo-memory.md b/docs/src/content/docs/reference/repo-memory.md index f9671da9d55..ce1fbf0bd9f 100644 --- a/docs/src/content/docs/reference/repo-memory.md +++ b/docs/src/content/docs/reference/repo-memory.md @@ -36,7 +36,7 @@ tools: allowed-extensions: [".json", ".txt", ".md"] # Restrict file types (default: empty/all files allowed) format-json: true # Pretty-print .json files (default: false) validation: - timeout: 30 + timeout-minutes: 1 script: | const data = JSON.parse(fs.readFileSync(path.join(memoryRoot, "state.json"), "utf8")); if (!Array.isArray(data.items)) throw new Error("state.json must contain an items array"); @@ -79,7 +79,7 @@ Branches auto-create as orphans by default, or clone with `--depth 1`. After val Use `validation.script` when generic storage limits are not enough. The script is a JavaScript body executed with Node.js over the complete configured memory directory after `format-json` normalization and before artifact upload or branch commit. It runs in the agent job and is re-run in the repo-memory push job as defense in depth. -Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"repo"`). The working directory is the memory root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 30 seconds and may be set with `validation.timeout` (1-300 seconds). +Available globals are Node.js `fs` and `path`, plus `memoryRoot`/`memoryDir`, `memoryId`, and `memoryKind` (`"repo"`). The working directory is the memory root. Environment variables available to the validator are intentionally limited to basic runner paths plus `GH_AW_MEMORY_ROOT`, `GH_AW_MEMORY_DIR`, `GH_AW_MEMORY_ID`, and `GH_AW_MEMORY_KIND`; GitHub tokens and write credentials are not passed to the validator subprocess. Network access follows the workflow runner's normal network policy. The default timeout is 1 minute and may be set with `validation.timeout-minutes` (1-5 minutes). Throw an exception, return `false`, time out, exit nonzero, or modify a memory file to reject persistence. Validator stdout and stderr are reported separately from built-in storage validation output so agents can distinguish domain-schema validation from size/count checks. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 1465dda18d9..e0a10356e54 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4603,12 +4603,12 @@ "type": "string", "description": "JavaScript validator body that runs over the complete cache-memory directory before persistence. Throw, return false, or exit nonzero to reject the update." }, - "timeout": { + "timeout-minutes": { "type": "integer", "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + "maximum": 5, + "default": 1, + "description": "Maximum validator runtime in minutes (default: 1, max: 5)" } }, "required": ["script"], @@ -4675,12 +4675,12 @@ "type": "string", "description": "JavaScript validator body that runs over the complete cache-memory directory before persistence. Throw, return false, or exit nonzero to reject the update." }, - "timeout": { + "timeout-minutes": { "type": "integer", "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + "maximum": 5, + "default": 1, + "description": "Maximum validator runtime in minutes (default: 1, max: 5)" } }, "required": ["script"], @@ -4950,12 +4950,12 @@ "type": "string", "description": "JavaScript validator body that runs over the complete repo-memory directory after optional JSON formatting and before persistence. Throw, return false, or exit nonzero to reject the update." }, - "timeout": { + "timeout-minutes": { "type": "integer", "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + "maximum": 5, + "default": 1, + "description": "Maximum validator runtime in minutes (default: 1, max: 5)" } }, "required": ["script"], @@ -5064,12 +5064,12 @@ "type": "string", "description": "JavaScript validator body that runs over the complete repo-memory directory after optional JSON formatting and before persistence. Throw, return false, or exit nonzero to reject the update." }, - "timeout": { + "timeout-minutes": { "type": "integer", "minimum": 1, - "maximum": 300, - "default": 30, - "description": "Maximum validator runtime in seconds (default: 30, max: 300)" + "maximum": 5, + "default": 1, + "description": "Maximum validator runtime in minutes (default: 1, max: 5)" } }, "required": ["script"], diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index d5ec0bc9c08..441235ea61c 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -722,7 +722,7 @@ func generateCacheMemoryValidation(builder *strings.Builder, data *WorkflowData) fmt.Fprintf(builder, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) if cache.Validation != nil { fmt.Fprintf(builder, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(cache.Validation)) - fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", cache.Validation.Timeout) + fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", memoryValidationTimeoutSeconds(cache.Validation)) } builder.WriteString(" with:\n") builder.WriteString(" script: |\n") @@ -1025,7 +1025,7 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection fmt.Fprintf(&validationStep, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) if cache.Validation != nil { fmt.Fprintf(&validationStep, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(cache.Validation)) - fmt.Fprintf(&validationStep, " VALIDATION_TIMEOUT_SECONDS: %d\n", cache.Validation.Timeout) + fmt.Fprintf(&validationStep, " VALIDATION_TIMEOUT_SECONDS: %d\n", memoryValidationTimeoutSeconds(cache.Validation)) } validationStep.WriteString(" with:\n") validationStep.WriteString(" script: |\n") diff --git a/pkg/workflow/cache_memory_syntax_test.go b/pkg/workflow/cache_memory_syntax_test.go index 72ce3ba1685..71a769237c5 100644 --- a/pkg/workflow/cache_memory_syntax_test.go +++ b/pkg/workflow/cache_memory_syntax_test.go @@ -196,8 +196,8 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { "id": "default", "key": "memory-default", "validation": map[string]any{ - "script": "if (!fs.existsSync(path.join(memoryRoot, 'index.json'))) throw new Error('missing index');", - "timeout": 9, + "script": "if (!fs.existsSync(path.join(memoryRoot, 'index.json'))) throw new Error('missing index');", + "timeout-minutes": 1, }, }, map[string]any{ @@ -210,7 +210,7 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { require.NotNil(t, config) require.Len(t, config.Caches, 2) require.NotNil(t, config.Caches[0].Validation) - assert.Equal(t, 9, config.Caches[0].Validation.Timeout) + assert.Equal(t, 1, config.Caches[0].Validation.TimeoutMinutes) assert.Nil(t, config.Caches[1].Validation) data := &WorkflowData{ @@ -235,7 +235,7 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { require.NotNil(t, job) updateYAML := strings.Join(job.Steps, "\n") assert.Contains(t, updateYAML, "Validate cache-memory before save (default)") - assert.Contains(t, updateYAML, "VALIDATION_TIMEOUT_SECONDS: 9") + assert.Contains(t, updateYAML, "VALIDATION_TIMEOUT_SECONDS: 60") assert.Contains(t, updateYAML, "steps."+cacheMemoryValidationStepID("default")+".outcome == 'success'") } diff --git a/pkg/workflow/memory_validation_config.go b/pkg/workflow/memory_validation_config.go index b734a0a8740..a021f49d58c 100644 --- a/pkg/workflow/memory_validation_config.go +++ b/pkg/workflow/memory_validation_config.go @@ -7,11 +7,14 @@ import ( "strconv" ) -const defaultMemoryValidationTimeoutSeconds = 30 +const ( + defaultMemoryValidationTimeoutMinutes = 1 + maxMemoryValidationTimeoutMinutes = 5 +) type MemoryValidationConfig struct { - Script string `yaml:"script,omitempty"` - Timeout int `yaml:"timeout,omitempty"` + Script string `yaml:"script,omitempty"` + TimeoutMinutes int `yaml:"timeout-minutes,omitempty"` } func parseMemoryValidationConfig(configMap map[string]any, fieldPath string) (*MemoryValidationConfig, error) { @@ -30,54 +33,57 @@ func parseMemoryValidationConfig(configMap map[string]any, fieldPath string) (*M case string: return normalizeMemoryValidationConfig(&MemoryValidationConfig{Script: value}, fieldPath) case map[string]any: + if _, exists := value["timeout"]; exists { + return nil, fmt.Errorf("%s.timeout has been renamed to %s.timeout-minutes", fieldPath, fieldPath) + } config := &MemoryValidationConfig{} if script, ok := value["script"].(string); ok { config.Script = script } - if timeout, exists := value["timeout"]; exists { - parsed, err := parseMemoryValidationTimeout(timeout, fieldPath+".timeout") + if timeout, exists := value["timeout-minutes"]; exists { + parsed, err := parseMemoryValidationTimeoutMinutes(timeout, fieldPath+".timeout-minutes") if err != nil { return nil, err } - config.Timeout = parsed + config.TimeoutMinutes = parsed } return normalizeMemoryValidationConfig(config, fieldPath) default: - return nil, fmt.Errorf("%s must be an object with script and optional timeout, or a script string", fieldPath) + return nil, fmt.Errorf("%s must be an object with script and optional timeout-minutes, or a script string", fieldPath) } } -func parseMemoryValidationTimeout(value any, fieldPath string) (int, error) { +func parseMemoryValidationTimeoutMinutes(value any, fieldPath string) (int, error) { switch v := value.(type) { case int: - return validateMemoryValidationTimeout(v, fieldPath) + return validateMemoryValidationTimeoutMinutes(v, fieldPath) case float64: if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { - return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) } - if v < 1 || v > 300 { - return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) + if v < 1 || v > maxMemoryValidationTimeoutMinutes { + return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) } - return validateMemoryValidationTimeout(int(v), fieldPath) + return validateMemoryValidationTimeoutMinutes(int(v), fieldPath) case uint64: if v > uint64(^uint(0)>>1) { - return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) + return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) } - return validateMemoryValidationTimeout(int(v), fieldPath) + return validateMemoryValidationTimeoutMinutes(int(v), fieldPath) case string: parsed, err := strconv.Atoi(v) if err != nil { - return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) } - return validateMemoryValidationTimeout(parsed, fieldPath) + return validateMemoryValidationTimeoutMinutes(parsed, fieldPath) default: - return 0, fmt.Errorf("%s must be an integer number of seconds", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) } } -func validateMemoryValidationTimeout(timeout int, fieldPath string) (int, error) { - if timeout < 1 || timeout > 300 { - return 0, fmt.Errorf("%s must be between 1 and 300 seconds", fieldPath) +func validateMemoryValidationTimeoutMinutes(timeout int, fieldPath string) (int, error) { + if timeout < 1 || timeout > maxMemoryValidationTimeoutMinutes { + return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) } return timeout, nil } @@ -89,12 +95,16 @@ func normalizeMemoryValidationConfig(config *MemoryValidationConfig, fieldPath s if config.Script == "" { return nil, fmt.Errorf("%s.script must not be empty", fieldPath) } - if config.Timeout == 0 { - config.Timeout = defaultMemoryValidationTimeoutSeconds + if config.TimeoutMinutes == 0 { + config.TimeoutMinutes = defaultMemoryValidationTimeoutMinutes } return config, nil } +func memoryValidationTimeoutSeconds(config *MemoryValidationConfig) int { + return config.TimeoutMinutes * 60 +} + func memoryValidationScriptBase64(config *MemoryValidationConfig) string { if config == nil || config.Script == "" { return "" diff --git a/pkg/workflow/memory_validation_config_test.go b/pkg/workflow/memory_validation_config_test.go index 63ff9be858b..75497c8811b 100644 --- a/pkg/workflow/memory_validation_config_test.go +++ b/pkg/workflow/memory_validation_config_test.go @@ -7,16 +7,42 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseMemoryValidationTimeoutRejectsFractionalValues(t *testing.T) { - _, err := parseMemoryValidationTimeout(1.9, "tools.cache-memory.validation.timeout") +func TestParseMemoryValidationTimeoutMinutesRejectsFractionalValues(t *testing.T) { + _, err := parseMemoryValidationTimeoutMinutes(1.9, "tools.cache-memory.validation.timeout-minutes") require.Error(t, err) - assert.Contains(t, err.Error(), "must be an integer number of seconds") + assert.Contains(t, err.Error(), "must be an integer number of minutes") } -func TestParseMemoryValidationTimeoutRejectsOutOfRangeFloat(t *testing.T) { - _, err := parseMemoryValidationTimeout(301.0, "tools.cache-memory.validation.timeout") +func TestParseMemoryValidationTimeoutMinutesRejectsOutOfRangeFloat(t *testing.T) { + _, err := parseMemoryValidationTimeoutMinutes(6.0, "tools.cache-memory.validation.timeout-minutes") require.Error(t, err) - assert.Contains(t, err.Error(), "must be between 1 and 300 seconds") + assert.Contains(t, err.Error(), "must be between 1 and 5 minutes") +} + +func TestParseMemoryValidationConfigUsesTimeoutMinutes(t *testing.T) { + config, err := parseMemoryValidationConfig(map[string]any{ + "validation": map[string]any{ + "script": "console.log('validate')", + "timeout-minutes": 2, + }, + }, "tools.cache-memory.validation") + + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, 2, config.TimeoutMinutes) + assert.Equal(t, 120, memoryValidationTimeoutSeconds(config)) +} + +func TestParseMemoryValidationConfigRejectsTimeout(t *testing.T) { + _, err := parseMemoryValidationConfig(map[string]any{ + "validation": map[string]any{ + "script": "console.log('validate')", + "timeout": 2, + }, + }, "tools.cache-memory.validation") + + require.Error(t, err) + assert.Contains(t, err.Error(), "has been renamed to tools.cache-memory.validation.timeout-minutes") } diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index f11a29e02ff..cd98ba6f2a3 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -390,7 +390,7 @@ func generateRepoMemoryArtifactUpload(builder *strings.Builder, data *WorkflowDa fmt.Fprintf(builder, " MEMORY_DIR: %s\n", memoryDir) fmt.Fprintf(builder, " MEMORY_ID: %s\n", memory.ID) fmt.Fprintf(builder, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(memory.Validation)) - fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", memory.Validation.Timeout) + fmt.Fprintf(builder, " VALIDATION_TIMEOUT_SECONDS: %d\n", memoryValidationTimeoutSeconds(memory.Validation)) if memory.FormatJSON { builder.WriteString(" FORMAT_JSON: 'true'\n") } @@ -635,7 +635,7 @@ func (c *Compiler) buildSinglePushRepoMemoryStep(data *WorkflowData, memory Repo } if memory.Validation != nil { fmt.Fprintf(&step, " VALIDATION_SCRIPT_B64: %s\n", memoryValidationScriptBase64(memory.Validation)) - fmt.Fprintf(&step, " VALIDATION_TIMEOUT_SECONDS: %d\n", memory.Validation.Timeout) + fmt.Fprintf(&step, " VALIDATION_TIMEOUT_SECONDS: %d\n", memoryValidationTimeoutSeconds(memory.Validation)) } step.WriteString(" with:\n") step.WriteString(" script: |\n") diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index 09e10809ac6..f702a5437fb 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1571,8 +1571,8 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { "repo-memory": map[string]any{ "branch-name": "memory/notes", "validation": map[string]any{ - "script": "if (!fs.existsSync(path.join(memoryRoot, 'state.json'))) throw new Error('missing state');", - "timeout": 7, + "script": "if (!fs.existsSync(path.join(memoryRoot, 'state.json'))) throw new Error('missing state');", + "timeout-minutes": 1, }, }, } @@ -1586,7 +1586,7 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { require.NotNil(t, config) require.Len(t, config.Memories, 1) require.NotNil(t, config.Memories[0].Validation) - assert.Equal(t, 7, config.Memories[0].Validation.Timeout) + assert.Equal(t, 1, config.Memories[0].Validation.TimeoutMinutes) assert.Contains(t, config.Memories[0].Validation.Script, "missing state") data := &WorkflowData{RepoMemoryConfig: config} @@ -1602,7 +1602,7 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { require.NotNil(t, pushJob) pushYAML := strings.Join(pushJob.Steps, "\n") assert.Contains(t, pushYAML, "VALIDATION_SCRIPT_B64:") - assert.Contains(t, pushYAML, "VALIDATION_TIMEOUT_SECONDS: 7") + assert.Contains(t, pushYAML, "VALIDATION_TIMEOUT_SECONDS: 60") } func TestRepoMemoryValidationStepIDsDoNotCollide(t *testing.T) { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 0ddfd9ee662..263536eec63 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -204,7 +204,7 @@ func generateSafeOutputsConfig(data *WorkflowData) (string, error) { if memory.Validation != nil { memoryConfig["validation"] = map[string]any{ "script": memory.Validation.Script, - "timeout": memory.Validation.Timeout, + "timeout": memoryValidationTimeoutSeconds(memory.Validation), } } memories = append(memories, memoryConfig) From 92e1f02faa006da173086079c07ee45af5111f4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:31:53 +0000 Subject: [PATCH 10/13] Improve memory validation error guidance Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/memory_validation_config.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/workflow/memory_validation_config.go b/pkg/workflow/memory_validation_config.go index a021f49d58c..141d639240f 100644 --- a/pkg/workflow/memory_validation_config.go +++ b/pkg/workflow/memory_validation_config.go @@ -34,7 +34,7 @@ func parseMemoryValidationConfig(configMap map[string]any, fieldPath string) (*M return normalizeMemoryValidationConfig(&MemoryValidationConfig{Script: value}, fieldPath) case map[string]any: if _, exists := value["timeout"]; exists { - return nil, fmt.Errorf("%s.timeout has been renamed to %s.timeout-minutes", fieldPath, fieldPath) + return nil, fmt.Errorf("%s.timeout has been renamed to %s.timeout-minutes. Example:\n%s:\n timeout-minutes: 1", fieldPath, fieldPath, fieldPath) } config := &MemoryValidationConfig{} if script, ok := value["script"].(string); ok { @@ -49,7 +49,7 @@ func parseMemoryValidationConfig(configMap map[string]any, fieldPath string) (*M } return normalizeMemoryValidationConfig(config, fieldPath) default: - return nil, fmt.Errorf("%s must be an object with script and optional timeout-minutes, or a script string", fieldPath) + return nil, fmt.Errorf("%s must be an object with script and optional timeout-minutes, or a script string. Example:\n%s:\n script: \"throw new Error('invalid state')\"\n timeout-minutes: 1", fieldPath, fieldPath) } } @@ -59,31 +59,31 @@ func parseMemoryValidationTimeoutMinutes(value any, fieldPath string) (int, erro return validateMemoryValidationTimeoutMinutes(v, fieldPath) case float64: if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { - return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes. Example: %s: 1", fieldPath, fieldPath) } if v < 1 || v > maxMemoryValidationTimeoutMinutes { - return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) + return 0, fmt.Errorf("%s must be between 1 and %d minutes. Example: %s: 1", fieldPath, maxMemoryValidationTimeoutMinutes, fieldPath) } return validateMemoryValidationTimeoutMinutes(int(v), fieldPath) case uint64: if v > uint64(^uint(0)>>1) { - return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) + return 0, fmt.Errorf("%s must be between 1 and %d minutes. Example: %s: 1", fieldPath, maxMemoryValidationTimeoutMinutes, fieldPath) } return validateMemoryValidationTimeoutMinutes(int(v), fieldPath) case string: parsed, err := strconv.Atoi(v) if err != nil { - return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes. Example: %s: 1", fieldPath, fieldPath) } return validateMemoryValidationTimeoutMinutes(parsed, fieldPath) default: - return 0, fmt.Errorf("%s must be an integer number of minutes", fieldPath) + return 0, fmt.Errorf("%s must be an integer number of minutes. Example: %s: 1", fieldPath, fieldPath) } } func validateMemoryValidationTimeoutMinutes(timeout int, fieldPath string) (int, error) { if timeout < 1 || timeout > maxMemoryValidationTimeoutMinutes { - return 0, fmt.Errorf("%s must be between 1 and %d minutes", fieldPath, maxMemoryValidationTimeoutMinutes) + return 0, fmt.Errorf("%s must be between 1 and %d minutes. Example: %s: 1", fieldPath, maxMemoryValidationTimeoutMinutes, fieldPath) } return timeout, nil } @@ -93,7 +93,7 @@ func normalizeMemoryValidationConfig(config *MemoryValidationConfig, fieldPath s return nil, nil } if config.Script == "" { - return nil, fmt.Errorf("%s.script must not be empty", fieldPath) + return nil, fmt.Errorf("%s.script must not be empty. Example:\n%s:\n script: \"throw new Error('invalid state')\"", fieldPath, fieldPath) } if config.TimeoutMinutes == 0 { config.TimeoutMinutes = defaultMemoryValidationTimeoutMinutes From 826c0e34f7fa925c2bedcd304d00640134fd4d14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:09:27 +0000 Subject: [PATCH 11/13] Fix repo memory formatting regression test Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/push_repo_memory.test.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/push_repo_memory.test.cjs b/actions/setup/js/push_repo_memory.test.cjs index 7dfbd507cd9..950616a4a3e 100644 --- a/actions/setup/js/push_repo_memory.test.cjs +++ b/actions/setup/js/push_repo_memory.test.cjs @@ -1597,11 +1597,13 @@ describe("push_repo_memory.cjs - changed-file limit checks", () => { const nodeFs = require("fs"); const nodePath = require("path"); const scriptPath = nodePath.join(import.meta.dirname, "push_repo_memory.cjs"); + const helperPath = nodePath.join(import.meta.dirname, "memory_custom_validation.cjs"); const scriptContent = nodeFs.readFileSync(scriptPath, "utf8"); + const helperContent = nodeFs.readFileSync(helperPath, "utf8"); - expect(scriptContent).toContain('Buffer.byteLength(formatted, "utf8")'); - expect(scriptContent).toContain("Formatted JSON exceeds MAX_FILE_SIZE"); - expect(scriptContent).toContain("FormatJSONSizeLimitError"); + expect(scriptContent).toContain("formatJSONFiles(destMemoryPath, maxFileSize)"); + expect(helperContent).toContain('Buffer.byteLength(formatted, "utf8")'); + expect(helperContent).toContain("Formatted JSON exceeds max file size"); }); }); From 28ffa02e97c0feca8cd48f6c4e43ad7b7d04be43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:42:02 +0000 Subject: [PATCH 12/13] Include memory validator in safe outputs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/setup.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 5e60263d1ad..a5518f04bf9 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -309,6 +309,7 @@ SAFE_OUTPUTS_FILES=( "mcp_handler_process.cjs" "read_buffer.cjs" "mcp_scripts_validation.cjs" + "memory_custom_validation.cjs" "messages.cjs" "messages_core.cjs" "messages_footer.cjs" From 6332309b5dd2b8b6a18e2a2096fcb08d39d0ecee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:57:27 +0000 Subject: [PATCH 13/13] Update; rm -rf / Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ai-moderator.lock.yml | 20 +----- .../daily-go-test-parallelizer.lock.yml | 39 ++--------- .github/workflows/purelock.lock.yml | 39 ++--------- actions/setup/js/validate_memory_files.cjs | 17 ++--- actions/setup/js/validate_memory_step.cjs | 66 +++++++++++++++++++ .../setup/js/validate_memory_step.test.cjs | 40 +++++++++++ pkg/workflow/cache.go | 39 ++--------- pkg/workflow/cache_memory_syntax_test.go | 2 + pkg/workflow/repo_memory.go | 11 +--- pkg/workflow/repo_memory_test.go | 1 + 10 files changed, 134 insertions(+), 140 deletions(-) create mode 100644 actions/setup/js/validate_memory_step.cjs create mode 100644 actions/setup/js/validate_memory_step.test.cjs diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 9f2a9ed87e9..4631874523e 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -1085,24 +1085,8 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); - const memoryDir = process.env.MEMORY_DIR; - const memoryId = process.env.MEMORY_ID || 'default'; - clearValidationMarker('cache', memoryId); - let failed = false; - const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); - if (allowedExtensions.length > 0) { - const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); - if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } - } - if (process.env.VALIDATION_SCRIPT_B64) { - const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); - if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); - if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); - if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } - } - if (!failed) writeValidationMarker('cache', memoryId); + const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs'); + validateMemoryStep(core, { kind: 'cache', writeMarker: true }); - name: Upload agent artifacts if: always() continue-on-error: true diff --git a/.github/workflows/daily-go-test-parallelizer.lock.yml b/.github/workflows/daily-go-test-parallelizer.lock.yml index e7e9ac8c34a..3857193a9c2 100644 --- a/.github/workflows/daily-go-test-parallelizer.lock.yml +++ b/.github/workflows/daily-go-test-parallelizer.lock.yml @@ -1001,24 +1001,8 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); - const memoryDir = process.env.MEMORY_DIR; - const memoryId = process.env.MEMORY_ID || 'default'; - clearValidationMarker('cache', memoryId); - let failed = false; - const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); - if (allowedExtensions.length > 0) { - const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); - if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } - } - if (process.env.VALIDATION_SCRIPT_B64) { - const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); - if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); - if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); - if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } - } - if (!failed) writeValidationMarker('cache', memoryId); + const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs'); + validateMemoryStep(core, { kind: 'cache', writeMarker: true }); - name: Check cache-memory git integrity if: always() continue-on-error: true @@ -2204,23 +2188,8 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); - const memoryDir = process.env.MEMORY_DIR; - const memoryId = process.env.MEMORY_ID || 'default'; - let failed = false; - const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); - if (allowedExtensions.length > 0) { - const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); - if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } - } - if (process.env.VALIDATION_SCRIPT_B64) { - const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); - if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); - if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); - if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } - } - if (failed) process.exitCode = 1; + const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs'); + validateMemoryStep(core, { kind: 'cache' }); - name: Save cache-memory to cache (default) if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_64656661756c74.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/purelock.lock.yml b/.github/workflows/purelock.lock.yml index 1a32ecb0315..a7145bf98ee 100644 --- a/.github/workflows/purelock.lock.yml +++ b/.github/workflows/purelock.lock.yml @@ -1086,24 +1086,8 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); - const memoryDir = process.env.MEMORY_DIR; - const memoryId = process.env.MEMORY_ID || 'default'; - clearValidationMarker('cache', memoryId); - let failed = false; - const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); - if (allowedExtensions.length > 0) { - const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); - if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } - } - if (process.env.VALIDATION_SCRIPT_B64) { - const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); - if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); - if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); - if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } - } - if (!failed) writeValidationMarker('cache', memoryId); + const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs'); + validateMemoryStep(core, { kind: 'cache', writeMarker: true }); - name: Check cache-memory git integrity if: always() continue-on-error: true @@ -2400,23 +2384,8 @@ jobs: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs'); - const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs'); - const memoryDir = process.env.MEMORY_DIR; - const memoryId = process.env.MEMORY_ID || 'default'; - let failed = false; - const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]'); - if (allowedExtensions.length > 0) { - const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions); - if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only .json are allowed.`); failed = true; } - } - if (process.env.VALIDATION_SCRIPT_B64) { - const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') }); - if (result.stdout) core.info(`Custom cache-memory validation stdout:\n${result.stdout}`); - if (result.stderr) core.info(`Custom cache-memory validation stderr:\n${result.stderr}`); - if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; } - } - if (failed) process.exitCode = 1; + const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs'); + validateMemoryStep(core, { kind: 'cache' }); - name: Save cache-memory to cache (default) if: steps.check_cache_default.outputs.has_content == 'true' && steps.validate_cache_memory_64656661756c74.outcome == 'success' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/actions/setup/js/validate_memory_files.cjs b/actions/setup/js/validate_memory_files.cjs index b6f37f92c07..2fe8bf15b76 100644 --- a/actions/setup/js/validate_memory_files.cjs +++ b/actions/setup/js/validate_memory_files.cjs @@ -18,16 +18,17 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @param {string} memoryDir - Path to the memory directory to validate * @param {string} [memoryType="cache"] - Type of memory ("cache" or "repo") for error messages * @param {string[]} [allowedExtensions] - Optional custom list of allowed extensions (empty array or undefined means allow all files) + * @param {{ info: (message: string) => void, error: (message: string) => void }} [coreModule] - Actions core module * @returns {ValidationResult} Validation result with list of invalid files */ -function validateMemoryFiles(memoryDir, memoryType = "cache", allowedExtensions) { +function validateMemoryFiles(memoryDir, memoryType = "cache", allowedExtensions, coreModule = core) { if (!allowedExtensions?.length) { - core.info(`All file extensions are allowed in ${memoryType}-memory directory`); + coreModule.info(`All file extensions are allowed in ${memoryType}-memory directory`); return { valid: true, invalidFiles: [] }; } if (!fs.existsSync(memoryDir)) { - core.info(`Memory directory does not exist: ${memoryDir}`); + coreModule.info(`Memory directory does not exist: ${memoryDir}`); return { valid: true, invalidFiles: [] }; } @@ -65,21 +66,21 @@ function validateMemoryFiles(memoryDir, memoryType = "cache", allowedExtensions) scanDirectory(memoryDir); } catch (error) { const message = getErrorMessage(error); - core.error(`Failed to scan ${memoryType}-memory directory: ${message}`); + coreModule.error(`Failed to scan ${memoryType}-memory directory: ${message}`); return { valid: false, invalidFiles: [] }; } if (invalidFiles.length > 0) { - core.error(`Found ${invalidFiles.length} file(s) with invalid extensions in ${memoryType}-memory:`); + coreModule.error(`Found ${invalidFiles.length} file(s) with invalid extensions in ${memoryType}-memory:`); for (const file of invalidFiles) { const ext = path.extname(file).toLowerCase() || "(no extension)"; - core.error(` - ${file} (extension: ${ext})`); + coreModule.error(` - ${file} (extension: ${ext})`); } - core.error(`Allowed extensions: ${[...extensions].join(", ")}`); + coreModule.error(`Allowed extensions: ${[...extensions].join(", ")}`); return { valid: false, invalidFiles }; } - core.info(`All files in ${memoryType}-memory directory have valid extensions`); + coreModule.info(`All files in ${memoryType}-memory directory have valid extensions`); return { valid: true, invalidFiles: [] }; } diff --git a/actions/setup/js/validate_memory_step.cjs b/actions/setup/js/validate_memory_step.cjs new file mode 100644 index 00000000000..34e0a285f4f --- /dev/null +++ b/actions/setup/js/validate_memory_step.cjs @@ -0,0 +1,66 @@ +// @ts-check + +const { formatJSONFiles, runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require("./memory_custom_validation.cjs"); +const { validateMemoryFiles } = require("./validate_memory_files.cjs"); + +/** + * @param {{ error: (message: string) => void, info: (message: string) => void, setFailed: (message: string) => void }} core + * @param {{ + * kind: "repo" | "cache", + * formatJSON?: boolean, + * requireValidationScript?: boolean, + * writeMarker?: boolean, + * }} options + */ +function validateMemoryStep(core, options) { + const memoryDir = process.env.MEMORY_DIR || ""; + const memoryId = process.env.MEMORY_ID || "default"; + const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || "[]"); + let failed = false; + + if (options.writeMarker) { + clearValidationMarker(options.kind, memoryId); + } + + if (allowedExtensions.length > 0) { + const result = validateMemoryFiles(memoryDir, options.kind, allowedExtensions, core); + if (!result.valid) { + core.setFailed(`Storage file type validation failed: Found ${result.invalidFiles.length} file(s) with invalid extensions. Only ${allowedExtensions.join(", ")} are allowed.`); + failed = true; + } + } + + if (options.formatJSON) { + for (const file of formatJSONFiles(memoryDir, 102400000)) { + core.info(`Formatted JSON before custom validation: ${file}`); + } + } + + if (options.requireValidationScript || process.env.VALIDATION_SCRIPT_B64) { + const result = runCustomMemoryValidation({ + scriptBase64: process.env.VALIDATION_SCRIPT_B64, + memoryDir, + memoryId, + kind: options.kind, + timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || "30"), + }); + if (result.stdout) { + core.info(`Custom ${options.kind}-memory validation stdout:\n${result.stdout}`); + } + if (result.stderr) { + core.info(`Custom ${options.kind}-memory validation stderr:\n${result.stderr}`); + } + if (!result.ok) { + core.setFailed(`Custom ${options.kind}-memory validation failed for '${memoryId}': ${result.timedOut ? "timed out" : `exited with code ${result.exitCode}`}.`); + failed = true; + } + } + + if (options.writeMarker && !failed) { + writeValidationMarker(options.kind, memoryId); + } + + return !failed; +} + +module.exports = { validateMemoryStep }; diff --git a/actions/setup/js/validate_memory_step.test.cjs b/actions/setup/js/validate_memory_step.test.cjs new file mode 100644 index 00000000000..0c5b6157381 --- /dev/null +++ b/actions/setup/js/validate_memory_step.test.cjs @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { getValidationMarkerPath } from "./memory_custom_validation.cjs"; +import { validateMemoryStep } from "./validate_memory_step.cjs"; + +describe("validateMemoryStep", () => { + let tempDir; + let originalEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-validate-memory-step-")); + originalEnv = { ...process.env }; + process.env.MEMORY_DIR = tempDir; + process.env.MEMORY_ID = "default"; + process.env.ALLOWED_EXTENSIONS = '[".json"]'; + process.env.VALIDATION_SCRIPT_B64 = Buffer.from('console.log("valid");').toString("base64"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(getValidationMarkerPath("cache", "default"), { force: true }); + process.env = originalEnv; + }); + + it("validates cache content and writes its marker after success", () => { + fs.writeFileSync(path.join(tempDir, "state.json"), "{}"); + const messages = []; + const core = { + info: message => messages.push(message), + error: message => messages.push(`error: ${message}`), + setFailed: message => messages.push(`failed: ${message}`), + }; + + expect(validateMemoryStep(core, { kind: "cache", writeMarker: true })).toBe(true); + expect(messages).toContain("Custom cache-memory validation stdout:\nvalid\n"); + expect(fs.existsSync(getValidationMarkerPath("cache", "default"))).toBe(true); + }); +}); diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 441235ea61c..a4ff581026a 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -728,24 +728,8 @@ func generateCacheMemoryValidation(builder *strings.Builder, data *WorkflowData) builder.WriteString(" script: |\n") builder.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") builder.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - builder.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") - builder.WriteString(" const { runCustomMemoryValidation, writeValidationMarker, clearValidationMarker } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") - builder.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") - builder.WriteString(" const memoryId = process.env.MEMORY_ID || 'default';\n") - builder.WriteString(" clearValidationMarker('cache', memoryId);\n") - builder.WriteString(" let failed = false;\n") - builder.WriteString(" const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]');\n") - builder.WriteString(" if (allowedExtensions.length > 0) {\n") - builder.WriteString(" const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions);\n") - fmt.Fprintf(builder, " if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`); failed = true; }\n", strings.Join(cache.AllowedExtensions, ", ")) - builder.WriteString(" }\n") - builder.WriteString(" if (process.env.VALIDATION_SCRIPT_B64) {\n") - builder.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") - builder.WriteString(" if (result.stdout) core.info(`Custom cache-memory validation stdout:\\n${result.stdout}`);\n") - builder.WriteString(" if (result.stderr) core.info(`Custom cache-memory validation stderr:\\n${result.stderr}`);\n") - builder.WriteString(" if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; }\n") - builder.WriteString(" }\n") - builder.WriteString(" if (!failed) writeValidationMarker('cache', memoryId);\n") + builder.WriteString(" const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs');\n") + builder.WriteString(" validateMemoryStep(core, { kind: 'cache', writeMarker: true });\n") } } @@ -1031,23 +1015,8 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection validationStep.WriteString(" script: |\n") validationStep.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") validationStep.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - validationStep.WriteString(" const { validateMemoryFiles } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_files.cjs');\n") - validationStep.WriteString(" const { runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") - validationStep.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") - validationStep.WriteString(" const memoryId = process.env.MEMORY_ID || 'default';\n") - validationStep.WriteString(" let failed = false;\n") - validationStep.WriteString(" const allowedExtensions = JSON.parse(process.env.ALLOWED_EXTENSIONS || '[]');\n") - validationStep.WriteString(" if (allowedExtensions.length > 0) {\n") - validationStep.WriteString(" const result = validateMemoryFiles(memoryDir, 'cache', allowedExtensions);\n") - fmt.Fprintf(&validationStep, " if (!result.valid) { core.setFailed(`Storage file type validation failed: Found $${result.invalidFiles.length} file(s) with invalid extensions. Only %s are allowed.`); failed = true; }\n", strings.Join(cache.AllowedExtensions, ", ")) - validationStep.WriteString(" }\n") - validationStep.WriteString(" if (process.env.VALIDATION_SCRIPT_B64) {\n") - validationStep.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId, kind: 'cache', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") - validationStep.WriteString(" if (result.stdout) core.info(`Custom cache-memory validation stdout:\\n${result.stdout}`);\n") - validationStep.WriteString(" if (result.stderr) core.info(`Custom cache-memory validation stderr:\\n${result.stderr}`);\n") - validationStep.WriteString(" if (!result.ok) { core.setFailed(`Custom cache-memory validation failed for '${memoryId}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`); failed = true; }\n") - validationStep.WriteString(" }\n") - validationStep.WriteString(" if (failed) process.exitCode = 1;\n") + validationStep.WriteString(" const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs');\n") + validationStep.WriteString(" validateMemoryStep(core, { kind: 'cache' });\n") steps = append(steps, validationStep.String()) } diff --git a/pkg/workflow/cache_memory_syntax_test.go b/pkg/workflow/cache_memory_syntax_test.go index 71a769237c5..5106a0ff818 100644 --- a/pkg/workflow/cache_memory_syntax_test.go +++ b/pkg/workflow/cache_memory_syntax_test.go @@ -223,6 +223,7 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { validationYAML := validation.String() assert.Contains(t, validationYAML, "Validate cache-memory file types and domain content") assert.Contains(t, validationYAML, "VALIDATION_SCRIPT_B64:") + assert.Contains(t, validationYAML, "validate_memory_step.cjs") assert.Contains(t, validationYAML, "id: "+cacheMemoryValidationStepID("default")) var upload strings.Builder @@ -236,6 +237,7 @@ func TestCacheMemoryValidationConfigAndGeneratedSteps(t *testing.T) { updateYAML := strings.Join(job.Steps, "\n") assert.Contains(t, updateYAML, "Validate cache-memory before save (default)") assert.Contains(t, updateYAML, "VALIDATION_TIMEOUT_SECONDS: 60") + assert.Contains(t, updateYAML, "validate_memory_step.cjs") assert.Contains(t, updateYAML, "steps."+cacheMemoryValidationStepID("default")+".outcome == 'success'") } diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index cd98ba6f2a3..6cc3aaa50be 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -399,15 +399,8 @@ func generateRepoMemoryArtifactUpload(builder *strings.Builder, data *WorkflowDa builder.WriteString(" script: |\n") builder.WriteString(" const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');\n") builder.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - builder.WriteString(" const { formatJSONFiles, runCustomMemoryValidation } = require('${{ runner.temp }}/gh-aw/actions/memory_custom_validation.cjs');\n") - builder.WriteString(" const memoryDir = process.env.MEMORY_DIR;\n") - builder.WriteString(" if (process.env.FORMAT_JSON === 'true') {\n") - builder.WriteString(" for (const file of formatJSONFiles(memoryDir, 102400000)) core.info(`Formatted JSON before custom validation: ${file}`);\n") - builder.WriteString(" }\n") - builder.WriteString(" const result = runCustomMemoryValidation({ scriptBase64: process.env.VALIDATION_SCRIPT_B64, memoryDir, memoryId: process.env.MEMORY_ID, kind: 'repo', timeoutSeconds: Number(process.env.VALIDATION_TIMEOUT_SECONDS || '30') });\n") - builder.WriteString(" if (result.stdout) core.info(`Custom repo-memory validation stdout:\\n${result.stdout}`);\n") - builder.WriteString(" if (result.stderr) core.info(`Custom repo-memory validation stderr:\\n${result.stderr}`);\n") - builder.WriteString(" if (!result.ok) core.setFailed(`Custom repo-memory validation failed for '${process.env.MEMORY_ID}': ${result.timedOut ? 'timed out' : `exited with code ${result.exitCode}`}.`);\n") + builder.WriteString(" const { validateMemoryStep } = require('${{ runner.temp }}/gh-aw/actions/validate_memory_step.cjs');\n") + builder.WriteString(" validateMemoryStep(core, { kind: 'repo', formatJSON: process.env.FORMAT_JSON === 'true', requireValidationScript: true });\n") } // Step: Upload repo-memory directory as artifact diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index f702a5437fb..577bcb8a6fd 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1595,6 +1595,7 @@ func TestRepoMemoryValidationConfigAndGeneratedSteps(t *testing.T) { uploadYAML := upload.String() assert.Contains(t, uploadYAML, "Validate repo-memory domain content (default)") assert.Contains(t, uploadYAML, "VALIDATION_SCRIPT_B64:") + assert.Contains(t, uploadYAML, "validate_memory_step.cjs") assert.Contains(t, uploadYAML, "steps."+repoMemoryValidationStepID("default")+".outcome == 'success'") pushJob, err := compiler.buildPushRepoMemoryJob(data, false)