diff --git a/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml b/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml
index 4ababcfb714..b86694c2103 100644
--- a/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml
+++ b/.github/workflows/chaos-pr-bundle-fuzzer.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f62fc93407bd6ab331f4e3abc2379d35615d8f0139042c81a957c760d5399ce1","body_hash":"6259ed3b76b0756c3579e48ba619eeffa8e740e694758e39577368ec457739c1","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6"}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"31f878f6a1363586ee1ff769b0cb474a62d9c81f9855a94ca87cab6bed417d9d","body_hash":"6259ed3b76b0756c3579e48ba619eeffa8e740e694758e39577368ec457739c1","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6"}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.58"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.22"},{"image":"ghcr.io/github/github-mcp-server:v1.1.0"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]}
# ___ _ _
# / _ \ | | (_)
@@ -229,24 +229,24 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF'
+ cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF'
- GH_AW_PROMPT_99d50a698bdb1384_EOF
+ GH_AW_PROMPT_321ecf5730b07296_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF'
+ cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF'
Tools: create_pull_request(max:5), missing_tool, missing_data, noop
- GH_AW_PROMPT_99d50a698bdb1384_EOF
+ GH_AW_PROMPT_321ecf5730b07296_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
- cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF'
+ cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF'
- GH_AW_PROMPT_99d50a698bdb1384_EOF
+ GH_AW_PROMPT_321ecf5730b07296_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF'
+ cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -275,14 +275,14 @@ jobs:
{{/if}}
- GH_AW_PROMPT_99d50a698bdb1384_EOF
+ GH_AW_PROMPT_321ecf5730b07296_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_99d50a698bdb1384_EOF'
+ cat << 'GH_AW_PROMPT_321ecf5730b07296_EOF'
{{#runtime-import .github/workflows/shared/otlp.md}}
{{#runtime-import .github/workflows/shared/noop-reminder.md}}
{{#runtime-import .github/workflows/chaos-pr-bundle-fuzzer.md}}
- GH_AW_PROMPT_99d50a698bdb1384_EOF
+ GH_AW_PROMPT_321ecf5730b07296_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -525,9 +525,9 @@ jobs:
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
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_3cfc8d51efc6b9c7_EOF'
- {"create_pull_request":{"allowed_files":["tmp/chaos/**","scratchpad/chaos/**","tests/chaos/**"],"draft":true,"excluded_files":[".github/workflows/**"],"expires":4,"if_no_changes":"ignore","labels":["test-in-progress"],"max":5,"max_patch_files":100,"max_patch_size":1024,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"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","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","recreate_ref":true,"title_prefix":"[chaos-test] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_3cfc8d51efc6b9c7_EOF
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_69c091d10d55b480_EOF'
+ {"create_pull_request":{"allowed_files":["tmp/chaos/**","scratchpad/chaos/**","tests/chaos/**"],"close_older_pull_requests":true,"draft":true,"excluded_files":[".github/workflows/**"],"expires":4,"if_no_changes":"ignore","labels":["test-in-progress"],"max":5,"max_patch_files":100,"max_patch_size":1024,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"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","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","recreate_ref":true,"title_prefix":"[chaos-test] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_69c091d10d55b480_EOF
- name: Generate Safe Outputs Tools
env:
GH_AW_TOOLS_META_JSON: |
@@ -742,7 +742,7 @@ jobs:
mkdir -p /home/runner/.copilot
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_16abf2005f7a55f7_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+ cat << GH_AW_MCP_CONFIG_f90db49140838049_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
{
"mcpServers": {
"github": {
@@ -788,7 +788,7 @@ jobs:
}
}
}
- GH_AW_MCP_CONFIG_16abf2005f7a55f7_EOF
+ GH_AW_MCP_CONFIG_f90db49140838049_EOF
- name: Mount MCP servers as CLIs
id: mount-mcp-clis
continue-on-error: true
@@ -1583,7 +1583,7 @@ jobs:
GH_AW_ALLOWED_DOMAINS: "*.grafana.net,*.sentry.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.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,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.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,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"tmp/chaos/**\",\"scratchpad/chaos/**\",\"tests/chaos/**\"],\"draft\":true,\"excluded_files\":[\".github/workflows/**\"],\"expires\":4,\"if_no_changes\":\"ignore\",\"labels\":[\"test-in-progress\"],\"max\":5,\"max_patch_files\":100,\"max_patch_size\":1024,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"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\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"recreate_ref\":true,\"title_prefix\":\"[chaos-test] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"allowed_files\":[\"tmp/chaos/**\",\"scratchpad/chaos/**\",\"tests/chaos/**\"],\"close_older_pull_requests\":true,\"draft\":true,\"excluded_files\":[\".github/workflows/**\"],\"expires\":4,\"if_no_changes\":\"ignore\",\"labels\":[\"test-in-progress\"],\"max\":5,\"max_patch_files\":100,\"max_patch_size\":1024,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"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\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"recreate_ref\":true,\"title_prefix\":\"[chaos-test] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
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/chaos-pr-bundle-fuzzer.md b/.github/workflows/chaos-pr-bundle-fuzzer.md
index 6d5f710b165..a072245e865 100644
--- a/.github/workflows/chaos-pr-bundle-fuzzer.md
+++ b/.github/workflows/chaos-pr-bundle-fuzzer.md
@@ -27,6 +27,7 @@ safe-outputs:
max: 5
expires: 4h
if-no-changes: "ignore"
+ close-older-pull-requests: true
allowed-files:
- "tmp/chaos/**"
- "scratchpad/chaos/**"
diff --git a/actions/setup/js/close_older_pull_requests.cjs b/actions/setup/js/close_older_pull_requests.cjs
new file mode 100644
index 00000000000..6293d12805a
--- /dev/null
+++ b/actions/setup/js/close_older_pull_requests.cjs
@@ -0,0 +1,224 @@
+// @ts-check
+///
+
+const { sanitizeContent } = require("./sanitize_content.cjs");
+const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs");
+const { buildMarkerSearchQuery, filterByMarker, logFilterSummary } = require("./close_older_search_helpers.cjs");
+
+/**
+ * Maximum number of older pull requests to close
+ */
+const MAX_CLOSE_COUNT = SHARED_MAX_CLOSE_COUNT;
+
+/**
+ * Delay between API calls in milliseconds to avoid rate limiting
+ */
+const API_DELAY_MS = 500;
+
+/**
+ * Search for open pull requests with a matching workflow-id marker
+ * @param {any} github - GitHub REST API instance
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {string} workflowId - Workflow ID to match in the marker
+ * @param {number} excludeNumber - PR number to exclude (the newly created one)
+ * @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering.
+ * When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same
+ * reusable workflow do not close each other's PRs. Falls back to `gh-aw-workflow-id`
+ * when not provided (backward compat for PRs created before this fix).
+ * @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the
+ * `gh-aw-close-key` marker is used as the primary search term and exact filter instead
+ * of the workflow-id / workflow-call-id markers.
+ * @returns {Promise, created_at: string}>>} Matching pull requests
+ */
+async function searchOlderPullRequests(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) {
+ core.info(`Starting search for older pull requests in ${owner}/${repo}`);
+ core.info(` Workflow ID: ${workflowId || "(none)"}`);
+ core.info(` Exclude PR number: ${excludeNumber}`);
+
+ if (!workflowId && !closeOlderKey) {
+ core.info("No workflow ID or close-older-key provided - cannot search for older pull requests");
+ return [];
+ }
+
+ const { searchQuery, exactMarker } = buildMarkerSearchQuery({
+ owner,
+ repo,
+ workflowId,
+ callerWorkflowId,
+ closeOlderKey,
+ entityQualifier: "is:pr",
+ });
+ core.info(`Executing GitHub search with query: ${searchQuery}`);
+
+ const result = await github.rest.search.issuesAndPullRequests({
+ q: searchQuery,
+ per_page: 50,
+ });
+
+ core.info(`Search API returned ${result?.data?.items?.length || 0} total results`);
+
+ if (!result || !result.data || !result.data.items) {
+ core.info("No results returned from search API");
+ return [];
+ }
+
+ core.info("Filtering search results...");
+
+ const { filtered: filteredItems, counters } = filterByMarker({
+ items: result.data.items,
+ excludeNumber,
+ exactMarker,
+ entityType: "pull request",
+ additionalFilter: (item, extra) => {
+ if (!item.pull_request) {
+ extra.issueCount = (extra.issueCount || 0) + 1;
+ return false;
+ }
+ return true;
+ },
+ });
+
+ const filtered = filteredItems.map(item => ({
+ number: item.number,
+ title: item.title,
+ html_url: item.html_url,
+ labels: item.labels || [],
+ created_at: item.created_at,
+ }));
+
+ logFilterSummary({
+ entityTypePlural: "pull requests",
+ counters,
+ extraLabels: [["issueCount", "Excluded issues"]],
+ });
+
+ return filtered;
+}
+
+/**
+ * Add comment to a GitHub Pull Request using REST API
+ * @param {any} github - GitHub REST API instance
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {number} prNumber - Pull request number
+ * @param {string} message - Comment body
+ * @returns {Promise<{id: number, html_url: string}>} Comment details
+ */
+async function addPullRequestComment(github, owner, repo, prNumber, message) {
+ core.info(`Adding comment to pull request #${prNumber} in ${owner}/${repo}`);
+ core.info(` Comment length: ${message.length} characters`);
+
+ const result = await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: sanitizeContent(message),
+ });
+
+ core.info(` ✓ Comment created successfully with ID: ${result.data.id}`);
+ core.info(` Comment URL: ${result.data.html_url}`);
+
+ return {
+ id: result.data.id,
+ html_url: result.data.html_url,
+ };
+}
+
+/**
+ * Close a GitHub Pull Request using REST API
+ * @param {any} github - GitHub REST API instance
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {number} prNumber - Pull request number
+ * @returns {Promise<{number: number, html_url: string}>} Pull request details
+ */
+async function closePullRequest(github, owner, repo, prNumber) {
+ core.info(`Closing pull request #${prNumber} in ${owner}/${repo}`);
+
+ const result = await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: prNumber,
+ state: "closed",
+ });
+
+ core.info(` ✓ Pull request #${result.data.number} closed successfully`);
+ core.info(` Pull request URL: ${result.data.html_url}`);
+
+ return {
+ number: result.data.number,
+ html_url: result.data.html_url,
+ };
+}
+
+/**
+ * Generate closing message for older pull requests
+ * @param {object} params - Parameters for the message
+ * @param {string} params.newPullRequestUrl - URL of the new pull request
+ * @param {number} params.newPullRequestNumber - Number of the new pull request
+ * @param {string} params.workflowName - Name of the workflow
+ * @param {string} params.runUrl - URL of the workflow run
+ * @returns {string} Closing message
+ */
+function getCloseOlderPullRequestMessage({ newPullRequestUrl, newPullRequestNumber, workflowName, runUrl }) {
+ return `This pull request is being closed as superseded. A newer pull request has been created: #${newPullRequestNumber}
+
+[View newer pull request](${newPullRequestUrl})
+
+---
+
+*This action was performed automatically by the [\`${workflowName}\`](${runUrl}) workflow.*`;
+}
+
+/**
+ * Close older pull requests that match the workflow-id marker
+ * @param {any} github - GitHub REST API instance
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {string} workflowId - Workflow ID to match in the marker
+ * @param {{number: number, html_url: string}} newPullRequest - The newly created pull request
+ * @param {string} workflowName - Name of the workflow
+ * @param {string} runUrl - URL of the workflow run
+ * @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering
+ * @param {string} [closeOlderKey] - Optional explicit deduplication key for close-older matching
+ * @returns {Promise>} List of closed pull requests
+ */
+async function closeOlderPullRequests(github, owner, repo, workflowId, newPullRequest, workflowName, runUrl, callerWorkflowId, closeOlderKey) {
+ const result = await closeOlderEntities(github, owner, repo, workflowId, newPullRequest, workflowName, runUrl, {
+ entityType: "pull request",
+ entityTypePlural: "pull requests",
+ // Use a closure so callerWorkflowId and closeOlderKey are forwarded to searchOlderPullRequests
+ // without going through the closeOlderEntities extraArgs mechanism (which appends
+ // excludeNumber last)
+ searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderPullRequests(gh, o, r, wid, excludeNumber, callerWorkflowId, closeOlderKey),
+ getCloseMessage: params =>
+ getCloseOlderPullRequestMessage({
+ newPullRequestUrl: params.newEntityUrl,
+ newPullRequestNumber: params.newEntityNumber,
+ workflowName: params.workflowName,
+ runUrl: params.runUrl,
+ }),
+ addComment: addPullRequestComment,
+ closeEntity: closePullRequest,
+ delayMs: API_DELAY_MS,
+ getEntityId: entity => entity.number,
+ getEntityUrl: entity => entity.html_url,
+ });
+
+ // Map to pull-request-specific return type
+ return result.map(item => ({
+ number: item.number,
+ html_url: item.html_url || "",
+ }));
+}
+
+module.exports = {
+ closeOlderPullRequests,
+ searchOlderPullRequests,
+ addPullRequestComment,
+ closePullRequest,
+ getCloseOlderPullRequestMessage,
+ MAX_CLOSE_COUNT,
+ API_DELAY_MS,
+};
diff --git a/actions/setup/js/close_older_pull_requests.test.cjs b/actions/setup/js/close_older_pull_requests.test.cjs
new file mode 100644
index 00000000000..a393464254e
--- /dev/null
+++ b/actions/setup/js/close_older_pull_requests.test.cjs
@@ -0,0 +1,459 @@
+// @ts-check
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { closeOlderPullRequests, searchOlderPullRequests, addPullRequestComment, closePullRequest, getCloseOlderPullRequestMessage, MAX_CLOSE_COUNT } from "./close_older_pull_requests.cjs";
+
+// Mock globals
+global.core = {
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+};
+
+describe("close_older_pull_requests", () => {
+ let mockGithub;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGithub = {
+ rest: {
+ search: {
+ issuesAndPullRequests: vi.fn(),
+ },
+ issues: {
+ createComment: vi.fn(),
+ },
+ pulls: {
+ update: vi.fn(),
+ },
+ },
+ };
+ });
+
+ describe("searchOlderPullRequests", () => {
+ it("should search for pull requests with workflow-id marker", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Chaos test - 2024-01",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ {
+ number: 124,
+ title: "Chaos test - 2024-02",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", 125);
+
+ expect(results).toHaveLength(2);
+ expect(results[0].number).toBe(123);
+ expect(results[1].number).toBe(124);
+ expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({
+ q: 'repo:owner/repo is:pr is:open "gh-aw-workflow-id: chaos-pr-bundle-fuzzer" in:body',
+ per_page: 50,
+ });
+ });
+
+ it("should exclude the newly created pull request", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Chaos test - old",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ {
+ number: 124,
+ title: "Chaos test - new",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", 124);
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ });
+
+ it("should return empty array if no workflow-id provided", async () => {
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "", 125);
+
+ expect(results).toHaveLength(0);
+ expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
+ });
+
+ it("should exclude issues (only return pull requests)", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Pull Request",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ {
+ number: 124,
+ title: "Issue - should be excluded",
+ html_url: "https://github.com/owner/repo/issues/124",
+ labels: [],
+ // No pull_request property means it's an issue
+ body: "",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 125);
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ });
+
+ it("should return empty array if no results", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 125);
+
+ expect(results).toHaveLength(0);
+ });
+
+ it("should exclude PRs whose body does not contain the exact marker", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Matching PR",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "Some content\n",
+ },
+ {
+ number: 124,
+ title: "Substring match - should be excluded",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ // Body has a related-but-longer workflow ID - GitHub search may match this
+ // but exact filtering should exclude it
+ body: "Some content\n",
+ },
+ {
+ number: 125,
+ title: "No marker - should be excluded",
+ html_url: "https://github.com/owner/repo/pull/125",
+ labels: [],
+ pull_request: {},
+ body: "PR without any marker",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", 999);
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ });
+
+ it("should filter by gh-aw-workflow-call-id when callerWorkflowId is provided", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Same caller - should be included",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "\n",
+ },
+ {
+ number: 124,
+ title: "Different caller - should be excluded",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ body: "\n",
+ },
+ {
+ number: 125,
+ title: "Old PR without call-id - should be excluded",
+ html_url: "https://github.com/owner/repo/pull/125",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "my-reusable-workflow", 999, "owner/repo/CallerA");
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ });
+
+ it("should use close-key marker as primary search term when closeOlderKey is provided", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "Has close-key marker - should be included",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "\n",
+ },
+ {
+ number: 124,
+ title: "Missing close-key marker - should be excluded",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "some-workflow", 999, undefined, "my-stable-key");
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({
+ q: 'repo:owner/repo is:pr is:open "gh-aw-close-key: my-stable-key" in:body',
+ per_page: 50,
+ });
+ });
+
+ it("should return empty array when neither workflowId nor closeOlderKey is provided", async () => {
+ const results = await searchOlderPullRequests(mockGithub, "owner", "repo", "", 999, undefined, undefined);
+
+ expect(results).toHaveLength(0);
+ expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("addPullRequestComment", () => {
+ it("should add comment to pull request", async () => {
+ mockGithub.rest.issues.createComment.mockResolvedValue({
+ data: {
+ id: 456,
+ html_url: "https://github.com/owner/repo/pull/123#issuecomment-456",
+ },
+ });
+
+ const result = await addPullRequestComment(mockGithub, "owner", "repo", 123, "Test comment");
+
+ expect(result).toEqual({
+ id: 456,
+ html_url: "https://github.com/owner/repo/pull/123#issuecomment-456",
+ });
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({
+ owner: "owner",
+ repo: "repo",
+ issue_number: 123,
+ body: "Test comment",
+ });
+ });
+ });
+
+ describe("closePullRequest", () => {
+ it("should close pull request", async () => {
+ mockGithub.rest.pulls.update.mockResolvedValue({
+ data: {
+ number: 123,
+ html_url: "https://github.com/owner/repo/pull/123",
+ },
+ });
+
+ const result = await closePullRequest(mockGithub, "owner", "repo", 123);
+
+ expect(result).toEqual({
+ number: 123,
+ html_url: "https://github.com/owner/repo/pull/123",
+ });
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({
+ owner: "owner",
+ repo: "repo",
+ pull_number: 123,
+ state: "closed",
+ });
+ });
+ });
+
+ describe("getCloseOlderPullRequestMessage", () => {
+ it("should generate closing message", () => {
+ const message = getCloseOlderPullRequestMessage({
+ newPullRequestUrl: "https://github.com/owner/repo/pull/125",
+ newPullRequestNumber: 125,
+ workflowName: "Test Workflow",
+ runUrl: "https://github.com/owner/repo/actions/runs/123",
+ });
+
+ expect(message).toContain("newer pull request has been created: #125");
+ expect(message).toContain("https://github.com/owner/repo/pull/125");
+ expect(message).toContain("Test Workflow");
+ expect(message).toContain("https://github.com/owner/repo/actions/runs/123");
+ });
+ });
+
+ describe("closeOlderPullRequests", () => {
+ it("should close older pull requests successfully", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "[chaos-test] Old PR",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ mockGithub.rest.issues.createComment.mockResolvedValue({
+ data: { id: 456, html_url: "https://github.com/owner/repo/pull/123#issuecomment-456" },
+ });
+
+ mockGithub.rest.pulls.update.mockResolvedValue({
+ data: { number: 123, html_url: "https://github.com/owner/repo/pull/123" },
+ });
+
+ const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" };
+ const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "chaos-pr-bundle-fuzzer", newPR, "Chaos PR Bundle Fuzzer", "https://github.com/owner/repo/actions/runs/123");
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(123);
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({
+ owner: "owner",
+ repo: "repo",
+ pull_number: 123,
+ state: "closed",
+ });
+ });
+
+ it("should limit to MAX_CLOSE_COUNT pull requests", async () => {
+ const items = [];
+ for (let i = 1; i <= 15; i++) {
+ items.push({
+ number: i,
+ title: `PR ${i}`,
+ html_url: `https://github.com/owner/repo/pull/${i}`,
+ labels: [],
+ pull_request: {},
+ body: "",
+ });
+ }
+
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: { items },
+ });
+
+ mockGithub.rest.issues.createComment.mockResolvedValue({
+ data: { id: 456, html_url: "https://github.com/owner/repo/pull/1#issuecomment-456" },
+ });
+
+ mockGithub.rest.pulls.update.mockResolvedValue({
+ data: { number: 1, html_url: "https://github.com/owner/repo/pull/1" },
+ });
+
+ const newPR = { number: 20, html_url: "https://github.com/owner/repo/pull/20" };
+ const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123");
+
+ expect(results).toHaveLength(MAX_CLOSE_COUNT);
+ expect(global.core.warning).toHaveBeenCalledWith(`⚠️ Found 15 older pull requests, but only closing the first ${MAX_CLOSE_COUNT}`);
+ });
+
+ it("should continue on error for individual pull requests", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: {
+ items: [
+ {
+ number: 123,
+ title: "PR 1",
+ html_url: "https://github.com/owner/repo/pull/123",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ {
+ number: 124,
+ title: "PR 2",
+ html_url: "https://github.com/owner/repo/pull/124",
+ labels: [],
+ pull_request: {},
+ body: "",
+ },
+ ],
+ },
+ });
+
+ // First PR fails
+ mockGithub.rest.issues.createComment.mockRejectedValueOnce(new Error("API Error"));
+
+ // Second PR succeeds
+ mockGithub.rest.issues.createComment.mockResolvedValueOnce({
+ data: { id: 456, html_url: "https://github.com/owner/repo/pull/124#issuecomment-456" },
+ });
+
+ mockGithub.rest.pulls.update.mockResolvedValue({
+ data: { number: 124, html_url: "https://github.com/owner/repo/pull/124" },
+ });
+
+ const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" };
+ const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123");
+
+ expect(results).toHaveLength(1);
+ expect(results[0].number).toBe(124);
+ expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Failed to close pull request #123"));
+ });
+
+ it("should return empty array if no older pull requests found", async () => {
+ mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
+ data: { items: [] },
+ });
+
+ const newPR = { number: 125, html_url: "https://github.com/owner/repo/pull/125" };
+ const results = await closeOlderPullRequests(mockGithub, "owner", "repo", "my-workflow", newPR, "My Workflow", "https://github.com/owner/repo/actions/runs/123");
+
+ expect(results).toHaveLength(0);
+ expect(global.core.info).toHaveBeenCalledWith("✓ No older pull requests found to close - operation complete");
+ });
+ });
+});
diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs
index 853bfdc878e..43adbcfbe32 100644
--- a/actions/setup/js/create_pull_request.cjs
+++ b/actions/setup/js/create_pull_request.cjs
@@ -15,7 +15,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { replaceTemporaryIdReferences, replaceTemporaryIdReferencesInPatch, getOrGenerateTemporaryId } = require("./temporary_id.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { addExpirationToFooter } = require("./ephemerals.cjs");
-const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
+const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
@@ -23,6 +23,7 @@ const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
const { createCheckoutManager } = require("./dynamic_checkout.cjs");
+const { closeOlderPullRequests } = require("./close_older_pull_requests.cjs");
const { getBaseBranch } = require("./get_base_branch.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
@@ -681,6 +682,9 @@ async function main(config = {}) {
const includeFooter = parseBoolTemplatable(config.footer, true);
const fallbackAsIssue = config.fallback_as_issue !== false; // Default to true (fallback enabled)
const autoCloseIssue = parseBoolTemplatable(config.auto_close_issue, true); // Default to true (auto-close enabled)
+ const closeOlderPullRequestsEnabled = parseBoolTemplatable(config.close_older_pull_requests, false);
+ const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : "";
+ const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : "";
// Environment validation - fail early if required variables are missing
const workflowId = process.env.GH_AW_WORKFLOW_ID;
@@ -688,6 +692,8 @@ async function main(config = {}) {
throw new Error("GH_AW_WORKFLOW_ID environment variable is required");
}
+ const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || "";
+
// Extract triggering issue number from context (for auto-linking PRs to issues)
const triggeringIssueNumber = typeof context !== "undefined" && context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
@@ -728,6 +734,12 @@ async function main(config = {}) {
if (expiresHours > 0) {
core.info(`Pull requests expire after: ${expiresHours} hours`);
}
+ if (closeOlderPullRequestsEnabled) {
+ core.info(`Close older pull requests enabled: older PRs with same workflow-id marker will be closed`);
+ if (rawCloseOlderKey) {
+ core.info(` Using explicit close-older-key: "${closeOlderKey}"`);
+ }
+ }
core.info(`Max count: ${maxCount}`);
core.info(`Max patch size: ${maxSizeKb} KB`);
core.info(`Max patch files: ${maxFiles}`);
@@ -1319,6 +1331,17 @@ async function main(config = {}) {
footerParts.push(workflowIdMarker);
}
+ // Embed gh-aw-workflow-call-id marker so callers sharing the same reusable workflow
+ // do not close each other's PRs when close-older-pull-requests is enabled.
+ if (callerWorkflowId) {
+ bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId));
+ }
+
+ // Embed gh-aw-close-key marker when an explicit deduplication key is set.
+ if (closeOlderKey) {
+ bodyLines.push(generateCloseKeyMarker(closeOlderKey));
+ }
+
bodyLines.push("");
// Prepare the body content
@@ -2181,6 +2204,25 @@ ${patchPreview}`;
// in the same repo as the activation, so the global client has the correct context for updating the comment.
await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number);
+ // Close older pull requests if enabled (best-effort: errors are logged but do not fail the workflow)
+ if (closeOlderPullRequestsEnabled) {
+ if (workflowId || closeOlderKey) {
+ const searchKey = closeOlderKey ? `gh-aw-close-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`;
+ core.info(`Attempting to close older pull requests for ${repoParts.owner}/${repoParts.repo}#${pullRequest.number} using ${searchKey}`);
+ try {
+ const closedPRs = await closeOlderPullRequests(github, repoParts.owner, repoParts.repo, workflowId, { number: pullRequest.number, html_url: pullRequest.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey);
+ if (closedPRs.length > 0) {
+ core.info(`Closed ${closedPRs.length} older pull request(s)`);
+ }
+ } catch (error) {
+ // Log error but don't fail the workflow
+ core.warning(`Failed to close older pull requests: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ } else {
+ core.warning("Close older pull requests enabled but neither GH_AW_WORKFLOW_ID nor close-older-key is set - skipping");
+ }
+ }
+
// Write summary to GitHub Actions summary
await core.summary
.addRaw(
diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md
index 0b0fd28c69c..902a49c1e7e 100644
--- a/docs/src/content/docs/reference/frontmatter-full.md
+++ b/docs/src/content/docs/reference/frontmatter-full.md
@@ -4731,6 +4731,22 @@ safe-outputs:
# (optional)
signed-commits: true
+ # When true, automatically close older open pull requests from the same workflow
+ # (identified by the workflow-id marker in the PR body) with a comment linking to
+ # the new PR. Searches for open PRs containing the workflow-id marker. Maximum 10
+ # pull requests will be closed. Only runs if PR creation succeeds.
+ # (optional)
+ close-older-pull-requests: true
+
+ # Optional explicit deduplication key for close-older matching. When set, a `` marker is embedded in the PR body and used as the
+ # primary key for searching and filtering older pull requests instead of the
+ # workflow-id markers. This gives deterministic isolation across caller workflows
+ # and is stable across workflow renames. The value is normalized to identifier
+ # style (lowercase alphanumeric, dashes, underscores).
+ # (optional)
+ close-older-key: "example-value"
+
# If true, emit step summary messages instead of making GitHub API calls for this
# specific output type (preview mode)
# (optional)
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 559178c0bb9..5b2f2354a37 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -6365,6 +6365,21 @@
"description": "When true (default), automatically appends a closing keyword (\"Fixes #N\") to the PR description when the workflow is triggered from an issue and no closing keyword is already present. This causes GitHub to auto-close the triggering issue when the PR is merged. Set to false to prevent this behavior, e.g., for partial-work PRs or multi-PR workflows. Accepts a boolean or a GitHub Actions expression.",
"default": true
},
+ "close-older-pull-requests": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/templatable_boolean"
+ }
+ ],
+ "description": "When true, automatically close older open pull requests with the same workflow-id marker (or close-older-key when set) with a comment linking to the new PR. Maximum 10 PRs will be closed. Only runs if PR creation succeeds.",
+ "default": false
+ },
+ "close-older-key": {
+ "type": "string",
+ "description": "Optional explicit deduplication key for close-older-pull-requests matching. When set, a `` marker is embedded in the PR body and used as the primary key for searching and filtering older PRs instead of the workflow-id markers. The value is normalized to identifier style (lowercase alphanumeric, dashes, underscores).",
+ "minLength": 1,
+ "pattern": "\\S"
+ },
"github-token-for-extra-empty-commit": {
"type": "string",
"description": "Token used to push an empty commit after PR creation to trigger CI events. Works around the GITHUB_TOKEN limitation where pushes don't trigger workflow runs. Defaults to the magic secret GH_AW_CI_TRIGGER_TOKEN if set in the repository. Use a secret expression (e.g. '${{ secrets.CI_TOKEN }}') for a custom token, or 'app' for GitHub App auth."
diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go
index 141020cb6af..4bcbbc00bca 100644
--- a/pkg/workflow/create_pull_request.go
+++ b/pkg/workflow/create_pull_request.go
@@ -17,6 +17,18 @@ func getFallbackAsIssue(config *CreatePullRequestsConfig) bool {
return *config.FallbackAsIssue
}
+// isCloseOlderPullRequestsEnabled returns true when close-older-pull-requests is
+// configured and not explicitly set to false ("false" or "0"). Any other non-empty
+// value, including GitHub Actions expressions like "${{ ... }}", is treated as enabled.
+// Used for compile-time permission calculation.
+func isCloseOlderPullRequestsEnabled(config *CreatePullRequestsConfig) bool {
+ if config == nil || config.CloseOlderPullRequests == nil {
+ return false
+ }
+ v := *config.CloseOlderPullRequests
+ return v != "" && v != "false" && v != "0"
+}
+
// CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output
type CreatePullRequestsConfig struct {
BaseSafeOutputConfig `yaml:",inline"`
@@ -53,6 +65,8 @@ type CreatePullRequestsConfig struct {
PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch).
SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true.
AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured.
+ CloseOlderPullRequests *string `yaml:"close-older-pull-requests,omitempty"` // When true, close older open pull requests with the same workflow-id marker when a new one is created. Capped at 10 closures per run.
+ CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers.
}
// parseCreatePullRequestsConfig handles only create-pull-request (singular) configuration
@@ -68,7 +82,7 @@ func (c *Compiler) parseCreatePullRequestsConfig(outputMap map[string]any) *Crea
outputMap,
"create-pull-request",
CreateParseOptions{
- BoolFields: []string{"draft", "allow-empty", "auto-merge", "footer", "auto-close-issue"},
+ BoolFields: []string{"draft", "allow-empty", "auto-merge", "footer", "auto-close-issue", "close-older-pull-requests"},
IntFields: []string{"max"},
HandleExpires: true,
},
diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go
index 0dde2900386..ab85dcc383d 100644
--- a/pkg/workflow/safe_output_handlers.go
+++ b/pkg/workflow/safe_output_handlers.go
@@ -159,6 +159,10 @@ var safeOutputHandlers = []safeOutputHandlerDescriptor{
if safeOutputs.CreatePullRequests.AllowWorkflows {
permissions.Set(PermissionWorkflows, PermissionWrite)
}
+ // close-older-pull-requests requires issues: write to add closing comments
+ if isCloseOlderPullRequestsEnabled(safeOutputs.CreatePullRequests) {
+ permissions.Set(PermissionIssues, PermissionWrite)
+ }
return permissions
},
},
diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go
index 7395051719c..885d988d4f4 100644
--- a/pkg/workflow/safe_outputs_config.go
+++ b/pkg/workflow/safe_outputs_config.go
@@ -1251,6 +1251,8 @@ var handlerRegistry = map[string]handlerBuilder{
AddIfTrue("recreate_ref", c.RecreateRef).
AddIfNotEmpty("patch_format", c.PatchFormat).
AddBoolPtr("signed_commits", c.SignedCommits).
+ AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests).
+ AddIfNotEmpty("close_older_key", c.CloseOlderKey).
AddIfTrue("staged", c.Staged)
return builder.Build()
},