From fedbcd061246cc2d45a361658ea69a169dc75890 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:12:24 +0000 Subject: [PATCH 01/10] Initial plan From 4d2e6b825bd27001084627ee11875806ef5ae4df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:23:46 +0000 Subject: [PATCH 02/10] Address SPDD safe output spec gaps Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 6 ++++ actions/setup/js/replace_label.test.cjs | 29 ++++++++++++++++++ pkg/workflow/replace_label_formal_test.go | 30 +++++++++++++++++-- specs/replace-label-spec.md | 8 +++++ specs/safe-output-outcome-evaluation.md | 7 +++-- specs/safe-outputs-scratchpad-removal.md | 1 + specs/security-architecture-spec-summary.md | 9 ++++-- .../security-architecture-spec-validation.md | 1 + 8 files changed, 83 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index dcfbade283a..9c189b97532 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -202,6 +202,12 @@ const main = createCountGatedHandler({ } const newLabelNames = [...new Set([...currentLabelNames.filter(n => n !== labelToRemove), labelToAdd])]; + const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); + if (!preWriteAddValidation.valid) { + core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); + return { success: false, error: preWriteAddValidation.error }; + } + core.info(`Executing REST setLabels: remove="${labelToRemove}", add="${labelToAdd}" on ${contextType} #${itemNumber} in ${itemRepo}`); const beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index a093bf9f302..0eabad173d1 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -147,6 +147,35 @@ describe("replace_label", () => { expect(result.success).toBe(false); }); + it("should reject label_to_add before setLabels when blocklist changes mid-flight", async () => { + let setLabelsCalls = 0; + const blocked = []; + mockGithub.rest.issues.get = async () => { + blocked.push("done"); + return { + data: { + title: "Test issue title", + labels: [ + { name: "in-progress", node_id: "LA_in_progress_123" }, + { name: "bug", node_id: "LA_bug_456" }, + ], + node_id: "I_issue_789", + }, + }; + }; + mockGithub.rest.issues.setLabels = async () => { + setLabelsCalls++; + return { data: [] }; + }; + + const handler = await main({ allowed_add: ["done"], blocked }); + const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain("blocked pattern"); + expect(setLabelsCalls).toBe(0); + }); + it("should skip when required-labels filter does not match", async () => { const handler = await main({ required_labels: ["approved"] }); // Issue has "in-progress" and "bug" but not "approved" diff --git a/pkg/workflow/replace_label_formal_test.go b/pkg/workflow/replace_label_formal_test.go index 5761c48a79a..f714447e4f5 100644 --- a/pkg/workflow/replace_label_formal_test.go +++ b/pkg/workflow/replace_label_formal_test.go @@ -574,17 +574,23 @@ func TestFormalStagedMode(t *testing.T) { // onWrite is invoked exactly once when the handler would call the write API // (issues.setLabels). In staged mode the handler must return before reaching // onWrite; this is the invariant asserted by TestFormalStagedMode_NoWriteAPI. -func formalRunReplaceLabel(staged bool, onWrite func()) formalReplaceLabelOutcome { +func formalRunReplaceLabel(staged bool, labelToAdd string, allowedAdd, blocked []string, beforeWrite func() []string, onWrite func()) formalReplaceLabelOutcome { if staged { return formalReplaceLabelOutcome{Success: true, Staged: true} } + if beforeWrite != nil { + blocked = beforeWrite() + } + if err := formalValidateSingleLabel(labelToAdd, allowedAdd, blocked, "label_to_add"); err != nil { + return formalReplaceLabelOutcome{Success: false} + } onWrite() return formalReplaceLabelOutcome{Success: true} } func TestFormalStagedMode_NoWriteAPI(t *testing.T) { writeCalls := 0 - outcome := formalRunReplaceLabel(true, func() { writeCalls++ }) + outcome := formalRunReplaceLabel(true, "done", nil, nil, nil, func() { writeCalls++ }) assert.True(t, outcome.Success) assert.True(t, outcome.Staged) assert.Zero(t, writeCalls, "staged mode must not call the write API (setLabels)") @@ -592,12 +598,30 @@ func TestFormalStagedMode_NoWriteAPI(t *testing.T) { func TestFormalNonStagedMode_InvokesWriteAPI(t *testing.T) { writeCalls := 0 - outcome := formalRunReplaceLabel(false, func() { writeCalls++ }) + outcome := formalRunReplaceLabel(false, "done", nil, nil, nil, func() { writeCalls++ }) assert.True(t, outcome.Success) assert.False(t, outcome.Staged) assert.Equal(t, 1, writeCalls, "non-staged mode must invoke the write API exactly once") } +func TestFormalBlockedLabelAddedViaReplaceLabelMidAllowlistChange_NoWriteAPI(t *testing.T) { + writeCalls := 0 + blocked := []string{} + outcome := formalRunReplaceLabel( + false, + "done", + []string{"done"}, + blocked, + func() []string { + return append(blocked, "done") + }, + func() { writeCalls++ }, + ) + + assert.False(t, outcome.Success) + assert.Zero(t, writeCalls, "label_to_add blocked before setLabels must be rejected without calling the write API") +} + func TestFormalSingleRESTCall(t *testing.T) { data, err := os.ReadFile(filepath.Join(formalRepoRoot(t), "actions", "setup", "js", "replace_label.cjs")) require.NoError(t, err) diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md index 572884bff6a..8215f35fbbb 100644 --- a/specs/replace-label-spec.md +++ b/specs/replace-label-spec.md @@ -486,6 +486,8 @@ Label allowlists and blocklists are the primary mechanism preventing AI agents f **RL-049**: Allowlist and blocklist evaluation MUST be performed server-side (in the JavaScript handler executing within GitHub Actions), not by the AI agent. Agents MUST NOT be trusted to self-enforce label restrictions. +**RL-049a**: A conforming implementation MUST re-check `label_to_add` against the current server-side allowlist and blocklist state immediately before invoking `PUT /repos/{owner}/{repo}/issues/{issue_number}/labels`. If the label is blocked or no longer allowed at that point, the implementation MUST reject the message without calling the write API. + ### 8.2 Cross-Repository Restrictions By default, `replace-label` operates on the repository of the triggering workflow. Cross-repository operation is opt-in and must be explicitly declared. @@ -518,6 +520,12 @@ Staged mode provides a mechanism for operators to audit AI agent label-transitio **RL-056**: When `staged: true`, the implementation MUST NOT call any write API endpoint. Read-only API calls performed during Stage 5 gate checks MAY proceed in staged mode. +The staged-mode preview SHOULD identify the `label_to_add` value that would be revalidated before the write call so operators can compare previewed transitions against protected-label policy before disabling staged mode. + +### 8.7 Sync Notes + +The REST failure and retry semantics in [Section 7](#7-error-handling) are mirrored by the `replace_label` outcome-evaluation rules in [`safe-output-outcome-evaluation.md` Section 30](safe-output-outcome-evaluation.md#30-replace_label). Changes to `404`, `5xx`, or `429` handling in either document SHOULD be reviewed against the other document in the same change. + --- ## 9. Compliance Testing diff --git a/specs/safe-output-outcome-evaluation.md b/specs/safe-output-outcome-evaluation.md index 8a786a9970c..f7869851700 100644 --- a/specs/safe-output-outcome-evaluation.md +++ b/specs/safe-output-outcome-evaluation.md @@ -683,8 +683,9 @@ No outcome to evaluate. Skip. | `label_to_add` is present on the item AND `label_to_remove` is absent | `accepted` | | `label_to_add` is absent from the item | `rejected` | | `label_to_add` is present but `label_to_remove` is also still present | `rejected` (partial failure — remove did not apply) | -| Item not found (`404`) | `rejected` | -| API transient failure (`5xx`, timeout, transport error) | `pending` | +| Item not found (`404`) | Outcome evaluation workers **MUST** classify as `rejected` | +| API transient failure (`5xx`, timeout, transport error) | Outcome evaluation workers **MUST** classify as `pending` | +| Rate-limit response (`403` exhaustion or `429`) | Outcome evaluation workers **MUST** classify as `pending` and **SHOULD** reschedule using the reset window | | `lifecycle` | N/A — `replace_label` has no lifecycle bot-close behavior | | `lifecycle_close` | N/A — `replace_label` has no lifecycle bot-close behavior | | `ignored` | N/A — label state is always evaluable when the item is accessible; no time-bounded engagement signal applies | @@ -710,6 +711,8 @@ No outcome to evaluate. Skip. 3. If the API returns rate-limit responses (`403` exhaustion or `429`), outcome evaluation workers **MUST** classify as `pending` and reschedule evaluation using the reset window. 4. While any transient API failure condition exists, outcome evaluation workers **MUST NOT** emit `accepted` or `rejected` for label replacement state. +**Sync note:** Keep the API failure safeguards above aligned with [`replace-label-spec.md` Section 7](replace-label-spec.md#7-error-handling), which defines the shared `404`, `5xx`, and `429` REST failure semantics for `replace_label`. + **References:** See [replace-label-spec.md](replace-label-spec.md) for the full definition of the `replace_label` safe-output type, including the message schema, processing model, and REST interface. --- diff --git a/specs/safe-outputs-scratchpad-removal.md b/specs/safe-outputs-scratchpad-removal.md index a3e93773531..0114b7cdb13 100644 --- a/specs/safe-outputs-scratchpad-removal.md +++ b/specs/safe-outputs-scratchpad-removal.md @@ -5,5 +5,6 @@ The deprecated [`scratchpad/safe-outputs-specification.md`](../scratchpad/safe-o ## Removal checklist - [ ] Before 2026-09-21, replace references to the deprecated scratchpad specification in doc-site navigation, workflow files, and internal links with the canonical specification. +- [ ] Owner: SPDD daily rotation. Before 2026-09-21, verify `grep -r "scratchpad/safe-outputs-specification.md" docs/ .github/` returns zero matches outside this removal notice before deleting the scratchpad file. - [ ] On or before 2026-09-21, delete `scratchpad/safe-outputs-specification.md`. - [ ] Verify that no remaining repository references resolve to the deleted scratchpad path. diff --git a/specs/security-architecture-spec-summary.md b/specs/security-architecture-spec-summary.md index f9e631e1233..e5b026bb21e 100644 --- a/specs/security-architecture-spec-summary.md +++ b/specs/security-architecture-spec-summary.md @@ -4,6 +4,7 @@ **Version**: 1.0.0 **Status**: Candidate Recommendation **Date**: January 29, 2026 +**Last validated**: v1.0.0 / 2026-07-15 ## Overview @@ -313,16 +314,18 @@ The specification documents the **current implementation** in gh-aw version 1.0. Key implementation files referenced in the specification: -- `pkg/workflow/safe_inputs_parser.go` - Input sanitization -- `pkg/workflow/safe_outputs_config.go` - Output isolation +- `pkg/workflow/safe_outputs_parser.go` - Safe-output frontmatter parsing +- `pkg/workflow/safe_outputs_config_base.go` - Shared safe-output configuration +- `pkg/workflow/safe_outputs_config_types.go` - Safe-output type configuration - `pkg/workflow/engine.go` - Network permissions - `pkg/workflow/compiler_safe_outputs.go` - Safe output compilation - `pkg/workflow/safe_jobs.go` - Threat detection - `pkg/workflow/compiler_types.go` - Core types -- Actions in `actions/setup/js/*.cjs` and `actions/setup/sh/*.sh` ### Spec-to-Lock Sync (v1.0.0) +Summary version **1.0.0** was last validated on **2026-07-15**, matching the validation marker in `specs/security-architecture-spec-validation.md`. + Summary version **1.0.0** corresponds to the minimum validated `.lock.yml` compiler behaviors recorded in `specs/security-architecture-spec-validation.md`: - Activation, agent, detection, and safe output jobs remain separated in compiled workflows diff --git a/specs/security-architecture-spec-validation.md b/specs/security-architecture-spec-validation.md index 0fac54db49b..4e15f4dc95b 100644 --- a/specs/security-architecture-spec-validation.md +++ b/specs/security-architecture-spec-validation.md @@ -2,6 +2,7 @@ **Document**: Validation of `security-architecture-spec.md` against compiled `.lock.yml` files **Date**: July 6, 2026 +**Last validated**: v1.0.0 / 2026-07-15 **Validator**: GitHub Copilot Agent **Scope**: Cross-reference specification requirements with actual implementation From 6b88f1c8c5eca8ce8628e3a223c601e9e9a677e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:57:44 +0000 Subject: [PATCH 03/10] Address replace_label review feedback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 21 +++++----- actions/setup/js/replace_label.test.cjs | 8 +++- pkg/workflow/replace_label_formal_test.go | 50 +++++++++++++---------- specs/replace-label-spec.md | 4 ++ 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 9c189b97532..3b9dadf43ff 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -202,25 +202,24 @@ const main = createCountGatedHandler({ } const newLabelNames = [...new Set([...currentLabelNames.filter(n => n !== labelToRemove), labelToAdd])]; - const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); - if (!preWriteAddValidation.valid) { - core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); - return { success: false, error: preWriteAddValidation.error }; - } - - core.info(`Executing REST setLabels: remove="${labelToRemove}", add="${labelToAdd}" on ${contextType} #${itemNumber} in ${itemRepo}`); - const beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); try { const { data: updatedLabels } = await withRetry( - () => - githubClient.rest.issues.setLabels({ + () => { + const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); + if (!preWriteAddValidation.valid) { + core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); + throw new Error(preWriteAddValidation.error); + } + + return githubClient.rest.issues.setLabels({ owner: repoParts.owner, repo: repoParts.repo, issue_number: itemNumber, labels: newLabelNames, - }), + }); + }, RATE_LIMIT_RETRY_CONFIG, `replace_label on ${contextType} #${itemNumber} in ${itemRepo}` ); diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index 0eabad173d1..3d5a3228566 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -149,9 +149,13 @@ describe("replace_label", () => { it("should reject label_to_add before setLabels when blocklist changes mid-flight", async () => { let setLabelsCalls = 0; + let getCalls = 0; const blocked = []; mockGithub.rest.issues.get = async () => { - blocked.push("done"); + getCalls++; + if (getCalls === 2) { + blocked.push("done"); + } return { data: { title: "Test issue title", @@ -173,6 +177,8 @@ describe("replace_label", () => { expect(result.success).toBe(false); expect(result.error).toContain("blocked pattern"); + expect(getCalls).toBe(2); + expect(blocked).toEqual(["done"]); expect(setLabelsCalls).toBe(0); }); diff --git a/pkg/workflow/replace_label_formal_test.go b/pkg/workflow/replace_label_formal_test.go index f714447e4f5..5c73101ca66 100644 --- a/pkg/workflow/replace_label_formal_test.go +++ b/pkg/workflow/replace_label_formal_test.go @@ -570,27 +570,37 @@ func TestFormalStagedMode(t *testing.T) { assert.Equal(t, "true", string(*cfg.Staged)) } +type formalRunReplaceLabelOpts struct { + Staged bool + LabelToAdd string + AllowedAdd []string + Blocked []string + BeforeWrite func() []string + OnWrite func() +} + // formalRunReplaceLabel models the core execute path of the replace_label handler. -// onWrite is invoked exactly once when the handler would call the write API +// OnWrite is invoked exactly once when the handler would call the write API // (issues.setLabels). In staged mode the handler must return before reaching -// onWrite; this is the invariant asserted by TestFormalStagedMode_NoWriteAPI. -func formalRunReplaceLabel(staged bool, labelToAdd string, allowedAdd, blocked []string, beforeWrite func() []string, onWrite func()) formalReplaceLabelOutcome { - if staged { +// OnWrite; this is the invariant asserted by TestFormalStagedMode_NoWriteAPI. +func formalRunReplaceLabel(opts formalRunReplaceLabelOpts) formalReplaceLabelOutcome { + if opts.Staged { return formalReplaceLabelOutcome{Success: true, Staged: true} } - if beforeWrite != nil { - blocked = beforeWrite() + blocked := opts.Blocked + if opts.BeforeWrite != nil { + blocked = opts.BeforeWrite() } - if err := formalValidateSingleLabel(labelToAdd, allowedAdd, blocked, "label_to_add"); err != nil { + if err := formalValidateSingleLabel(opts.LabelToAdd, opts.AllowedAdd, blocked, "label_to_add"); err != nil { return formalReplaceLabelOutcome{Success: false} } - onWrite() + opts.OnWrite() return formalReplaceLabelOutcome{Success: true} } func TestFormalStagedMode_NoWriteAPI(t *testing.T) { writeCalls := 0 - outcome := formalRunReplaceLabel(true, "done", nil, nil, nil, func() { writeCalls++ }) + outcome := formalRunReplaceLabel(formalRunReplaceLabelOpts{Staged: true, LabelToAdd: "done", OnWrite: func() { writeCalls++ }}) assert.True(t, outcome.Success) assert.True(t, outcome.Staged) assert.Zero(t, writeCalls, "staged mode must not call the write API (setLabels)") @@ -598,25 +608,23 @@ func TestFormalStagedMode_NoWriteAPI(t *testing.T) { func TestFormalNonStagedMode_InvokesWriteAPI(t *testing.T) { writeCalls := 0 - outcome := formalRunReplaceLabel(false, "done", nil, nil, nil, func() { writeCalls++ }) + outcome := formalRunReplaceLabel(formalRunReplaceLabelOpts{LabelToAdd: "done", OnWrite: func() { writeCalls++ }}) assert.True(t, outcome.Success) assert.False(t, outcome.Staged) assert.Equal(t, 1, writeCalls, "non-staged mode must invoke the write API exactly once") } -func TestFormalBlockedLabelAddedViaReplaceLabelMidAllowlistChange_NoWriteAPI(t *testing.T) { +func TestFormalBlockedLabelAddedViaReplaceLabelMidBlocklistChange_NoWriteAPI(t *testing.T) { writeCalls := 0 - blocked := []string{} - outcome := formalRunReplaceLabel( - false, - "done", - []string{"done"}, - blocked, - func() []string { - return append(blocked, "done") + outcome := formalRunReplaceLabel(formalRunReplaceLabelOpts{ + LabelToAdd: "done", + AllowedAdd: []string{"done"}, + Blocked: []string{}, + BeforeWrite: func() []string { + return []string{"done"} }, - func() { writeCalls++ }, - ) + OnWrite: func() { writeCalls++ }, + }) assert.False(t, outcome.Success) assert.Zero(t, writeCalls, "label_to_add blocked before setLabels must be rejected without calling the write API") diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md index 8215f35fbbb..60ceb058a4b 100644 --- a/specs/replace-label-spec.md +++ b/specs/replace-label-spec.md @@ -457,6 +457,10 @@ In the Octokit client this is `githubClient.rest.issues.setLabels(params)`. **RL-046**: When the `setLabels` REST call fails (e.g., HTTP 422 for an invalid label name), the implementation MUST log a `core.error()` entry and MUST return `{ success: false, error: }`. For HTTP-level failures the call is all-or-nothing — either all label changes are applied or none are. For HTTP 200 responses, see §7.4 for partial-success response handling. +**RL-046a**: If `setLabels` returns HTTP 404, the implementation MUST treat the message as rejected because the authoritative labeling target or repository is no longer reachable. + +**RL-046b**: If `setLabels` returns HTTP 5xx, times out, or fails with a transport error, the implementation MUST treat the condition as transient and retry according to the retry policy in §7.3. If retries are exhausted, the implementation MUST return `{ success: false, error: }` without reporting a successful replacement. + ### 7.3 Rate-Limit Retry Policy **RL-048**: The `setLabels` REST call (Stage 8) MUST apply the `RATE_LIMIT_RETRY_CONFIG` retry policy from `actions/setup/js/error_recovery.cjs`. This policy covers secondary rate-limit responses (HTTP 403 with Retry-After header) and primary rate-limit responses (HTTP 429). From facce81bb52ee34bfc5fdaba534fd3252222a20d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:06:22 +0000 Subject: [PATCH 04/10] Clarify replace_label non-retryable policy rejection Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 12 ++++++++++-- specs/replace-label-spec.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 3b9dadf43ff..b1b71018ec9 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -33,6 +33,12 @@ const { createCountGatedHandler } = require("./handler_scaffold.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); +const POLICY_REJECTION_ERROR_NAME = "ReplaceLabelPolicyRejectionError"; +const SET_LABELS_RETRY_CONFIG = { + ...RATE_LIMIT_RETRY_CONFIG, + shouldRetry: error => error?.name !== POLICY_REJECTION_ERROR_NAME && RATE_LIMIT_RETRY_CONFIG.shouldRetry(error), +}; + /** * Validate a single label against blocked and allowed-list patterns. * Uses explicit rejection semantics — does not silently filter or truncate the label name. @@ -210,7 +216,9 @@ const main = createCountGatedHandler({ const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); if (!preWriteAddValidation.valid) { core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); - throw new Error(preWriteAddValidation.error); + const policyError = new Error(preWriteAddValidation.error); + policyError.name = POLICY_REJECTION_ERROR_NAME; + throw policyError; } return githubClient.rest.issues.setLabels({ @@ -220,7 +228,7 @@ const main = createCountGatedHandler({ labels: newLabelNames, }); }, - RATE_LIMIT_RETRY_CONFIG, + SET_LABELS_RETRY_CONFIG, `replace_label on ${contextType} #${itemNumber} in ${itemRepo}` ); diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md index 60ceb058a4b..eeffdd4a1a8 100644 --- a/specs/replace-label-spec.md +++ b/specs/replace-label-spec.md @@ -457,7 +457,7 @@ In the Octokit client this is `githubClient.rest.issues.setLabels(params)`. **RL-046**: When the `setLabels` REST call fails (e.g., HTTP 422 for an invalid label name), the implementation MUST log a `core.error()` entry and MUST return `{ success: false, error: }`. For HTTP-level failures the call is all-or-nothing — either all label changes are applied or none are. For HTTP 200 responses, see §7.4 for partial-success response handling. -**RL-046a**: If `setLabels` returns HTTP 404, the implementation MUST treat the message as rejected because the authoritative labeling target or repository is no longer reachable. +**RL-046a**: If `setLabels` returns HTTP 404, the implementation MUST treat the request as rejected because the authoritative labeling target or repository is no longer reachable. **RL-046b**: If `setLabels` returns HTTP 5xx, times out, or fails with a transport error, the implementation MUST treat the condition as transient and retry according to the retry policy in §7.3. If retries are exhausted, the implementation MUST return `{ success: false, error: }` without reporting a successful replacement. From f1f3049c16cbdc949cf4346ce5a05231d6794f29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:08:08 +0000 Subject: [PATCH 05/10] Refresh replace_label pre-write state per retry Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index b1b71018ec9..15f5878cbb1 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -208,11 +208,11 @@ const main = createCountGatedHandler({ } const newLabelNames = [...new Set([...currentLabelNames.filter(n => n !== labelToRemove), labelToAdd])]; - const beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); - try { + let beforeState; const { data: updatedLabels } = await withRetry( - () => { + async () => { + beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); if (!preWriteAddValidation.valid) { core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); From 1e67eac9d305401db681bdc98a29deebdd990b17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:10:51 +0000 Subject: [PATCH 06/10] Retry replace_label setLabels on 5xx errors Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 15f5878cbb1..51653870100 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -36,7 +36,10 @@ const { resolveInvocationContext } = require("./invocation_context_helpers.cjs") const POLICY_REJECTION_ERROR_NAME = "ReplaceLabelPolicyRejectionError"; const SET_LABELS_RETRY_CONFIG = { ...RATE_LIMIT_RETRY_CONFIG, - shouldRetry: error => error?.name !== POLICY_REJECTION_ERROR_NAME && RATE_LIMIT_RETRY_CONFIG.shouldRetry(error), + shouldRetry: error => { + const status = error?.response?.status ?? error?.status ?? null; + return error?.name !== POLICY_REJECTION_ERROR_NAME && ((status >= 500 && status < 600) || RATE_LIMIT_RETRY_CONFIG.shouldRetry(error)); + }, }; /** From a47dd6e6031f270898fdf90506e5993974720e8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:12:41 +0000 Subject: [PATCH 07/10] Re-read replace_label policy before setLabels Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 11 +++++++---- actions/setup/js/replace_label.test.cjs | 8 ++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 51653870100..465169eb985 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -77,15 +77,18 @@ function validateSingleLabel(labelName, allowedPatterns, blockedPatterns, fieldN const main = createCountGatedHandler({ handlerType: HANDLER_TYPE, setup: async (config, maxCount, isStaged) => { - const blockedPatterns = config.blocked || []; + const currentAllowedAdd = () => (Array.isArray(config.allowed_add) ? config.allowed_add : []); + const currentAllowedRemove = () => (Array.isArray(config.allowed_remove) ? config.allowed_remove : []); + const currentBlockedPatterns = () => (Array.isArray(config.blocked) ? config.blocked : []); const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; const requiredTitlePrefix = config.required_title_prefix || ""; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const githubClient = await createAuthenticatedGitHubClient(config); // Config keys use snake_case (set by the Go handler config builder) - const configAllowedAdd = Array.isArray(config.allowed_add) ? config.allowed_add : []; - const configAllowedRemove = Array.isArray(config.allowed_remove) ? config.allowed_remove : []; + const configAllowedAdd = currentAllowedAdd(); + const configAllowedRemove = currentAllowedRemove(); + const blockedPatterns = currentBlockedPatterns(); /** @type {{from: string, to: string}[]} */ const configAllowedTransitions = Array.isArray(config.allowed_transitions) ? config.allowed_transitions : []; @@ -216,7 +219,7 @@ const main = createCountGatedHandler({ const { data: updatedLabels } = await withRetry( async () => { beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); - const preWriteAddValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); + const preWriteAddValidation = validateSingleLabel(labelToAdd, currentAllowedAdd(), currentBlockedPatterns(), "label_to_add"); if (!preWriteAddValidation.valid) { core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); const policyError = new Error(preWriteAddValidation.error); diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs index 3d5a3228566..a0f4fcb7e38 100644 --- a/actions/setup/js/replace_label.test.cjs +++ b/actions/setup/js/replace_label.test.cjs @@ -150,11 +150,11 @@ describe("replace_label", () => { it("should reject label_to_add before setLabels when blocklist changes mid-flight", async () => { let setLabelsCalls = 0; let getCalls = 0; - const blocked = []; + const config = { allowed_add: ["done"], blocked: [] }; mockGithub.rest.issues.get = async () => { getCalls++; if (getCalls === 2) { - blocked.push("done"); + config.blocked = ["done"]; } return { data: { @@ -172,13 +172,13 @@ describe("replace_label", () => { return { data: [] }; }; - const handler = await main({ allowed_add: ["done"], blocked }); + const handler = await main(config); const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {}); expect(result.success).toBe(false); expect(result.error).toContain("blocked pattern"); expect(getCalls).toBe(2); - expect(blocked).toEqual(["done"]); + expect(config.blocked).toEqual(["done"]); expect(setLabelsCalls).toBe(0); }); From 7f78a30324795cb2199effa603643b978860d3fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:14:40 +0000 Subject: [PATCH 08/10] Clarify replace_label policy snapshots Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 465169eb985..bdef641aa83 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -86,17 +86,17 @@ const main = createCountGatedHandler({ const githubClient = await createAuthenticatedGitHubClient(config); // Config keys use snake_case (set by the Go handler config builder) - const configAllowedAdd = currentAllowedAdd(); - const configAllowedRemove = currentAllowedRemove(); - const blockedPatterns = currentBlockedPatterns(); + const initialAllowedAdd = currentAllowedAdd(); + const initialAllowedRemove = currentAllowedRemove(); + const initialBlockedPatterns = currentBlockedPatterns(); /** @type {{from: string, to: string}[]} */ const configAllowedTransitions = Array.isArray(config.allowed_transitions) ? config.allowed_transitions : []; core.info(`Replace label configuration: max=${maxCount}`); if (configAllowedTransitions.length > 0) core.info(`Allowed transitions: ${configAllowedTransitions.map(t => `"${t.from}" → "${t.to}"`).join(", ")}`); - if (configAllowedAdd.length > 0) core.info(`Allowed labels to add: ${configAllowedAdd.join(", ")}`); - if (configAllowedRemove.length > 0) core.info(`Allowed labels to remove: ${configAllowedRemove.join(", ")}`); - if (blockedPatterns.length > 0) core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`); + if (initialAllowedAdd.length > 0) core.info(`Allowed labels to add: ${initialAllowedAdd.join(", ")}`); + if (initialAllowedRemove.length > 0) core.info(`Allowed labels to remove: ${initialAllowedRemove.join(", ")}`); + if (initialBlockedPatterns.length > 0) core.info(`Blocked patterns: ${initialBlockedPatterns.join(", ")}`); if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`); if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); core.info(`Default target repo: ${defaultTargetRepo}`); @@ -143,14 +143,14 @@ const main = createCountGatedHandler({ } // Validate label_to_remove against blocked patterns and allowed-remove list - const removeValidation = validateSingleLabel(labelToRemove, configAllowedRemove, blockedPatterns, "label_to_remove"); + const removeValidation = validateSingleLabel(labelToRemove, initialAllowedRemove, initialBlockedPatterns, "label_to_remove"); if (!removeValidation.valid) { core.warning(`label_to_remove validation failed: ${removeValidation.error}`); return { success: false, error: removeValidation.error }; } // Validate label_to_add against blocked patterns and allowed-add list - const addValidation = validateSingleLabel(labelToAdd, configAllowedAdd, blockedPatterns, "label_to_add"); + const addValidation = validateSingleLabel(labelToAdd, initialAllowedAdd, initialBlockedPatterns, "label_to_add"); if (!addValidation.valid) { core.warning(`label_to_add validation failed: ${addValidation.error}`); return { success: false, error: addValidation.error }; From e3acd8e4d67b902645f58b94e1e3b9bbb7a03fa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:16:24 +0000 Subject: [PATCH 09/10] Revalidate remove label before setLabels Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index bdef641aa83..18cf7d225af 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -219,6 +219,13 @@ const main = createCountGatedHandler({ const { data: updatedLabels } = await withRetry( async () => { beforeState = await fetchIssueState(githubClient, repoParts, itemNumber); + const preWriteRemoveValidation = validateSingleLabel(labelToRemove, currentAllowedRemove(), currentBlockedPatterns(), "label_to_remove"); + if (!preWriteRemoveValidation.valid) { + core.warning(`label_to_remove validation failed before setLabels: ${preWriteRemoveValidation.error}`); + const policyError = new Error(preWriteRemoveValidation.error); + policyError.name = POLICY_REJECTION_ERROR_NAME; + throw policyError; + } const preWriteAddValidation = validateSingleLabel(labelToAdd, currentAllowedAdd(), currentBlockedPatterns(), "label_to_add"); if (!preWriteAddValidation.valid) { core.warning(`label_to_add validation failed before setLabels: ${preWriteAddValidation.error}`); From a4778c5f0dfad4785daefd84279f69448450879d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:18:35 +0000 Subject: [PATCH 10/10] Recompute replace_label labels per retry Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/replace_label.cjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs index 18cf7d225af..5676fe74ad4 100644 --- a/actions/setup/js/replace_label.cjs +++ b/actions/setup/js/replace_label.cjs @@ -38,7 +38,9 @@ const SET_LABELS_RETRY_CONFIG = { ...RATE_LIMIT_RETRY_CONFIG, shouldRetry: error => { const status = error?.response?.status ?? error?.status ?? null; - return error?.name !== POLICY_REJECTION_ERROR_NAME && ((status >= 500 && status < 600) || RATE_LIMIT_RETRY_CONFIG.shouldRetry(error)); + const retryableHttpStatus = typeof status === "number" && status >= 500 && status < 600; + const retryableTransportError = status == null && RATE_LIMIT_RETRY_CONFIG.shouldRetry(error); + return error?.name !== POLICY_REJECTION_ERROR_NAME && (retryableHttpStatus || retryableTransportError || RATE_LIMIT_RETRY_CONFIG.shouldRetry(error)); }, }; @@ -208,11 +210,10 @@ const main = createCountGatedHandler({ // Compute the new label set: current labels minus labelToRemove, plus labelToAdd (deduped). // If labelToRemove is not on the issue we still proceed — it simply won't appear in the set. const currentLabelNames = (item.labels || []).map(/** @param {any} l */ l => (typeof l === "string" ? l : l.name || "")).filter(Boolean); - const labelToRemoveIsPresent = currentLabelNames.includes(labelToRemove); + let labelToRemoveIsPresent = currentLabelNames.includes(labelToRemove); if (!labelToRemoveIsPresent) { core.info(`Label "${labelToRemove}" is not present on ${contextType} #${itemNumber} in ${itemRepo} — will only add "${labelToAdd}"`); } - const newLabelNames = [...new Set([...currentLabelNames.filter(n => n !== labelToRemove), labelToAdd])]; try { let beforeState; @@ -233,6 +234,9 @@ const main = createCountGatedHandler({ policyError.name = POLICY_REJECTION_ERROR_NAME; throw policyError; } + const beforeWriteLabelNames = normalizeLabelNames(beforeState.labels); + labelToRemoveIsPresent = beforeWriteLabelNames.includes(labelToRemove); + const newLabelNames = [...new Set([...beforeWriteLabelNames.filter(n => n !== labelToRemove), labelToAdd])]; return githubClient.rest.issues.setLabels({ owner: repoParts.owner,