diff --git a/.changeset/patch-add-label-command-trigger.md b/.changeset/patch-add-label-command-trigger.md
new file mode 100644
index 0000000000..1d59bc3869
--- /dev/null
+++ b/.changeset/patch-add-label-command-trigger.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": patch
+---
+
+Add support for the `label_command` trigger so workflows can run when a configured label is added to an issue, pull request, or discussion. The activation job now removes the triggering label at startup and exposes `needs.activation.outputs.label_command` for downstream use.
diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml
index c02f0dca5f..1cd2d38757 100644
--- a/.github/workflows/cloclo.lock.yml
+++ b/.github/workflows/cloclo.lock.yml
@@ -27,7 +27,7 @@
# - shared/jqschema.md
# - shared/mcp/serena-go.md
#
-# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"183df887ed669775015161c11ab4c4b3f52f06a00a94ec9d64785a0eda3be9c2","strict":true}
+# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ad111cf099d958c340ed839a8e88f5053a501ce37fb239bdc2645ee1a872d14a","strict":true}
name: "/cloclo"
"on":
@@ -35,6 +35,7 @@ name: "/cloclo"
types:
- created
- edited
+ - labeled
discussion_comment:
types:
- created
@@ -44,15 +45,17 @@ name: "/cloclo"
- created
- edited
issues:
- # names: # Label filtering applied via job conditions
- # - cloclo # Label filtering applied via job conditions
types:
+ - opened
+ - edited
+ - reopened
- labeled
pull_request:
types:
- opened
- edited
- reopened
+ - labeled
pull_request_review_comment:
types:
- created
@@ -70,9 +73,7 @@ jobs:
activation:
needs: pre_activation
if: >
- (needs.pre_activation.outputs.activated == 'true') && ((((github.event_name == 'issues' || github.event_name == 'issue_comment' ||
- github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' ||
- github.event_name == 'discussion_comment') && ((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) ||
+ (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) ||
(github.event.issue.body == '/cloclo')) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) ||
(github.event.comment.body == '/cloclo')) && (github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') &&
(((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) &&
@@ -82,10 +83,9 @@ jobs:
((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) ||
(github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) ||
(github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) ||
- (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' ||
- github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' ||
- github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') ||
- (github.event.label.name == 'cloclo'))))
+ (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') ||
+ (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') &&
+ (github.event.label.name == 'cloclo')))
runs-on: ubuntu-slim
permissions:
contents: read
@@ -97,6 +97,7 @@ jobs:
comment_id: ${{ steps.add-comment.outputs.comment-id }}
comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
model: ${{ steps.generate_aw_info.outputs.model }}
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
slash_command: ${{ needs.pre_activation.outputs.matched_command }}
@@ -196,6 +197,17 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs');
await main();
+ - name: Remove trigger label
+ id: remove_trigger_label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_LABEL_NAMES: '["cloclo"]'
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs');
+ await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -1534,8 +1546,6 @@ jobs:
pre_activation:
if: >
- (((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' ||
- github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') &&
((github.event_name == 'issues') && ((startsWith(github.event.issue.body, '/cloclo ')) || (github.event.issue.body == '/cloclo')) ||
(github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) || (github.event.comment.body == '/cloclo')) &&
(github.event.issue.pull_request == null)) || (github.event_name == 'issue_comment') && (((startsWith(github.event.comment.body, '/cloclo ')) ||
@@ -1545,10 +1555,9 @@ jobs:
((startsWith(github.event.pull_request.body, '/cloclo ')) || (github.event.pull_request.body == '/cloclo')) ||
(github.event_name == 'discussion') && ((startsWith(github.event.discussion.body, '/cloclo ')) || (github.event.discussion.body == '/cloclo')) ||
(github.event_name == 'discussion_comment') && ((startsWith(github.event.comment.body, '/cloclo ')) ||
- (github.event.comment.body == '/cloclo')))) || (!(github.event_name == 'issues' || github.event_name == 'issue_comment' ||
- github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' ||
- github.event_name == 'discussion_comment'))) && ((github.event_name != 'issues') || ((github.event.action != 'labeled') ||
- (github.event.label.name == 'cloclo')))
+ (github.event.comment.body == '/cloclo'))) || ((github.event_name == 'issues') && (github.event.label.name == 'cloclo') ||
+ (github.event_name == 'pull_request') && (github.event.label.name == 'cloclo') || (github.event_name == 'discussion') &&
+ (github.event.label.name == 'cloclo'))
runs-on: ubuntu-slim
permissions:
contents: read
diff --git a/.github/workflows/cloclo.md b/.github/workflows/cloclo.md
index 6cba6c17f2..77103bd6ab 100644
--- a/.github/workflows/cloclo.md
+++ b/.github/workflows/cloclo.md
@@ -2,9 +2,7 @@
on:
slash_command:
name: cloclo
- issues:
- types: [labeled]
- names: [cloclo]
+ label_command: cloclo
status-comment: true
permissions:
contents: read
diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml
index a9a25231b6..4bec7e995d 100644
--- a/.github/workflows/smoke-copilot.lock.yml
+++ b/.github/workflows/smoke-copilot.lock.yml
@@ -29,24 +29,26 @@
# - shared/github-queries-mcp-script.md
# - shared/reporting.md
#
-# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1a7fa2c830c582eef2c1ca858d020772b9efc949615721d692484047ab127162","strict":true}
+# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c69fb0db5e338569de880edcb18e606cf17efe9016ab532a0c4f17c1ba71729c","strict":true}
name: "Smoke Copilot"
"on":
pull_request:
- # names: # Label filtering applied via job conditions
- # - smoke # Label filtering applied via job conditions
types:
- labeled
schedule:
- cron: "47 */12 * * *"
- workflow_dispatch: null
+ workflow_dispatch:
+ inputs:
+ item_number:
+ description: The number of the issue, pull request, or discussion
+ required: true
+ type: string
permissions: {}
concurrency:
- group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}"
- cancel-in-progress: true
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}"
run-name: "Smoke Copilot"
@@ -54,8 +56,8 @@ jobs:
activation:
needs: pre_activation
if: >
- (needs.pre_activation.outputs.activated == 'true') && (((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) &&
- ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke'))))
+ (needs.pre_activation.outputs.activated == 'true') && (((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) ||
+ (!(github.event_name == 'pull_request')))
runs-on: ubuntu-slim
permissions:
contents: read
@@ -63,14 +65,12 @@ jobs:
issues: write
pull-requests: write
outputs:
- body: ${{ steps.sanitized.outputs.body }}
comment_id: ${{ steps.add-comment.outputs.comment-id }}
comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
model: ${{ steps.generate_aw_info.outputs.model }}
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
- text: ${{ steps.sanitized.outputs.text }}
- title: ${{ steps.sanitized.outputs.title }}
steps:
- name: Checkout actions folder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -143,15 +143,6 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs');
await main();
- - name: Compute current body text
- id: sanitized
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- with:
- script: |
- const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('/opt/gh-aw/actions/compute_text.cjs');
- await main();
- name: Add comment with workflow run link
id: add-comment
if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id)
@@ -166,6 +157,18 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs');
await main();
+ - name: Remove trigger label
+ id: remove_trigger_label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_LABEL_NAMES: '["smoke"]'
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/remove_trigger_label.cjs');
+ await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -2330,8 +2333,7 @@ jobs:
pre_activation:
if: >
- ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) &&
- ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke')))
+ ((github.event_name == 'pull_request') && (github.event.label.name == 'smoke')) || (!(github.event_name == 'pull_request'))
runs-on: ubuntu-slim
permissions:
contents: read
diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md
index 8ea8a0d1fc..6c91699f5e 100644
--- a/.github/workflows/smoke-copilot.md
+++ b/.github/workflows/smoke-copilot.md
@@ -3,9 +3,9 @@ description: Smoke Copilot
on:
schedule: every 12h
workflow_dispatch:
- pull_request:
- types: [labeled]
- names: ["smoke"]
+ label_command:
+ name: smoke
+ events: [pull_request]
reaction: "eyes"
status-comment: true
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
diff --git a/actions/setup/js/remove_trigger_label.cjs b/actions/setup/js/remove_trigger_label.cjs
new file mode 100644
index 0000000000..b4346cb203
--- /dev/null
+++ b/actions/setup/js/remove_trigger_label.cjs
@@ -0,0 +1,139 @@
+// @ts-check
+///
+
+const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs");
+
+/**
+ * Remove the label that triggered this workflow from the issue, pull request, or discussion.
+ * This allows the same label to be applied again later to re-trigger the workflow.
+ *
+ * Supported events: issues (labeled), pull_request (labeled), discussion (labeled).
+ * For workflow_dispatch, the step emits an empty label_name output and exits without error.
+ */
+async function main() {
+ const labelNamesJSON = process.env.GH_AW_LABEL_NAMES;
+
+ const { getErrorMessage } = require("./error_helpers.cjs");
+
+ if (!labelNamesJSON) {
+ core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES not specified.`);
+ return;
+ }
+
+ let labelNames = [];
+ try {
+ labelNames = JSON.parse(labelNamesJSON);
+ if (!Array.isArray(labelNames)) {
+ core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_LABEL_NAMES must be a JSON array.`);
+ return;
+ }
+ } catch (error) {
+ core.setFailed(`${ERR_CONFIG}: Configuration error: Failed to parse GH_AW_LABEL_NAMES: ${getErrorMessage(error)}`);
+ return;
+ }
+
+ const eventName = context.eventName;
+
+ // For workflow_dispatch and other non-labeled events, nothing to remove.
+ if (eventName === "workflow_dispatch") {
+ core.info("Event is workflow_dispatch – skipping label removal.");
+ core.setOutput("label_name", "");
+ return;
+ }
+
+ // Retrieve the label that was added from the event payload.
+ const triggerLabel = context.payload?.label?.name;
+ if (!triggerLabel) {
+ core.info(`Event ${eventName} has no label payload – skipping label removal.`);
+ core.setOutput("label_name", "");
+ return;
+ }
+
+ // Confirm that this label is one of the configured command labels.
+ if (!labelNames.includes(triggerLabel)) {
+ core.info(`Trigger label '${triggerLabel}' is not in the configured label-command list [${labelNames.join(", ")}] – skipping removal.`);
+ core.setOutput("label_name", triggerLabel);
+ return;
+ }
+
+ core.info(`Removing trigger label '${triggerLabel}' (event: ${eventName})`);
+
+ const owner = context.repo?.owner;
+ const repo = context.repo?.repo;
+ if (!owner || !repo) {
+ core.setFailed(`${ERR_CONFIG}: Configuration error: Unable to determine repository owner/name from context.`);
+ return;
+ }
+
+ try {
+ if (eventName === "issues") {
+ const issueNumber = context.payload?.issue?.number;
+ if (!issueNumber) {
+ core.warning("No issue number found in payload – skipping label removal.");
+ core.setOutput("label_name", triggerLabel);
+ return;
+ }
+ await github.rest.issues.removeLabel({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ name: triggerLabel,
+ });
+ core.info(`✓ Removed label '${triggerLabel}' from issue #${issueNumber}`);
+ } else if (eventName === "pull_request") {
+ // Pull requests share the issues API for labels.
+ const prNumber = context.payload?.pull_request?.number;
+ if (!prNumber) {
+ core.warning("No pull request number found in payload – skipping label removal.");
+ core.setOutput("label_name", triggerLabel);
+ return;
+ }
+ await github.rest.issues.removeLabel({
+ owner,
+ repo,
+ issue_number: prNumber,
+ name: triggerLabel,
+ });
+ core.info(`✓ Removed label '${triggerLabel}' from pull request #${prNumber}`);
+ } else if (eventName === "discussion") {
+ // Discussions require the GraphQL API for label management.
+ const discussionNodeId = context.payload?.discussion?.node_id;
+ const labelNodeId = context.payload?.label?.node_id;
+ if (!discussionNodeId || !labelNodeId) {
+ core.warning("No discussion or label node_id found in payload – skipping label removal.");
+ core.setOutput("label_name", triggerLabel);
+ return;
+ }
+ await github.graphql(
+ `
+ mutation RemoveLabelFromDiscussion($labelableId: ID!, $labelIds: [ID!]!) {
+ removeLabelsFromLabelable(input: { labelableId: $labelableId, labelIds: $labelIds }) {
+ clientMutationId
+ }
+ }
+ `,
+ {
+ labelableId: discussionNodeId,
+ labelIds: [labelNodeId],
+ }
+ );
+ core.info(`✓ Removed label '${triggerLabel}' from discussion`);
+ } else {
+ core.info(`Event '${eventName}' does not support label removal – skipping.`);
+ }
+ } catch (error) {
+ // Non-fatal: log a warning but do not fail the step.
+ // A 404 status means the label is no longer present on the item (e.g., another concurrent
+ // workflow run already removed it), which is an expected outcome in multi-workflow setups.
+ const status = /** @type {any} */ error?.status;
+ if (status === 404) {
+ core.info(`Label '${triggerLabel}' is no longer present on the item – already removed by another run.`);
+ } else {
+ core.warning(`${ERR_API}: Failed to remove label '${triggerLabel}': ${getErrorMessage(error)}`);
+ }
+ }
+
+ core.setOutput("label_name", triggerLabel);
+}
+
+module.exports = { main };
diff --git a/docs/src/content/docs/patterns/label-ops.md b/docs/src/content/docs/patterns/label-ops.md
index 4672f65a78..5f2e38e252 100644
--- a/docs/src/content/docs/patterns/label-ops.md
+++ b/docs/src/content/docs/patterns/label-ops.md
@@ -5,14 +5,95 @@ sidebar:
badge: { text: 'Event-triggered', variant: 'success' }
---
-LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports label-based triggers with filtering to activate workflows only for specific label changes while maintaining secure, automated responses.
+LabelOps uses GitHub labels as workflow triggers, metadata, and state markers. GitHub Agentic Workflows supports two distinct approaches to label-based triggers: `label_command` for command-style one-shot activation, and `names:` filtering for persistent label-state awareness.
## When to Use LabelOps
Use LabelOps for priority-based workflows (run checks when `priority: high` is added), stage transitions (trigger actions when moving between workflow states), specialized processing (different workflows for different label categories), and team coordination (automate handoffs between teams using labels).
+## Label Command Trigger
+
+The `label_command` trigger treats a label as a one-shot command: applying the label fires the workflow, and the label is **automatically removed** so it can be re-applied to re-trigger. This is the right choice when you want a label to mean "do this now" rather than "this item has this property."
+
+```aw wrap
+---
+on:
+ label_command: deploy
+permissions:
+ contents: read
+ actions: write
+safe-outputs:
+ add-comment:
+ max: 1
+---
+
+# Deploy Preview
+
+A `deploy` label was applied to this pull request. Build and deploy a preview environment and post the URL as a comment.
+
+The matched label name is available as `${{ needs.activation.outputs.label_command }}` if needed to distinguish between multiple label commands.
+```
+
+After activation the `deploy` label is removed from the pull request, so a reviewer can apply it again to trigger another deployment without any cleanup step.
+
+### Syntax
+
+`label_command` accepts a shorthand string, a map with a single name, or a map with multiple names and an optional `events` restriction:
+
+```yaml
+# Shorthand — fires on issues, pull_request, and discussion
+on: "label-command deploy"
+
+# Map with a single name
+on:
+ label_command: deploy
+
+# Restrict to specific event types
+on:
+ label_command:
+ name: deploy
+ events: [issues, pull_request]
+
+# Multiple label names
+on:
+ label_command:
+ names: [deploy, redeploy]
+ events: [pull_request]
+```
+
+The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, filtered to the named labels. It also adds a `workflow_dispatch` trigger with an `item_number` input so you can test the workflow manually without applying a real label.
+
+### Accessing the matched label
+
+The label that triggered the workflow is exposed as an output of the activation job:
+
+```
+${{ needs.activation.outputs.label_command }}
+```
+
+This is useful when a workflow handles multiple label commands and needs to branch on which one was applied.
+
+### Combining with slash commands
+
+`label_command` can be combined with `slash_command:` in the same workflow. The two triggers are OR'd — the workflow activates when either condition is met:
+
+```yaml
+on:
+ slash_command: deploy
+ label_command:
+ name: deploy
+ events: [pull_request]
+```
+
+This lets a workflow be triggered both by a `/deploy` comment and by applying a `deploy` label, sharing the same agent logic.
+
+> [!NOTE]
+> The automatic label removal requires `issues: write` or `pull-requests: write` permission (depending on item type). Add the relevant permission to your frontmatter when using `label_command`.
+
## Label Filtering
+Use `names:` filtering when you want the workflow to run whenever a label is present on an item and the label should remain attached. This is suitable for monitoring label state rather than reacting to a transient command.
+
GitHub Agentic Workflows allows you to filter `labeled` and `unlabeled` events to trigger only for specific label names using the `names` field:
```aw wrap
@@ -42,7 +123,16 @@ Check the issue for:
Respond with a comment outlining next steps and recommended actions.
```
-This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes.
+This workflow activates only when the `bug`, `critical`, or `security` labels are added to an issue, not for other label changes. The labels remain on the issue after the workflow runs.
+
+### Choosing between `label_command` and `names:` filtering
+
+| | `label_command` | `names:` filtering |
+|---|---|---|
+| Label lifecycle | Removed automatically after trigger | Stays on the item |
+| Re-triggerable | Yes — reapply the label | Only on the next `labeled` event |
+| Typical use | "Do this now" commands | State-based routing |
+| Supported items | Issues, pull requests, discussions | Issues, pull requests |
### Label Filter Syntax
diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md
index b1fead53ad..f218491fee 100644
--- a/docs/src/content/docs/reference/triggers.md
+++ b/docs/src/content/docs/reference/triggers.md
@@ -282,9 +282,32 @@ See the [Security Architecture](/gh-aw/introduction/architecture/) for details.
The `slash_command:` trigger creates workflows that respond to `/command-name` mentions in issues, pull requests, and comments. See [Command Triggers](/gh-aw/reference/command-triggers/) for complete documentation including event filtering, context text, reactions, and examples.
+### Label Command Trigger (`label_command:`)
+
+The `label_command:` trigger activates a workflow when a specific label is applied to an issue, pull request, or discussion, and **automatically removes that label** so it can be re-applied to re-trigger. This treats a label as a one-shot command rather than a persistent state marker.
+
+```yaml wrap
+# Fires on issues, pull_request, and discussion by default
+on:
+ label_command: deploy
+
+# Restrict to specific event types
+on:
+ label_command:
+ name: deploy
+ events: [pull_request]
+
+# Shorthand string form
+on: "label-command deploy"
+```
+
+The compiler generates `issues`, `pull_request`, and/or `discussion` events with `types: [labeled]`, adds a `workflow_dispatch` trigger with `item_number` for manual testing, and injects a label removal step in the activation job. The matched label name is exposed as `needs.activation.outputs.label_command`.
+
+`label_command` can be combined with `slash_command:` — the workflow activates when either condition is met. See [LabelOps](/gh-aw/patterns/label-ops/) for patterns and examples.
+
### Label Filtering (`names:`)
-Filter issue and pull request triggers by label names using the `names:` field:
+Filter issue and pull request triggers by label names using the `names:` field. Unlike `label_command`, the label stays on the item after the workflow runs.
```yaml wrap
on:
@@ -477,7 +500,7 @@ on:
Instead of writing full YAML trigger configurations, you can use natural-language shorthand strings with `on:`. The compiler expands these into standard GitHub Actions trigger syntax and automatically includes `workflow_dispatch` so the workflow can also be run manually.
-For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above.
+For label-based shorthands (`on: issue labeled bug`, `on: pull_request labeled needs-review`), see [Label Filtering](#label-filtering-names) above. For the label-command pattern, see [Label Command Trigger](#label-command-trigger-label_command) above.
### Push and Pull Request
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 21d35a520e..304cf603ed 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -672,6 +672,7 @@ const CheckStopTimeStepID StepID = "check_stop_time"
const CheckSkipIfMatchStepID StepID = "check_skip_if_match"
const CheckSkipIfNoMatchStepID StepID = "check_skip_if_no_match"
const CheckCommandPositionStepID StepID = "check_command_position"
+const RemoveTriggerLabelStepID StepID = "remove_trigger_label"
const CheckRateLimitStepID StepID = "check_rate_limit"
const CheckSkipRolesStepID StepID = "check_skip_roles"
const CheckSkipBotsStepID StepID = "check_skip_bots"
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 9b69f7cedd..21014dfdae 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -300,6 +300,86 @@
}
]
},
+ "label_command": {
+ "description": "On Label Command trigger: fires when a specific label is added to an issue, pull request, or discussion. The triggering label is automatically removed at workflow start so it can be applied again to re-trigger. Use the 'events' field to restrict which item types (issues, pull_request, discussion) activate the trigger.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Label name as a string (shorthand format). The workflow fires when this label is added to any supported item type (issue, pull request, or discussion)."
+ },
+ {
+ "type": "object",
+ "description": "Label command configuration object with label name(s) and optional event filtering.",
+ "properties": {
+ "name": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Single label name that acts as a command (e.g., 'deploy' triggers the workflow when the 'deploy' label is added)."
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "description": "Array of label names — any of these labels will trigger the workflow.",
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "description": "A label name"
+ },
+ "maxItems": 25
+ }
+ ],
+ "description": "Label name(s) that trigger the workflow when added to an issue, pull request, or discussion."
+ },
+ "names": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Single label name."
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "description": "Array of label names — any of these labels will trigger the workflow.",
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "description": "A label name"
+ },
+ "maxItems": 25
+ }
+ ],
+ "description": "Alternative to 'name': label name(s) that trigger the workflow."
+ },
+ "events": {
+ "description": "Item types where the label-command trigger should be active. Default is all supported types: issues, pull_request, discussion.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Single item type or '*' for all types.",
+ "enum": ["*", "issues", "pull_request", "discussion"]
+ },
+ {
+ "type": "array",
+ "minItems": 1,
+ "description": "Array of item types where the trigger is active.",
+ "items": {
+ "type": "string",
+ "description": "Item type.",
+ "enum": ["*", "issues", "pull_request", "discussion"]
+ },
+ "maxItems": 3
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ ]
+ },
"push": {
"description": "Push event trigger that runs the workflow when code is pushed to the repository",
"type": "object",
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index 95df0b548c..a2b0a77f9f 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -1,6 +1,7 @@
package workflow
import (
+ "encoding/json"
"errors"
"fmt"
"strings"
@@ -114,16 +115,31 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
checkoutSteps := c.generateCheckoutGitHubFolderForActivation(data)
steps = append(steps, checkoutSteps...)
- // Mint a single activation app token upfront if a GitHub App is configured and either
- // the reaction or status-comment step will need it. This avoids minting multiple tokens.
+ // Mint a single activation app token upfront if a GitHub App is configured and any
+ // step in the activation job will need it (reaction, status-comment, or label removal).
+ // This avoids minting multiple tokens.
hasReaction := data.AIReaction != "" && data.AIReaction != "none"
hasStatusComment := data.StatusComment != nil && *data.StatusComment
- if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment) {
- // Build the combined permissions needed for reactions and/or status comments
+ hasLabelCommand := len(data.LabelCommand) > 0
+ // Compute filtered label events once and reuse below (permissions + app token scopes)
+ filteredLabelEvents := FilterLabelCommandEvents(data.LabelCommandEvents)
+ if data.ActivationGitHubApp != nil && (hasReaction || hasStatusComment || hasLabelCommand) {
+ // Build the combined permissions needed for all activation steps.
+ // For label removal we only add the scopes required by the enabled events.
appPerms := NewPermissions()
- appPerms.Set(PermissionIssues, PermissionWrite)
- appPerms.Set(PermissionPullRequests, PermissionWrite)
- appPerms.Set(PermissionDiscussions, PermissionWrite)
+ if hasReaction || hasStatusComment {
+ appPerms.Set(PermissionIssues, PermissionWrite)
+ appPerms.Set(PermissionPullRequests, PermissionWrite)
+ appPerms.Set(PermissionDiscussions, PermissionWrite)
+ }
+ if hasLabelCommand {
+ if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") {
+ appPerms.Set(PermissionIssues, PermissionWrite)
+ }
+ if sliceutil.Contains(filteredLabelEvents, "discussion") {
+ appPerms.Set(PermissionDiscussions, PermissionWrite)
+ }
+ }
steps = append(steps, c.buildActivationAppTokenMintStep(data.ActivationGitHubApp, appPerms)...)
}
@@ -284,6 +300,35 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
}
}
+ // Add label removal step and label_command output for label-command workflows.
+ // When a label-command trigger fires, the triggering label is immediately removed
+ // so that the same label can be applied again to trigger the workflow in the future.
+ if len(data.LabelCommand) > 0 {
+ // The removal step only makes sense for actual "labeled" events; for
+ // workflow_dispatch we skip it silently via the env-based label check.
+ steps = append(steps, " - name: Remove trigger label\n")
+ steps = append(steps, fmt.Sprintf(" id: %s\n", constants.RemoveTriggerLabelStepID))
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script")))
+ steps = append(steps, " env:\n")
+ // Pass label names as a JSON array so the script can validate the label
+ labelNamesJSON, err := json.Marshal(data.LabelCommand)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal label-command names: %w", err)
+ }
+ steps = append(steps, fmt.Sprintf(" GH_AW_LABEL_NAMES: '%s'\n", string(labelNamesJSON)))
+ steps = append(steps, " with:\n")
+ // Use GitHub App or custom token if configured (avoids needing elevated GITHUB_TOKEN permissions)
+ labelToken := c.resolveActivationToken(data)
+ if labelToken != "${{ secrets.GITHUB_TOKEN }}" {
+ steps = append(steps, fmt.Sprintf(" github-token: %s\n", labelToken))
+ }
+ steps = append(steps, " script: |\n")
+ steps = append(steps, generateGitHubScriptWithRequire("remove_trigger_label.cjs"))
+
+ // Expose the matched label name as a job output for downstream jobs to consume
+ outputs["label_command"] = fmt.Sprintf("${{ steps.%s.outputs.label_name }}", constants.RemoveTriggerLabelStepID)
+ }
+
// If no steps have been added, add a placeholder step to make the job valid
// This can happen when the activation job is created only for an if condition
if len(steps) == 0 {
@@ -426,6 +471,21 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
permsMap[PermissionIssues] = PermissionWrite
}
+ // Add write permissions for label removal when label_command is configured.
+ // Only grant the scopes required by the enabled events:
+ // - issues/pull_request events need issues:write (PR labels use the issues REST API)
+ // - discussion events need discussions:write
+ // When a github-app token is configured, the GITHUB_TOKEN permissions are irrelevant
+ // for the label removal step (it uses the app token instead), so we skip them.
+ if hasLabelCommand && data.ActivationGitHubApp == nil {
+ if sliceutil.Contains(filteredLabelEvents, "issues") || sliceutil.Contains(filteredLabelEvents, "pull_request") {
+ permsMap[PermissionIssues] = PermissionWrite
+ }
+ if sliceutil.Contains(filteredLabelEvents, "discussion") {
+ permsMap[PermissionDiscussions] = PermissionWrite
+ }
+ }
+
perms := NewPermissionsFromMap(permsMap)
permissions := perms.RenderToYAML()
diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go
index 23ac893f85..8799258d91 100644
--- a/pkg/workflow/compiler_orchestrator_workflow.go
+++ b/pkg/workflow/compiler_orchestrator_workflow.go
@@ -596,6 +596,7 @@ func (c *Compiler) extractAdditionalConfigurations(
// Extract and process mcp-scripts and safe-outputs
workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(frontmatter)
+ workflowData.LabelCommand, workflowData.LabelCommandEvents = c.extractLabelCommandConfig(frontmatter)
workflowData.Jobs = c.extractJobsFromFrontmatter(frontmatter)
// Merge jobs from imported YAML workflows
diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go
index eea5c8502d..55eab625fa 100644
--- a/pkg/workflow/compiler_safe_outputs.go
+++ b/pkg/workflow/compiler_safe_outputs.go
@@ -22,6 +22,7 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
// Check if "slash_command" or "command" (deprecated) is used as a trigger in the "on" section
// Also extract "reaction" from the "on" section
var hasCommand bool
+ var hasLabelCommand bool
var hasReaction bool
var hasStopAfter bool
var hasStatusComment bool
@@ -139,8 +140,36 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
// Clear the On field so applyDefaults will handle command trigger generation
workflowData.On = ""
}
- // Extract other (non-conflicting) events excluding slash_command, command, reaction, status-comment, and stop-after
- otherEvents = filterMapKeys(onMap, "slash_command", "command", "reaction", "status-comment", "stop-after", "github-token", "github-app")
+
+ // Detect label_command trigger
+ if _, hasLabelCommandKey := onMap["label_command"]; hasLabelCommandKey {
+ hasLabelCommand = true
+ // Set default label names from WorkflowData if already populated by extractLabelCommandConfig
+ if len(workflowData.LabelCommand) == 0 {
+ // extractLabelCommandConfig has not been called yet or returned nothing;
+ // set a placeholder so applyDefaults knows this is a label-command workflow.
+ // The actual label names will be extracted from the frontmatter in applyDefaults
+ // via extractLabelCommandConfig which was called in parseOnSectionRaw.
+ baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md")
+ workflowData.LabelCommand = []string{baseName}
+ }
+ // Validate: existing issues/pull_request/discussion triggers that have non-label types
+ // would be silently overridden by the label_command generation. Require label-only types
+ // (labeled/unlabeled) so the merge is deterministic and user config is not lost.
+ labelConflictingEvents := []string{"issues", "pull_request", "discussion"}
+ for _, eventName := range labelConflictingEvents {
+ if eventValue, hasConflict := onMap[eventName]; hasConflict {
+ if !parser.IsLabelOnlyEvent(eventValue) {
+ return fmt.Errorf("cannot use 'label_command' with '%s' trigger (non-label types); use only labeled/unlabeled types or remove this trigger", eventName)
+ }
+ }
+ }
+ // Clear the On field so applyDefaults will handle label-command trigger generation
+ workflowData.On = ""
+ }
+
+ // Extract other (non-conflicting) events excluding slash_command, command, label_command, reaction, status-comment, and stop-after
+ otherEvents = filterMapKeys(onMap, "slash_command", "command", "label_command", "reaction", "status-comment", "stop-after", "github-token", "github-app")
}
}
@@ -149,6 +178,12 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
workflowData.Command = nil
}
+ // Clear label-command field if no label_command trigger was found
+ if !hasLabelCommand {
+ workflowData.LabelCommand = nil
+ workflowData.LabelCommandEvents = nil
+ }
+
// Auto-enable "eyes" reaction for command triggers if no explicit reaction was specified
if hasCommand && !hasReaction && workflowData.AIReaction == "" {
workflowData.AIReaction = "eyes"
@@ -159,6 +194,10 @@ func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *Work
// We'll store this and handle it in applyDefaults
workflowData.On = "" // This will trigger command handling in applyDefaults
workflowData.CommandOtherEvents = otherEvents
+ } else if hasLabelCommand && len(otherEvents) > 0 {
+ // Store other events for label-command merging in applyDefaults
+ workflowData.On = "" // This will trigger label-command handling in applyDefaults
+ workflowData.LabelCommandOtherEvents = otherEvents
} else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 {
// Only re-marshal the "on" if we have to
onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents})
diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go
index 0be848abe8..e267e91054 100644
--- a/pkg/workflow/compiler_types.go
+++ b/pkg/workflow/compiler_types.go
@@ -385,6 +385,9 @@ type WorkflowData struct {
Command []string // for /command trigger support - multiple command names
CommandEvents []string // events where command should be active (nil = all events)
CommandOtherEvents map[string]any // for merging command with other events
+ LabelCommand []string // for label-command trigger support - label names that act as commands
+ LabelCommandEvents []string // events where label-command should be active (nil = all: issues, pull_request, discussion)
+ LabelCommandOtherEvents map[string]any // for merging label-command with other events
AIReaction string // AI reaction type like "eyes", "heart", etc.
StatusComment *bool // whether to post status comments (default: true when ai-reaction is set, false otherwise)
ActivationGitHubToken string // custom github token from on.github-token for reactions/comments
diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go
index f129087d0b..7b4c2fce28 100644
--- a/pkg/workflow/frontmatter_extraction_yaml.go
+++ b/pkg/workflow/frontmatter_extraction_yaml.go
@@ -690,6 +690,73 @@ func (c *Compiler) extractCommandConfig(frontmatter map[string]any) (commandName
return nil, nil
}
+// extractLabelCommandConfig extracts the label-command configuration from frontmatter
+// including label name(s) and the events field.
+// It reads on.label_command which can be:
+// - a string: label name directly (e.g. label_command: "deploy")
+// - a map with "name" or "names" and optional "events" fields
+//
+// Returns (labelNames, labelEvents) where labelEvents is nil for default (all events).
+func (c *Compiler) extractLabelCommandConfig(frontmatter map[string]any) (labelNames []string, labelEvents []string) {
+ frontmatterLog.Print("Extracting label-command configuration from frontmatter")
+ onValue, exists := frontmatter["on"]
+ if !exists {
+ return nil, nil
+ }
+ onMap, ok := onValue.(map[string]any)
+ if !ok {
+ return nil, nil
+ }
+ labelCommandValue, hasLabelCommand := onMap["label_command"]
+ if !hasLabelCommand {
+ return nil, nil
+ }
+
+ // Simple string form: label_command: "my-label"
+ if nameStr, ok := labelCommandValue.(string); ok {
+ frontmatterLog.Printf("Extracted label-command name (shorthand): %s", nameStr)
+ return []string{nameStr}, nil
+ }
+
+ // Map form: label_command: {name: "...", names: [...], events: [...]}
+ if lcMap, ok := labelCommandValue.(map[string]any); ok {
+ var names []string
+ var events []string
+
+ if nameVal, hasName := lcMap["name"]; hasName {
+ if nameStr, ok := nameVal.(string); ok {
+ names = []string{nameStr}
+ } else if nameArray, ok := nameVal.([]any); ok {
+ for _, item := range nameArray {
+ if s, ok := item.(string); ok {
+ names = append(names, s)
+ }
+ }
+ }
+ }
+ if namesVal, hasNames := lcMap["names"]; hasNames {
+ if namesArray, ok := namesVal.([]any); ok {
+ for _, item := range namesArray {
+ if s, ok := item.(string); ok {
+ names = append(names, s)
+ }
+ }
+ } else if namesStr, ok := namesVal.(string); ok {
+ names = append(names, namesStr)
+ }
+ }
+
+ if eventsVal, hasEvents := lcMap["events"]; hasEvents {
+ events = ParseCommandEvents(eventsVal)
+ }
+
+ frontmatterLog.Printf("Extracted label-command config: names=%v, events=%v", names, events)
+ return names, events
+ }
+
+ return nil, nil
+}
+
// isGitHubAppNestedField returns true if the trimmed YAML line represents a known
// nested field or array item inside an on.github-app object.
func isGitHubAppNestedField(trimmedLine string) bool {
diff --git a/pkg/workflow/label_command.go b/pkg/workflow/label_command.go
new file mode 100644
index 0000000000..f46c3393b2
--- /dev/null
+++ b/pkg/workflow/label_command.go
@@ -0,0 +1,95 @@
+package workflow
+
+import (
+ "errors"
+ "slices"
+
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var labelCommandLog = logger.New("workflow:label_command")
+
+// labelCommandSupportedEvents defines the GitHub Actions events that support label-command triggers
+var labelCommandSupportedEvents = []string{"issues", "pull_request", "discussion"}
+
+// FilterLabelCommandEvents returns the label-command events to use based on the specified identifiers.
+// If identifiers is nil or empty, returns all supported events.
+func FilterLabelCommandEvents(identifiers []string) []string {
+ if len(identifiers) == 0 {
+ labelCommandLog.Print("No label-command event identifiers specified, returning all events")
+ return labelCommandSupportedEvents
+ }
+
+ var result []string
+ for _, id := range identifiers {
+ if slices.Contains(labelCommandSupportedEvents, id) {
+ result = append(result, id)
+ }
+ }
+
+ labelCommandLog.Printf("Filtered label-command events: %v -> %v", identifiers, result)
+ return result
+}
+
+// buildLabelCommandCondition creates a condition that checks whether the triggering label
+// matches one of the configured label-command names. For non-label events (e.g.
+// workflow_dispatch and any other events in LabelCommandOtherEvents), the condition
+// passes unconditionally so that manual runs and other triggers still work.
+func buildLabelCommandCondition(labelNames []string, labelCommandEvents []string, hasOtherEvents bool) (ConditionNode, error) {
+ labelCommandLog.Printf("Building label-command condition: labels=%v, events=%v, has_other_events=%t",
+ labelNames, labelCommandEvents, hasOtherEvents)
+
+ if len(labelNames) == 0 {
+ return nil, errors.New("no label names provided for label-command trigger")
+ }
+
+ filteredEvents := FilterLabelCommandEvents(labelCommandEvents)
+ if len(filteredEvents) == 0 {
+ return nil, errors.New("no valid events specified for label-command trigger")
+ }
+
+ // Build the label-name match condition: label1 == name OR label2 == name ...
+ var labelNameChecks []ConditionNode
+ for _, labelName := range labelNames {
+ labelNameChecks = append(labelNameChecks, BuildEquals(
+ BuildPropertyAccess("github.event.label.name"),
+ BuildStringLiteral(labelName),
+ ))
+ }
+ var labelNameMatch ConditionNode
+ if len(labelNameChecks) == 1 {
+ labelNameMatch = labelNameChecks[0]
+ } else {
+ labelNameMatch = BuildDisjunction(false, labelNameChecks...)
+ }
+
+ // Build per-event checks: (event_name == 'issues' AND label matches) OR ...
+ var eventChecks []ConditionNode
+ for _, event := range filteredEvents {
+ eventChecks = append(eventChecks, &AndNode{
+ Left: BuildEventTypeEquals(event),
+ Right: labelNameMatch,
+ })
+ }
+ labelCondition := BuildDisjunction(false, eventChecks...)
+
+ if !hasOtherEvents {
+ // No other events — the label condition is the entire condition.
+ return labelCondition, nil
+ }
+
+ // When there are other events (e.g. workflow_dispatch from the expanded shorthand, or
+ // user-supplied events), we allow non-label events through unconditionally and only
+ // require the label-name check for label events.
+ var labelEventChecks []ConditionNode
+ for _, event := range filteredEvents {
+ labelEventChecks = append(labelEventChecks, BuildEventTypeEquals(event))
+ }
+ isLabelEvent := BuildDisjunction(false, labelEventChecks...)
+ isNotLabelEvent := &NotNode{Child: isLabelEvent}
+
+ return &OrNode{
+ Left: &AndNode{Left: isLabelEvent, Right: labelNameMatch},
+ Right: isNotLabelEvent,
+ }, nil
+}
diff --git a/pkg/workflow/label_command_parser.go b/pkg/workflow/label_command_parser.go
new file mode 100644
index 0000000000..0115a0b89b
--- /dev/null
+++ b/pkg/workflow/label_command_parser.go
@@ -0,0 +1,19 @@
+package workflow
+
+import (
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var labelCommandParserLog = logger.New("workflow:label_command_parser")
+
+// expandLabelCommandShorthand takes a label name and returns a map that represents
+// the expanded label_command + workflow_dispatch configuration.
+// This is the intermediate form stored in the frontmatter "on" map before
+// parseOnSection processes it into WorkflowData.LabelCommand.
+func expandLabelCommandShorthand(labelName string) map[string]any {
+ labelCommandParserLog.Printf("Expanding label-command shorthand for label: %s", labelName)
+ return map[string]any{
+ "label_command": labelName,
+ "workflow_dispatch": nil,
+ }
+}
diff --git a/pkg/workflow/label_command_test.go b/pkg/workflow/label_command_test.go
new file mode 100644
index 0000000000..dcb5df80f4
--- /dev/null
+++ b/pkg/workflow/label_command_test.go
@@ -0,0 +1,439 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/github/gh-aw/pkg/stringutil"
+
+ "github.com/goccy/go-yaml"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestLabelCommandShorthandPreprocessing verifies that "label-command " shorthand
+// is expanded into the label_command map form by the schedule preprocessor.
+func TestLabelCommandShorthandPreprocessing(t *testing.T) {
+ tests := []struct {
+ name string
+ onValue string
+ wantLabelName string
+ wantErr bool
+ }{
+ {
+ name: "simple label-command shorthand",
+ onValue: "label-command deploy",
+ wantLabelName: "deploy",
+ },
+ {
+ name: "label-command with hyphenated label",
+ onValue: "label-command needs-review",
+ wantLabelName: "needs-review",
+ },
+ {
+ name: "label-command without label name",
+ onValue: "label-command ",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ frontmatter := map[string]any{
+ "on": tt.onValue,
+ }
+
+ compiler := NewCompiler()
+ err := compiler.preprocessScheduleFields(frontmatter, "", "")
+ if tt.wantErr {
+ assert.Error(t, err, "expected error for input %q", tt.onValue)
+ return
+ }
+
+ require.NoError(t, err, "preprocessScheduleFields() should not error")
+
+ onVal := frontmatter["on"]
+ onMap, ok := onVal.(map[string]any)
+ require.True(t, ok, "on field should be a map after expansion, got %T", onVal)
+
+ labelCmd, hasLabel := onMap["label_command"]
+ require.True(t, hasLabel, "on map should have label_command key")
+ assert.Equal(t, tt.wantLabelName, labelCmd,
+ "label_command value should be %q", tt.wantLabelName)
+
+ _, hasDispatch := onMap["workflow_dispatch"]
+ assert.True(t, hasDispatch, "on map should have workflow_dispatch key")
+ })
+ }
+}
+
+// TestExpandLabelCommandShorthand verifies the expand helper function.
+func TestExpandLabelCommandShorthand(t *testing.T) {
+ result := expandLabelCommandShorthand("deploy")
+
+ labelCmd, ok := result["label_command"]
+ require.True(t, ok, "expanded map should have label_command key")
+ assert.Equal(t, "deploy", labelCmd, "label_command should equal the label name")
+
+ _, hasDispatch := result["workflow_dispatch"]
+ assert.True(t, hasDispatch, "expanded map should have workflow_dispatch key")
+}
+
+// TestFilterLabelCommandEvents verifies that FilterLabelCommandEvents returns correct subsets.
+func TestFilterLabelCommandEvents(t *testing.T) {
+ tests := []struct {
+ name string
+ identifiers []string
+ want []string
+ }{
+ {
+ name: "nil identifiers returns all events",
+ identifiers: nil,
+ want: []string{"issues", "pull_request", "discussion"},
+ },
+ {
+ name: "empty identifiers returns all events",
+ identifiers: []string{},
+ want: []string{"issues", "pull_request", "discussion"},
+ },
+ {
+ name: "single issues event",
+ identifiers: []string{"issues"},
+ want: []string{"issues"},
+ },
+ {
+ name: "issues and pull_request only",
+ identifiers: []string{"issues", "pull_request"},
+ want: []string{"issues", "pull_request"},
+ },
+ {
+ name: "unsupported event is filtered out",
+ identifiers: []string{"issues", "unknown_event"},
+ want: []string{"issues"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := FilterLabelCommandEvents(tt.identifiers)
+ assert.Equal(t, tt.want, got, "FilterLabelCommandEvents(%v)", tt.identifiers)
+ })
+ }
+}
+
+// TestBuildLabelCommandCondition verifies the condition builder for label-command triggers.
+func TestBuildLabelCommandCondition(t *testing.T) {
+ tests := []struct {
+ name string
+ labelNames []string
+ events []string
+ hasOtherEvents bool
+ wantErr bool
+ wantContains []string
+ wantNotContains []string
+ }{
+ {
+ name: "single label all events no other events",
+ labelNames: []string{"deploy"},
+ events: nil,
+ wantContains: []string{
+ "github.event.label.name == 'deploy'",
+ "github.event_name == 'issues'",
+ "github.event_name == 'pull_request'",
+ "github.event_name == 'discussion'",
+ },
+ },
+ {
+ name: "multiple labels all events",
+ labelNames: []string{"deploy", "release"},
+ events: nil,
+ wantContains: []string{
+ "github.event.label.name == 'deploy'",
+ "github.event.label.name == 'release'",
+ },
+ },
+ {
+ name: "single label issues only",
+ labelNames: []string{"triage"},
+ events: []string{"issues"},
+ wantContains: []string{
+ "github.event_name == 'issues'",
+ "github.event.label.name == 'triage'",
+ },
+ wantNotContains: []string{
+ "github.event_name == 'pull_request'",
+ "github.event_name == 'discussion'",
+ },
+ },
+ {
+ name: "no label names returns error",
+ labelNames: []string{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ condition, err := buildLabelCommandCondition(tt.labelNames, tt.events, tt.hasOtherEvents)
+ if tt.wantErr {
+ assert.Error(t, err, "expected an error")
+ return
+ }
+
+ require.NoError(t, err, "buildLabelCommandCondition() should not error")
+ rendered := condition.Render()
+
+ for _, want := range tt.wantContains {
+ assert.Contains(t, rendered, want,
+ "condition should contain %q, got: %s", want, rendered)
+ }
+ for _, notWant := range tt.wantNotContains {
+ assert.NotContains(t, rendered, notWant,
+ "condition should NOT contain %q, got: %s", notWant, rendered)
+ }
+ })
+ }
+}
+
+// TestLabelCommandWorkflowCompile verifies that a workflow with label_command trigger
+// compiles to a valid GitHub Actions workflow with:
+// - label-based events (issues, pull_request, discussion) in the on: section
+// - workflow_dispatch with item_number input
+// - a label-name condition in the activation job's if:
+// - a remove_trigger_label step in the activation job
+// - a label_command output on the activation job
+func TestLabelCommandWorkflowCompile(t *testing.T) {
+ tempDir := t.TempDir()
+
+ workflowContent := `---
+name: Label Command Test
+on:
+ label_command: deploy
+engine: copilot
+---
+
+Deploy the application because label "deploy" was added.
+`
+
+ workflowPath := filepath.Join(tempDir, "label-command-test.md")
+ err := os.WriteFile(workflowPath, []byte(workflowContent), 0644)
+ require.NoError(t, err, "failed to write test workflow")
+
+ compiler := NewCompiler()
+ err = compiler.CompileWorkflow(workflowPath)
+ require.NoError(t, err, "CompileWorkflow() should not error")
+
+ lockFilePath := stringutil.MarkdownToLockFile(workflowPath)
+ lockContent, err := os.ReadFile(lockFilePath)
+ require.NoError(t, err, "failed to read lock file")
+
+ lockStr := string(lockContent)
+
+ // Verify the on: section includes label-based events
+ assert.Contains(t, lockStr, "issues:", "on section should contain issues event")
+ assert.Contains(t, lockStr, "pull_request:", "on section should contain pull_request event")
+ assert.Contains(t, lockStr, "discussion:", "on section should contain discussion event")
+ assert.Contains(t, lockStr, "labeled", "on section should contain labeled type")
+ assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch")
+ assert.Contains(t, lockStr, "item_number:", "workflow_dispatch should include item_number input")
+
+ // Parse the YAML to check the activation job
+ var workflow map[string]any
+ err = yaml.Unmarshal(lockContent, &workflow)
+ require.NoError(t, err, "failed to parse lock file as YAML")
+
+ jobs, ok := workflow["jobs"].(map[string]any)
+ require.True(t, ok, "workflow should have jobs")
+
+ activation, ok := jobs["activation"].(map[string]any)
+ require.True(t, ok, "workflow should have an activation job")
+
+ // Verify the activation job has a label_command output
+ activationOutputs, ok := activation["outputs"].(map[string]any)
+ require.True(t, ok, "activation job should have outputs")
+
+ labelCmdOutput, hasOutput := activationOutputs["label_command"]
+ assert.True(t, hasOutput, "activation job should have label_command output")
+ assert.Contains(t, labelCmdOutput, "remove_trigger_label",
+ "label_command output should reference the remove_trigger_label step")
+
+ // Verify the remove_trigger_label step exists in the activation job
+ activationSteps, ok := activation["steps"].([]any)
+ require.True(t, ok, "activation job should have steps")
+
+ foundRemoveStep := false
+ for _, step := range activationSteps {
+ stepMap, ok := step.(map[string]any)
+ if !ok {
+ continue
+ }
+ if id, ok := stepMap["id"].(string); ok && id == "remove_trigger_label" {
+ foundRemoveStep = true
+ break
+ }
+ }
+ assert.True(t, foundRemoveStep, "activation job should contain a remove_trigger_label step")
+
+ // Verify the workflow condition includes the label name check
+ agentJob, hasAgent := jobs["agent"].(map[string]any)
+ require.True(t, hasAgent, "workflow should have an agent job")
+ _ = agentJob // presence check is sufficient
+}
+
+// TestLabelCommandWorkflowCompileShorthand verifies the "label-command " string shorthand.
+func TestLabelCommandWorkflowCompileShorthand(t *testing.T) {
+ tempDir := t.TempDir()
+
+ workflowContent := `---
+name: Label Command Shorthand Test
+on: "label-command needs-review"
+engine: copilot
+---
+
+Triggered by the needs-review label.
+`
+
+ workflowPath := filepath.Join(tempDir, "label-command-shorthand.md")
+ err := os.WriteFile(workflowPath, []byte(workflowContent), 0644)
+ require.NoError(t, err, "failed to write test workflow")
+
+ compiler := NewCompiler()
+ err = compiler.CompileWorkflow(workflowPath)
+ require.NoError(t, err, "CompileWorkflow() should not error for shorthand form")
+
+ lockFilePath := stringutil.MarkdownToLockFile(workflowPath)
+ lockContent, err := os.ReadFile(lockFilePath)
+ require.NoError(t, err, "failed to read lock file")
+
+ lockStr := string(lockContent)
+ assert.Contains(t, lockStr, "labeled", "compiled workflow should contain labeled type")
+ assert.Contains(t, lockStr, "remove_trigger_label", "compiled workflow should contain remove_trigger_label step")
+}
+
+// TestLabelCommandWorkflowWithEvents verifies that specifying events: restricts
+// which GitHub Actions events are generated.
+func TestLabelCommandWorkflowWithEvents(t *testing.T) {
+ tempDir := t.TempDir()
+
+ workflowContent := `---
+name: Label Command Issues Only
+on:
+ label_command:
+ name: deploy
+ events: [issues]
+engine: copilot
+---
+
+Triggered by the deploy label on issues only.
+`
+
+ workflowPath := filepath.Join(tempDir, "label-command-issues-only.md")
+ err := os.WriteFile(workflowPath, []byte(workflowContent), 0644)
+ require.NoError(t, err, "failed to write test workflow")
+
+ compiler := NewCompiler()
+ err = compiler.CompileWorkflow(workflowPath)
+ require.NoError(t, err, "CompileWorkflow() should not error")
+
+ lockFilePath := stringutil.MarkdownToLockFile(workflowPath)
+ lockContent, err := os.ReadFile(lockFilePath)
+ require.NoError(t, err, "failed to read lock file")
+
+ lockStr := string(lockContent)
+
+ // Should have issues event
+ assert.Contains(t, lockStr, "issues:", "on section should contain issues event")
+
+ // workflow_dispatch is always added
+ assert.Contains(t, lockStr, "workflow_dispatch:", "on section should contain workflow_dispatch")
+
+ // pull_request and discussion should NOT be present since events: [issues] was specified
+ // (However, they may be commented or absent — check the YAML structure)
+ var workflow map[string]any
+ err = yaml.Unmarshal(lockContent, &workflow)
+ require.NoError(t, err, "failed to parse lock file as YAML")
+
+ onSection, ok := workflow["on"].(map[string]any)
+ require.True(t, ok, "workflow on: section should be a map")
+
+ _, hasPR := onSection["pull_request"]
+ assert.False(t, hasPR, "pull_request event should not be present when events=[issues]")
+
+ _, hasDiscussion := onSection["discussion"]
+ assert.False(t, hasDiscussion, "discussion event should not be present when events=[issues]")
+}
+
+// TestLabelCommandNoClashWithExistingLabelTrigger verifies that label_command can coexist
+// with an existing label-only issues trigger without creating a duplicate issues: YAML block.
+// The existing issues block types are merged into the label_command-generated issues block.
+func TestLabelCommandNoClashWithExistingLabelTrigger(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Workflow that has both an explicit "issues: types: [labeled]" block AND label_command.
+ // This is the exact key-clash scenario: without merging, two "issues:" keys would appear
+ // in the compiled YAML, which is invalid and silently broken.
+ workflowContent := `---
+name: No Clash Test
+on:
+ label_command: deploy
+ issues:
+ types: [labeled]
+engine: copilot
+---
+
+Both label-command and existing issues labeled trigger.
+`
+
+ workflowPath := filepath.Join(tempDir, "no-clash-test.md")
+ err := os.WriteFile(workflowPath, []byte(workflowContent), 0644)
+ require.NoError(t, err, "failed to write test workflow")
+
+ compiler := NewCompiler()
+ err = compiler.CompileWorkflow(workflowPath)
+ require.NoError(t, err, "CompileWorkflow() should not error when mixing label_command with existing label trigger")
+
+ lockFilePath := stringutil.MarkdownToLockFile(workflowPath)
+ lockContent, err := os.ReadFile(lockFilePath)
+ require.NoError(t, err, "failed to read lock file")
+
+ lockStr := string(lockContent)
+
+ // Verify there is exactly ONE "issues:" block at the YAML top level
+ // (count occurrences that are a key, not embedded in other values)
+ issuesCount := strings.Count(lockStr, "\n issues:\n") + strings.Count(lockStr, "\nissues:\n")
+ assert.Equal(t, 1, issuesCount,
+ "there should be exactly one 'issues:' trigger block in the compiled YAML, got %d. Compiled:\n%s",
+ issuesCount, lockStr)
+}
+
+// TestLabelCommandConflictWithNonLabelTrigger verifies that using label_command alongside
+// an issues/pull_request trigger with non-label types returns a validation error.
+func TestLabelCommandConflictWithNonLabelTrigger(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Workflow with label_command and issues: types: [opened] — non-label type conflicts
+ workflowContent := `---
+name: Conflict Test
+on:
+ label_command: deploy
+ issues:
+ types: [opened]
+engine: copilot
+---
+
+This should fail validation.
+`
+
+ workflowPath := filepath.Join(tempDir, "conflict-test.md")
+ err := os.WriteFile(workflowPath, []byte(workflowContent), 0644)
+ require.NoError(t, err, "failed to write test workflow")
+
+ compiler := NewCompiler()
+ err = compiler.CompileWorkflow(workflowPath)
+ require.Error(t, err, "CompileWorkflow() should error when label_command is combined with non-label issues trigger")
+ assert.Contains(t, err.Error(), "label_command", "error should mention label_command")
+}
diff --git a/pkg/workflow/schedule_preprocessing.go b/pkg/workflow/schedule_preprocessing.go
index db346221cd..09aff29557 100644
--- a/pkg/workflow/schedule_preprocessing.go
+++ b/pkg/workflow/schedule_preprocessing.go
@@ -128,6 +128,18 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown
return nil
}
+ // Check if it's a label-command shorthand (starts with "label-command ")
+ if labelName, ok := strings.CutPrefix(onStr, "label-command "); ok {
+ labelName = strings.TrimSpace(labelName)
+ if labelName == "" {
+ return errors.New("label-command shorthand requires a label name after 'label-command'")
+ }
+ schedulePreprocessingLog.Printf("Converting shorthand 'on: %s' to label_command + workflow_dispatch", onStr)
+ onMap := expandLabelCommandShorthand(labelName)
+ frontmatter["on"] = onMap
+ return nil
+ }
+
// Check if it's a label trigger shorthand (labeled label1 label2...)
entityType, labelNames, isLabelTrigger, err := parseLabelTriggerShorthand(onStr)
if err != nil {
diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go
index 0954a0cf01..eef5ff7293 100644
--- a/pkg/workflow/tools.go
+++ b/pkg/workflow/tools.go
@@ -22,11 +22,14 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error
// Check if this is a command trigger workflow (by checking if user specified "on.command")
isCommandTrigger := false
+ isLabelCommandTrigger := false
if data.On == "" {
// parseOnSection may have already detected the command trigger and populated data.Command
// (this covers slash_command map format, slash_command shorthand "on: /name", and deprecated "command:")
if len(data.Command) > 0 {
isCommandTrigger = true
+ } else if len(data.LabelCommand) > 0 {
+ isLabelCommandTrigger = true
} else {
// Check the original frontmatter for command trigger
content, err := os.ReadFile(markdownPath)
@@ -40,6 +43,8 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error
isCommandTrigger = true
} else if _, hasCommand := onMap["command"]; hasCommand {
isCommandTrigger = true
+ } else if _, hasLabelCommand := onMap["label_command"]; hasLabelCommand {
+ isLabelCommandTrigger = true
}
}
}
@@ -72,6 +77,33 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error
maps.Copy(commandEventsMap, data.CommandOtherEvents)
}
+ // If label_command is also configured alongside slash_command, merge label events
+ // into the existing command events map to avoid duplicate YAML keys.
+ if len(data.LabelCommand) > 0 {
+ labelEventNames := FilterLabelCommandEvents(data.LabelCommandEvents)
+ for _, eventName := range labelEventNames {
+ if existingAny, ok := commandEventsMap[eventName]; ok {
+ if existingMap, ok := existingAny.(map[string]any); ok {
+ switch t := existingMap["types"].(type) {
+ case []string:
+ newTypes := make([]any, len(t)+1)
+ for i, s := range t {
+ newTypes[i] = s
+ }
+ newTypes[len(t)] = "labeled"
+ existingMap["types"] = newTypes
+ case []any:
+ existingMap["types"] = append(t, "labeled")
+ }
+ }
+ } else {
+ commandEventsMap[eventName] = map[string]any{
+ "types": []any{"labeled"},
+ }
+ }
+ }
+ }
+
// Convert merged events to YAML
mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": commandEventsMap})
if err == nil {
@@ -112,7 +144,97 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error
}
if data.If == "" {
- data.If = commandConditionTree.Render()
+ if len(data.LabelCommand) > 0 {
+ // Combine: (slash_command condition) OR (label_command condition)
+ // This allows the workflow to activate via either mechanism.
+ labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, false)
+ if err != nil {
+ return fmt.Errorf("failed to build combined label-command condition: %w", err)
+ }
+ combined := &OrNode{Left: commandConditionTree, Right: labelConditionTree}
+ data.If = combined.Render()
+ } else {
+ data.If = commandConditionTree.Render()
+ }
+ }
+ } else if isLabelCommandTrigger {
+ toolsLog.Print("Workflow is label-command trigger, configuring label events")
+
+ // Build the label-command events map
+ // Generate events: issues, pull_request, discussion with types: [labeled]
+ filteredEvents := FilterLabelCommandEvents(data.LabelCommandEvents)
+ labelEventsMap := make(map[string]any)
+ for _, eventName := range filteredEvents {
+ labelEventsMap[eventName] = map[string]any{
+ "types": []any{"labeled"},
+ }
+ }
+
+ // Add workflow_dispatch with item_number input for manual testing
+ labelEventsMap["workflow_dispatch"] = map[string]any{
+ "inputs": map[string]any{
+ "item_number": map[string]any{
+ "description": "The number of the issue, pull request, or discussion",
+ "required": true,
+ "type": "string",
+ },
+ },
+ }
+ // Signal that this workflow has a dispatch item_number input so that
+ // applyWorkflowDispatchFallbacks and concurrency key building add the
+ // necessary inputs.item_number fallbacks for manual workflow_dispatch runs.
+ data.HasDispatchItemNumber = true
+
+ // Merge other events (if any) — this handles the no-clash requirement:
+ // if the user also has e.g. "issues: {types: [labeled], names: [bug]}" as a
+ // regular label trigger alongside label_command, merge the "types" arrays
+ // rather than generating a duplicate "issues:" block or silently dropping config.
+ if len(data.LabelCommandOtherEvents) > 0 {
+ for eventKey, eventVal := range data.LabelCommandOtherEvents {
+ if existing, exists := labelEventsMap[eventKey]; exists {
+ // Merge types arrays from user config into the label_command-generated entry.
+ existingMap, _ := existing.(map[string]any)
+ userMap, _ := eventVal.(map[string]any)
+ if existingMap != nil && userMap != nil {
+ existingTypes, _ := existingMap["types"].([]any)
+ userTypes, _ := userMap["types"].([]any)
+ merged := make([]any, 0, len(existingTypes)+len(userTypes))
+ merged = append(merged, existingTypes...)
+ merged = append(merged, userTypes...)
+ existingMap["types"] = merged
+ // Other fields (names, branches, etc.) from the user config are preserved.
+ for k, v := range userMap {
+ if k != "types" {
+ existingMap[k] = v
+ }
+ }
+ }
+ } else {
+ labelEventsMap[eventKey] = eventVal
+ }
+ }
+ }
+
+ // Convert merged events to YAML
+ mergedEventsYAML, err := yaml.Marshal(map[string]any{"on": labelEventsMap})
+ if err != nil {
+ return fmt.Errorf("failed to marshal label-command events: %w", err)
+ }
+ yamlStr := strings.TrimSuffix(string(mergedEventsYAML), "\n")
+ yamlStr = parser.QuoteCronExpressions(yamlStr)
+ // Pass frontmatter so label names in "names:" fields get commented out
+ yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, map[string]any{})
+ data.On = yamlStr
+
+ // Build the label-command condition
+ hasOtherEvents := len(data.LabelCommandOtherEvents) > 0
+ labelConditionTree, err := buildLabelCommandCondition(data.LabelCommand, data.LabelCommandEvents, hasOtherEvents)
+ if err != nil {
+ return fmt.Errorf("failed to build label-command condition: %w", err)
+ }
+
+ if data.If == "" {
+ data.If = labelConditionTree.Render()
}
} else {
data.On = `on:
@@ -141,7 +263,7 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error
}
// Generate concurrency configuration using the dedicated concurrency module
- data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger)
+ data.Concurrency = GenerateConcurrencyConfig(data, isCommandTrigger || isLabelCommandTrigger)
if data.RunName == "" {
data.RunName = fmt.Sprintf(`run-name: "%s"`, data.Name)