From ad1f44b78f1d97f4d1da16fe8693a992c3288b2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:54:32 +0000 Subject: [PATCH 1/8] Initial plan From db06a9a48541919a3a54e35429ba3b9749d56e1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:16:33 +0000 Subject: [PATCH 2/8] Add ignored-files field to create-pull-request and push-to-pull-request-branch Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.test.cjs | 164 ++++++++++++++++++ actions/setup/js/manifest_file_helpers.cjs | 63 +++++-- .../setup/js/manifest_file_helpers.test.cjs | 80 ++++++++- .../js/push_to_pull_request_branch.test.cjs | 93 ++++++++++ actions/setup/js/types/handler-factory.d.ts | 2 + pkg/parser/schemas/main_workflow_schema.json | 46 +++-- pkg/workflow/compiler_safe_outputs_config.go | 2 + pkg/workflow/create_pull_request.go | 1 + pkg/workflow/push_to_pull_request_branch.go | 4 + 9 files changed, 428 insertions(+), 27 deletions(-) diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 1c9fe47e0d5..2f16ed6a0cf 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -590,3 +590,167 @@ ${diffs} expect(result.error).toContain("protected files"); }); }); + +// ignored-files exclusion list +// ────────────────────────────────────────────────────── + +describe("create_pull_request - ignored-files exclusion list", () => { + let tempDir; + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.GH_AW_WORKFLOW_ID = "test-workflow"; + process.env.GITHUB_REPOSITORY = "test-owner/test-repo"; + process.env.GITHUB_BASE_REF = "main"; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-pr-ignored-test-")); + + global.core = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, + }; + global.github = { + rest: { + pulls: { + create: vi.fn().mockResolvedValue({ data: { number: 1, html_url: "https://github.com/test" } }), + }, + repos: { + get: vi.fn().mockResolvedValue({ data: { default_branch: "main" } }), + }, + }, + graphql: vi.fn(), + }; + global.context = { + eventName: "workflow_dispatch", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: {}, + }; + global.exec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }), + }; + + // Clear module cache so globals are picked up fresh + delete require.cache[require.resolve("./create_pull_request.cjs")]; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + delete global.core; + delete global.github; + delete global.context; + delete global.exec; + vi.clearAllMocks(); + }); + + /** + * Creates a minimal git patch touching the given file paths. + */ + function createPatchWithFiles(...filePaths) { + const diffs = filePaths + .map( + p => `diff --git a/${p} b/${p} +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/${p} +@@ -0,0 +1 @@ ++content +` + ) + .join("\n"); + return `From abc123 Mon Sep 17 00:00:00 2001 +From: Test Author +Date: Mon, 1 Jan 2024 00:00:00 +0000 +Subject: [PATCH] Test commit + +${diffs} +-- +2.34.1 +`; + } + + function writePatch(content) { + const p = path.join(tempDir, "test.patch"); + fs.writeFileSync(p, content); + return p; + } + + it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { + // auto-generated/file.txt would violate the allowed-files list but is ignored + const patchPath = writePatch(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ + ignored_files: ["auto-generated/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + + expect(result.error || "").not.toContain("outside the allowed-files list"); + }); + + it("should still block non-ignored files that violate the allowed-files list", async () => { + const patchPath = writePatch(createPatchWithFiles("src/index.js", "other/file.txt")); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ + ignored_files: ["auto-generated/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("outside the allowed-files list"); + expect(result.error).toContain("other/file.txt"); + expect(result.error).not.toContain("src/index.js"); + }); + + it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { + // package.json is protected but is ignored → should not trigger protection + const patchPath = writePatch(createPatchWithFiles("src/index.js", "package.json")); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ + ignored_files: ["package.json"], + protected_files: ["package.json"], + protected_files_policy: "blocked", + }); + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + + expect(result.error || "").not.toContain("protected files"); + }); + + it("should allow when all patch files are ignored (even with allowed-files set)", async () => { + const patchPath = writePatch(createPatchWithFiles("dist/bundle.js")); + + const { main } = require("./create_pull_request.cjs"); + const handler = await main({ + ignored_files: ["dist/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + + expect(result.error || "").not.toContain("outside the allowed-files list"); + }); +}); diff --git a/actions/setup/js/manifest_file_helpers.cjs b/actions/setup/js/manifest_file_helpers.cjs index 5ed98e04d58..056da6af0fe 100644 --- a/actions/setup/js/manifest_file_helpers.cjs +++ b/actions/setup/js/manifest_file_helpers.cjs @@ -126,14 +126,42 @@ function checkAllowedFiles(patchContent, allowedFilePatterns) { return { hasDisallowedFiles: disallowedFiles.length > 0, disallowedFiles }; } +/** + * Identifies which files in a patch match the given list of ignored-file glob patterns. + * Matching is done against the full file path (e.g. `.github/workflows/ci.yml`). + * Files that match are excluded from subsequent `allowed-files` and `protected-files` + * checks inside `checkFileProtection`. + * + * Glob matching supports `*` (matches any characters except `/`) and `**` (matches + * any characters including `/`). + * + * @param {string} patchContent - The git patch content + * @param {string[]} ignoredFilePatterns - Glob patterns for files to ignore + * @returns {{ ignoredFiles: string[] }} + */ +function checkIgnoredFiles(patchContent, ignoredFilePatterns) { + if (!ignoredFilePatterns || ignoredFilePatterns.length === 0) { + return { ignoredFiles: [] }; + } + const allPaths = extractPathsFromPatch(patchContent); + if (allPaths.length === 0) { + return { ignoredFiles: [] }; + } + const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); + const compiledPatterns = ignoredFilePatterns.map(p => globPatternToRegex(p)); + const ignoredFiles = allPaths.filter(p => compiledPatterns.some(re => re.test(p))); + return { ignoredFiles }; +} + /** * Evaluates a patch against the configured file-protection policy and returns a * single structured result, eliminating nested branching in callers. * - * The two checks are orthogonal and both must pass: - * 1. If `allowed_files` is set → every file must match at least one pattern (deny if not). - * 2. `protected-files` policy applies independently: "allowed" = skip, "fallback-to-issue" - * = create review issue, default ("blocked") = deny. + * The checks are applied in order and all must pass: + * 0. If `ignored_files` is set → matching files are excluded from all subsequent checks. + * 1. If `allowed_files` is set → every non-ignored file must match at least one pattern (deny if not). + * 2. `protected-files` policy applies independently to non-ignored files: "allowed" = skip, + * "fallback-to-issue" = create review issue, default ("blocked") = deny. * * To allow an agent to write protected files, set both `allowed-files` (strict scope) and * `protected-files: allowed` (explicit permission) — neither overrides the other implicitly. @@ -143,16 +171,29 @@ function checkAllowedFiles(patchContent, allowedFilePatterns) { * @returns {{ action: 'allow' } | { action: 'deny', source: 'allowlist'|'protected', files: string[] } | { action: 'fallback', files: string[] }} */ function checkFileProtection(patchContent, config) { - // Step 1: allowlist check (if configured) + // Step 0: build ignored-file sets (applied before all other checks) + const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; + const { ignoredFiles } = checkIgnoredFiles(patchContent, ignoredFilePatterns); + const ignoredPaths = new Set(ignoredFiles); + // Build basename set for filtering manifest (basename-only) results + const ignoredBasenames = new Set( + ignoredFiles.map(p => { + const parts = p.split("/"); + return parts[parts.length - 1]; + }) + ); + + // Step 1: allowlist check (if configured) — applied to non-ignored files only const allowedFilePatterns = Array.isArray(config.allowed_files) ? config.allowed_files : []; if (allowedFilePatterns.length > 0) { const { disallowedFiles } = checkAllowedFiles(patchContent, allowedFilePatterns); - if (disallowedFiles.length > 0) { - return { action: "deny", source: "allowlist", files: disallowedFiles }; + const effectiveDisallowed = disallowedFiles.filter(f => !ignoredPaths.has(f)); + if (effectiveDisallowed.length > 0) { + return { action: "deny", source: "allowlist", files: effectiveDisallowed }; } } - // Step 2: protected-files check (independent of allowlist) + // Step 2: protected-files check (independent of allowlist) — applied to non-ignored files only if (config.protected_files_policy === "allowed") { return { action: "allow" }; } @@ -161,7 +202,9 @@ function checkFileProtection(patchContent, config) { const prefixes = Array.isArray(config.protected_path_prefixes) ? config.protected_path_prefixes : []; const { manifestFilesFound } = checkForManifestFiles(patchContent, manifestFiles); const { protectedPathsFound } = checkForProtectedPaths(patchContent, prefixes); - const allFound = [...manifestFilesFound, ...protectedPathsFound]; + const effectiveManifest = manifestFilesFound.filter(f => !ignoredBasenames.has(f)); + const effectivePaths = protectedPathsFound.filter(f => !ignoredPaths.has(f)); + const allFound = [...effectiveManifest, ...effectivePaths]; if (allFound.length === 0) { return { action: "allow" }; @@ -170,4 +213,4 @@ function checkFileProtection(patchContent, config) { return config.protected_files_policy === "fallback-to-issue" ? { action: "fallback", files: allFound } : { action: "deny", source: "protected", files: allFound }; } -module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkFileProtection }; +module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, checkFileProtection }; diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index 1c832bcf87d..ea1eaef65c0 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "module"; const require = createRequire(import.meta.url); -const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); describe("manifest_file_helpers", () => { describe("extractFilenamesFromPatch", () => { @@ -336,6 +336,36 @@ index abc..def 100644 }); }); + describe("checkIgnoredFiles", () => { + const makePatch = (...filePaths) => filePaths.map(p => `diff --git a/${p} b/${p}\nindex abc..def 100644\n`).join("\n"); + + it("should return empty when patterns is empty", () => { + const result = checkIgnoredFiles(makePatch("src/index.js"), []); + expect(result.ignoredFiles).toEqual([]); + }); + + it("should return empty for empty patch", () => { + const result = checkIgnoredFiles("", ["auto-generated/**"]); + expect(result.ignoredFiles).toEqual([]); + }); + + it("should identify files matching ignored patterns", () => { + const result = checkIgnoredFiles(makePatch("auto-generated/file.txt", "src/index.js"), ["auto-generated/**"]); + expect(result.ignoredFiles).toContain("auto-generated/file.txt"); + expect(result.ignoredFiles).not.toContain("src/index.js"); + }); + + it("should return all files when all match ignored patterns", () => { + const result = checkIgnoredFiles(makePatch("auto-generated/a.txt", "auto-generated/b.txt"), ["auto-generated/**"]); + expect(result.ignoredFiles).toHaveLength(2); + }); + + it("should support ** glob for deep path matching", () => { + const result = checkIgnoredFiles(makePatch("dist/deep/nested/bundle.js"), ["dist/**"]); + expect(result.ignoredFiles).toContain("dist/deep/nested/bundle.js"); + }); + }); + describe("checkFileProtection", () => { const makePatch = (...filePaths) => filePaths.map(p => `diff --git a/${p} b/${p}\nindex abc..def 100644\n`).join("\n"); @@ -410,5 +440,53 @@ index abc..def 100644 expect(result.action).toBe("deny"); expect(result.source).toBe("allowlist"); }); + + it("should allow when ignored file would have violated the allowlist", () => { + // auto-generated/file.txt is outside allowed-files but is ignored → allow + const result = checkFileProtection(makePatch("auto-generated/file.txt"), { + ignored_files: ["auto-generated/**"], + allowed_files: ["src/**"], + }); + expect(result.action).toBe("allow"); + }); + + it("should still deny non-ignored files that violate the allowlist", () => { + // auto-generated/file.txt is ignored, but src/bad.js is outside allowed-files + const result = checkFileProtection(makePatch("auto-generated/file.txt", "src/bad.js"), { + ignored_files: ["auto-generated/**"], + allowed_files: ["src/good.js"], + }); + expect(result.action).toBe("deny"); + expect(result.source).toBe("allowlist"); + expect(result.files).toContain("src/bad.js"); + expect(result.files).not.toContain("auto-generated/file.txt"); + }); + + it("should allow when ignored file would have triggered protected-files", () => { + // package.json is protected but it is ignored → allow + const result = checkFileProtection(makePatch("package.json"), { + ignored_files: ["package.json"], + protected_files: ["package.json"], + protected_files_policy: "blocked", + }); + expect(result.action).toBe("allow"); + }); + + it("should allow when ignored file would have triggered protected path prefix", () => { + const result = checkFileProtection(makePatch(".github/workflows/ci.yml"), { + ignored_files: [".github/**"], + protected_path_prefixes: [".github/"], + protected_files_policy: "blocked", + }); + expect(result.action).toBe("allow"); + }); + + it("should allow when all patch files are ignored", () => { + const result = checkFileProtection(makePatch("dist/bundle.js", "dist/bundle.css"), { + ignored_files: ["dist/**"], + allowed_files: ["src/**"], + }); + expect(result.action).toBe("allow"); + }); }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 0a2dc34f42d..ce4befac7d6 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1165,6 +1165,99 @@ ${diffs} expect(result.error).not.toContain(".changeset/my-fix.md"); }); }); + + // ignored-files exclusion list + // ────────────────────────────────────────────────────── + + describe("ignored-files exclusion list", () => { + /** + * Helper to create a patch that touches only the given file path(s). + */ + function createPatchWithFiles(...filePaths) { + const diffs = filePaths + .map( + p => `diff --git a/${p} b/${p} +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/${p} +@@ -0,0 +1 @@ ++content +` + ) + .join("\n"); + return `From abc123 Mon Sep 17 00:00:00 2001 +From: Test Author +Date: Mon, 1 Jan 2024 00:00:00 +0000 +Subject: [PATCH] Test commit + +${diffs} +-- +2.34.1 +`; + } + + it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { + // auto-generated/file.txt would violate the allowed-files list but is ignored + const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const module = await loadModule(); + const handler = await module.main({ + ignored_files: ["auto-generated/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath }, {}); + + expect(result.error || "").not.toContain("outside the allowed-files list"); + }); + + it("should still block non-ignored files that violate the allowed-files list", async () => { + const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "other/file.txt")); + + const module = await loadModule(); + const handler = await module.main({ + ignored_files: ["auto-generated/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("outside the allowed-files list"); + expect(result.error).toContain("other/file.txt"); + expect(result.error).not.toContain("src/index.js"); + }); + + it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { + // package.json is protected but is ignored → should not trigger protection + const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "package.json")); + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const module = await loadModule(); + const handler = await module.main({ + ignored_files: ["package.json"], + protected_files: ["package.json"], + protected_files_policy: "blocked", + }); + const result = await handler({ patch_path: patchPath }, {}); + + expect(result.error || "").not.toContain("protected files"); + }); + + it("should allow when all patch files are ignored (even with allowed-files set)", async () => { + const patchPath = createPatchFile(createPatchWithFiles("dist/bundle.js")); + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + + const module = await loadModule(); + const handler = await module.main({ + ignored_files: ["dist/**"], + allowed_files: ["src/**"], + }); + const result = await handler({ patch_path: patchPath }, {}); + + expect(result.error || "").not.toContain("outside the allowed-files list"); + }); + }); }); // ────────────────────────────────────────────────────── diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index 9360b9ab506..55e7c16f24c 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -10,6 +10,8 @@ interface HandlerConfig { max?: number; /** Strict allowlist of glob patterns for files eligible for push/create. Checked independently of protected-files; both checks must pass. */ allowed_files?: string[]; + /** List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. */ + ignored_files?: string[]; /** List of filenames (basenames) whose presence in a patch triggers protected-file handling */ protected_files?: string[]; /** List of path prefixes that trigger protected-file handling when any changed file matches */ diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 8735062df3a..6fa9c2aa2ca 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1418,12 +1418,12 @@ "description": "Skip workflow execution for specific GitHub users. Useful for preventing workflows from running for specific accounts (e.g., bots, specific team members)." }, "roles": { - "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).", + "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).", "oneOf": [ { "type": "string", "enum": ["all"], - "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)" + "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)" }, { "type": "array", @@ -2287,7 +2287,7 @@ }, "network": { "$comment": "Strict mode requirements: When strict=true, the 'network' field must be present (not null/undefined) and cannot contain standalone wildcard '*' in allowed domains (but patterns like '*.example.com' ARE allowed). This is validated in Go code (pkg/workflow/strict_mode_validation.go) via validateStrictNetwork().", - "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' \u2014 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.", + "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' — 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.", "examples": [ "defaults", { @@ -2675,7 +2675,7 @@ ] }, "plugins": { - "description": "\u26a0\ufe0f EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", + "description": "⚠️ EXPERIMENTAL: Plugin configuration for installing plugins before workflow execution. Supports array format (list of repos/plugin configs) and object format (repos + custom token). Note: Plugin support is experimental and may change in future releases.", "examples": [ ["github/copilot-plugin", "acme/custom-tools"], [ @@ -2872,7 +2872,7 @@ [ { "name": "Verify Post-Steps Execution", - "run": "echo \"\u2705 Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" + "run": "echo \"✅ Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" }, { "name": "Upload Test Results", @@ -5357,6 +5357,13 @@ "type": "boolean", "description": "When true, the random salt suffix is not appended to the agent-specified branch name. Invalid characters are still replaced for security, and casing is always preserved regardless of this setting. Useful when the target repository enforces branch naming conventions (e.g. Jira keys in uppercase such as 'bugfix/BR-329-red'). Defaults to false.", "default": false + }, + "ignored-files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks — matching files are excluded from all protection checks and from the effective patch. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false, @@ -5559,7 +5566,7 @@ "oneOf": [ { "type": "object", - "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only \u2014 threads on other PRs cannot be resolved.", + "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only — threads on other PRs cannot be resolved.", "properties": { "max": { "description": "Maximum number of review threads to resolve (default: 10) Supports integer or GitHub Actions expression (e.g. '${{ inputs.max }}').", @@ -6408,7 +6415,14 @@ "items": { "type": "string" }, - "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." + "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." + }, + "ignored-files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks — matching files are excluded from all protection checks and from the effective patch. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false @@ -7176,8 +7190,8 @@ }, "staged-title": { "type": "string", - "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '\ud83c\udfad Preview: {operation}'", - "examples": ["\ud83c\udfad Preview: {operation}", "## Staged Mode: {operation}"] + "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '🎭 Preview: {operation}'", + "examples": ["🎭 Preview: {operation}", "## Staged Mode: {operation}"] }, "staged-description": { "type": "string", @@ -7191,18 +7205,18 @@ }, "run-success": { "type": "string", - "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.'", - "examples": ["\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.", "\u2705 [{workflow_name}]({run_url}) finished."] + "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '✅ Agentic [{workflow_name}]({run_url}) completed successfully.'", + "examples": ["✅ Agentic [{workflow_name}]({run_url}) completed successfully.", "✅ [{workflow_name}]({run_url}) finished."] }, "run-failure": { "type": "string", - "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", - "examples": ["\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "\u274c [{workflow_name}]({run_url}) {status}."] + "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", + "examples": ["❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "❌ [{workflow_name}]({run_url}) {status}."] }, "detection-failure": { "type": "string", - "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", - "examples": ["\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "\u26a0\ufe0f Detection job failed in [{workflow_name}]({run_url})."] + "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", + "examples": ["⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "⚠️ Detection job failed in [{workflow_name}]({run_url})."] }, "agent-failure-issue": { "type": "string", @@ -8343,7 +8357,7 @@ }, "auth": { "type": "array", - "description": "Authentication bindings \u2014 maps logical roles (e.g. 'api-key') to GitHub Actions secret names", + "description": "Authentication bindings — maps logical roles (e.g. 'api-key') to GitHub Actions secret names", "items": { "type": "object", "properties": { diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 5e8bd413724..63214d0fdba 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -487,6 +487,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("ignored_files", c.IgnoredFiles). AddIfTrue("preserve_branch_name", c.PreserveBranchName) return builder.Build() }, @@ -515,6 +516,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("ignored_files", c.IgnoredFiles). Build() }, "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 977788db946..97e7eefdf44 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -34,6 +34,7 @@ type CreatePullRequestsConfig struct { GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" pushes the branch but creates a review issue. AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. + IgnoredFiles []string `yaml:"ignored-files,omitempty"` // List of glob patterns for files to ignore. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). } diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index a319d4d1326..e447ebaa2fd 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -22,6 +22,7 @@ type PushToPullRequestBranchConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories in format "owner/repo" that push to pull request branch can target ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" creates a review issue instead of pushing. AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. + IgnoredFiles []string `yaml:"ignored-files,omitempty"` // List of glob patterns for files to ignore. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. } // buildCheckoutRepository generates a checkout step with optional target repository and custom token @@ -146,6 +147,9 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) // Parse allowed-files: list of glob patterns forming a strict allowlist of eligible files pushToBranchConfig.AllowedFiles = ParseStringArrayFromConfig(configMap, "allowed-files", pushToPullRequestBranchLog) + // Parse ignored-files: list of glob patterns for files to exclude from all checks + pushToBranchConfig.IgnoredFiles = ParseStringArrayFromConfig(configMap, "ignored-files", pushToPullRequestBranchLog) + // Parse common base fields with default max of 0 (no limit) c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0) } From 529fb6fc999ceb021e5ef95aaa9ede455f905970 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:27:53 +0000 Subject: [PATCH 3/8] Add ignored-files .github/workflows/*.lock.yml to cloclo workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/cloclo.lock.yml | 4 ++-- .github/workflows/cloclo.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 1ef58032a67..01bd76451ed 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -27,7 +27,7 @@ # - shared/jqschema.md # - shared/mcp/serena-go.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"f0cbb935127a3229a5af7be73bfbe1e18cd4d83455bfab734bc621259e3a9b8c","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"37380a62bb633d6d50e7fd8aed2b7726152cb6bc4aca148772df780a87512e0b","strict":true} name: "/cloclo" "on": @@ -1646,7 +1646,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request\":{\"expires\":48,\"labels\":[\"automation\",\"cloclo\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"title_prefix\":\"[cloclo] \"},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request\":{\"expires\":48,\"ignored_files\":[\".github/workflows/*.lock.yml\"],\"labels\":[\"automation\",\"cloclo\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"title_prefix\":\"[cloclo] \"},\"missing_data\":{},\"missing_tool\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cloclo.md b/.github/workflows/cloclo.md index 3df5ee3b718..cc908fd8a6a 100644 --- a/.github/workflows/cloclo.md +++ b/.github/workflows/cloclo.md @@ -32,6 +32,8 @@ safe-outputs: expires: 2d title-prefix: "[cloclo] " labels: [automation, cloclo] + ignored-files: + - ".github/workflows/*.lock.yml" add-comment: max: 1 messages: From c36d95d359a08ff1767b5093eb08305f9a19be3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:52:10 +0000 Subject: [PATCH 4/8] refactor: use git :(exclude) pathspecs to filter ignored-files at patch generation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 16 ++- actions/setup/js/create_pull_request.test.cjs | 16 ++- actions/setup/js/manifest_file_helpers.cjs | 110 ++++++++++++----- .../setup/js/manifest_file_helpers.test.cjs | 113 ++++++++++++------ .../setup/js/push_to_pull_request_branch.cjs | 17 ++- .../js/push_to_pull_request_branch.test.cjs | 16 ++- pkg/parser/schemas/main_workflow_schema.json | 4 +- 7 files changed, 221 insertions(+), 71 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index a6f52827628..1396c9fe24b 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -23,7 +23,7 @@ const { createCheckoutManager } = require("./dynamic_checkout.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { checkFileProtection, filterPatchByIgnoredFiles } = require("./manifest_file_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); /** @@ -340,6 +340,20 @@ async function main(config = {}) { if (patchFilePath && fs.existsSync(patchFilePath)) { patchContent = fs.readFileSync(patchFilePath, "utf8"); + + // Apply ignored-files filter: strip diff sections for matching files from the patch + // so they are absent from the resulting commit (`.gitignore`-style exclusion). + const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; + if (ignoredFilePatterns.length > 0) { + const filteredContent = filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns); + if (filteredContent !== patchContent) { + core.info(`ignored-files: filtered patch (${ignoredFilePatterns.join(", ")})`); + patchContent = filteredContent; + // Write the filtered patch back so git am uses the correct content + fs.writeFileSync(patchFilePath, patchContent, "utf8"); + } + } + isEmpty = !patchContent || !patchContent.trim(); } diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 2f16ed6a0cf..39c80854b19 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -697,7 +697,8 @@ ${diffs} } it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // auto-generated/file.txt would violate the allowed-files list but is ignored + // auto-generated/file.txt would violate the allowed-files list but is ignored → + // it is stripped from the patch before protection checks run const patchPath = writePatch(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); const { main } = require("./create_pull_request.cjs"); @@ -708,6 +709,10 @@ ${diffs} const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); expect(result.error || "").not.toContain("outside the allowed-files list"); + // Verify the patch file was rewritten to exclude the ignored file + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).not.toContain("auto-generated/file.txt"); + expect(filteredPatch).toContain("src/index.js"); }); it("should still block non-ignored files that violate the allowed-files list", async () => { @@ -727,7 +732,7 @@ ${diffs} }); it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // package.json is protected but is ignored → should not trigger protection + // package.json is protected but is ignored → stripped from patch before protection checks const patchPath = writePatch(createPatchWithFiles("src/index.js", "package.json")); const { main } = require("./create_pull_request.cjs"); @@ -739,6 +744,9 @@ ${diffs} const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); expect(result.error || "").not.toContain("protected files"); + // Verify the patch file was rewritten to exclude package.json + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).not.toContain("package.json"); }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { @@ -751,6 +759,10 @@ ${diffs} }); const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + // Patch is empty after filtering — no allowlist violation triggered expect(result.error || "").not.toContain("outside the allowed-files list"); + // Verify the patch file was cleared + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).toBe(""); }); }); diff --git a/actions/setup/js/manifest_file_helpers.cjs b/actions/setup/js/manifest_file_helpers.cjs index 056da6af0fe..7f1843e34e8 100644 --- a/actions/setup/js/manifest_file_helpers.cjs +++ b/actions/setup/js/manifest_file_helpers.cjs @@ -129,8 +129,6 @@ function checkAllowedFiles(patchContent, allowedFilePatterns) { /** * Identifies which files in a patch match the given list of ignored-file glob patterns. * Matching is done against the full file path (e.g. `.github/workflows/ci.yml`). - * Files that match are excluded from subsequent `allowed-files` and `protected-files` - * checks inside `checkFileProtection`. * * Glob matching supports `*` (matches any characters except `/`) and `**` (matches * any characters including `/`). @@ -153,47 +151,107 @@ function checkIgnoredFiles(patchContent, ignoredFilePatterns) { return { ignoredFiles }; } +/** + * Filters a git patch to remove entire diff sections for files matching the given glob patterns. + * This implements `.gitignore`-style exclusion: matching files are completely stripped from the + * patch so they will not appear in the resulting commit when the patch is applied with `git am`. + * + * Both the `a/` (original) and `b/` (new) sides of each diff section are checked + * against the patterns so that renames are handled correctly: if either side matches, the + * entire diff section is removed. + * + * Glob matching supports `*` (matches any characters except `/`) and `**` (matches + * any characters including `/`). + * + * @param {string} patchContent - The git patch content (from git format-patch) + * @param {string[]} ignoredFilePatterns - Glob patterns for files to exclude from the patch + * @returns {string} The filtered patch with ignored file sections removed, or empty string if all files were ignored + */ +function filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns) { + if (!ignoredFilePatterns || ignoredFilePatterns.length === 0) { + return patchContent; + } + if (!patchContent || !patchContent.trim()) { + return patchContent; + } + const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); + const compiledPatterns = ignoredFilePatterns.map(p => globPatternToRegex(p)); + + /** + * @param {string} filePath + * @returns {boolean} + */ + function isIgnored(filePath) { + return compiledPatterns.some(re => re.test(filePath)); + } + + // Find the start of the first diff section + const firstDiffMatch = /^diff --git /m.exec(patchContent); + if (!firstDiffMatch) { + return patchContent; // No diff sections found + } + + const header = patchContent.slice(0, firstDiffMatch.index); + const diffContent = patchContent.slice(firstDiffMatch.index); + + // Split into individual diff sections; lookahead keeps "diff --git" prefix with each section + const sections = diffContent.split(/(?=^diff --git )/m); + + const keptSections = sections.filter(section => { + if (!section.startsWith("diff --git ")) { + return true; // keep non-diff content (e.g. trailing version signature) + } + // Extract paths from "diff --git a/ b/" + const headerMatch = /^diff --git a\/(.+) b\/(.+)$/m.exec(section); + if (!headerMatch) { + return true; // can't parse, keep it + } + const aPath = headerMatch[1].trimEnd(); + const bPath = headerMatch[2].trimEnd(); + // Remove this section if either path matches an ignored pattern + return !isIgnored(aPath) && !isIgnored(bPath); + }); + + // If no diff sections remain, return empty string + const hasRemainingDiffs = keptSections.some(s => s.startsWith("diff --git ")); + if (!hasRemainingDiffs) { + return ""; + } + + return header + keptSections.join(""); +} + /** * Evaluates a patch against the configured file-protection policy and returns a * single structured result, eliminating nested branching in callers. * * The checks are applied in order and all must pass: - * 0. If `ignored_files` is set → matching files are excluded from all subsequent checks. - * 1. If `allowed_files` is set → every non-ignored file must match at least one pattern (deny if not). - * 2. `protected-files` policy applies independently to non-ignored files: "allowed" = skip, + * 1. If `allowed_files` is set → every file in the patch must match at least one pattern (deny if not). + * 2. `protected-files` policy applies independently: "allowed" = skip, * "fallback-to-issue" = create review issue, default ("blocked") = deny. * * To allow an agent to write protected files, set both `allowed-files` (strict scope) and * `protected-files: allowed` (explicit permission) — neither overrides the other implicitly. * - * @param {string} patchContent - The git patch content + * Note: `ignored-files` filtering must be applied to the patch **before** calling this function + * (see `filterPatchByIgnoredFiles`). The ignored files will already be absent from the patch + * by the time protection checks run. + * + * @param {string} patchContent - The git patch content (after ignored-files have been filtered out) * @param {HandlerConfig} config * @returns {{ action: 'allow' } | { action: 'deny', source: 'allowlist'|'protected', files: string[] } | { action: 'fallback', files: string[] }} */ function checkFileProtection(patchContent, config) { - // Step 0: build ignored-file sets (applied before all other checks) - const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; - const { ignoredFiles } = checkIgnoredFiles(patchContent, ignoredFilePatterns); - const ignoredPaths = new Set(ignoredFiles); - // Build basename set for filtering manifest (basename-only) results - const ignoredBasenames = new Set( - ignoredFiles.map(p => { - const parts = p.split("/"); - return parts[parts.length - 1]; - }) - ); - - // Step 1: allowlist check (if configured) — applied to non-ignored files only + // Step 1: allowlist check (if configured) const allowedFilePatterns = Array.isArray(config.allowed_files) ? config.allowed_files : []; if (allowedFilePatterns.length > 0) { const { disallowedFiles } = checkAllowedFiles(patchContent, allowedFilePatterns); - const effectiveDisallowed = disallowedFiles.filter(f => !ignoredPaths.has(f)); - if (effectiveDisallowed.length > 0) { - return { action: "deny", source: "allowlist", files: effectiveDisallowed }; + if (disallowedFiles.length > 0) { + return { action: "deny", source: "allowlist", files: disallowedFiles }; } } - // Step 2: protected-files check (independent of allowlist) — applied to non-ignored files only + // Step 2: protected-files check (independent of allowlist) if (config.protected_files_policy === "allowed") { return { action: "allow" }; } @@ -202,9 +260,7 @@ function checkFileProtection(patchContent, config) { const prefixes = Array.isArray(config.protected_path_prefixes) ? config.protected_path_prefixes : []; const { manifestFilesFound } = checkForManifestFiles(patchContent, manifestFiles); const { protectedPathsFound } = checkForProtectedPaths(patchContent, prefixes); - const effectiveManifest = manifestFilesFound.filter(f => !ignoredBasenames.has(f)); - const effectivePaths = protectedPathsFound.filter(f => !ignoredPaths.has(f)); - const allFound = [...effectiveManifest, ...effectivePaths]; + const allFound = [...manifestFilesFound, ...protectedPathsFound]; if (allFound.length === 0) { return { action: "allow" }; @@ -213,4 +269,4 @@ function checkFileProtection(patchContent, config) { return config.protected_files_policy === "fallback-to-issue" ? { action: "fallback", files: allFound } : { action: "deny", source: "protected", files: allFound }; } -module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, checkFileProtection }; +module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, filterPatchByIgnoredFiles, checkFileProtection }; diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index ea1eaef65c0..70c138974f1 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "module"; const require = createRequire(import.meta.url); -const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, filterPatchByIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); describe("manifest_file_helpers", () => { describe("extractFilenamesFromPatch", () => { @@ -440,53 +440,96 @@ index abc..def 100644 expect(result.action).toBe("deny"); expect(result.source).toBe("allowlist"); }); + }); - it("should allow when ignored file would have violated the allowlist", () => { - // auto-generated/file.txt is outside allowed-files but is ignored → allow - const result = checkFileProtection(makePatch("auto-generated/file.txt"), { - ignored_files: ["auto-generated/**"], - allowed_files: ["src/**"], - }); - expect(result.action).toBe("allow"); + describe("filterPatchByIgnoredFiles", () => { + /** + * Build a minimal git format-patch containing one diff section per file path. + * @param {...string} filePaths + * @returns {string} + */ + function makePatch(...filePaths) { + const diffs = filePaths + .map( + p => `diff --git a/${p} b/${p} +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/${p} +@@ -0,0 +1 @@ ++content +` + ) + .join(""); + return `From abc123 Mon Sep 17 00:00:00 2001\nFrom: Test \nDate: Mon, 1 Jan 2024 00:00:00 +0000\nSubject: [PATCH] Test\n\n${diffs}--\n2.34.1\n`; + } + + it("should return original patch when no patterns provided", () => { + const patch = makePatch("src/index.js"); + expect(filterPatchByIgnoredFiles(patch, [])).toBe(patch); + expect(filterPatchByIgnoredFiles(patch, null)).toBe(patch); }); - it("should still deny non-ignored files that violate the allowlist", () => { - // auto-generated/file.txt is ignored, but src/bad.js is outside allowed-files - const result = checkFileProtection(makePatch("auto-generated/file.txt", "src/bad.js"), { - ignored_files: ["auto-generated/**"], - allowed_files: ["src/good.js"], - }); - expect(result.action).toBe("deny"); - expect(result.source).toBe("allowlist"); - expect(result.files).toContain("src/bad.js"); - expect(result.files).not.toContain("auto-generated/file.txt"); + it("should return original patch for empty or null content", () => { + expect(filterPatchByIgnoredFiles("", ["dist/**"])).toBe(""); + expect(filterPatchByIgnoredFiles(null, ["dist/**"])).toBe(null); }); - it("should allow when ignored file would have triggered protected-files", () => { - // package.json is protected but it is ignored → allow - const result = checkFileProtection(makePatch("package.json"), { - ignored_files: ["package.json"], - protected_files: ["package.json"], - protected_files_policy: "blocked", - }); - expect(result.action).toBe("allow"); + it("should remove diff section for a file matching the pattern", () => { + const patch = makePatch("src/index.js", "auto-generated/file.txt"); + const result = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); + expect(result).toContain("diff --git a/src/index.js"); + expect(result).not.toContain("diff --git a/auto-generated/file.txt"); }); - it("should allow when ignored file would have triggered protected path prefix", () => { - const result = checkFileProtection(makePatch(".github/workflows/ci.yml"), { - ignored_files: [".github/**"], - protected_path_prefixes: [".github/"], + it("should return empty string when all files are ignored", () => { + const patch = makePatch("dist/bundle.js", "dist/bundle.css"); + const result = filterPatchByIgnoredFiles(patch, ["dist/**"]); + expect(result).toBe(""); + }); + + it("should support ** glob for deep path matching", () => { + const patch = makePatch("dist/deep/nested/bundle.js", "src/index.js"); + const result = filterPatchByIgnoredFiles(patch, ["dist/**"]); + expect(result).not.toContain("dist/deep/nested/bundle.js"); + expect(result).toContain("src/index.js"); + }); + + it("should remove section when a-side of a rename matches the pattern", () => { + const renamePatch = `From abc123 Mon Sep 17 00:00:00 2001\nSubject: rename\n\ndiff --git a/auto-generated/old.txt b/src/new.txt\nsimilarity index 100%\nrename from auto-generated/old.txt\nrename to src/new.txt\n`; + const result = filterPatchByIgnoredFiles(renamePatch, ["auto-generated/**"]); + expect(result).toBe(""); + }); + + it("should allow when ignored file would have violated the allowlist (integration)", () => { + // Simulate what handlers do: filter patch first, then check protection + const patch = makePatch("auto-generated/file.txt"); + const filtered = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); + // After filtering the patch is empty — no files remain to violate the allowlist + expect(filtered).toBe(""); + }); + + it("should allow when ignored file would have triggered protected-files (integration)", () => { + const patch = makePatch("src/index.js", "package.json"); + const filtered = filterPatchByIgnoredFiles(patch, ["package.json"]); + // package.json diff is gone; only src/index.js remains + const result = checkFileProtection(filtered, { + protected_files: ["package.json"], protected_files_policy: "blocked", }); expect(result.action).toBe("allow"); }); - it("should allow when all patch files are ignored", () => { - const result = checkFileProtection(makePatch("dist/bundle.js", "dist/bundle.css"), { - ignored_files: ["dist/**"], - allowed_files: ["src/**"], + it("should still deny non-ignored files that violate the allowlist (integration)", () => { + const patch = makePatch("auto-generated/file.txt", "src/bad.js"); + const filtered = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); + // auto-generated stripped; src/bad.js remains and is outside src/good.js allowlist + const result = checkFileProtection(filtered, { + allowed_files: ["src/good.js"], }); - expect(result.action).toBe("allow"); + expect(result.action).toBe("deny"); + expect(result.source).toBe("allowlist"); + expect(result.files).toContain("src/bad.js"); }); }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 0348c4fed02..17e86b44598 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -11,7 +11,7 @@ const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"); const { detectForkPR } = require("./pr_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); -const { checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { checkFileProtection, filterPatchByIgnoredFiles } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); const { getGitAuthEnv } = require("./git_helpers.cjs"); @@ -116,7 +116,20 @@ async function main(config = {}) { } } - const patchContent = fs.readFileSync(patchFilePath, "utf8"); + let patchContent = fs.readFileSync(patchFilePath, "utf8"); + + // Apply ignored-files filter: strip diff sections for matching files from the patch + // so they are absent from the resulting commit (`.gitignore`-style exclusion). + const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; + if (ignoredFilePatterns.length > 0) { + const filteredContent = filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns); + if (filteredContent !== patchContent) { + core.info(`ignored-files: filtered patch (${ignoredFilePatterns.join(", ")})`); + patchContent = filteredContent; + // Write the filtered patch back so git am uses the correct content + fs.writeFileSync(patchFilePath, patchContent, "utf8"); + } + } // Check for actual error conditions if (patchContent.includes("Failed to generate patch")) { diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index ce4befac7d6..d1e0358376a 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1198,7 +1198,8 @@ ${diffs} } it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // auto-generated/file.txt would violate the allowed-files list but is ignored + // auto-generated/file.txt would violate the allowed-files list but is ignored → + // it is stripped from the patch before protection checks run const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); @@ -1210,6 +1211,10 @@ ${diffs} const result = await handler({ patch_path: patchPath }, {}); expect(result.error || "").not.toContain("outside the allowed-files list"); + // Verify the patch file was rewritten to exclude the ignored file + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).not.toContain("auto-generated/file.txt"); + expect(filteredPatch).toContain("src/index.js"); }); it("should still block non-ignored files that violate the allowed-files list", async () => { @@ -1229,7 +1234,7 @@ ${diffs} }); it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // package.json is protected but is ignored → should not trigger protection + // package.json is protected but is ignored → stripped from patch before protection checks const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "package.json")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); @@ -1242,6 +1247,9 @@ ${diffs} const result = await handler({ patch_path: patchPath }, {}); expect(result.error || "").not.toContain("protected files"); + // Verify the patch file was rewritten to exclude package.json + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).not.toContain("package.json"); }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { @@ -1255,7 +1263,11 @@ ${diffs} }); const result = await handler({ patch_path: patchPath }, {}); + // Patch is empty after filtering — no allowlist violation triggered expect(result.error || "").not.toContain("outside the allowed-files list"); + // Verify the patch file was cleared + const filteredPatch = fs.readFileSync(patchPath, "utf8"); + expect(filteredPatch).toBe(""); }); }); }); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6fa9c2aa2ca..30bf5d66a03 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5363,7 +5363,7 @@ "items": { "type": "string" }, - "description": "List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks — matching files are excluded from all protection checks and from the effective patch. Supports * (any characters except /) and ** (any characters including /)." + "description": "List of glob patterns for files to exclude from the patch before it is applied. Matching files are stripped from the patch entirely so they will not appear in the resulting commit and will not be subject to allowed-files or protected-files checks. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false, @@ -6422,7 +6422,7 @@ "items": { "type": "string" }, - "description": "List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks — matching files are excluded from all protection checks and from the effective patch. Supports * (any characters except /) and ** (any characters including /)." + "description": "List of glob patterns for files to exclude from the patch before it is applied. Matching files are stripped from the patch entirely so they will not appear in the resulting commit and will not be subject to allowed-files or protected-files checks. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false From 3118bc5b65b095088168f3ff2749b6970df2acc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:03:42 +0000 Subject: [PATCH 5/8] feat: filter ignored-files via git :(exclude) pathspecs at patch generation time Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pull_request.cjs | 16 +-- actions/setup/js/create_pull_request.test.cjs | 30 ++--- actions/setup/js/generate_git_patch.cjs | 24 +++- actions/setup/js/generate_git_patch.test.cjs | 109 ++++++++++++++++++ actions/setup/js/manifest_file_helpers.cjs | 80 +------------ .../setup/js/manifest_file_helpers.test.cjs | 93 +-------------- .../setup/js/push_to_pull_request_branch.cjs | 17 +-- .../js/push_to_pull_request_branch.test.cjs | 30 ++--- actions/setup/js/safe_outputs_handlers.cjs | 8 ++ 9 files changed, 169 insertions(+), 238 deletions(-) diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 1396c9fe24b..a6f52827628 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -23,7 +23,7 @@ const { createCheckoutManager } = require("./dynamic_checkout.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { checkFileProtection, filterPatchByIgnoredFiles } = require("./manifest_file_helpers.cjs"); +const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); /** @@ -340,20 +340,6 @@ async function main(config = {}) { if (patchFilePath && fs.existsSync(patchFilePath)) { patchContent = fs.readFileSync(patchFilePath, "utf8"); - - // Apply ignored-files filter: strip diff sections for matching files from the patch - // so they are absent from the resulting commit (`.gitignore`-style exclusion). - const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; - if (ignoredFilePatterns.length > 0) { - const filteredContent = filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns); - if (filteredContent !== patchContent) { - core.info(`ignored-files: filtered patch (${ignoredFilePatterns.join(", ")})`); - patchContent = filteredContent; - // Write the filtered patch back so git am uses the correct content - fs.writeFileSync(patchFilePath, patchContent, "utf8"); - } - } - isEmpty = !patchContent || !patchContent.trim(); } diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 39c80854b19..187fbd27009 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -697,9 +697,9 @@ ${diffs} } it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // auto-generated/file.txt would violate the allowed-files list but is ignored → - // it is stripped from the patch before protection checks run - const patchPath = writePatch(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: the patch already contains only the non-ignored file. + const patchPath = writePatch(createPatchWithFiles("src/index.js")); const { main } = require("./create_pull_request.cjs"); const handler = await main({ @@ -709,10 +709,6 @@ ${diffs} const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); expect(result.error || "").not.toContain("outside the allowed-files list"); - // Verify the patch file was rewritten to exclude the ignored file - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).not.toContain("auto-generated/file.txt"); - expect(filteredPatch).toContain("src/index.js"); }); it("should still block non-ignored files that violate the allowed-files list", async () => { @@ -732,8 +728,9 @@ ${diffs} }); it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // package.json is protected but is ignored → stripped from patch before protection checks - const patchPath = writePatch(createPatchWithFiles("src/index.js", "package.json")); + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: the patch already contains only the non-ignored file. + const patchPath = writePatch(createPatchWithFiles("src/index.js")); const { main } = require("./create_pull_request.cjs"); const handler = await main({ @@ -744,25 +741,20 @@ ${diffs} const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); expect(result.error || "").not.toContain("protected files"); - // Verify the patch file was rewritten to exclude package.json - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).not.toContain("package.json"); }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { - const patchPath = writePatch(createPatchWithFiles("dist/bundle.js")); - + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: all files were excluded so the patch file is absent. const { main } = require("./create_pull_request.cjs"); const handler = await main({ ignored_files: ["dist/**"], allowed_files: ["src/**"], }); - const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); + // No patch file — simulates all changes being ignored at generation time + const result = await handler({ patch_path: path.join(tempDir, "nonexistent.patch"), title: "Test PR", body: "" }, {}); - // Patch is empty after filtering — no allowlist violation triggered + // No patch → treated as no changes, not an allowlist violation expect(result.error || "").not.toContain("outside the allowed-files list"); - // Verify the patch file was cleared - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).toBe(""); }); }); diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index f5531237a62..54a6abc3b7a 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -96,6 +96,9 @@ function getPatchPathForRepo(branchName, repoSlug) { * Required for multi-repo scenarios to prevent patch file collisions. * @param {string} [options.token] - GitHub token for git authentication. Falls back to GITHUB_TOKEN env var. * Use this for cross-repo scenarios where a custom PAT with access to the target repo is needed. + * @param {string[]} [options.ignoredFiles] - Glob patterns for files to exclude from the patch. + * Each pattern is passed to `git format-patch` as a `:(exclude)` pathspec so the + * matching files are never included in the generated patch. * @returns {Promise} Object with patch info or error */ async function generateGitPatch(branchName, baseBranch, options = {}) { @@ -103,6 +106,21 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // Support custom cwd for multi-repo scenarios const cwd = options.cwd || process.env.GITHUB_WORKSPACE || process.cwd(); // Include repo slug in patch path for multi-repo disambiguation + + // Build :(exclude) pathspec arguments from the ignoredFiles option. + // These are appended after "--" so git treats them as pathspecs, not revisions. + // Using git's native pathspec magic keeps the exclusions out of the patch entirely + // without any post-processing of the generated patch file. + const excludePathspecs = Array.isArray(options.ignoredFiles) && options.ignoredFiles.length > 0 ? options.ignoredFiles.map(p => `:(exclude)${p}`) : []; + + /** + * Returns the arguments to append to a format-patch call when ignoredFiles is set. + * Produces ["--", ":(exclude)pattern1", ":(exclude)pattern2", ...] or []. + * @returns {string[]} + */ + function excludeArgs() { + return excludePathspecs.length > 0 ? ["--", ...excludePathspecs] : []; + } const patchPath = options.repoSlug ? getPatchPathForRepo(branchName, options.repoSlug) : getPatchPath(branchName); // Validate baseBranch early to avoid confusing git errors (e.g., origin/undefined) @@ -235,7 +253,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { if (commitCount > 0) { // Generate patch from the determined base to the branch - const patchContent = execGitSync(["format-patch", `${baseRef}..${branchName}`, "--stdout"], { cwd }); + const patchContent = execGitSync(["format-patch", `${baseRef}..${branchName}`, "--stdout", ...excludeArgs()], { cwd }); if (patchContent && patchContent.trim()) { fs.writeFileSync(patchPath, patchContent, "utf8"); @@ -304,7 +322,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { if (commitCount > 0) { // Generate patch from GITHUB_SHA to HEAD - const patchContent = execGitSync(["format-patch", `${githubSha}..HEAD`, "--stdout"], { cwd }); + const patchContent = execGitSync(["format-patch", `${githubSha}..HEAD`, "--stdout", ...excludeArgs()], { cwd }); if (patchContent && patchContent.trim()) { fs.writeFileSync(patchPath, patchContent, "utf8"); @@ -362,7 +380,7 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { } if (baseCommit) { - const patchContent = execGitSync(["format-patch", `${baseCommit}..${branchName}`, "--stdout"], { cwd }); + const patchContent = execGitSync(["format-patch", `${baseCommit}..${branchName}`, "--stdout", ...excludeArgs()], { cwd }); if (patchContent && patchContent.trim()) { fs.writeFileSync(patchPath, patchContent, "utf8"); diff --git a/actions/setup/js/generate_git_patch.test.cjs b/actions/setup/js/generate_git_patch.test.cjs index 1dd690a024f..85c1696f7c5 100644 --- a/actions/setup/js/generate_git_patch.test.cjs +++ b/actions/setup/js/generate_git_patch.test.cjs @@ -1,4 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "child_process"; +import { createRequire } from "module"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const require = createRequire(import.meta.url); describe("generateGitPatch", () => { let originalEnv; @@ -376,3 +383,105 @@ describe("getPatchPath", () => { expect(getPatchPath("Feature/BRANCH")).toBe("/tmp/gh-aw/aw-feature-branch.patch"); }); }); + +// ────────────────────────────────────────────────────── +// ignoredFiles option – end-to-end with a real git repo +// ────────────────────────────────────────────────────── + +describe("generateGitPatch – ignoredFiles option", () => { + let repoDir; + let originalEnv; + + beforeEach(() => { + originalEnv = { GITHUB_WORKSPACE: process.env.GITHUB_WORKSPACE, GITHUB_SHA: process.env.GITHUB_SHA }; + + // Set up the core global required by git_helpers.cjs + global.core = { debug: () => {}, info: () => {}, warning: () => {}, error: () => {} }; + + // Create an isolated git repo in a temp directory + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-patch-test-")); + execSync("git init -b main", { cwd: repoDir }); + execSync('git config user.email "test@example.com"', { cwd: repoDir }); + execSync('git config user.name "Test"', { cwd: repoDir }); + + // Initial commit so the repo has a base + fs.writeFileSync(path.join(repoDir, "README.md"), "# Repo\n"); + execSync("git add .", { cwd: repoDir }); + execSync('git commit -m "init"', { cwd: repoDir }); + + // Record the initial commit SHA for GITHUB_SHA (Strategy 2 base) + const sha = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim(); + process.env.GITHUB_SHA = sha; + // Clear GITHUB_WORKSPACE so the cwd option is used instead + delete process.env.GITHUB_WORKSPACE; + + // Reset module cache so each test gets a fresh module instance + delete require.cache[require.resolve("./generate_git_patch.cjs")]; + }); + + afterEach(() => { + // Restore env + Object.entries(originalEnv).forEach(([k, v]) => { + if (v !== undefined) process.env[k] = v; + else delete process.env[k]; + }); + // Clean up temp repo + if (repoDir && fs.existsSync(repoDir)) { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + delete require.cache[require.resolve("./generate_git_patch.cjs")]; + delete global.core; + }); + + function commitFiles(files) { + for (const [filePath, content] of Object.entries(files)) { + const abs = path.join(repoDir, filePath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + execSync("git add .", { cwd: repoDir }); + execSync('git commit -m "add files"', { cwd: repoDir }); + } + + it("should include all files when ignoredFiles is not set", async () => { + commitFiles({ + "src/index.js": "console.log('hello');\n", + "dist/bundle.js": "/* bundled */\n", + }); + + const { generateGitPatch } = require("./generate_git_patch.cjs"); + const result = await generateGitPatch(null, "main", { cwd: repoDir }); + + expect(result.success).toBe(true); + const patch = fs.readFileSync(result.patchPath, "utf8"); + expect(patch).toContain("src/index.js"); + expect(patch).toContain("dist/bundle.js"); + }); + + it("should exclude files matching ignoredFiles patterns from the patch", async () => { + commitFiles({ + "src/index.js": "console.log('hello');\n", + "dist/bundle.js": "/* bundled */\n", + }); + + const { generateGitPatch } = require("./generate_git_patch.cjs"); + const result = await generateGitPatch(null, "main", { cwd: repoDir, ignoredFiles: ["dist/**"] }); + + expect(result.success).toBe(true); + const patch = fs.readFileSync(result.patchPath, "utf8"); + expect(patch).toContain("src/index.js"); + expect(patch).not.toContain("dist/bundle.js"); + }); + + it("should return no patch when all files are ignored", async () => { + commitFiles({ + "dist/bundle.js": "/* bundled */\n", + }); + + const { generateGitPatch } = require("./generate_git_patch.cjs"); + const result = await generateGitPatch(null, "main", { cwd: repoDir, ignoredFiles: ["dist/**"] }); + + // All changes were excluded — patch is empty so generation reports no changes + expect(result.success).toBe(false); + }); +}); diff --git a/actions/setup/js/manifest_file_helpers.cjs b/actions/setup/js/manifest_file_helpers.cjs index 7f1843e34e8..96f53b06671 100644 --- a/actions/setup/js/manifest_file_helpers.cjs +++ b/actions/setup/js/manifest_file_helpers.cjs @@ -151,76 +151,6 @@ function checkIgnoredFiles(patchContent, ignoredFilePatterns) { return { ignoredFiles }; } -/** - * Filters a git patch to remove entire diff sections for files matching the given glob patterns. - * This implements `.gitignore`-style exclusion: matching files are completely stripped from the - * patch so they will not appear in the resulting commit when the patch is applied with `git am`. - * - * Both the `a/` (original) and `b/` (new) sides of each diff section are checked - * against the patterns so that renames are handled correctly: if either side matches, the - * entire diff section is removed. - * - * Glob matching supports `*` (matches any characters except `/`) and `**` (matches - * any characters including `/`). - * - * @param {string} patchContent - The git patch content (from git format-patch) - * @param {string[]} ignoredFilePatterns - Glob patterns for files to exclude from the patch - * @returns {string} The filtered patch with ignored file sections removed, or empty string if all files were ignored - */ -function filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns) { - if (!ignoredFilePatterns || ignoredFilePatterns.length === 0) { - return patchContent; - } - if (!patchContent || !patchContent.trim()) { - return patchContent; - } - const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); - const compiledPatterns = ignoredFilePatterns.map(p => globPatternToRegex(p)); - - /** - * @param {string} filePath - * @returns {boolean} - */ - function isIgnored(filePath) { - return compiledPatterns.some(re => re.test(filePath)); - } - - // Find the start of the first diff section - const firstDiffMatch = /^diff --git /m.exec(patchContent); - if (!firstDiffMatch) { - return patchContent; // No diff sections found - } - - const header = patchContent.slice(0, firstDiffMatch.index); - const diffContent = patchContent.slice(firstDiffMatch.index); - - // Split into individual diff sections; lookahead keeps "diff --git" prefix with each section - const sections = diffContent.split(/(?=^diff --git )/m); - - const keptSections = sections.filter(section => { - if (!section.startsWith("diff --git ")) { - return true; // keep non-diff content (e.g. trailing version signature) - } - // Extract paths from "diff --git a/ b/" - const headerMatch = /^diff --git a\/(.+) b\/(.+)$/m.exec(section); - if (!headerMatch) { - return true; // can't parse, keep it - } - const aPath = headerMatch[1].trimEnd(); - const bPath = headerMatch[2].trimEnd(); - // Remove this section if either path matches an ignored pattern - return !isIgnored(aPath) && !isIgnored(bPath); - }); - - // If no diff sections remain, return empty string - const hasRemainingDiffs = keptSections.some(s => s.startsWith("diff --git ")); - if (!hasRemainingDiffs) { - return ""; - } - - return header + keptSections.join(""); -} - /** * Evaluates a patch against the configured file-protection policy and returns a * single structured result, eliminating nested branching in callers. @@ -233,11 +163,11 @@ function filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns) { * To allow an agent to write protected files, set both `allowed-files` (strict scope) and * `protected-files: allowed` (explicit permission) — neither overrides the other implicitly. * - * Note: `ignored-files` filtering must be applied to the patch **before** calling this function - * (see `filterPatchByIgnoredFiles`). The ignored files will already be absent from the patch - * by the time protection checks run. + * Note: `ignored-files` are excluded at patch generation time via `git format-patch` + * `:(exclude)` pathspecs (see `generateGitPatch` options), so they will never appear in + * the patch passed to this function. * - * @param {string} patchContent - The git patch content (after ignored-files have been filtered out) + * @param {string} patchContent - The git patch content * @param {HandlerConfig} config * @returns {{ action: 'allow' } | { action: 'deny', source: 'allowlist'|'protected', files: string[] } | { action: 'fallback', files: string[] }} */ @@ -269,4 +199,4 @@ function checkFileProtection(patchContent, config) { return config.protected_files_policy === "fallback-to-issue" ? { action: "fallback", files: allFound } : { action: "deny", source: "protected", files: allFound }; } -module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, filterPatchByIgnoredFiles, checkFileProtection }; +module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, checkFileProtection }; diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index 70c138974f1..69e661fc251 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "module"; const require = createRequire(import.meta.url); -const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, filterPatchByIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); describe("manifest_file_helpers", () => { describe("extractFilenamesFromPatch", () => { @@ -441,95 +441,4 @@ index abc..def 100644 expect(result.source).toBe("allowlist"); }); }); - - describe("filterPatchByIgnoredFiles", () => { - /** - * Build a minimal git format-patch containing one diff section per file path. - * @param {...string} filePaths - * @returns {string} - */ - function makePatch(...filePaths) { - const diffs = filePaths - .map( - p => `diff --git a/${p} b/${p} -new file mode 100644 -index 0000000..abc1234 ---- /dev/null -+++ b/${p} -@@ -0,0 +1 @@ -+content -` - ) - .join(""); - return `From abc123 Mon Sep 17 00:00:00 2001\nFrom: Test \nDate: Mon, 1 Jan 2024 00:00:00 +0000\nSubject: [PATCH] Test\n\n${diffs}--\n2.34.1\n`; - } - - it("should return original patch when no patterns provided", () => { - const patch = makePatch("src/index.js"); - expect(filterPatchByIgnoredFiles(patch, [])).toBe(patch); - expect(filterPatchByIgnoredFiles(patch, null)).toBe(patch); - }); - - it("should return original patch for empty or null content", () => { - expect(filterPatchByIgnoredFiles("", ["dist/**"])).toBe(""); - expect(filterPatchByIgnoredFiles(null, ["dist/**"])).toBe(null); - }); - - it("should remove diff section for a file matching the pattern", () => { - const patch = makePatch("src/index.js", "auto-generated/file.txt"); - const result = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); - expect(result).toContain("diff --git a/src/index.js"); - expect(result).not.toContain("diff --git a/auto-generated/file.txt"); - }); - - it("should return empty string when all files are ignored", () => { - const patch = makePatch("dist/bundle.js", "dist/bundle.css"); - const result = filterPatchByIgnoredFiles(patch, ["dist/**"]); - expect(result).toBe(""); - }); - - it("should support ** glob for deep path matching", () => { - const patch = makePatch("dist/deep/nested/bundle.js", "src/index.js"); - const result = filterPatchByIgnoredFiles(patch, ["dist/**"]); - expect(result).not.toContain("dist/deep/nested/bundle.js"); - expect(result).toContain("src/index.js"); - }); - - it("should remove section when a-side of a rename matches the pattern", () => { - const renamePatch = `From abc123 Mon Sep 17 00:00:00 2001\nSubject: rename\n\ndiff --git a/auto-generated/old.txt b/src/new.txt\nsimilarity index 100%\nrename from auto-generated/old.txt\nrename to src/new.txt\n`; - const result = filterPatchByIgnoredFiles(renamePatch, ["auto-generated/**"]); - expect(result).toBe(""); - }); - - it("should allow when ignored file would have violated the allowlist (integration)", () => { - // Simulate what handlers do: filter patch first, then check protection - const patch = makePatch("auto-generated/file.txt"); - const filtered = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); - // After filtering the patch is empty — no files remain to violate the allowlist - expect(filtered).toBe(""); - }); - - it("should allow when ignored file would have triggered protected-files (integration)", () => { - const patch = makePatch("src/index.js", "package.json"); - const filtered = filterPatchByIgnoredFiles(patch, ["package.json"]); - // package.json diff is gone; only src/index.js remains - const result = checkFileProtection(filtered, { - protected_files: ["package.json"], - protected_files_policy: "blocked", - }); - expect(result.action).toBe("allow"); - }); - - it("should still deny non-ignored files that violate the allowlist (integration)", () => { - const patch = makePatch("auto-generated/file.txt", "src/bad.js"); - const filtered = filterPatchByIgnoredFiles(patch, ["auto-generated/**"]); - // auto-generated stripped; src/bad.js remains and is outside src/good.js allowlist - const result = checkFileProtection(filtered, { - allowed_files: ["src/good.js"], - }); - expect(result.action).toBe("deny"); - expect(result.source).toBe("allowlist"); - expect(result.files).toContain("src/bad.js"); - }); - }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 17e86b44598..0348c4fed02 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -11,7 +11,7 @@ const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs"); const { detectForkPR } = require("./pr_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); -const { checkFileProtection, filterPatchByIgnoredFiles } = require("./manifest_file_helpers.cjs"); +const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); const { getGitAuthEnv } = require("./git_helpers.cjs"); @@ -116,20 +116,7 @@ async function main(config = {}) { } } - let patchContent = fs.readFileSync(patchFilePath, "utf8"); - - // Apply ignored-files filter: strip diff sections for matching files from the patch - // so they are absent from the resulting commit (`.gitignore`-style exclusion). - const ignoredFilePatterns = Array.isArray(config.ignored_files) ? config.ignored_files : []; - if (ignoredFilePatterns.length > 0) { - const filteredContent = filterPatchByIgnoredFiles(patchContent, ignoredFilePatterns); - if (filteredContent !== patchContent) { - core.info(`ignored-files: filtered patch (${ignoredFilePatterns.join(", ")})`); - patchContent = filteredContent; - // Write the filtered patch back so git am uses the correct content - fs.writeFileSync(patchFilePath, patchContent, "utf8"); - } - } + const patchContent = fs.readFileSync(patchFilePath, "utf8"); // Check for actual error conditions if (patchContent.includes("Failed to generate patch")) { diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index d1e0358376a..6f0dfe38be2 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1198,9 +1198,9 @@ ${diffs} } it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // auto-generated/file.txt would violate the allowed-files list but is ignored → - // it is stripped from the patch before protection checks run - const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "auto-generated/file.txt")); + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: the patch already contains only the non-ignored file. + const patchPath = createPatchFile(createPatchWithFiles("src/index.js")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); const module = await loadModule(); @@ -1211,10 +1211,6 @@ ${diffs} const result = await handler({ patch_path: patchPath }, {}); expect(result.error || "").not.toContain("outside the allowed-files list"); - // Verify the patch file was rewritten to exclude the ignored file - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).not.toContain("auto-generated/file.txt"); - expect(filteredPatch).toContain("src/index.js"); }); it("should still block non-ignored files that violate the allowed-files list", async () => { @@ -1234,8 +1230,9 @@ ${diffs} }); it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // package.json is protected but is ignored → stripped from patch before protection checks - const patchPath = createPatchFile(createPatchWithFiles("src/index.js", "package.json")); + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: the patch already contains only the non-ignored file. + const patchPath = createPatchFile(createPatchWithFiles("src/index.js")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); const module = await loadModule(); @@ -1247,27 +1244,22 @@ ${diffs} const result = await handler({ patch_path: patchPath }, {}); expect(result.error || "").not.toContain("protected files"); - // Verify the patch file was rewritten to exclude package.json - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).not.toContain("package.json"); }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { - const patchPath = createPatchFile(createPatchWithFiles("dist/bundle.js")); - mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); + // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // Simulate post-generation: all files were excluded so no patch file is produced. + const nonexistentPath = path.join(tempDir, "nonexistent.patch"); const module = await loadModule(); const handler = await module.main({ ignored_files: ["dist/**"], allowed_files: ["src/**"], }); - const result = await handler({ patch_path: patchPath }, {}); + const result = await handler({ patch_path: nonexistentPath }, {}); - // Patch is empty after filtering — no allowlist violation triggered + // No patch → treated as no changes, not an allowlist violation expect(result.error || "").not.toContain("outside the allowed-files list"); - // Verify the patch file was cleared - const filteredPatch = fs.readFileSync(patchPath, "utf8"); - expect(filteredPatch).toBe(""); }); }); }); diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index 8db48a8574e..d9ca3cf5b2a 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -324,6 +324,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (prConfig["github-token"]) { patchOptions.token = prConfig["github-token"]; } + // Pass ignored_files so git excludes them via :(exclude) pathspecs at generation time. + if (Array.isArray(prConfig.ignored_files) && prConfig.ignored_files.length > 0) { + patchOptions.ignoredFiles = prConfig.ignored_files; + } const patchResult = await generateGitPatch(entry.branch, baseBranch, patchOptions); if (!patchResult.success) { @@ -434,6 +438,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (pushConfig["github-token"]) { pushPatchOptions.token = pushConfig["github-token"]; } + // Pass ignored_files so git excludes them via :(exclude) pathspecs at generation time. + if (Array.isArray(pushConfig.ignored_files) && pushConfig.ignored_files.length > 0) { + pushPatchOptions.ignoredFiles = pushConfig.ignored_files; + } const patchResult = await generateGitPatch(entry.branch, baseBranch, pushPatchOptions); if (!patchResult.success) { From 61b3746fd5139d5a937f54152c6627afa4ab860e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:20:24 +0000 Subject: [PATCH 6/8] feat: rename ignored-files to excluded-files to match git :(exclude) terminology Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/cloclo.lock.yml | 4 +-- .github/workflows/cloclo.md | 2 +- actions/setup/js/create_pull_request.test.cjs | 22 ++++++++-------- actions/setup/js/generate_git_patch.cjs | 10 +++---- actions/setup/js/generate_git_patch.test.cjs | 12 ++++----- actions/setup/js/manifest_file_helpers.cjs | 24 ++++++++--------- .../setup/js/manifest_file_helpers.test.cjs | 26 +++++++++---------- .../js/push_to_pull_request_branch.test.cjs | 22 ++++++++-------- actions/setup/js/safe_outputs_handlers.cjs | 12 ++++----- actions/setup/js/types/handler-factory.d.ts | 4 +-- pkg/parser/schemas/main_workflow_schema.json | 8 +++--- pkg/workflow/compiler_safe_outputs_config.go | 4 +-- pkg/workflow/create_pull_request.go | 2 +- pkg/workflow/push_to_pull_request_branch.go | 6 ++--- 14 files changed, 79 insertions(+), 79 deletions(-) diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 01bd76451ed..574e0b6e1d5 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -27,7 +27,7 @@ # - shared/jqschema.md # - shared/mcp/serena-go.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"37380a62bb633d6d50e7fd8aed2b7726152cb6bc4aca148772df780a87512e0b","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ca1d4f87f9266a8717fba7feba5d81fcd0712569366788866211a0ac56e0f30a","strict":true} name: "/cloclo" "on": @@ -1646,7 +1646,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request\":{\"expires\":48,\"ignored_files\":[\".github/workflows/*.lock.yml\"],\"labels\":[\"automation\",\"cloclo\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"title_prefix\":\"[cloclo] \"},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"create_pull_request\":{\"excluded_files\":[\".github/workflows/*.lock.yml\"],\"expires\":48,\"labels\":[\"automation\",\"cloclo\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"title_prefix\":\"[cloclo] \"},\"missing_data\":{},\"missing_tool\":{}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cloclo.md b/.github/workflows/cloclo.md index cc908fd8a6a..cb1efe20bc2 100644 --- a/.github/workflows/cloclo.md +++ b/.github/workflows/cloclo.md @@ -32,7 +32,7 @@ safe-outputs: expires: 2d title-prefix: "[cloclo] " labels: [automation, cloclo] - ignored-files: + excluded-files: - ".github/workflows/*.lock.yml" add-comment: max: 1 diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 187fbd27009..6959948d87d 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -591,10 +591,10 @@ ${diffs} }); }); -// ignored-files exclusion list +// excluded-files exclusion list // ────────────────────────────────────────────────────── -describe("create_pull_request - ignored-files exclusion list", () => { +describe("create_pull_request - excluded-files exclusion list", () => { let tempDir; let originalEnv; @@ -696,14 +696,14 @@ ${diffs} return p; } - it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + it("should ignore files matching excluded-files patterns (not blocked by allowed-files)", async () => { + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: the patch already contains only the non-ignored file. const patchPath = writePatch(createPatchWithFiles("src/index.js")); const { main } = require("./create_pull_request.cjs"); const handler = await main({ - ignored_files: ["auto-generated/**"], + excluded_files: ["auto-generated/**"], allowed_files: ["src/**"], }); const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); @@ -716,7 +716,7 @@ ${diffs} const { main } = require("./create_pull_request.cjs"); const handler = await main({ - ignored_files: ["auto-generated/**"], + excluded_files: ["auto-generated/**"], allowed_files: ["src/**"], }); const result = await handler({ patch_path: patchPath, title: "Test PR", body: "" }, {}); @@ -727,14 +727,14 @@ ${diffs} expect(result.error).not.toContain("src/index.js"); }); - it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + it("should ignore files matching excluded-files patterns (not blocked by protected-files)", async () => { + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: the patch already contains only the non-ignored file. const patchPath = writePatch(createPatchWithFiles("src/index.js")); const { main } = require("./create_pull_request.cjs"); const handler = await main({ - ignored_files: ["package.json"], + excluded_files: ["package.json"], protected_files: ["package.json"], protected_files_policy: "blocked", }); @@ -744,11 +744,11 @@ ${diffs} }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: all files were excluded so the patch file is absent. const { main } = require("./create_pull_request.cjs"); const handler = await main({ - ignored_files: ["dist/**"], + excluded_files: ["dist/**"], allowed_files: ["src/**"], }); // No patch file — simulates all changes being ignored at generation time diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index 54a6abc3b7a..be5d9c07e9d 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -96,8 +96,8 @@ function getPatchPathForRepo(branchName, repoSlug) { * Required for multi-repo scenarios to prevent patch file collisions. * @param {string} [options.token] - GitHub token for git authentication. Falls back to GITHUB_TOKEN env var. * Use this for cross-repo scenarios where a custom PAT with access to the target repo is needed. - * @param {string[]} [options.ignoredFiles] - Glob patterns for files to exclude from the patch. - * Each pattern is passed to `git format-patch` as a `:(exclude)` pathspec so the + * @param {string[]} [options.excludedFiles] - Glob patterns for files to exclude from the patch. + * Each pattern is passed to `git format-patch` as a `:(exclude)` magic pathspec so * matching files are never included in the generated patch. * @returns {Promise} Object with patch info or error */ @@ -107,14 +107,14 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { const cwd = options.cwd || process.env.GITHUB_WORKSPACE || process.cwd(); // Include repo slug in patch path for multi-repo disambiguation - // Build :(exclude) pathspec arguments from the ignoredFiles option. + // Build :(exclude) pathspec arguments from the excludedFiles option. // These are appended after "--" so git treats them as pathspecs, not revisions. // Using git's native pathspec magic keeps the exclusions out of the patch entirely // without any post-processing of the generated patch file. - const excludePathspecs = Array.isArray(options.ignoredFiles) && options.ignoredFiles.length > 0 ? options.ignoredFiles.map(p => `:(exclude)${p}`) : []; + const excludePathspecs = Array.isArray(options.excludedFiles) && options.excludedFiles.length > 0 ? options.excludedFiles.map(p => `:(exclude)${p}`) : []; /** - * Returns the arguments to append to a format-patch call when ignoredFiles is set. + * Returns the arguments to append to a format-patch call when excludedFiles is set. * Produces ["--", ":(exclude)pattern1", ":(exclude)pattern2", ...] or []. * @returns {string[]} */ diff --git a/actions/setup/js/generate_git_patch.test.cjs b/actions/setup/js/generate_git_patch.test.cjs index 85c1696f7c5..4212f44ea6c 100644 --- a/actions/setup/js/generate_git_patch.test.cjs +++ b/actions/setup/js/generate_git_patch.test.cjs @@ -385,10 +385,10 @@ describe("getPatchPath", () => { }); // ────────────────────────────────────────────────────── -// ignoredFiles option – end-to-end with a real git repo +// excludedFiles option – end-to-end with a real git repo // ────────────────────────────────────────────────────── -describe("generateGitPatch – ignoredFiles option", () => { +describe("generateGitPatch – excludedFiles option", () => { let repoDir; let originalEnv; @@ -443,7 +443,7 @@ describe("generateGitPatch – ignoredFiles option", () => { execSync('git commit -m "add files"', { cwd: repoDir }); } - it("should include all files when ignoredFiles is not set", async () => { + it("should include all files when excludedFiles is not set", async () => { commitFiles({ "src/index.js": "console.log('hello');\n", "dist/bundle.js": "/* bundled */\n", @@ -458,14 +458,14 @@ describe("generateGitPatch – ignoredFiles option", () => { expect(patch).toContain("dist/bundle.js"); }); - it("should exclude files matching ignoredFiles patterns from the patch", async () => { + it("should exclude files matching excludedFiles patterns from the patch", async () => { commitFiles({ "src/index.js": "console.log('hello');\n", "dist/bundle.js": "/* bundled */\n", }); const { generateGitPatch } = require("./generate_git_patch.cjs"); - const result = await generateGitPatch(null, "main", { cwd: repoDir, ignoredFiles: ["dist/**"] }); + const result = await generateGitPatch(null, "main", { cwd: repoDir, excludedFiles: ["dist/**"] }); expect(result.success).toBe(true); const patch = fs.readFileSync(result.patchPath, "utf8"); @@ -479,7 +479,7 @@ describe("generateGitPatch – ignoredFiles option", () => { }); const { generateGitPatch } = require("./generate_git_patch.cjs"); - const result = await generateGitPatch(null, "main", { cwd: repoDir, ignoredFiles: ["dist/**"] }); + const result = await generateGitPatch(null, "main", { cwd: repoDir, excludedFiles: ["dist/**"] }); // All changes were excluded — patch is empty so generation reports no changes expect(result.success).toBe(false); diff --git a/actions/setup/js/manifest_file_helpers.cjs b/actions/setup/js/manifest_file_helpers.cjs index 96f53b06671..cab489f711b 100644 --- a/actions/setup/js/manifest_file_helpers.cjs +++ b/actions/setup/js/manifest_file_helpers.cjs @@ -127,28 +127,28 @@ function checkAllowedFiles(patchContent, allowedFilePatterns) { } /** - * Identifies which files in a patch match the given list of ignored-file glob patterns. + * Identifies which files in a patch match the given list of excluded-file glob patterns. * Matching is done against the full file path (e.g. `.github/workflows/ci.yml`). * * Glob matching supports `*` (matches any characters except `/`) and `**` (matches * any characters including `/`). * * @param {string} patchContent - The git patch content - * @param {string[]} ignoredFilePatterns - Glob patterns for files to ignore - * @returns {{ ignoredFiles: string[] }} + * @param {string[]} excludedFilePatterns - Glob patterns for files to exclude + * @returns {{ excludedFiles: string[] }} */ -function checkIgnoredFiles(patchContent, ignoredFilePatterns) { - if (!ignoredFilePatterns || ignoredFilePatterns.length === 0) { - return { ignoredFiles: [] }; +function checkExcludedFiles(patchContent, excludedFilePatterns) { + if (!excludedFilePatterns || excludedFilePatterns.length === 0) { + return { excludedFiles: [] }; } const allPaths = extractPathsFromPatch(patchContent); if (allPaths.length === 0) { - return { ignoredFiles: [] }; + return { excludedFiles: [] }; } const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); - const compiledPatterns = ignoredFilePatterns.map(p => globPatternToRegex(p)); - const ignoredFiles = allPaths.filter(p => compiledPatterns.some(re => re.test(p))); - return { ignoredFiles }; + const compiledPatterns = excludedFilePatterns.map(p => globPatternToRegex(p)); + const excludedFiles = allPaths.filter(p => compiledPatterns.some(re => re.test(p))); + return { excludedFiles }; } /** @@ -163,7 +163,7 @@ function checkIgnoredFiles(patchContent, ignoredFilePatterns) { * To allow an agent to write protected files, set both `allowed-files` (strict scope) and * `protected-files: allowed` (explicit permission) — neither overrides the other implicitly. * - * Note: `ignored-files` are excluded at patch generation time via `git format-patch` + * Note: `excluded-files` are excluded at patch generation time via `git format-patch` * `:(exclude)` pathspecs (see `generateGitPatch` options), so they will never appear in * the patch passed to this function. * @@ -199,4 +199,4 @@ function checkFileProtection(patchContent, config) { return config.protected_files_policy === "fallback-to-issue" ? { action: "fallback", files: allFound } : { action: "deny", source: "protected", files: allFound }; } -module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkIgnoredFiles, checkFileProtection }; +module.exports = { extractFilenamesFromPatch, extractPathsFromPatch, checkForManifestFiles, checkForProtectedPaths, checkAllowedFiles, checkExcludedFiles, checkFileProtection }; diff --git a/actions/setup/js/manifest_file_helpers.test.cjs b/actions/setup/js/manifest_file_helpers.test.cjs index 69e661fc251..8b279fbeb98 100644 --- a/actions/setup/js/manifest_file_helpers.test.cjs +++ b/actions/setup/js/manifest_file_helpers.test.cjs @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { createRequire } from "module"; const require = createRequire(import.meta.url); -const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkIgnoredFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); +const { extractFilenamesFromPatch, checkForManifestFiles, checkAllowedFiles, checkExcludedFiles, checkFileProtection } = require("./manifest_file_helpers.cjs"); describe("manifest_file_helpers", () => { describe("extractFilenamesFromPatch", () => { @@ -336,33 +336,33 @@ index abc..def 100644 }); }); - describe("checkIgnoredFiles", () => { + describe("checkExcludedFiles", () => { const makePatch = (...filePaths) => filePaths.map(p => `diff --git a/${p} b/${p}\nindex abc..def 100644\n`).join("\n"); it("should return empty when patterns is empty", () => { - const result = checkIgnoredFiles(makePatch("src/index.js"), []); - expect(result.ignoredFiles).toEqual([]); + const result = checkExcludedFiles(makePatch("src/index.js"), []); + expect(result.excludedFiles).toEqual([]); }); it("should return empty for empty patch", () => { - const result = checkIgnoredFiles("", ["auto-generated/**"]); - expect(result.ignoredFiles).toEqual([]); + const result = checkExcludedFiles("", ["auto-generated/**"]); + expect(result.excludedFiles).toEqual([]); }); it("should identify files matching ignored patterns", () => { - const result = checkIgnoredFiles(makePatch("auto-generated/file.txt", "src/index.js"), ["auto-generated/**"]); - expect(result.ignoredFiles).toContain("auto-generated/file.txt"); - expect(result.ignoredFiles).not.toContain("src/index.js"); + const result = checkExcludedFiles(makePatch("auto-generated/file.txt", "src/index.js"), ["auto-generated/**"]); + expect(result.excludedFiles).toContain("auto-generated/file.txt"); + expect(result.excludedFiles).not.toContain("src/index.js"); }); it("should return all files when all match ignored patterns", () => { - const result = checkIgnoredFiles(makePatch("auto-generated/a.txt", "auto-generated/b.txt"), ["auto-generated/**"]); - expect(result.ignoredFiles).toHaveLength(2); + const result = checkExcludedFiles(makePatch("auto-generated/a.txt", "auto-generated/b.txt"), ["auto-generated/**"]); + expect(result.excludedFiles).toHaveLength(2); }); it("should support ** glob for deep path matching", () => { - const result = checkIgnoredFiles(makePatch("dist/deep/nested/bundle.js"), ["dist/**"]); - expect(result.ignoredFiles).toContain("dist/deep/nested/bundle.js"); + const result = checkExcludedFiles(makePatch("dist/deep/nested/bundle.js"), ["dist/**"]); + expect(result.excludedFiles).toContain("dist/deep/nested/bundle.js"); }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.test.cjs b/actions/setup/js/push_to_pull_request_branch.test.cjs index 6f0dfe38be2..74351e141f1 100644 --- a/actions/setup/js/push_to_pull_request_branch.test.cjs +++ b/actions/setup/js/push_to_pull_request_branch.test.cjs @@ -1166,10 +1166,10 @@ ${diffs} }); }); - // ignored-files exclusion list + // excluded-files exclusion list // ────────────────────────────────────────────────────── - describe("ignored-files exclusion list", () => { + describe("excluded-files exclusion list", () => { /** * Helper to create a patch that touches only the given file path(s). */ @@ -1197,15 +1197,15 @@ ${diffs} `; } - it("should ignore files matching ignored-files patterns (not blocked by allowed-files)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + it("should ignore files matching excluded-files patterns (not blocked by allowed-files)", async () => { + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: the patch already contains only the non-ignored file. const patchPath = createPatchFile(createPatchWithFiles("src/index.js")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); const module = await loadModule(); const handler = await module.main({ - ignored_files: ["auto-generated/**"], + excluded_files: ["auto-generated/**"], allowed_files: ["src/**"], }); const result = await handler({ patch_path: patchPath }, {}); @@ -1218,7 +1218,7 @@ ${diffs} const module = await loadModule(); const handler = await module.main({ - ignored_files: ["auto-generated/**"], + excluded_files: ["auto-generated/**"], allowed_files: ["src/**"], }); const result = await handler({ patch_path: patchPath }, {}); @@ -1229,15 +1229,15 @@ ${diffs} expect(result.error).not.toContain("src/index.js"); }); - it("should ignore files matching ignored-files patterns (not blocked by protected-files)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + it("should ignore files matching excluded-files patterns (not blocked by protected-files)", async () => { + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: the patch already contains only the non-ignored file. const patchPath = createPatchFile(createPatchWithFiles("src/index.js")); mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "abc123\n", stderr: "" }); const module = await loadModule(); const handler = await module.main({ - ignored_files: ["package.json"], + excluded_files: ["package.json"], protected_files: ["package.json"], protected_files_policy: "blocked", }); @@ -1247,13 +1247,13 @@ ${diffs} }); it("should allow when all patch files are ignored (even with allowed-files set)", async () => { - // ignored-files are excluded at patch generation time via git :(exclude) pathspecs. + // excluded-files are excluded at patch generation time via git :(exclude) pathspecs. // Simulate post-generation: all files were excluded so no patch file is produced. const nonexistentPath = path.join(tempDir, "nonexistent.patch"); const module = await loadModule(); const handler = await module.main({ - ignored_files: ["dist/**"], + excluded_files: ["dist/**"], allowed_files: ["src/**"], }); const result = await handler({ patch_path: nonexistentPath }, {}); diff --git a/actions/setup/js/safe_outputs_handlers.cjs b/actions/setup/js/safe_outputs_handlers.cjs index d9ca3cf5b2a..5dfd2a15b61 100644 --- a/actions/setup/js/safe_outputs_handlers.cjs +++ b/actions/setup/js/safe_outputs_handlers.cjs @@ -324,9 +324,9 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (prConfig["github-token"]) { patchOptions.token = prConfig["github-token"]; } - // Pass ignored_files so git excludes them via :(exclude) pathspecs at generation time. - if (Array.isArray(prConfig.ignored_files) && prConfig.ignored_files.length > 0) { - patchOptions.ignoredFiles = prConfig.ignored_files; + // Pass excluded_files so git excludes them via :(exclude) pathspecs at generation time. + if (Array.isArray(prConfig.excluded_files) && prConfig.excluded_files.length > 0) { + patchOptions.excludedFiles = prConfig.excluded_files; } const patchResult = await generateGitPatch(entry.branch, baseBranch, patchOptions); @@ -438,9 +438,9 @@ function createHandlers(server, appendSafeOutput, config = {}) { if (pushConfig["github-token"]) { pushPatchOptions.token = pushConfig["github-token"]; } - // Pass ignored_files so git excludes them via :(exclude) pathspecs at generation time. - if (Array.isArray(pushConfig.ignored_files) && pushConfig.ignored_files.length > 0) { - pushPatchOptions.ignoredFiles = pushConfig.ignored_files; + // Pass excluded_files so git excludes them via :(exclude) pathspecs at generation time. + if (Array.isArray(pushConfig.excluded_files) && pushConfig.excluded_files.length > 0) { + pushPatchOptions.excludedFiles = pushConfig.excluded_files; } const patchResult = await generateGitPatch(entry.branch, baseBranch, pushPatchOptions); diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index 55e7c16f24c..b40b336a270 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -10,8 +10,8 @@ interface HandlerConfig { max?: number; /** Strict allowlist of glob patterns for files eligible for push/create. Checked independently of protected-files; both checks must pass. */ allowed_files?: string[]; - /** List of glob patterns for files to ignore when creating the patch. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. */ - ignored_files?: string[]; + /** List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. */ + excluded_files?: string[]; /** List of filenames (basenames) whose presence in a patch triggers protected-file handling */ protected_files?: string[]; /** List of path prefixes that trigger protected-file handling when any changed file matches */ diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 30bf5d66a03..b551d22d6e1 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5358,12 +5358,12 @@ "description": "When true, the random salt suffix is not appended to the agent-specified branch name. Invalid characters are still replaced for security, and casing is always preserved regardless of this setting. Useful when the target repository enforces branch naming conventions (e.g. Jira keys in uppercase such as 'bugfix/BR-329-red'). Defaults to false.", "default": false }, - "ignored-files": { + "excluded-files": { "type": "array", "items": { "type": "string" }, - "description": "List of glob patterns for files to exclude from the patch before it is applied. Matching files are stripped from the patch entirely so they will not appear in the resulting commit and will not be subject to allowed-files or protected-files checks. Supports * (any characters except /) and ** (any characters including /)." + "description": "List of glob patterns for files to exclude from the patch. Each pattern is passed to `git format-patch` as a `:(exclude)` magic pathspec, so matching files are stripped by git at generation time and will not appear in the commit. Excluded files are also not subject to `allowed-files` or `protected-files` checks. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false, @@ -6417,12 +6417,12 @@ }, "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." }, - "ignored-files": { + "excluded-files": { "type": "array", "items": { "type": "string" }, - "description": "List of glob patterns for files to exclude from the patch before it is applied. Matching files are stripped from the patch entirely so they will not appear in the resulting commit and will not be subject to allowed-files or protected-files checks. Supports * (any characters except /) and ** (any characters including /)." + "description": "List of glob patterns for files to exclude from the patch. Each pattern is passed to `git format-patch` as a `:(exclude)` magic pathspec, so matching files are stripped by git at generation time and will not appear in the commit. Excluded files are also not subject to `allowed-files` or `protected-files` checks. Supports * (any characters except /) and ** (any characters including /)." } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 63214d0fdba..941b755cd2d 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -487,7 +487,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("ignored_files", c.IgnoredFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). AddIfTrue("preserve_branch_name", c.PreserveBranchName) return builder.Build() }, @@ -516,7 +516,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("ignored_files", c.IgnoredFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). Build() }, "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 97e7eefdf44..1533f9340f9 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -34,7 +34,7 @@ type CreatePullRequestsConfig struct { GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" pushes the branch but creates a review issue. AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. - IgnoredFiles []string `yaml:"ignored-files,omitempty"` // List of glob patterns for files to ignore. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. + ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). } diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index e447ebaa2fd..1837162bcf7 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -22,7 +22,7 @@ type PushToPullRequestBranchConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories in format "owner/repo" that push to pull request branch can target ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "blocked" (default) hard-blocks, "allowed" permits all changes, "fallback-to-issue" creates a review issue instead of pushing. AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. - IgnoredFiles []string `yaml:"ignored-files,omitempty"` // List of glob patterns for files to ignore. Applied before allowed-files and protected-files checks; matching files are excluded from all checks. + ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. } // buildCheckoutRepository generates a checkout step with optional target repository and custom token @@ -147,8 +147,8 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) // Parse allowed-files: list of glob patterns forming a strict allowlist of eligible files pushToBranchConfig.AllowedFiles = ParseStringArrayFromConfig(configMap, "allowed-files", pushToPullRequestBranchLog) - // Parse ignored-files: list of glob patterns for files to exclude from all checks - pushToBranchConfig.IgnoredFiles = ParseStringArrayFromConfig(configMap, "ignored-files", pushToPullRequestBranchLog) + // Parse excluded-files: list of glob patterns for files to exclude via git :(exclude) pathspecs + pushToBranchConfig.ExcludedFiles = ParseStringArrayFromConfig(configMap, "excluded-files", pushToPullRequestBranchLog) // Parse common base fields with default max of 0 (no limit) c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0) From c1210f807a95f529fcda11804dbc2fe3c48b3569 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 15:52:35 +0000 Subject: [PATCH 7/8] fix: merge main and update wasm golden files + codex test for GITHUB_HOST support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/codex_engine_test.go | 5 +++-- .../TestWasmGolden_CompileFixtures/basic-copilot.golden | 1 + .../TestWasmGolden_CompileFixtures/smoke-copilot.golden | 1 + .../TestWasmGolden_CompileFixtures/with-imports.golden | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/codex_engine_test.go b/pkg/workflow/codex_engine_test.go index 1f8242b6f22..a1aa6470302 100644 --- a/pkg/workflow/codex_engine_test.go +++ b/pkg/workflow/codex_engine_test.go @@ -203,8 +203,8 @@ func TestCodexEngineRenderMCPConfig(t *testing.T) { "startup_timeout_sec = 120", "tool_timeout_sec = 60", fmt.Sprintf("container = \"ghcr.io/github/github-mcp-server:%s\"", constants.DefaultGitHubMCPServerVersion), - "env = { \"GITHUB_PERSONAL_ACCESS_TOKEN\" = \"$GH_AW_GITHUB_TOKEN\", \"GITHUB_READ_ONLY\" = \"1\", \"GITHUB_TOOLSETS\" = \"context,repos,issues,pull_requests\" }", - "env_vars = [\"GITHUB_PERSONAL_ACCESS_TOKEN\", \"GITHUB_READ_ONLY\", \"GITHUB_TOOLSETS\"]", + "env = { \"GITHUB_HOST\" = \"$GITHUB_SERVER_URL\", \"GITHUB_PERSONAL_ACCESS_TOKEN\" = \"$GH_AW_GITHUB_TOKEN\", \"GITHUB_READ_ONLY\" = \"1\", \"GITHUB_TOOLSETS\" = \"context,repos,issues,pull_requests\" }", + "env_vars = [\"GITHUB_HOST\", \"GITHUB_PERSONAL_ACCESS_TOKEN\", \"GITHUB_READ_ONLY\", \"GITHUB_TOOLSETS\"]", "GH_AW_MCP_CONFIG_EOF", "", "# Generate JSON config for MCP gateway", @@ -214,6 +214,7 @@ func TestCodexEngineRenderMCPConfig(t *testing.T) { "\"github\": {", fmt.Sprintf("\"container\": \"ghcr.io/github/github-mcp-server:%s\",", constants.DefaultGitHubMCPServerVersion), "\"env\": {", + "\"GITHUB_HOST\": \"$GITHUB_SERVER_URL\",", "\"GITHUB_LOCKDOWN_MODE\": \"$GITHUB_MCP_LOCKDOWN\",", "\"GITHUB_PERSONAL_ACCESS_TOKEN\": \"$GITHUB_MCP_SERVER_TOKEN\",", "\"GITHUB_READ_ONLY\": \"1\",", diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden index 5e10584e523..0689af9e6b6 100644 --- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -305,6 +305,7 @@ jobs: "type": "stdio", "container": "ghcr.io/github/github-mcp-server:v0.32.0", "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden index d333d6caf93..602158a39b6 100644 --- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden +++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden @@ -468,6 +468,7 @@ jobs: "type": "stdio", "container": "ghcr.io/github/github-mcp-server:v0.32.0", "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden index ec49dc8ecba..df886e62182 100644 --- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden @@ -308,6 +308,7 @@ jobs: "type": "stdio", "container": "ghcr.io/github/github-mcp-server:v0.32.0", "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", "GITHUB_READ_ONLY": "1", From f333f70290e2bc006134aed6a9d3d85205e207aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:07:27 +0000 Subject: [PATCH 8/8] fix: rename local excludeArgs variable to avoid shadowing excludeArgs function Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_git_patch.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index be5d9c07e9d..50684574e29 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -357,8 +357,8 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { if (remoteRefs.length > 0) { // Find commits on current branch not reachable from any remote ref // This gets commits the agent added that haven't been pushed anywhere - const excludeArgs = remoteRefs.flatMap(ref => ["--not", ref]); - const revListArgs = ["rev-list", "--count", branchName, ...excludeArgs]; + const remoteExcludeArgs = remoteRefs.flatMap(ref => ["--not", ref]); + const revListArgs = ["rev-list", "--count", branchName, ...remoteExcludeArgs]; const commitCount = parseInt(execGitSync(revListArgs, { cwd }).trim(), 10); debugLog(`Strategy 3: Found ${commitCount} commits not reachable from any remote ref`);