diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index f7b01ce4e5..6f4c67d1ae 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -463,7 +463,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_8597b0f49e0186a1_EOF' - {"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_to_pull_request_branch":{"allowed_files":[".changeset/**"],"commit_title_suffix":" [skip-ci]","if_no_changes":"warn","max_patch_size":1024,"patch_format":"bundle","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","CODEOWNERS","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_path_prefixes":[".github/",".agents/",".codex/"]},"report_incomplete":{},"update_pull_request":{"allow_body":true,"allow_title":false,"default_operation":"append","max":1}} + {"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_to_pull_request_branch":{"allowed_files":[".changeset/**"],"commit_title_suffix":" [skip-ci]","if_no_changes":"warn","max_patch_size":1024,"patch_format":"bundle","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","CODEOWNERS","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_path_prefixes":[".github/",".agents/",".codex/"]},"report_incomplete":{},"update_pull_request":{"allow_body":true,"allow_title":false,"default_operation":"append","max":1,"update_branch":false}} GH_AW_SAFE_OUTPUTS_CONFIG_8597b0f49e0186a1_EOF - name: Write Safe Outputs Tools env: @@ -600,9 +600,12 @@ jobs: "type": "string", "sanitize": true, "maxLength": 256 + }, + "update_branch": { + "type": "boolean" } }, - "customValidation": "requiresOneOf:title,body" + "customValidation": "requiresOneOf:title,body,update_branch" } } uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 @@ -1208,7 +1211,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,172.30.0.1,api.npms.io,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,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,deb.nodesource.com,deno.land,docs.github.com,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,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,openai.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkg.go.dev,ppa.launchpad.net,proxy.golang.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,sum.golang.org,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\".changeset/**\"],\"commit_title_suffix\":\" [skip-ci]\",\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"patch_format\":\"bundle\",\"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\",\"CODEOWNERS\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".codex/\"]},\"report_incomplete\":{},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":false,\"default_operation\":\"append\",\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\".changeset/**\"],\"commit_title_suffix\":\" [skip-ci]\",\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"patch_format\":\"bundle\",\"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\",\"CODEOWNERS\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".codex/\"]},\"report_incomplete\":{},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":false,\"default_operation\":\"append\",\"max\":1,\"update_branch\":false}}" 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/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index a4eeb2e193..4c9ea302f4 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -973,7 +973,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_12c35d07cbe22e8f_EOF' - {"add_comment":{"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-claude"]},"add_reviewer":{"max":2,"target":"*"},"close_pull_request":{"max":1,"staged":true},"create_code_scanning_alert":{"driver":"Smoke Claude"},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-claude","expires":2,"group":true,"labels":["automation","testing"],"max":1},"create_pull_request_review_comment":{"max":5,"side":"RIGHT","target":"*"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"post_slack_message":{"description":"Post a message to a fictitious Slack channel (smoke test only — no real Slack integration)","inputs":{"channel":{"default":"#general","description":"Slack channel name to post to","required":false,"type":"string"},"message":{"description":"Message text to post","required":false,"type":"string"}}},"push_to_pull_request_branch":{"allowed_files":["smoke-test-files/smoke-claude-push-test.md"],"if_no_changes":"warn","labels":["smoke-claude"],"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","CODEOWNERS","CLAUDE.md","AGENTS.md"],"protected_path_prefixes":[".github/",".agents/",".claude/"],"staged":true,"target":"*"},"report_incomplete":{},"resolve_pull_request_review_thread":{"max":5},"submit_pull_request_review":{"footer":"always","max":1},"update_pull_request":{"allow_body":true,"allow_title":true,"max":1,"target":"*"}} + {"add_comment":{"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-claude"]},"add_reviewer":{"max":2,"target":"*"},"close_pull_request":{"max":1,"staged":true},"create_code_scanning_alert":{"driver":"Smoke Claude"},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-claude","expires":2,"group":true,"labels":["automation","testing"],"max":1},"create_pull_request_review_comment":{"max":5,"side":"RIGHT","target":"*"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"post_slack_message":{"description":"Post a message to a fictitious Slack channel (smoke test only — no real Slack integration)","inputs":{"channel":{"default":"#general","description":"Slack channel name to post to","required":false,"type":"string"},"message":{"description":"Message text to post","required":false,"type":"string"}}},"push_to_pull_request_branch":{"allowed_files":["smoke-test-files/smoke-claude-push-test.md"],"if_no_changes":"warn","labels":["smoke-claude"],"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","CODEOWNERS","CLAUDE.md","AGENTS.md"],"protected_path_prefixes":[".github/",".agents/",".claude/"],"staged":true,"target":"*"},"report_incomplete":{},"resolve_pull_request_review_thread":{"max":5},"submit_pull_request_review":{"footer":"always","max":1},"update_pull_request":{"allow_body":true,"allow_title":true,"max":1,"target":"*","update_branch":false}} GH_AW_SAFE_OUTPUTS_CONFIG_12c35d07cbe22e8f_EOF - name: Write Safe Outputs Tools env: @@ -1361,9 +1361,12 @@ jobs: "type": "string", "sanitize": true, "maxLength": 256 + }, + "update_branch": { + "type": "boolean" } }, - "customValidation": "requiresOneOf:title,body" + "customValidation": "requiresOneOf:title,body,update_branch" } } uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 @@ -2995,7 +2998,7 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUT_SCRIPTS: "{\"post_slack_message\":\"safe_output_script_post_slack_message.cjs\"}" - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"add_reviewer\":{\"max\":2,\"target\":\"*\"},\"close_pull_request\":{\"max\":1,\"staged\":true},\"create_code_scanning_alert\":{\"driver\":\"Smoke Claude\"},\"create_issue\":{\"close_older_issues\":true,\"close_older_key\":\"smoke-claude\",\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\",\"target\":\"*\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"smoke-test-files/smoke-claude-push-test.md\"],\"if_no_changes\":\"warn\",\"labels\":[\"smoke-claude\"],\"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\",\"CODEOWNERS\",\"CLAUDE.md\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"staged\":true,\"target\":\"*\"},\"report_incomplete\":{},\"resolve_pull_request_review_thread\":{\"max\":5},\"submit_pull_request_review\":{\"footer\":\"always\",\"max\":1},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":true,\"max\":1,\"target\":\"*\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"add_reviewer\":{\"max\":2,\"target\":\"*\"},\"close_pull_request\":{\"max\":1,\"staged\":true},\"create_code_scanning_alert\":{\"driver\":\"Smoke Claude\"},\"create_issue\":{\"close_older_issues\":true,\"close_older_key\":\"smoke-claude\",\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\",\"target\":\"*\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"allowed_files\":[\"smoke-test-files/smoke-claude-push-test.md\"],\"if_no_changes\":\"warn\",\"labels\":[\"smoke-claude\"],\"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\",\"CODEOWNERS\",\"CLAUDE.md\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"staged\":true,\"target\":\"*\"},\"report_incomplete\":{},\"resolve_pull_request_review_thread\":{\"max\":5},\"submit_pull_request_review\":{\"footer\":\"always\",\"max\":1},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":true,\"max\":1,\"target\":\"*\",\"update_branch\":false}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index 34d2517a42..cbb24fdf46 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -445,7 +445,7 @@ function executeCustomValidation(item, customValidation, lineNum, itemType) { // Parse custom validation rule if (customValidation.startsWith("requiresOneOf:")) { const fields = customValidation.slice("requiresOneOf:".length).split(","); - const hasValidField = fields.some(field => item[field] !== undefined); + const hasValidField = fields.some(field => item[field] !== undefined && item[field] !== false); if (!hasValidField) { return { isValid: false, diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 4b9517e9c5..0f6fbdbfd0 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -46,6 +46,16 @@ const SAMPLE_VALIDATION_CONFIG = { issue_number: { issueOrPRNumber: true }, }, }, + update_pull_request: { + defaultMax: 1, + customValidation: "requiresOneOf:title,body,update_branch", + fields: { + title: { type: "string", sanitize: true, maxLength: 256 }, + body: { type: "string", sanitize: true, maxLength: 65000 }, + update_branch: { type: "boolean" }, + pull_request_number: { issueOrPRNumber: true }, + }, + }, assign_to_agent: { defaultMax: 1, customValidation: "requiresOneOf:issue_number,pull_number", @@ -399,6 +409,23 @@ describe("safe_output_type_validator", () => { expect(result.error).toContain("issue_number"); expect(result.error).toContain("pull_number"); }); + + it("should fail for update_pull_request when update_branch is false and no title/body is provided", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_pull_request", update_branch: false }, "update_pull_request", 1); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("requires at least one of"); + }); + + it("should pass for update_pull_request when update_branch is true", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem({ type: "update_pull_request", update_branch: true }, "update_pull_request", 1); + + expect(result.isValid).toBe(true); + }); }); describe("custom validation: startLineLessOrEqualLine", () => { diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 3d87db2ed1..51594c0c98 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -783,6 +783,10 @@ "enum": ["replace", "append", "prepend"], "description": "How to update the PR body: 'replace' (default - completely overwrite), 'append' (add to end with separator), or 'prepend' (add to start with separator). Title is always replaced." }, + "update_branch": { + "type": "boolean", + "description": "When true, update the pull request branch with the latest base branch changes before applying other updates. Defaults to false." + }, "pull_request_number": { "type": ["number", "string"], "description": "Pull request number to update. This is the numeric ID from the GitHub URL (e.g., 234 in github.com/owner/repo/pull/234). Required when the workflow target is '*' (any PR)." diff --git a/actions/setup/js/types/safe-outputs.d.ts b/actions/setup/js/types/safe-outputs.d.ts index b57933516c..d509c4e7f4 100644 --- a/actions/setup/js/types/safe-outputs.d.ts +++ b/actions/setup/js/types/safe-outputs.d.ts @@ -235,6 +235,8 @@ interface UpdatePullRequestItem extends BaseSafeOutputItem { body?: string; /** Update operation for body: 'replace' (default), 'append', or 'prepend' */ operation?: "replace" | "append" | "prepend"; + /** When true, updates the pull request branch with the latest base branch changes before other updates */ + update_branch?: boolean; /** Optional pull request number for target "*" */ pull_request_number?: number | string; /** Whether the PR should be a draft (true) or ready for review (false) */ diff --git a/actions/setup/js/update_pull_request.cjs b/actions/setup/js/update_pull_request.cjs index 9400f17449..bbb9363752 100644 --- a/actions/setup/js/update_pull_request.cjs +++ b/actions/setup/js/update_pull_request.cjs @@ -15,6 +15,8 @@ const { sanitizeTitle } = require("./sanitize_title.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); +const { getErrorMessage } = require("./error_helpers.cjs"); +const { withRetry, isTransientError } = require("./error_recovery.cjs"); /** * Execute the pull request update API call @@ -32,6 +34,32 @@ async function executePRUpdate(github, context, prNumber, updateData) { // Remove internal fields const { _operation, _rawBody, _includeFooter, _workflowRepo, ...apiData } = updateData; + const updateBranch = apiData.update_branch === true; + delete apiData.update_branch; + + if (updateBranch) { + core.info(`Updating pull request #${prNumber} branch with base branch changes`); + try { + await withRetry( + () => + github.rest.pulls.updateBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }), + { + maxRetries: 1, + initialDelayMs: 0, + jitterMs: 0, + shouldRetry: isTransientError, + }, + `update pull request #${prNumber} branch from base` + ); + } catch (error) { + core.warning(`Failed to update pull request #${prNumber} branch from base: ${getErrorMessage(error)}`); + throw error; + } + } // If we have a body, process it with the appropriate operation if (rawBody !== undefined) { @@ -77,6 +105,13 @@ async function executePRUpdate(github, context, prNumber, updateData) { core.info(`Will update body (length: ${apiData.body.length})`); } + if (Object.keys(apiData).length === 0) { + return { + number: prNumber, + html_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}`, + }; + } + const { data: pr } = await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, @@ -141,6 +176,12 @@ function buildPRUpdateData(item, config) { hasUpdates = true; } + const updateBranch = item.update_branch !== undefined ? item.update_branch === true : config.update_branch === true; + if (updateBranch) { + updateData.update_branch = true; + hasUpdates = true; + } + if (!hasUpdates) { return { success: true, @@ -181,7 +222,8 @@ const main = createUpdateHandlerFactory({ additionalConfig: { allow_title: true, allow_body: true, + update_branch: false, }, }); -module.exports = { main }; +module.exports = { main, buildPRUpdateData }; diff --git a/actions/setup/js/update_pull_request.test.cjs b/actions/setup/js/update_pull_request.test.cjs index 522d66bddc..e4a34d88f6 100644 --- a/actions/setup/js/update_pull_request.test.cjs +++ b/actions/setup/js/update_pull_request.test.cjs @@ -23,6 +23,7 @@ const mockGithub = { pulls: { get: vi.fn(), update: vi.fn(), + updateBranch: vi.fn(), }, }, }; @@ -81,6 +82,11 @@ describe("update_pull_request.cjs - executePRUpdate function", () => { html_url: "https://github.com/testowner/testrepo/pull/100", }, }); + mockGithub.rest.pulls.updateBranch.mockResolvedValue({ + data: { + message: "Branch updated", + }, + }); }); describe("Replace operation", () => { @@ -738,3 +744,107 @@ describe("update_pull_request.cjs - executePRUpdate function", () => { }); }); }); + +describe("update_pull_request.cjs - update_branch behavior", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + updatePRModule = await import("./update_pull_request.cjs"); + + mockGithub.rest.pulls.get.mockResolvedValue({ + data: { + number: 100, + title: "Test PR", + body: "Original body content", + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + mockGithub.rest.pulls.update.mockResolvedValue({ + data: { + number: 100, + title: "Updated PR", + body: "Original body content", + html_url: "https://github.com/testowner/testrepo/pull/100", + }, + }); + + mockGithub.rest.pulls.updateBranch.mockResolvedValue({ + data: { + message: "Branch updated", + }, + }); + }); + + it("should include update_branch when item requests it", () => { + const result = updatePRModule.buildPRUpdateData({ update_branch: true }, {}); + + expect(result.success).toBe(true); + expect(result.data.update_branch).toBe(true); + }); + + it("should inherit update_branch from config when item does not set it", () => { + const result = updatePRModule.buildPRUpdateData({}, { update_branch: true }); + + expect(result.success).toBe(true); + expect(result.data.update_branch).toBe(true); + }); + + it("should call updateBranch when update_branch is enabled and no other fields are updated", async () => { + const handler = await updatePRModule.main({ update_branch: true }); + + const result = await handler({ pull_request_number: 100 }); + + expect(result.success).toBe(true); + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + pull_number: 100, + }); + expect(mockGithub.rest.pulls.get).not.toHaveBeenCalled(); + expect(mockGithub.rest.pulls.update).not.toHaveBeenCalled(); + }); + + it("should call updateBranch before pulls.update when update_branch and title update are both requested", async () => { + const handler = await updatePRModule.main({}); + + await handler({ + pull_request_number: 100, + title: "Updated PR", + update_branch: true, + }); + + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalled(); + expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({ + owner: "testowner", + repo: "testrepo", + pull_number: 100, + title: "Updated PR", + }); + }); + + it("should retry updateBranch on transient failures", async () => { + mockGithub.rest.pulls.updateBranch.mockRejectedValueOnce(new Error("timeout contacting github")).mockResolvedValueOnce({ + data: { message: "Branch updated after retry" }, + }); + + const handler = await updatePRModule.main({ update_branch: true }); + const result = await handler({ pull_request_number: 100 }); + + expect(result.success).toBe(true); + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(2); + }); + + it("should log update-branch operation failure when updateBranch fails", async () => { + mockGithub.rest.pulls.updateBranch.mockRejectedValueOnce(new Error("branch update forbidden")); + + const handler = await updatePRModule.main({ update_branch: true }); + const result = await handler({ pull_request_number: 100 }); + + expect(result.success).toBe(false); + expect(result.error).toContain("update pull request #100 branch from base failed"); + expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to update pull request #100 branch from base")); + }); +}); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e29e729d7a..732d46cc8a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -34,6 +34,13 @@ description: "Description of the workflow" # (optional) source: "example-value" +# Optional workflow location redirect for updates. Format: workflow spec or GitHub +# URL (e.g., owner/repo/path@ref or +# https://github.com/owner/repo/blob/main/path.md). When present, update follows +# this location and rewrites source. +# (optional) +redirect: "example-value" + # Optional tracker identifier to tag all created assets (issues, discussions, # comments, pull requests). Must be at least 8 characters and contain only # alphanumeric characters, hyphens, and underscores. This identifier will be @@ -820,9 +827,10 @@ on: # (optional) # This field supports multiple formats (oneOf): - # Option 1: Allow any authenticated user to trigger the workflow (⚠️ disables - # permission checking entirely - use with caution) - roles: "all" + # Option 1: Single repository permission level that can trigger the workflow. Use + # 'all' to allow any authenticated user (⚠️ disables permission checking entirely + # - use with caution) + roles: "admin" # Option 2: List of repository permission levels that can trigger the workflow. # Permission checks are automatically applied to potentially unsafe triggers. @@ -1530,8 +1538,8 @@ engine: "example-value" # Option 2: Extended engine configuration object with advanced options for model # selection, turn limiting, environment variables, and custom steps engine: - # AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini', - # 'crush') or a named catalog entry + # AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini', 'crush') + # or a named catalog entry id: "example-value" # Optional version of the AI engine action (e.g., 'beta', 'stable', 20). Has @@ -1764,8 +1772,7 @@ engine: # Option 4: Engine definition: full declarative metadata for a named engine entry # (used in builtin engine shared workflow files such as @builtin:engines/*.md) engine: - # Unique engine identifier (e.g. 'copilot', 'claude', 'codex', 'gemini', - # 'crush') + # Unique engine identifier (e.g. 'copilot', 'claude', 'codex', 'gemini', 'crush') id: "example-value" # Human-readable display name for the engine @@ -3407,6 +3414,18 @@ safe-outputs: reviewers: [] # Array items: string + # Optional team reviewer(s) to assign to the pull request. Accepts either a single + # string or an array of team slugs. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Single team slug to assign as a reviewer to the pull request. + team-reviewers: "example-value" + + # Option 2: List of team slugs to assign as reviewers to the pull request. + team-reviewers: [] + # Array items: string + # Optional assignee(s) for a fallback issue created when pull request creation # cannot proceed, including protected-files fallback-to-issue and pull request # creation or push failures. Accepts either a single string or an array of @@ -3485,6 +3504,14 @@ safe-outputs: # (optional) base-branch: "example-value" + # Optional list of allowed base branch patterns (glob syntax, e.g. 'main', + # 'release/*'). When configured, the agent may provide a `base` field in + # create_pull_request output to override base-branch for a single run, but only if + # it matches one of these patterns. + # (optional) + allowed-base-branches: [] + # Array of strings + # Controls whether AI-generated footer is added to the pull request. When false, # the visible footer content is omitted but XML markers (workflow-id, tracker-id, # metadata) are still included for searchability. Defaults to true. @@ -4036,6 +4063,18 @@ safe-outputs: reviewers: [] # Array of strings + # Optional allowed team reviewer or list of allowed team reviewers. If omitted, + # any team reviewers are allowed. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: string + team-reviewers: "example-value" + + # Option 2: array + team-reviewers: [] + # Array items: string + # Optional maximum number of reviewers to add (default: 3) Supports integer or # GitHub Actions expression (e.g. '${{ inputs.max }}'). # (optional) @@ -4485,6 +4524,11 @@ safe-outputs: # (optional) body: true + # When true, update the pull request branch with the latest base branch changes + # before applying other updates. Defaults to false. + # (optional) + update-branch: true + # Default operation for body updates: 'append' (add to end), 'prepend' (add to # start), or 'replace' (overwrite completely). Defaults to 'replace' if not # specified. @@ -4576,6 +4620,12 @@ safe-outputs: # (optional) if-no-changes: "warn" + # When true, treat deleted/missing pull request branch errors as a skipped push + # instead of a hard failure. Useful when the PR branch may be deleted before safe + # outputs run. + # (optional) + ignore-missing-branch-failure: true + # Optional suffix to append to generated commit titles (e.g., ' [skip ci]' to # prevent triggering CI on the commit) # (optional) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 23994195e5..447f82c772 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6976,6 +6976,10 @@ "type": "boolean", "description": "Allow updating pull request body - defaults to true, set to false to disable" }, + "update-branch": { + "type": "boolean", + "description": "When true, update the pull request branch with the latest base branch changes before applying other updates. Defaults to false." + }, "operation": { "type": "string", "description": "Default operation for body updates: 'append' (add to end), 'prepend' (add to start), or 'replace' (overwrite completely). Defaults to 'replace' if not specified.", diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 23666116af..578303508d 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -1203,6 +1203,69 @@ func TestHandlerConfigUpdateFields(t *testing.T) { } } +func TestUpdatePullRequestUpdateBranchHandlerConfig(t *testing.T) { + tests := []struct { + name string + updateBranch *bool + expected bool + }{ + { + name: "defaults update_branch to false", + updateBranch: nil, + expected: false, + }, + { + name: "sets update_branch true when configured", + updateBranch: testBoolPtr(true), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdatePullRequests: &UpdatePullRequestsConfig{ + UpdateBranch: tt.updateBranch, + }, + }, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + foundHandlerConfig := false + + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + foundHandlerConfig = true + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + + var config map[string]map[string]any + err := json.Unmarshal([]byte(jsonStr), &config) + require.NoError(t, err) + + updatePRConfig, ok := config["update_pull_request"] + require.True(t, ok, "Expected update_pull_request config") + + updateBranchValue, ok := updatePRConfig["update_branch"] + require.True(t, ok, "Expected update_branch key in update_pull_request config") + assert.Equal(t, tt.expected, updateBranchValue) + } + } + } + + require.True(t, foundHandlerConfig, "Expected GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG in generated steps") + }) + } +} + // TestEmptySafeOutputsConfig tests behavior with no safe outputs func TestEmptySafeOutputsConfig(t *testing.T) { compiler := NewCompiler() diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go index c42bb7abc6..e8aae50ac8 100644 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ b/pkg/workflow/compiler_safe_outputs_handlers.go @@ -437,6 +437,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target", c.Target). AddBoolPtrOrDefault("allow_title", c.Title, true). AddBoolPtrOrDefault("allow_body", c.Body, true). + AddBoolPtrOrDefault("update_branch", c.UpdateBranch, false). AddStringPtr("default_operation", c.Operation). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("target-repo", c.TargetRepoSlug). diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index c640af9a38..f842835971 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -935,6 +935,10 @@ ], "description": "How to update the PR body: 'replace' (default - completely overwrite), 'append' (add to end with separator), or 'prepend' (add to start with separator). Title is always replaced." }, + "update_branch": { + "type": "boolean", + "description": "When true, update the pull request branch with the latest base branch changes before applying other updates. Defaults to false." + }, "pull_request_number": { "type": [ "number", diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index c90b706ff6..847f579902 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -188,11 +188,27 @@ func TestUpdateDiscussionValidationConfig(t *testing.T) { } } +func TestUpdatePullRequestValidationConfig(t *testing.T) { + config, ok := ValidationConfig["update_pull_request"] + if !ok { + t.Fatal("update_pull_request not found in ValidationConfig") + } + + if config.CustomValidation != "requiresOneOf:title,body,update_branch" { + t.Errorf("update_pull_request customValidation = %q, want %q", config.CustomValidation, "requiresOneOf:title,body,update_branch") + } + + if _, ok := config.Fields["update_branch"]; !ok { + t.Error("update_pull_request Fields is missing the 'update_branch' field") + } +} + func TestValidationConfigConsistency(t *testing.T) { // Verify that all types with customValidation have valid validation rules validCustomValidations := map[string]bool{ "requiresOneOf:status,title,body": true, "requiresOneOf:title,body": true, + "requiresOneOf:title,body,update_branch": true, "requiresOneOf:title,body,labels": true, "requiresOneOf:issue_number,pull_number": true, "requiresOneOf:reviewers,team_reviewers": true, diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index 74ff34d27c..06d684bbcb 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -147,7 +147,12 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio } if safeOutputs.UpdatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdatePullRequests.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-pull-request") - permissions.Merge(NewPermissionsContentsReadPRWrite()) + if safeOutputs.UpdatePullRequests.UpdateBranch != nil && *safeOutputs.UpdatePullRequests.UpdateBranch { + safeOutputsPermissionsLog.Print("update-pull-request has update-branch enabled; requiring contents: write") + permissions.Merge(NewPermissionsContentsWritePRWrite()) + } else { + permissions.Merge(NewPermissionsContentsReadPRWrite()) + } } if safeOutputs.ClosePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.ClosePullRequests.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for close-pull-request") diff --git a/pkg/workflow/safe_outputs_permissions_test.go b/pkg/workflow/safe_outputs_permissions_test.go index f1ffc6bb76..84621dc0e3 100644 --- a/pkg/workflow/safe_outputs_permissions_test.go +++ b/pkg/workflow/safe_outputs_permissions_test.go @@ -218,6 +218,35 @@ func TestComputePermissionsForSafeOutputs(t *testing.T) { PermissionPullRequests: PermissionWrite, }, }, + { + name: "update-pull-request without update-branch requires contents read", + safeOutputs: &SafeOutputsConfig{ + UpdatePullRequests: &UpdatePullRequestsConfig{ + UpdateEntityConfig: UpdateEntityConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + }, + }, + }, + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionRead, + PermissionPullRequests: PermissionWrite, + }, + }, + { + name: "update-pull-request with update-branch requires contents write", + safeOutputs: &SafeOutputsConfig{ + UpdatePullRequests: &UpdatePullRequestsConfig{ + UpdateEntityConfig: UpdateEntityConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + }, + UpdateBranch: boolPtr(true), + }, + }, + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionWrite, + PermissionPullRequests: PermissionWrite, + }, + }, { name: "create-pull-request with fallback-as-issue (default) - includes issues permission", safeOutputs: &SafeOutputsConfig{ diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 98cc8729e5..88751ca7eb 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -154,11 +154,12 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, "update_pull_request": { DefaultMax: 1, - CustomValidation: "requiresOneOf:title,body", + CustomValidation: "requiresOneOf:title,body,update_branch", Fields: map[string]FieldValidation{ "title": {Type: "string", Sanitize: true, MaxLength: 256}, "body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, "operation": {Type: "string", Enum: []string{"replace", "append", "prepend"}}, + "update_branch": {Type: "boolean"}, "draft": {Type: "boolean"}, "pull_request_number": {IssueOrPRNumber: true}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" diff --git a/pkg/workflow/update_pull_request_helpers.go b/pkg/workflow/update_pull_request_helpers.go index 058e783e52..8448d391c2 100644 --- a/pkg/workflow/update_pull_request_helpers.go +++ b/pkg/workflow/update_pull_request_helpers.go @@ -12,10 +12,11 @@ var updatePullRequestLog = logger.New("workflow:update_pull_request") // UpdatePullRequestsConfig holds configuration for updating GitHub pull requests from agent output type UpdatePullRequestsConfig struct { UpdateEntityConfig `yaml:",inline"` - Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable - Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable - Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. + Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable + Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable + UpdateBranch *bool `yaml:"update-branch,omitempty"` // When true, update PR branch with latest base branch changes before applying other updates. Defaults to false. + Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. } // parseUpdatePullRequestsConfig handles update-pull-request configuration @@ -28,6 +29,7 @@ func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *Upda return []UpdateEntityFieldSpec{ {Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "update-branch", Mode: FieldParsingBoolValue, Dest: &cfg.UpdateBranch}, {Name: "footer", Mode: FieldParsingTemplatableBool, StringDest: &cfg.Footer}, } }, func(configMap map[string]any, cfg *UpdatePullRequestsConfig) {