diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs
index 37ab983e882..9f4962c58ba 100644
--- a/actions/setup/js/add_labels.cjs
+++ b/actions/setup/js/add_labels.cjs
@@ -212,6 +212,7 @@ const main = createCountGatedHandler({
handlerType: HANDLER_TYPE,
setup: async (config, maxCount, isStaged) => {
const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config;
+ const target = config.target || "triggering";
const issueIntentEnabled = config.issue_intent !== false;
const issueIntentStrict = config.issue_intent === true; // strict mode: plain-string labels rejected, metadata required
const createIfMissing = config.create_if_missing === true;
@@ -248,15 +249,24 @@ const main = createCountGatedHandler({
const { repo: itemRepo, repoParts } = repoResult;
core.info(`Target repository: ${itemRepo}`);
- // Determine target issue/PR number
- // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number
- const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
- if (!targetResult.success) return targetResult;
const effectiveContext = resolveInvocationContext(context);
- const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ let itemNumber;
+
+ if (target === "*") {
+ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number
+ const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
+ if (!targetResult.success) return targetResult;
+ itemNumber = targetResult.number ?? triggeringItemNumber;
+ } else if (target === "triggering") {
+ itemNumber = triggeringItemNumber;
+ } else {
+ itemNumber = Number(target);
+ }
- if (!itemNumber || Number.isNaN(Number(itemNumber))) {
- const error = "No issue/PR number available";
+ itemNumber = Number(itemNumber);
+ if (!Number.isInteger(itemNumber) || itemNumber <= 0) {
+ const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available";
core.warning(error);
return { success: false, error };
}
diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs
index 7006c4ca721..304332c1350 100644
--- a/actions/setup/js/add_labels.test.cjs
+++ b/actions/setup/js/add_labels.test.cjs
@@ -112,8 +112,100 @@ describe("add_labels", () => {
});
describe("handleAddLabels", () => {
+ describe("AL-005 runtime target authorization", () => {
+ it("AL-002 ignores a conflicting item_number when target is triggering", async () => {
+ const handler = await main({ max: 10, target: "triggering" });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(addLabelsCalls[0].issue_number).toBe(123);
+ });
+
+ it("AL-001 defaults to the triggering item when target is omitted", async () => {
+ const handler = await main({ max: 10 });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(addLabelsCalls[0].issue_number).toBe(123);
+ });
+
+ it("AL-003 ignores a conflicting item_number when target is a fixed number", async () => {
+ const handler = await main({ max: 10, target: "789" });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(789);
+ expect(addLabelsCalls[0].issue_number).toBe(789);
+ });
+
+ it("AL-003 uses a fixed numeric target without an item_number", async () => {
+ const handler = await main({ max: 10, target: "789" });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(789);
+ expect(addLabelsCalls[0].issue_number).toBe(789);
+ });
+
+ it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => {
+ const handler = await main({ max: 10, target });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ labels: ["bug"] }, {});
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Invalid issue/PR number");
+ expect(addLabelsCalls).toHaveLength(0);
+ });
+
+ it("AL-004 accepts item_number when target is wildcard", async () => {
+ const handler = await main({ max: 10, target: "*" });
+ const addLabelsCalls = [];
+ mockGithub.rest.issues.addLabels = async params => {
+ addLabelsCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(456);
+ expect(addLabelsCalls[0].issue_number).toBe(456);
+ });
+ });
+
it("should add labels to an issue using explicit item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -138,7 +230,7 @@ describe("add_labels", () => {
});
it("should accept structured label entries and add normalized label names", async () => {
- const handler = await main({ max: 10, issue_intent: true });
+ const handler = await main({ max: 10, target: "*", issue_intent: true });
const graphqlMutationCalls = [];
const originalGraphql = mockGithub.graphql;
@@ -291,7 +383,7 @@ describe("add_labels", () => {
});
it("should report a confidence-gated intent label as suggested rather than added", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
mockGithub.rest.issues.get = async () => ({
data: {
@@ -460,7 +552,7 @@ describe("add_labels", () => {
});
it("should accept issue_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -482,7 +574,7 @@ describe("add_labels", () => {
});
it("should accept pr_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -504,7 +596,7 @@ describe("add_labels", () => {
});
it("should accept pull_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -609,7 +701,7 @@ describe("add_labels", () => {
});
it("should handle invalid item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const result = await handler(
{
@@ -969,7 +1061,7 @@ describe("add_labels", () => {
});
it("should fall back to the REST add-labels endpoint for PRs when using issue_intent (pull_request field)", async () => {
- const handler = await main({ max: 10, issue_intent: true });
+ const handler = await main({ max: 10, target: "*", issue_intent: true });
const graphqlMutationCalls = [];
const addLabelsCalls = [];
@@ -1013,7 +1105,7 @@ describe("add_labels", () => {
});
it("should fall back to the REST add-labels endpoint for PRs when node_id starts with PR_", async () => {
- const handler = await main({ max: 10, issue_intent: true });
+ const handler = await main({ max: 10, target: "*", issue_intent: true });
const graphqlMutationCalls = [];
const addLabelsCalls = [];
@@ -1267,7 +1359,7 @@ describe("add_labels", () => {
});
it("should resolve temporary ID in item_number to real issue number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -1290,7 +1382,7 @@ describe("add_labels", () => {
});
it("should defer when item_number is an unresolved temporary ID", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const result = await handler(
{
@@ -1306,7 +1398,7 @@ describe("add_labels", () => {
});
it("should resolve temporary ID with hash prefix in item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
@@ -1328,7 +1420,7 @@ describe("add_labels", () => {
});
it("should preview labels in staged mode without calling API", async () => {
- const handler = await main({ max: 10, staged: true });
+ const handler = await main({ max: 10, target: "*", staged: true });
const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs
index a46444b150d..fccec1a12d4 100644
--- a/actions/setup/js/remove_labels.cjs
+++ b/actions/setup/js/remove_labels.cjs
@@ -29,6 +29,7 @@ const main = createCountGatedHandler({
// Extract configuration
const allowedLabels = config.allowed || [];
const blockedPatterns = config.blocked || [];
+ const target = config.target || "triggering";
const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : [];
const requiredTitlePrefix = config.required_title_prefix || "";
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
@@ -67,15 +68,24 @@ const main = createCountGatedHandler({
const { repo: itemRepo, repoParts } = repoResult;
core.info(`Target repository: ${itemRepo}`);
- // Determine target issue/PR number
- // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number
- const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
- if (!targetResult.success) return targetResult;
const effectiveContext = resolveInvocationContext(context);
- const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ let itemNumber;
+
+ if (target === "*") {
+ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number
+ const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
+ if (!targetResult.success) return targetResult;
+ itemNumber = targetResult.number ?? triggeringItemNumber;
+ } else if (target === "triggering") {
+ itemNumber = triggeringItemNumber;
+ } else {
+ itemNumber = Number(target);
+ }
- if (!itemNumber || Number.isNaN(Number(itemNumber))) {
- const error = "No issue/PR number available";
+ itemNumber = Number(itemNumber);
+ if (!Number.isInteger(itemNumber) || itemNumber <= 0) {
+ const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available";
core.warning(error);
return { success: false, error };
}
diff --git a/actions/setup/js/remove_labels.test.cjs b/actions/setup/js/remove_labels.test.cjs
index a46965b905e..83cbaeff744 100644
--- a/actions/setup/js/remove_labels.test.cjs
+++ b/actions/setup/js/remove_labels.test.cjs
@@ -81,8 +81,100 @@ describe("remove_labels", () => {
});
describe("handleRemoveLabels", () => {
+ describe("runtime target authorization", () => {
+ it("RML-002 ignores a conflicting item_number when target is triggering", async () => {
+ const handler = await main({ max: 10, target: "triggering" });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(removeLabelCalls[0].issue_number).toBe(123);
+ });
+
+ it("RML-001 defaults to the triggering item when target is omitted", async () => {
+ const handler = await main({ max: 10 });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(removeLabelCalls[0].issue_number).toBe(123);
+ });
+
+ it("RML-003 ignores a conflicting item_number when target is a fixed number", async () => {
+ const handler = await main({ max: 10, target: "789" });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(789);
+ expect(removeLabelCalls[0].issue_number).toBe(789);
+ });
+
+ it("RML-003 uses a fixed numeric target without an item_number", async () => {
+ const handler = await main({ max: 10, target: "789" });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(789);
+ expect(removeLabelCalls[0].issue_number).toBe(789);
+ });
+
+ it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => {
+ const handler = await main({ max: 10, target });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ labels: ["bug"] }, {});
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Invalid issue/PR number");
+ expect(removeLabelCalls).toHaveLength(0);
+ });
+
+ it("RML-004 accepts item_number when target is wildcard", async () => {
+ const handler = await main({ max: 10, target: "*" });
+ const removeLabelCalls = [];
+ mockGithub.rest.issues.removeLabel = async params => {
+ removeLabelCalls.push(params);
+ return {};
+ };
+
+ const result = await handler({ item_number: 456, labels: ["bug"] }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(456);
+ expect(removeLabelCalls[0].issue_number).toBe(456);
+ });
+ });
+
it("should remove labels from an issue using explicit item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -108,7 +200,7 @@ describe("remove_labels", () => {
});
it("should accept structured label entries and remove normalized label names", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -131,7 +223,7 @@ describe("remove_labels", () => {
});
it("should accept issue_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -153,7 +245,7 @@ describe("remove_labels", () => {
});
it("should accept pr_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -175,7 +267,7 @@ describe("remove_labels", () => {
});
it("should accept pull_number as an alias for item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -280,7 +372,7 @@ describe("remove_labels", () => {
});
it("should handle invalid item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const result = await handler(
{
@@ -654,7 +746,7 @@ describe("remove_labels", () => {
});
it("should resolve temporary ID in item_number to real issue number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
@@ -677,7 +769,7 @@ describe("remove_labels", () => {
});
it("should defer when item_number is an unresolved temporary ID", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const result = await handler(
{
@@ -693,7 +785,7 @@ describe("remove_labels", () => {
});
it("should resolve temporary ID with hash prefix in item_number", async () => {
- const handler = await main({ max: 10 });
+ const handler = await main({ max: 10, target: "*" });
const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
diff --git a/actions/setup/js/replace_label.cjs b/actions/setup/js/replace_label.cjs
index 5676fe74ad4..b4e776d5f9f 100644
--- a/actions/setup/js/replace_label.cjs
+++ b/actions/setup/js/replace_label.cjs
@@ -79,6 +79,7 @@ function validateSingleLabel(labelName, allowedPatterns, blockedPatterns, fieldN
const main = createCountGatedHandler({
handlerType: HANDLER_TYPE,
setup: async (config, maxCount, isStaged) => {
+ const target = config.target || "triggering";
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 : []);
@@ -120,14 +121,23 @@ const main = createCountGatedHandler({
const { repo: itemRepo, repoParts } = repoResult;
core.info(`Target repository: ${itemRepo}`);
- // Determine target issue/PR number
- const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
- if (!targetResult.success) return targetResult;
const effectiveContext = resolveInvocationContext(context);
- const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ const triggeringItemNumber = effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number;
+ let itemNumber;
- if (!itemNumber || Number.isNaN(Number(itemNumber))) {
- const error = "No issue/PR number available";
+ if (target === "*") {
+ const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE });
+ if (!targetResult.success) return targetResult;
+ itemNumber = targetResult.number ?? triggeringItemNumber;
+ } else if (target === "triggering") {
+ itemNumber = triggeringItemNumber;
+ } else {
+ itemNumber = Number(target);
+ }
+
+ itemNumber = Number(itemNumber);
+ if (!Number.isInteger(itemNumber) || itemNumber <= 0) {
+ const error = target !== "*" && target !== "triggering" ? "Invalid issue/PR number" : "No issue/PR number available";
core.warning(error);
return { success: false, error };
}
diff --git a/actions/setup/js/replace_label.test.cjs b/actions/setup/js/replace_label.test.cjs
index a0f4fcb7e38..05cbe72797d 100644
--- a/actions/setup/js/replace_label.test.cjs
+++ b/actions/setup/js/replace_label.test.cjs
@@ -69,6 +69,100 @@ describe("replace_label", () => {
global.context = mockContext;
});
+ describe("runtime target authorization", () => {
+ const message = { label_to_remove: "in-progress", label_to_add: "done", item_number: 99 };
+
+ it("T-RL-015 ignores a conflicting item_number when target is triggering", async () => {
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: params.labels.map(name => ({ name })) };
+ };
+ const handler = await main({ target: "triggering" });
+
+ const result = await handler(message, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(42);
+ expect(setLabelsCalls[0].issue_number).toBe(42);
+ });
+
+ it("T-RL-014 defaults to the triggering item when target is omitted", async () => {
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: params.labels.map(name => ({ name })) };
+ };
+ const handler = await main({});
+
+ const result = await handler(message, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(42);
+ expect(setLabelsCalls[0].issue_number).toBe(42);
+ });
+
+ it("T-RL-016 ignores a conflicting item_number when target is a fixed number", async () => {
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: params.labels.map(name => ({ name })) };
+ };
+ const handler = await main({ target: "123" });
+
+ const result = await handler(message, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(setLabelsCalls[0].issue_number).toBe(123);
+ });
+
+ it("T-RL-017 uses a fixed numeric target without an item_number", async () => {
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: params.labels.map(name => ({ name })) };
+ };
+ const handler = await main({ target: "123" });
+
+ const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(123);
+ expect(setLabelsCalls[0].issue_number).toBe(123);
+ });
+
+ it.each([-1, 1.5, Infinity])("rejects invalid fixed target %s", async target => {
+ const handler = await main({ target });
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: [] };
+ };
+
+ const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {});
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe("Invalid issue/PR number");
+ expect(setLabelsCalls).toHaveLength(0);
+ });
+
+ it("T-RL-018 accepts item_number when target is wildcard", async () => {
+ const setLabelsCalls = [];
+ mockGithub.rest.issues.setLabels = async params => {
+ setLabelsCalls.push(params);
+ return { data: params.labels.map(name => ({ name })) };
+ };
+ const handler = await main({ target: "*" });
+
+ const result = await handler(message, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(99);
+ expect(setLabelsCalls[0].issue_number).toBe(99);
+ });
+ });
+
it("should replace label when both labels are valid", async () => {
const handler = await main({ allowed_add: [], allowed_remove: [], blocked: [] });
const result = await handler({ label_to_remove: "in-progress", label_to_add: "done" }, {});
diff --git a/actions/setup/js/update_handler_factory.cjs b/actions/setup/js/update_handler_factory.cjs
index e635a4650e0..0d759b00427 100644
--- a/actions/setup/js/update_handler_factory.cjs
+++ b/actions/setup/js/update_handler_factory.cjs
@@ -44,10 +44,10 @@ function createStandardResolveNumber(config) {
const { itemType, itemNumberField, supportsPR, supportsIssue } = config;
return function resolveNumber(item, updateTarget, context, resolvedTemporaryIds) {
- // Resolve temporary IDs in the item number field before target resolution
+ // Resolve model-provided temporary IDs only when wildcard targeting allows them.
let resolvedItem = item;
const itemNumberValue = item[itemNumberField];
- if (resolvedTemporaryIds && itemNumberValue != null) {
+ if (updateTarget === "*" && resolvedTemporaryIds && itemNumberValue != null) {
const tempIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
const resolvedTarget = resolveRepoIssueTarget(itemNumberValue, tempIdMap, context.repo.owner, context.repo.repo);
if (resolvedTarget.wasTemporaryId && resolvedTarget.resolved) {
diff --git a/actions/setup/js/update_handler_factory.test.cjs b/actions/setup/js/update_handler_factory.test.cjs
index 1d76f485513..b8d4d157eb9 100644
--- a/actions/setup/js/update_handler_factory.test.cjs
+++ b/actions/setup/js/update_handler_factory.test.cjs
@@ -417,7 +417,7 @@ describe("update_handler_factory.cjs", () => {
});
describe("createStandardResolveNumber", () => {
- it("should create a resolve function that uses resolveTarget helper", async () => {
+ it("UI-001 defaults an omitted target to the triggering issue", async () => {
const resolveNumber = factoryModule.createStandardResolveNumber({
itemType: "update_issue",
itemNumberField: "issue_number",
@@ -425,7 +425,21 @@ describe("update_handler_factory.cjs", () => {
supportsIssue: true,
});
- const item = { issue_number: 42 };
+ const result = resolveNumber({ issue_number: 99 }, undefined, mockContext);
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(42);
+ });
+
+ it("UI-002 ignores a conflicting issue_number when target is triggering", async () => {
+ const resolveNumber = factoryModule.createStandardResolveNumber({
+ itemType: "update_issue",
+ itemNumberField: "issue_number",
+ supportsPR: false,
+ supportsIssue: true,
+ });
+
+ const item = { issue_number: 99 };
const updateTarget = "triggering";
const context = mockContext;
@@ -435,6 +449,48 @@ describe("update_handler_factory.cjs", () => {
expect(result.number).toBe(42);
});
+ it("UI-003 ignores a conflicting issue_number when target is fixed", async () => {
+ const resolveNumber = factoryModule.createStandardResolveNumber({
+ itemType: "update_issue",
+ itemNumberField: "issue_number",
+ supportsPR: false,
+ supportsIssue: true,
+ });
+
+ const result = resolveNumber({ issue_number: 99 }, "17", mockContext);
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(17);
+ });
+
+ it("UI-004 accepts issue_number when target is wildcard", async () => {
+ const resolveNumber = factoryModule.createStandardResolveNumber({
+ itemType: "update_issue",
+ itemNumberField: "issue_number",
+ supportsPR: false,
+ supportsIssue: true,
+ });
+
+ const result = resolveNumber({ issue_number: 99 }, "*", mockContext);
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(99);
+ });
+
+ it("UI-005 ignores unresolved temporary IDs when target is triggering", async () => {
+ const resolveNumber = factoryModule.createStandardResolveNumber({
+ itemType: "update_issue",
+ itemNumberField: "issue_number",
+ supportsPR: false,
+ supportsIssue: true,
+ });
+
+ const result = resolveNumber({ issue_number: "aw_pending" }, "triggering", mockContext, {});
+
+ expect(result.success).toBe(true);
+ expect(result.number).toBe(42);
+ });
+
it("should handle different item number fields", async () => {
const resolveNumber = factoryModule.createStandardResolveNumber({
itemType: "update_pull_request",
diff --git a/docs/src/content/docs/specs/safe-outputs-specification.md b/docs/src/content/docs/specs/safe-outputs-specification.md
index bff8fa645d2..5cba5b85369 100644
--- a/docs/src/content/docs/specs/safe-outputs-specification.md
+++ b/docs/src/content/docs/specs/safe-outputs-specification.md
@@ -7,9 +7,9 @@ sidebar:
# Safe Outputs MCP Gateway Specification
-**Version**: 1.29.0
+**Version**: 1.29.3
**Status**: Working Draft
-**Publication Date**: 2026-09-02
+**Publication Date**: 2026-09-12
**Editor**: GitHub Agentic Workflows Team
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)
**Latest Published Version**: This document
@@ -2715,6 +2715,7 @@ This section provides complete definitions for all remaining safe output types.
**Configuration Parameters**:
- `max`: Operation limit (default: 1)
+- `target`: `"triggering"` (default), `"*"`, or a fixed issue number
- `target-repo`: Cross-repository target
- `allowed-repos`: Cross-repo allowlist
- `staged`: Staged mode override
@@ -2726,6 +2727,16 @@ This section provides complete definitions for all remaining safe output types.
- Cross-repository targets MUST be validated against the `allowed-repos` allowlist
- Issue number MUST be validated as a positive integer belonging to the target repository
+**UI-001**: If `target` is omitted, the processor MUST interpret it as `target: "triggering"`.
+
+**UI-002**: For `target: "triggering"`, the processor MUST use only the issue number from trusted triggering-event context and MUST ignore any agent-supplied `issue_number`.
+
+**UI-003**: For a fixed numeric `target`, the processor MUST use the configured issue number and MUST ignore any conflicting agent-supplied `issue_number`.
+
+**UI-004**: Only `target: "*"` MAY select an issue from the agent-supplied `issue_number`.
+
+**UI-005**: Schema shaping, prompt instructions, and temporary-ID resolution MUST NOT replace or precede runtime target authorization. Agent-supplied target identifiers, including unresolved temporary IDs, MUST be ignored unless `target` is `"*"`.
+
**Required Permissions**:
*GitHub Actions Token*:
@@ -3594,6 +3605,14 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement
- Requires both `issues: write` and `pull-requests: write` to support labeling both entity types
- Labels must exist in repository; non-existent labels generate warnings
+**Target Authorization Requirements**:
+
+- **AL-001**: An omitted `target` configuration MUST be interpreted as `target: "triggering"`.
+- **AL-002**: With `target: "triggering"`, the handler MUST use only the issue or pull request number from trusted triggering-event context. It MUST ignore agent-supplied `item_number` and equivalent aliases.
+- **AL-003**: With a fixed numeric `target`, the handler MUST use the configured number whether or not the agent supplies an item number. It MUST ignore conflicting agent-supplied target identifiers.
+- **AL-004**: Only `target: "*"` MAY select an issue or pull request from an agent-supplied `item_number` or equivalent alias.
+- **AL-005**: The privileged handler MUST enforce AL-001 through AL-004 at runtime. Agent-facing schema shaping or prompt instructions MAY reduce invalid requests but MUST NOT replace runtime enforcement.
+
---
#### Type: remove_labels
@@ -3622,6 +3641,14 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement
- Same permissions as `add_labels`
- Missing labels are silently ignored (no error)
+**Target Authorization Requirements**:
+
+- **RML-001**: An omitted `target` configuration MUST be interpreted as `target: "triggering"`.
+- **RML-002**: With `target: "triggering"`, the handler MUST use only the issue or pull request number from trusted triggering-event context. It MUST ignore agent-supplied `item_number` and equivalent aliases.
+- **RML-003**: With a fixed numeric `target`, the handler MUST use the configured number whether or not the agent supplies an item number. It MUST ignore conflicting agent-supplied target identifiers.
+- **RML-004**: Only `target: "*"` MAY select an issue or pull request from an agent-supplied `item_number` or equivalent alias.
+- **RML-005**: The privileged handler MUST enforce RML-001 through RML-004 at runtime.
+
---
#### Type: add_reviewer
@@ -5607,6 +5634,21 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries
- **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired.
- **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility.
+**Version 1.29.3** (2026-09-12):
+
+- **Specified**: Runtime target authorization for `update_issue`, including wildcard-only resolution of agent-supplied temporary issue IDs.
+- **Updated**: Publication metadata to 1.29.3.
+
+**Version 1.29.2** (2026-09-12):
+
+- **Specified**: Runtime target authorization for `remove_labels`, matching `add_labels`: omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers.
+- **Updated**: Publication metadata to 1.29.2.
+
+**Version 1.29.1** (2026-09-12):
+
+- **Specified**: Runtime target authorization for `add_labels`. Omitted and explicit `triggering` targets are restricted to trusted event context, fixed numeric targets override agent output, and only wildcard targets may use agent-supplied item numbers.
+- **Updated**: Publication metadata to 1.29.1.
+
**Version 1.29.0** (2026-09-02):
- **Added**: `linear_create_issue`, `linear_add_comment`, and `linear_update_issue` Safe Output definitions.
diff --git a/pkg/workflow/checkout_runtime_order_test.go b/pkg/workflow/checkout_runtime_order_test.go
index fb7ae1567cf..bea0295c49b 100644
--- a/pkg/workflow/checkout_runtime_order_test.go
+++ b/pkg/workflow/checkout_runtime_order_test.go
@@ -11,22 +11,22 @@ import (
"github.com/github/gh-aw/pkg/constants"
)
-// otlpTelemetryStepNames are compiler-injected observability steps. They are emitted
-// for every workflow because the OTLP endpoint defaults to the enterprise
-// GH_AW_DEFAULT_OTLP_ENDPOINT secret or variable / GH_AW_DEFAULT_OTLP_HEADERS
-// secret pair, so they are not part of the checkout ordering contract exercised
-// by these tests.
-var otlpTelemetryStepNames = map[string]bool{
- "Mask OTLP telemetry headers": true,
- "Mask OTLP custom attribute values": true,
- "Check OTLP telemetry configuration": true,
+// compilerInjectedStepNames are not part of the checkout ordering contract exercised
+// by these tests. OTLP steps are emitted for every workflow because the endpoint
+// defaults to the enterprise GH_AW_DEFAULT_OTLP_ENDPOINT secret or variable /
+// GH_AW_DEFAULT_OTLP_HEADERS secret pair.
+var compilerInjectedStepNames = map[string]bool{
+ "Mask OTLP telemetry headers": true,
+ "Mask OTLP custom attribute values": true,
+ "Check OTLP telemetry configuration": true,
+ "Initialize agent execution evidence": true,
}
-// filterOTLPTelemetrySteps removes compiler-injected OTLP steps from a step name list.
-func filterOTLPTelemetrySteps(names []string) []string {
+// filterCompilerInjectedSteps removes unrelated compiler-injected steps from a step name list.
+func filterCompilerInjectedSteps(names []string) []string {
filtered := make([]string, 0, len(names))
for _, name := range names {
- if otlpTelemetryStepNames[name] {
+ if compilerInjectedStepNames[name] {
continue
}
filtered = append(filtered, name)
@@ -142,7 +142,7 @@ steps:
}
}
- stepNames = filterOTLPTelemetrySteps(stepNames)
+ stepNames = filterCompilerInjectedSteps(stepNames)
t.Logf("Found %d steps: %v", len(stepNames), stepNames)
@@ -461,7 +461,7 @@ Run node --version to check the Node.js version.
}
}
- stepNames = filterOTLPTelemetrySteps(stepNames)
+ stepNames = filterCompilerInjectedSteps(stepNames)
if len(stepNames) < 4 {
t.Fatalf("Expected at least 4 steps, got %d: %v", len(stepNames), stepNames)
diff --git a/pkg/workflow/replace_label_formal_test.go b/pkg/workflow/replace_label_formal_test.go
index 5c73101ca66..cdde8132ea0 100644
--- a/pkg/workflow/replace_label_formal_test.go
+++ b/pkg/workflow/replace_label_formal_test.go
@@ -478,13 +478,27 @@ func TestFormalReplaceLabelP14_CrossRepoRestriction(t *testing.T) {
}
func TestFormalReplaceLabelP15_TargetModeEnforcement(t *testing.T) {
- n, ok := formalResolveTargetNumber("triggering", 42, 99)
- require.True(t, ok)
- assert.Equal(t, 42, n)
+ tests := []struct {
+ name string
+ target string
+ trigger int
+ requested int
+ expected int
+ }{
+ {name: "T-RL-014 omitted target uses triggering item", trigger: 42, requested: 99, expected: 42},
+ {name: "T-RL-015 triggering target ignores requested item", target: "triggering", trigger: 42, requested: 99, expected: 42},
+ {name: "T-RL-016 fixed target ignores requested item", target: "123", trigger: 42, requested: 99, expected: 123},
+ {name: "T-RL-017 fixed target works without requested item", target: "123", trigger: 42, expected: 123},
+ {name: "T-RL-018 wildcard target uses requested item", target: "*", trigger: 42, requested: 99, expected: 99},
+ }
- n, ok = formalResolveTargetNumber("*", 42, 99)
- require.True(t, ok)
- assert.Equal(t, 99, n)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ n, ok := formalResolveTargetNumber(tt.target, tt.trigger, tt.requested)
+ require.True(t, ok)
+ assert.Equal(t, tt.expected, n)
+ })
+ }
}
func TestFormalReplaceLabelEdge_BothLabelsIdentical(t *testing.T) {
diff --git a/pkg/workflow/safe_outputs_specification_add_labels_test.go b/pkg/workflow/safe_outputs_specification_add_labels_test.go
new file mode 100644
index 00000000000..af1b083712a
--- /dev/null
+++ b/pkg/workflow/safe_outputs_specification_add_labels_test.go
@@ -0,0 +1,31 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSafeOutputsSpecificationDocumentsAddLabelsTargetAuthorization(t *testing.T) {
+ specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md"))
+ specBytes, err := os.ReadFile(specPath)
+ require.NoError(t, err, "should read safe outputs specification")
+
+ section := extractSpecTypeSection(t, string(specBytes), "add_labels")
+
+ assert.Contains(t, section, "**AL-001**", "spec should define the omitted target default")
+ assert.Contains(t, section, "interpreted as `target: \"triggering\"`", "spec should default omitted targets to triggering")
+ assert.Contains(t, section, "**AL-002**", "spec should define triggering target authorization")
+ assert.Contains(t, section, "only the issue or pull request number from trusted triggering-event context", "spec should restrict triggering targets to event context")
+ assert.Contains(t, section, "**AL-003**", "spec should define fixed target authorization")
+ assert.Contains(t, section, "ignore conflicting agent-supplied target identifiers", "spec should make fixed targets override agent output")
+ assert.Contains(t, section, "**AL-004**", "spec should define wildcard target authorization")
+ assert.Contains(t, section, "Only `target: \"*\"` MAY select", "spec should reserve agent-selected targets for wildcard mode")
+ assert.Contains(t, section, "**AL-005**", "spec should require runtime enforcement")
+ assert.Contains(t, section, "MUST NOT replace runtime enforcement", "spec should not rely on schema shaping or prompts for authorization")
+}
diff --git a/pkg/workflow/safe_outputs_specification_remove_labels_test.go b/pkg/workflow/safe_outputs_specification_remove_labels_test.go
new file mode 100644
index 00000000000..101619b8181
--- /dev/null
+++ b/pkg/workflow/safe_outputs_specification_remove_labels_test.go
@@ -0,0 +1,26 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSafeOutputsSpecificationDocumentsRemoveLabelsTargetAuthorization(t *testing.T) {
+ specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md"))
+ specBytes, err := os.ReadFile(specPath)
+ require.NoError(t, err, "should read safe outputs specification")
+
+ section := extractSpecTypeSection(t, string(specBytes), "remove_labels")
+
+ assert.Contains(t, section, "**RML-001**", "spec should define the omitted target default")
+ assert.Contains(t, section, "**RML-002**", "spec should restrict triggering targets to event context")
+ assert.Contains(t, section, "**RML-003**", "spec should make fixed targets override agent output")
+ assert.Contains(t, section, "**RML-004**", "spec should reserve agent-selected targets for wildcard mode")
+ assert.Contains(t, section, "**RML-005**", "spec should require runtime enforcement")
+}
diff --git a/pkg/workflow/safe_outputs_specification_update_issue_test.go b/pkg/workflow/safe_outputs_specification_update_issue_test.go
new file mode 100644
index 00000000000..685a23ccada
--- /dev/null
+++ b/pkg/workflow/safe_outputs_specification_update_issue_test.go
@@ -0,0 +1,26 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSafeOutputsSpecificationDocumentsUpdateIssueTargetAuthorization(t *testing.T) {
+ specPath := findRepoFile(t, filepath.Join("docs", "src", "content", "docs", "specs", "safe-outputs-specification.md"))
+ specBytes, err := os.ReadFile(specPath)
+ require.NoError(t, err, "should read safe outputs specification")
+
+ section := extractSpecTypeSection(t, string(specBytes), "update_issue")
+
+ assert.Contains(t, section, "**UI-001**", "spec should define the omitted target default")
+ assert.Contains(t, section, "**UI-002**", "spec should restrict triggering targets to event context")
+ assert.Contains(t, section, "**UI-003**", "spec should make fixed targets override agent output")
+ assert.Contains(t, section, "**UI-004**", "spec should reserve agent-selected targets for wildcard mode")
+ assert.Contains(t, section, "**UI-005**", "spec should require runtime target authorization before temporary-ID resolution")
+}
diff --git a/specs/replace-label-spec.md b/specs/replace-label-spec.md
index eeffdd4a1a8..16f04a16810 100644
--- a/specs/replace-label-spec.md
+++ b/specs/replace-label-spec.md
@@ -7,11 +7,11 @@ sidebar:
# replace-label Safe-Output Type Specification
-**Version**: 1.0.0
+**Version**: 1.0.2
**Status**: Candidate Recommendation
**Latest Version**: https://github.com/github/gh-aw/blob/main/specs/replace-label-spec.md
**Editors**: GitHub gh-aw Team (GitHub, Inc.)
-**Publication Date**: 2026-06-20
+**Publication Date**: 2026-09-12
---
@@ -161,7 +161,7 @@ safe-outputs:
| `allowed-remove` | `string[]` | `[]` (any) | Glob patterns for labels the agent is permitted to remove. When empty or absent, no remove restriction applies. |
| `blocked` | `string[]` | `[]` (none) | Glob patterns that are unconditionally prohibited for both add and remove operations. Applied after allowlist checks. |
| `max` | `integer` or GHA expression | `5` | Maximum number of `replace-label` operations permitted in a single workflow run. Supports GitHub Actions expressions (e.g., `${{ inputs.max_labels }}`). |
-| `target` | `"triggering"` \| `"*"` \| integer | `"triggering"` | Determines which issue/PR may be targeted. `"triggering"` restricts to the event item; `"*"` permits any item (requires `item_number` in message); an integer pins to a specific item number. |
+| `target` | `"triggering"` \| `"*"` \| integer | `"triggering"` | Determines which issue/PR may be targeted. `"triggering"` restricts to the event item; `"*"` permits an `item_number` from the message, or falls back to the event item when absent; an integer pins to a specific item number. |
| `target-repo` | `string` | (current repo) | Default target repository in `owner/repo` format for cross-repository operations. |
| `allowed-repos` | `string[]` | `[]` | Additional repositories the agent may target, beyond `target-repo`. |
| `github-token` | `string` | (workflow default) | GitHub token or GitHub Actions expression for authentication. Overrides the workflow-level token for this type only. |
@@ -196,7 +196,7 @@ AI agents emit `replace_label` messages as part of the safe-outputs protocol. Th
|-------|------|----------|------------|-------------|
| `label_to_remove` | `string` | Yes | 128 characters | Name of the label to remove from the target item. The label need not currently be present on the item (see §5.3.4). |
| `label_to_add` | `string` | Yes | 128 characters | Name of the label to add to the target item. The label need not pre-exist in the repository (see §5.4). |
-| `item_number` | `integer` or temporary-ID `string` | No | — | Issue or pull request number to target. When absent, falls back to the triggering item derived from the GitHub Actions event context. May be a temporary-ID string resolved by the gh-aw temporary-ID framework. |
+| `item_number` | `integer` or temporary-ID `string` | No | — | Issue or pull request number to target when `target: "*"` is configured. When absent, falls back to the triggering item derived from the GitHub Actions event context. May be a temporary-ID string resolved by the gh-aw temporary-ID framework. |
| `repo` | `string` | No | 256 characters | Target repository in `owner/repo` format. Overrides the configured `target-repo` for this message only. Must satisfy the `allowed-repos` configuration constraint. |
**RL-004**: A conforming implementation MUST reject any `replace_label` message in which `label_to_remove` is absent, empty after trimming, or exceeds 128 characters.
@@ -209,7 +209,7 @@ AI agents emit `replace_label` messages as part of the safe-outputs protocol. Th
#### 4.2.2 Aliased Item Number Fields
-For compatibility with agents that follow other safe-output conventions, the handler MUST also accept the following field names as aliases for `item_number`:
+With `target: "*"`, the handler MUST also accept the following field names as aliases for `item_number`:
- `issue_number`
- `pr_number`
@@ -315,22 +315,19 @@ Messages that fail schema validation MUST be rejected with a structured error lo
#### 5.3.2 Item Number Resolution
-**RL-016**: The target item number is resolved as follows, in priority order:
+**RL-016**: The target item number MUST be resolved from the configured `target` mode before considering agent-supplied fields. Omitted `target` MUST be interpreted as `"triggering"`.
-1. The item number resolved from any temporary-ID field (`item_number`, `issue_number`, `pr_number`, `pull_number`) via the gh-aw temporary-ID framework.
-2. A literal numeric value from the same aliased fields.
-3. The triggering issue number from `github.event.issue.number`.
-4. The triggering pull request number from `github.event.pull_request.number`.
-
-**RL-017**: When no item number can be resolved through any of the four mechanisms above, the message MUST be rejected with the error "No issue/PR number available".
+**RL-017**: When no item number can be resolved for the configured target mode, the message MUST be rejected with the error "No issue/PR number available".
#### 5.3.3 Target Mode Enforcement
-**RL-018**: When `target` is set to `"triggering"`, the resolved item number MUST equal the triggering item's number. A message specifying a different `item_number` MUST be rejected.
+**RL-018**: When `target` is set to `"triggering"` or omitted, the handler MUST use the triggering item's number and MUST ignore agent-supplied item-number fields.
+
+**RL-019**: When `target` is set to an explicit integer, the handler MUST use that integer and MUST ignore agent-supplied item-number fields.
-**RL-019**: When `target` is set to an explicit integer, the resolved item number MUST equal that integer. Messages specifying a different number MUST be rejected.
+**RL-020**: Only when `target` is set to `"*"` MAY the handler resolve an item number from `item_number`, `issue_number`, `pr_number`, or `pull_number`, subject to repository constraints. If no agent-supplied number is present, the handler MAY fall back to the triggering item.
-**RL-020**: When `target` is set to `"*"`, any item number is permitted, subject to repository constraints.
+**RL-020a**: The privileged handler MUST enforce RL-016 and RL-018 through RL-020 at runtime.
### 5.4 Stage 4: Label Validation
@@ -561,7 +558,15 @@ For outcome evaluation compliance (verifying that the `replace_label` outcome ev
- **T-RL-012**: Verify that the default max of 5 is enforced when `max` is absent from configuration.
- **T-RL-013**: Verify that a GHA expression in `max` is resolved at runtime.
-#### 9.2.3 Label Validation Tests
+#### 9.2.3 Target Authorization Tests
+
+- **T-RL-014**: Verify that an omitted `target` ignores a conflicting agent-supplied item number and uses the triggering item.
+- **T-RL-015**: Verify that `target: "triggering"` ignores a conflicting agent-supplied item number.
+- **T-RL-016**: Verify that a fixed numeric target ignores a conflicting agent-supplied item number.
+- **T-RL-017**: Verify that a fixed numeric target is used when the message omits an item number.
+- **T-RL-018**: Verify that `target: "*"` accepts an agent-supplied item number.
+
+#### 9.2.4 Label Validation Tests
The normative compliance fixtures for the allowlist and blocklist edge cases in
this subsection live in `specs/replace-label-compliance/rl-002-allowlist-enforcement.yaml`
@@ -583,20 +588,20 @@ Fixture linkage check (2026-08-01):
- [x] T-RL-024 covered by `specs/replace-label-compliance/rl-003-blocklist-ordering.yaml`
- [x] T-RL-025 covered by `specs/replace-label-compliance/rl-002-allowlist-enforcement.yaml`
-#### 9.2.4 Gate Check Tests
+#### 9.2.5 Gate Check Tests
- **T-RL-030**: Verify that an item satisfying all `required-labels` proceeds to the mutation stage.
- **T-RL-031**: Verify that an item missing a required label is skipped (`skipped: true`) without failing.
- **T-RL-032**: Verify that an item with a title matching `required-title-prefix` proceeds.
- **T-RL-033**: Verify that an item whose title does not match `required-title-prefix` is skipped without failing.
-#### 9.2.5 Label Set Computation Tests
+#### 9.2.6 Label Set Computation Tests
- **T-RL-040**: Verify that when `label_to_remove` is on the item, the computed new label set excludes it and includes `label_to_add`.
- **T-RL-041**: Verify that when `label_to_add` is passed to `setLabels` and the label does not exist in the repository, the call fails with a hard error.
- **T-RL-044**: Verify that when `label_to_remove` is not on the item, the computed new label set adds `label_to_add` without removing any label.
-#### 9.2.6 REST setLabels Tests
+#### 9.2.7 REST setLabels Tests
- **T-RL-050**: Verify that `setLabels` is called with the correct `owner`, `repo`, `issue_number`, and `labels` array.
- **T-RL-051**: Verify that the updated label list returned by `setLabels` is logged.
@@ -604,13 +609,13 @@ Fixture linkage check (2026-08-01):
- **T-RL-053**: Verify that `label_to_add` always appears exactly once in the `labels` array.
- **T-RL-054**: Verify that rate-limit responses trigger retry behavior.
-#### 9.2.7 Staged Mode Tests
+#### 9.2.8 Staged Mode Tests
- **T-RL-060**: Verify that no write API call is made when `staged: true`.
- **T-RL-061**: Verify that the preview log entry includes the correct label names, item number, and repository.
- **T-RL-062**: Verify that staged mode returns `{ success: true, staged: true }`.
-#### 9.2.8 Cross-Repository Tests
+#### 9.2.9 Cross-Repository Tests
- **T-RL-070**: Verify that a message with a `repo` in `allowed-repos` is accepted.
- **T-RL-071**: Verify that a message with a `repo` not in `allowed-repos` is rejected.
@@ -629,7 +634,11 @@ Fixture linkage check (2026-08-01):
| RL-007 String sanitization | T-RL-006 | 1 | Required |
| RL-010 Count gate enforcement | T-RL-010, T-RL-011 | 1 | Required |
| RL-012 Default max = 5 | T-RL-012 | 1 | Required |
+| RL-016 Target mode precedence | T-RL-014 – T-RL-018 | 1 | Required |
| RL-017 No item number error | T-RL-006 | 1 | Required |
+| RL-018 Triggering target authorization | T-RL-014, T-RL-015 | 1 | Required |
+| RL-019 Fixed target authorization | T-RL-016, T-RL-017 | 1 | Required |
+| RL-020 Wildcard target authorization | T-RL-018 | 1 | Required |
| RL-024 required-labels gate | T-RL-030, T-RL-031 | 1 | Required |
| RL-025 required-title-prefix gate | T-RL-032, T-RL-033 | 1 | Required |
| RL-027 Staged mode no writes | T-RL-060 | 1 | Required |
@@ -700,6 +709,7 @@ With `staged: true` in the configuration:
safe-outputs:
replace-label:
staged: true
+ target: "*"
allowed-add: ["done"]
allowed-remove: ["in-progress"]
```
@@ -727,6 +737,7 @@ The message is rejected with `{ success: false }`. The label must be created in
```yaml
safe-outputs:
replace-label:
+ target: "*"
target-repo: "owner/infra"
allowed-repos: ["owner/infra", "owner/platform"]
allowed-add: ["deployed"]
@@ -797,6 +808,11 @@ The message is skipped. The workflow run is not marked as failed.
## Change Log
+### Version 1.0.2 (Revision) — 2026-09-12
+
+- Clarified that configured target modes take precedence over agent-supplied item numbers.
+- Added T-RL-014 through T-RL-018 target authorization tests.
+
### Version 1.0.1 (Revision) — 2026-06-22
- Replaced GraphQL mutation (Stage 8) with a single REST `PUT /repos/{owner}/{repo}/issues/{issue_number}/labels` call (`setLabels`), achieving true atomicity: either the entire label set update succeeds or fails with no partial-success scenario.