diff --git a/.github/agents/agentic-campaign-designer.agent.md b/.github/agents/agentic-campaign-designer.agent.md index ecd7f7dd46..529c1713a0 100644 --- a/.github/agents/agentic-campaign-designer.agent.md +++ b/.github/agents/agentic-campaign-designer.agent.md @@ -51,23 +51,12 @@ Help identify relevant workflows: Guide the user to specify: -**Required: Allowed Repositories** +**Scope selectors** ```yaml -allowed-repos: - - owner/repo1 - - owner/repo2 -``` -OR use organization-wide scope: -```yaml -allowed-orgs: - - myorg -``` - -**Optional: Discovery Repositories** (where to find worker workflow outputs) -```yaml -discovery-repos: +scope: - owner/repo1 - owner/repo2 + - org:myorg ``` **Risk Assessment:** @@ -90,9 +79,10 @@ state: planned workflows: - workflow-1 - workflow-2 -allowed-repos: +scope: - owner/repo1 - owner/repo2 + - org:myorg owners: - @username risk-level: @@ -280,7 +270,7 @@ Point users to these resources: A well-designed agentic campaign has: - ✅ Clear, measurable objective - ✅ 2-4 relevant workflows identified -- ✅ Explicit repository scope (allowed-repos or allowed-orgs) +- ✅ Explicit repository scope (`scope`) - ✅ Appropriate risk level - ✅ Defined owners and stakeholders - ✅ Governance guardrails configured @@ -353,7 +343,7 @@ Agent: 🎉 Your agentic campaign spec is ready! 2. Edit `.github/workflows/security-2025.campaign.md` and update: - workflows: [vulnerability-scanner, dependency-updater] - - allowed-orgs: [mycompany] + - scope: [org:mycompany] - owners: [@yourname] - Add KPIs if desired diff --git a/.github/aw/close-agentic-campaign.md b/.github/aw/close-agentic-campaign.md index e22f090cee..b3a40b6e01 100644 --- a/.github/aw/close-agentic-campaign.md +++ b/.github/aw/close-agentic-campaign.md @@ -4,13 +4,13 @@ Execute all four steps in strict order: 1. Read State (no writes) 2. Make Decisions (no writes) -3. Write State (update-project only) +3. Dispatch Workers (dispatch-workflow only) 4. Report The following rules are mandatory and override inferred behavior: - The GitHub Project board is the single source of truth. -- All project writes MUST comply with `project_update_instructions.md`. +- All project writes MUST comply with the Project Update Instructions (in workers). - State reads and state writes MUST NOT be interleaved. - Do NOT infer missing data or invent values. - Do NOT reorganize hierarchy. diff --git a/.github/aw/generate-agentic-campaign.md b/.github/aw/generate-agentic-campaign.md index c37fbfdec2..a64096b400 100644 --- a/.github/aw/generate-agentic-campaign.md +++ b/.github/aw/generate-agentic-campaign.md @@ -37,8 +37,7 @@ name: description: project-url: workflows: [, ] -allowed-repos: [owner/repo1, owner/repo2] # Required: repositories campaign can operate on -allowed-orgs: [org-name] # Optional: organizations campaign can operate on +scope: [owner/repo1, owner/repo2, org:org-name] # Optional: defaults to current repository owners: [@] risk-level: state: planned @@ -155,8 +154,7 @@ In addition to the structure above, include these exact items: **Allowed Repos/Orgs (Required):** -- `allowed-repos`: **Required** - List of repositories (format: `owner/repo`) that campaign can discover and operate on -- `allowed-orgs`: Optional - GitHub organizations campaign can operate on +- `scope`: **Optional** - Scope selectors for repos and orgs this campaign can discover and operate on (defaults to current repo) - Defines campaign scope as a reviewable contract for security and governance **Workflow Discovery:** diff --git a/.github/aw/orchestrate-agentic-campaign.md b/.github/aw/orchestrate-agentic-campaign.md index 343477a35b..9318bd46ec 100644 --- a/.github/aw/orchestrate-agentic-campaign.md +++ b/.github/aw/orchestrate-agentic-campaign.md @@ -1,10 +1,10 @@ # Orchestrator Instructions -This orchestrator coordinates a single campaign by discovering worker outputs, making deterministic decisions, -and synchronizing campaign state into a GitHub Project board. +This orchestrator coordinates a single campaign by discovering worker outputs and making deterministic decisions. -**Scope:** orchestration only (discovery, planning, pacing, reporting). -**Write authority:** all project write semantics are governed by **Project Update Instructions** and MUST be followed. +**Scope:** orchestration only (discovery, planning, pacing, reporting). +**Actuation model:** **dispatch-only** — the orchestrator may only act by dispatching allowlisted worker workflows. +**Write authority:** all GitHub writes (Projects, issues/PRs, comments, status updates) must happen in worker workflows. --- @@ -17,33 +17,18 @@ and synchronizing campaign state into a GitHub Project board. - On throttling (HTTP 429 / rate-limit 403), do not retry aggressively; back off and end the run after reporting what remains. {{ if .CursorGlob }} -**Cursor file (repo-memory)**: `{{ .CursorGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/cursor.json` -- If it exists: read first and continue from its boundary. -- If it does not exist: create it by end of run. +**Cursor file (repo-memory)**: `{{ .CursorGlob }}` +**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/cursor.json` +- If it exists: read first and continue from its boundary. +- If it does not exist: create it by end of run. - Always write the updated cursor back to the same path. {{ end }} {{ if .MetricsGlob }} -**Metrics snapshots (repo-memory)**: `{{ .MetricsGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/metrics/*.json` +**Metrics snapshots (repo-memory)**: `{{ .MetricsGlob }}` +**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/metrics/*.json` - Persist one append-only JSON metrics snapshot per run (new file per run; do not rewrite history). - Use UTC date (`YYYY-MM-DD`) in the filename (example: `metrics/2025-12-22.json`). -- Each snapshot MUST include ALL required fields (even if zero): - - `campaign_id` (string): The campaign identifier - - `date` (string): UTC date in YYYY-MM-DD format - - `tasks_total` (number): Total number of tasks (>= 0, even if 0) - - `tasks_completed` (number): Completed task count (>= 0, even if 0) -- Optional fields (include only if available): `tasks_in_progress`, `tasks_blocked`, `velocity_per_day`, `estimated_completion` -- Example minimum valid snapshot: - ```json - { - "campaign_id": "{{.CampaignID}}", - "date": "2025-12-22", - "tasks_total": 0, - "tasks_completed": 0 - } - ``` {{ end }} {{ if gt .MaxDiscoveryItemsPerRun 0 }} @@ -52,12 +37,6 @@ and synchronizing campaign state into a GitHub Project board. {{ if gt .MaxDiscoveryPagesPerRun 0 }} **Read budget**: max discovery pages per run: {{ .MaxDiscoveryPagesPerRun }} {{ end }} -{{ if gt .MaxProjectUpdatesPerRun 0 }} -**Write budget**: max project updates per run: {{ .MaxProjectUpdatesPerRun }} -{{ end }} -{{ if gt .MaxProjectCommentsPerRun 0 }} -**Write budget**: max project comments per run: {{ .MaxProjectCommentsPerRun }} -{{ end }} --- @@ -68,128 +47,26 @@ and synchronizing campaign state into a GitHub Project board. 3. Correlation is explicit (tracker-id AND labels) 4. Reads and writes are separate steps (never interleave) 5. Idempotent operation is mandatory (safe to re-run) -6. Only predefined project fields may be updated -7. **Project Update Instructions take precedence for all project writes** -8. **Campaign items MUST be labeled** for discovery and isolation - ---- - -## Campaign Label Requirements - -**All campaign-related issues, PRs, and discussions MUST have two labels:** - -1. **`agentic-campaign`** - Generic label marking content as part of ANY campaign - - Prevents other workflows from processing campaign items - - Enables campaign-wide queries and filters - -2. **`z_campaign_{{.CampaignID}}`** - Campaign-specific label - - Enables precise discovery of items belonging to THIS campaign - - Format: `z_campaign_` (lowercase, hyphen-separated) - - Example: `z_campaign_security-q1-2025` - -**Worker Responsibilities:** -- Workers creating issues/PRs as campaign output MUST add both labels -- Workers SHOULD use `create-issue` or `create-pr` safe outputs with labels configuration -- If workers cannot add labels automatically, campaign orchestrator will attempt to add them during discovery - -**Non-Campaign Workflow Responsibilities:** -- Workflows triggered by issues/PRs SHOULD skip items with `agentic-campaign` label -- Use `skip-if-match` configuration to filter out campaign items: - ```yaml - on: - issues: - types: [opened, labeled] - skip-if-match: - query: "label:agentic-campaign" - max: 0 # Skip if ANY campaign items match - ``` +6. Orchestrators do not write GitHub state directly --- ## Execution Steps (Required Order) -### Step 0 — Epic Issue Initialization [FIRST RUN ONLY] - -**Campaign Epic Issue Requirements:** -- Each project board MUST have exactly ONE Epic issue representing the campaign -- The Epic serves as the parent for all campaign work issues -- The Epic is narrative-only and tracks overall campaign progress - -**On every run, before other steps:** - -1) **Check for existing Epic issue** by searching the repository for: - - An open issue with label `epic` or `type:epic` - - Body text containing: `campaign_id: {{.CampaignID}}` - -2) **If no Epic issue exists**, create it using `create-issue`: - ```yaml - create-issue: - title: "{{if .CampaignName}}{{.CampaignName}}{{else}}Campaign: {{.CampaignID}}{{end}}" - body: | - ## Campaign Overview - - This Epic issue tracks the overall progress of the campaign. All work items are sub-issues of this Epic. - - **Campaign Details:** - - Campaign ID: `{{.CampaignID}}` - - Project Board: {{.ProjectURL}} - {{ if .Workflows }}- Worker Workflows: {{range $i, $w := .Workflows}}{{if $i}}, {{end}}`{{$w}}`{{end}}{{ end }} - - --- - `campaign_id: {{.CampaignID}}` - labels: - - agentic-campaign - - z_campaign_{{.CampaignID}} - - epic - - type:epic - ``` - -3) **After creating the Epic** (or if Epic exists but not on board), add it to the project board: - ```yaml - update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" - content_number: - fields: - status: "In Progress" - campaign_id: "{{.CampaignID}}" - worker_workflow: "unknown" - repository: "" - priority: "High" - size: "Large" - start_date: "" - end_date: "" - ``` - -4) **Record the Epic issue number** in repo-memory for reference (e.g., in cursor file or metadata). - -**Note:** This step typically runs only on the first orchestrator execution. On subsequent runs, verify the Epic exists and is on the board, but do not recreate it. - ---- - ### Step 1 — Read State (Discovery) [NO WRITES] **IMPORTANT**: Discovery has been precomputed. Read the discovery manifest instead of performing GitHub-wide searches. 1) Read the precomputed discovery manifest: `./.gh-aw/campaign.discovery.json` - - This manifest contains all discovered worker outputs with normalized metadata - - Schema version: v1 - - Fields: campaign_id, generated_at, discovery (total_items, cursor info), summary (counts), items (array of normalized items) - -2) Read current GitHub Project board state (items + required fields). -3) Parse discovered items from the manifest: +2) Parse discovered items from the manifest: - Each item has: url, content_type (issue/pull_request/discussion), number, repo, created_at, updated_at, state - Closed items have: closed_at (for issues) or merged_at (for PRs) - Items are pre-sorted by updated_at for deterministic processing -4) Check the manifest summary for work counts: - - `needs_add_count`: Number of items that need to be added to the project - - `needs_update_count`: Number of items that need status updates - - If both are 0, you may skip to reporting step +3) Check the manifest summary for work counts. -5) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. +4) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. ### Step 2 — Make Decisions (Planning) [NO WRITES] @@ -198,66 +75,27 @@ and synchronizing campaign state into a GitHub Project board. - Closed (issue/discussion) → `Done` - Merged (PR) → `Done` -**Why use explicit GitHub state?** - GitHub is the source of truth for work status. Inferring status from other signals (labels, comments) would be unreliable and could cause incorrect tracking. - -6) Calculate required date fields for each item (per Project Update Instructions): +6) Calculate required date fields (for workers that sync Projects): - `start_date`: format `created_at` as `YYYY-MM-DD` - `end_date`: - if closed/merged → format `closed_at`/`merged_at` as `YYYY-MM-DD` - - if open → **today's date** formatted `YYYY-MM-DD` (required for roadmap view) - -**Why use today for open items?** - GitHub Projects requires end_date for roadmap views. Using today's date shows the item is actively tracked and updates automatically each run until completion. - -7) Do NOT implement idempotency by comparing against the board. You may compare for reporting only. - -**Why no comparison for idempotency?** - The safe-output system handles deduplication. Comparing would add complexity and potential race conditions. Trust the infrastructure. - -8) Apply write budget: -- If `MaxProjectUpdatesPerRun > 0`, select at most that many items this run using deterministic order - (e.g., oldest `updated_at` first; tie-break by ID/number). -- Defer remaining items to next run via cursor. - -**Why use deterministic order?** - Ensures predictable behavior and prevents starvation. Oldest items are processed first, ensuring fair treatment of all work items. The cursor saves progress for next run. - -### Step 3 — Write State (Execution) [WRITES ONLY] - -9) For each selected item, send an `update-project` request. -- Do NOT interleave reads. -- Do NOT pre-check whether the item is on the board. -- **All write semantics MUST follow Project Update Instructions**, including: - - first add → full required fields (status, campaign_id, worker_workflow, repo, priority, size, start_date, end_date) - - existing item → status-only update unless explicit backfill is required - -10) Record per-item outcome: success/failure + error details. + - if open → **today's date** formatted `YYYY-MM-DD` -### Step 4 — Report & Status Update +7) Reads and writes are separate steps (never interleave). -11) **REQUIRED: Create a project status update summarizing this run** +### Step 3 — Dispatch Workers (Execution) [DISPATCH ONLY] -Every campaign run MUST create a status update using `create-project-status-update` safe output. This is the primary communication mechanism for conveying campaign progress to stakeholders. +8) For each selected unit of work, dispatch a worker workflow using `dispatch-workflow`. -**Required Sections:** +Constraints: +- Only dispatch allowlisted workflows. +- Keep within the dispatch-workflow max for this run. -- **Most Important Findings**: Highlight the 2-3 most critical discoveries, insights, or blockers from this run -- **What Was Learned**: Document key learnings, patterns observed, or insights gained during this run -- **Campaign Progress**: Report on campaign metrics and trends with baseline → current → target format, including direction and velocity -- **Campaign Summary**: Tasks completed, in progress, blocked, and overall completion percentage -- **Next Steps**: Clear action items and priorities for the next run +### Step 4 — Report (No Writes) -**Configuration:** -- Set appropriate status: ON_TRACK, AT_RISK, OFF_TRACK, or COMPLETE -- Use today's date for start_date and target_date (or appropriate future date for target) -- Body must be comprehensive yet concise (target: 200-400 words) +9) Summarize what you dispatched, what remains, and what should run next. -Example status update: -```yaml -create-project-status-update: - project: "{{.ProjectURL}}" - status: "ON_TRACK" - start_date: "2026-01-06" - target_date: "2026-01-31" - body: | - ## Campaign Run Summary +If a status update is required on the GitHub Project, dispatch a dedicated reporting/sync worker to perform that write. **Discovered:** 25 items (15 issues, 10 PRs) **Processed:** 10 items added to project, 5 updated diff --git a/.github/aw/update-agentic-campaign-project.md b/.github/aw/update-agentic-campaign-project.md index 8ed4c79a66..c64c49f666 100644 --- a/.github/aw/update-agentic-campaign-project.md +++ b/.github/aw/update-agentic-campaign-project.md @@ -10,7 +10,8 @@ If any other instructions conflict with this file, THIS FILE TAKES PRECEDENCE fo ## 0) Hard Requirements (Do Not Deviate) -- Writes MUST use only the `update-project` safe-output. +- Orchestrators are dispatch-only and MUST NOT perform project writes directly. +- Worker workflows performing project writes MUST use only the `update-project` safe-output. - All writes MUST target exactly: - **Project URL**: `{{.ProjectURL}}` - Every item MUST include: diff --git a/.github/workflows/security-alert-burndown.campaign.lock.yml b/.github/workflows/security-alert-burndown.campaign.lock.yml index 0abccd72ba..f643d1c7e6 100644 --- a/.github/workflows/security-alert-burndown.campaign.lock.yml +++ b/.github/workflows/security-alert-burndown.campaign.lock.yml @@ -69,11 +69,9 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: - actions: read contents: read issues: read pull-requests: read - security-events: read concurrency: group: "gh-aw-claude-${{ github.workflow }}" env: @@ -113,6 +111,7 @@ jobs: - env: GH_AW_CAMPAIGN_ID: security-alert-burndown GH_AW_CURSOR_PATH: "" + GH_AW_DISCOVERY_REPOS: githubnext/gh-aw GH_AW_MAX_DISCOVERY_ITEMS: "100" GH_AW_MAX_DISCOVERY_PAGES: "5" GH_AW_PROJECT_URL: https://github.com/orgs/githubnext/projects/134 @@ -196,71 +195,10 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_comment":{"max":3},"create_issue":{"max":1},"create_project_status_update":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_project":{"max":10}} + {"dispatch_workflow":{"max":3,"workflows":["code-scanning-fixer","security-fix-pr","dependabot-bundler","secret-scanning-triage"]},"missing_data":{},"missing_tool":{},"noop":{"max":1}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ - { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "parent": { - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", - "type": [ - "number", - "string" - ] - }, - "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "type": "string" - }, - "title": { - "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_issue" - }, - { - "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. CONSTRAINTS: Maximum 3 comment(s) can be added.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation.", - "type": "string" - }, - "item_number": { - "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool will attempt to resolve the target from the current workflow context (triggering issue, PR, or discussion).", - "type": "number" - } - }, - "required": [ - "body" - ], - "type": "object" - }, - "name": "add_comment" - }, { "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", "inputSchema": { @@ -303,133 +241,6 @@ jobs: }, "name": "noop" }, - { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "campaign_id": { - "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", - "type": "string" - }, - "content_number": { - "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456). Required when content_type is 'issue' or 'pull_request'.", - "type": "number" - }, - "content_type": { - "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project. Required when operation is not specified.", - "enum": [ - "issue", - "pull_request", - "draft_issue" - ], - "type": "string" - }, - "create_if_missing": { - "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", - "type": "boolean" - }, - "draft_body": { - "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", - "type": "string" - }, - "draft_title": { - "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", - "type": "string" - }, - "field_definitions": { - "description": "Field definitions to create when operation is create_fields. Required when operation='create_fields'.", - "items": { - "additionalProperties": false, - "properties": { - "data_type": { - "description": "Field type. Use SINGLE_SELECT with options for enumerated values.", - "enum": [ - "TEXT", - "NUMBER", - "DATE", - "SINGLE_SELECT", - "ITERATION" - ], - "type": "string" - }, - "name": { - "description": "Field name to create (e.g., 'size', 'priority').", - "type": "string" - }, - "options": { - "description": "Options for SINGLE_SELECT fields.", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "name", - "data_type" - ], - "type": "object" - }, - "type": "array" - }, - "fields": { - "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", - "type": "object" - }, - "operation": { - "description": "Optional operation mode. Use create_fields to create required campaign fields up-front, or create_view to add a project view. When omitted, the tool adds/updates project items.", - "enum": [ - "create_fields", - "create_view" - ], - "type": "string" - }, - "project": { - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", - "type": "string" - }, - "view": { - "additionalProperties": false, - "description": "View definition to create when operation is create_view. Required when operation='create_view'.", - "properties": { - "filter": { - "type": "string" - }, - "layout": { - "enum": [ - "table", - "board", - "roadmap" - ], - "type": "string" - }, - "name": { - "type": "string" - }, - "visible_fields": { - "description": "Field IDs to show in the view (table/board only).", - "items": { - "type": "number" - }, - "type": "array" - } - }, - "required": [ - "name", - "layout" - ], - "type": "object" - } - }, - "required": [ - "project" - ], - "type": "object" - }, - "name": "update_project" - }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -458,139 +269,55 @@ jobs: "name": "missing_data" }, { - "description": "Create a status update on a GitHub Projects v2 board to communicate project progress. Use this when you need to provide stakeholder updates with status indicators, timeline information, and progress summaries. Status updates create a historical record of project progress tracked over time. Requires project URL, status indicator, dates, and markdown body describing progress/trends/findings.", + "_workflow_name": "code-scanning-fixer", + "description": "Dispatch the 'code-scanning-fixer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in the same repository.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "code_scanning_fixer" + }, + { + "_workflow_name": "security-fix-pr", + "description": "Dispatch the 'security-fix-pr' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in the same repository.", "inputSchema": { "additionalProperties": false, "properties": { - "body": { - "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state.", - "type": "string" - }, - "project": { - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", - "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", - "type": "string" - }, - "start_date": { - "description": "Optional project start date in YYYY-MM-DD format (e.g., '2026-01-06').", - "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", - "type": "string" - }, - "status": { - "description": "Status indicator for the project. Defaults to ON_TRACK. Values: ON_TRACK (progressing well), AT_RISK (has issues/blockers), OFF_TRACK (significantly behind), COMPLETE (finished), INACTIVE (paused/cancelled).", - "enum": [ - "ON_TRACK", - "AT_RISK", - "OFF_TRACK", - "COMPLETE", - "INACTIVE" - ], - "type": "string" - }, - "target_date": { - "description": "Optional project target/end date in YYYY-MM-DD format (e.g., '2026-12-31').", - "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "security_url": { + "default": "", + "description": "Security alert URL (e.g., https://github.com/owner/repo/security/code-scanning/123)", "type": "string" } }, - "required": [ - "project", - "body" - ], "type": "object" }, - "name": "create_project_status_update" + "name": "security_fix_pr" + }, + { + "_workflow_name": "dependabot-bundler", + "description": "Dispatch the 'dependabot-bundler' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in the same repository.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "dependabot_bundler" + }, + { + "_workflow_name": "secret-scanning-triage", + "description": "Dispatch the 'secret-scanning-triage' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in the same repository.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "secret_scanning_triage" } ] EOF cat > /opt/gh-aw/safeoutputs/validation.json << 'EOF' { - "add_comment": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "item_number": { - "issueOrPRNumber": true - } - } - }, - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "create_project_status_update": { - "defaultMax": 10, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65536 - }, - "project": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", - "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" - }, - "start_date": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$", - "patternError": "must be in YYYY-MM-DD format" - }, - "status": { - "type": "string", - "enum": [ - "INACTIVE", - "ON_TRACK", - "AT_RISK", - "OFF_TRACK", - "COMPLETE" - ] - }, - "target_date": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$", - "patternError": "must be in YYYY-MM-DD format" - } - } - }, "missing_tool": { "defaultMax": 20, "fields": { @@ -623,43 +350,6 @@ jobs: "maxLength": 65000 } } - }, - "update_project": { - "defaultMax": 10, - "fields": { - "campaign_id": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "content_number": { - "optionalPositiveInteger": true - }, - "content_type": { - "type": "string", - "enum": [ - "issue", - "pull_request" - ] - }, - "fields": { - "type": "object" - }, - "issue": { - "optionalPositiveInteger": true - }, - "project": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", - "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" - }, - "pull_request": { - "optionalPositiveInteger": true - } - } } } EOF @@ -733,7 +423,7 @@ jobs: "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests,actions,code_security" + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" } }, "safeoutputs": { @@ -851,7 +541,7 @@ jobs: To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - **Available tools**: add_comment, create_issue, create_project_status_update, missing_tool, noop, update_project + **Available tools**: dispatch_workflow, missing_tool, noop **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. @@ -1203,11 +893,11 @@ jobs: --- # Orchestrator Instructions - This orchestrator coordinates a single campaign by discovering worker outputs, making deterministic decisions, - and synchronizing campaign state into a GitHub Project board. + This orchestrator coordinates a single campaign by discovering worker outputs and making deterministic decisions. - **Scope:** orchestration only (discovery, planning, pacing, reporting). - **Write authority:** all project write semantics are governed by **Project Update Instructions** and MUST be followed. + **Scope:** orchestration only (discovery, planning, pacing, reporting). + **Actuation model:** **dispatch-only** — the orchestrator may only act by dispatching allowlisted worker workflows. + **Write authority:** all GitHub writes (Projects, issues/PRs, comments, status updates) must happen in worker workflows. --- @@ -1230,12 +920,6 @@ jobs: **Read budget**: max discovery pages per run: 5 - **Write budget**: max project updates per run: 10 - - - **Write budget**: max project comments per run: 3 - - --- ## Core Principles @@ -1245,128 +929,26 @@ jobs: 3. Correlation is explicit (tracker-id AND labels) 4. Reads and writes are separate steps (never interleave) 5. Idempotent operation is mandatory (safe to re-run) - 6. Only predefined project fields may be updated - 7. **Project Update Instructions take precedence for all project writes** - 8. **Campaign items MUST be labeled** for discovery and isolation - - --- - - ## Campaign Label Requirements - - **All campaign-related issues, PRs, and discussions MUST have two labels:** - - 1. **`agentic-campaign`** - Generic label marking content as part of ANY campaign - - Prevents other workflows from processing campaign items - - Enables campaign-wide queries and filters - - 2. **`z_campaign_security-alert-burndown`** - Campaign-specific label - - Enables precise discovery of items belonging to THIS campaign - - Format: `z_campaign_` (lowercase, hyphen-separated) - - Example: `z_campaign_security-q1-2025` - - **Worker Responsibilities:** - - Workers creating issues/PRs as campaign output MUST add both labels - - Workers SHOULD use `create-issue` or `create-pr` safe outputs with labels configuration - - If workers cannot add labels automatically, campaign orchestrator will attempt to add them during discovery - - **Non-Campaign Workflow Responsibilities:** - - Workflows triggered by issues/PRs SHOULD skip items with `agentic-campaign` label - - Use `skip-if-match` configuration to filter out campaign items: - ```yaml - on: - issues: - types: [opened, labeled] - skip-if-match: - query: "label:agentic-campaign" - max: 0 # Skip if ANY campaign items match - ``` + 6. Orchestrators do not write GitHub state directly --- ## Execution Steps (Required Order) - ### Step 0 — Epic Issue Initialization [FIRST RUN ONLY] - - **Campaign Epic Issue Requirements:** - - Each project board MUST have exactly ONE Epic issue representing the campaign - - The Epic serves as the parent for all campaign work issues - - The Epic is narrative-only and tracks overall campaign progress - - **On every run, before other steps:** - - 1) **Check for existing Epic issue** by searching the repository for: - - An open issue with label `epic` or `type:epic` - - Body text containing: `campaign_id: security-alert-burndown` - - 2) **If no Epic issue exists**, create it using `create-issue`: - ```yaml - create-issue: - title: "Security Alert Burndown" - body: | - ## Campaign Overview - - This Epic issue tracks the overall progress of the campaign. All work items are sub-issues of this Epic. - - **Campaign Details:** - - Campaign ID: `security-alert-burndown` - - Project Board: https://github.com/orgs/githubnext/projects/134 - - Worker Workflows: `code-scanning-fixer`, `security-fix-pr`, `dependabot-bundler`, `secret-scanning-triage` - - --- - `campaign_id: security-alert-burndown` - labels: - - agentic-campaign - - z_campaign_security-alert-burndown - - epic - - type:epic - ``` - - 3) **After creating the Epic** (or if Epic exists but not on board), add it to the project board: - ```yaml - update-project: - project: "https://github.com/orgs/githubnext/projects/134" - campaign_id: "security-alert-burndown" - content_type: "issue" - content_number: - fields: - status: "In Progress" - campaign_id: "security-alert-burndown" - worker_workflow: "unknown" - repository: "" - priority: "High" - size: "Large" - start_date: "" - end_date: "" - ``` - - 4) **Record the Epic issue number** in repo-memory for reference (e.g., in cursor file or metadata). - - **Note:** This step typically runs only on the first orchestrator execution. On subsequent runs, verify the Epic exists and is on the board, but do not recreate it. - - --- - ### Step 1 — Read State (Discovery) [NO WRITES] **IMPORTANT**: Discovery has been precomputed. Read the discovery manifest instead of performing GitHub-wide searches. 1) Read the precomputed discovery manifest: `./.gh-aw/campaign.discovery.json` - - This manifest contains all discovered worker outputs with normalized metadata - - Schema version: v1 - - Fields: campaign_id, generated_at, discovery (total_items, cursor info), summary (counts), items (array of normalized items) - - 2) Read current GitHub Project board state (items + required fields). - 3) Parse discovered items from the manifest: + 2) Parse discovered items from the manifest: - Each item has: url, content_type (issue/pull_request/discussion), number, repo, created_at, updated_at, state - Closed items have: closed_at (for issues) or merged_at (for PRs) - Items are pre-sorted by updated_at for deterministic processing - 4) Check the manifest summary for work counts: - - `needs_add_count`: Number of items that need to be added to the project - - `needs_update_count`: Number of items that need status updates - - If both are 0, you may skip to reporting step + 3) Check the manifest summary for work counts. - 5) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. + 4) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. ### Step 2 — Make Decisions (Planning) [NO WRITES] @@ -1375,68 +957,27 @@ jobs: - Closed (issue/discussion) → `Done` - Merged (PR) → `Done` - **Why use explicit GitHub state?** - GitHub is the source of truth for work status. Inferring status from other signals (labels, comments) would be unreliable and could cause incorrect tracking. - - 6) Calculate required date fields for each item (per Project Update Instructions): + 6) Calculate required date fields (for workers that sync Projects): - `start_date`: format `created_at` as `YYYY-MM-DD` - `end_date`: - if closed/merged → format `closed_at`/`merged_at` as `YYYY-MM-DD` - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - if open → **today's date** formatted `YYYY-MM-DD` (required for roadmap view) + - if open → **today's date** formatted `YYYY-MM-DD` - **Why use today for open items?** - GitHub Projects requires end_date for roadmap views. Using today's date shows the item is actively tracked and updates automatically each run until completion. + 7) Reads and writes are separate steps (never interleave). - 7) Do NOT implement idempotency by comparing against the board. You may compare for reporting only. + ### Step 3 — Dispatch Workers (Execution) [DISPATCH ONLY] - **Why no comparison for idempotency?** - The safe-output system handles deduplication. Comparing would add complexity and potential race conditions. Trust the infrastructure. + 8) For each selected unit of work, dispatch a worker workflow using `dispatch-workflow`. - 8) Apply write budget: - - If `MaxProjectUpdatesPerRun > 0`, select at most that many items this run using deterministic order - (e.g., oldest `updated_at` first; tie-break by ID/number). - - Defer remaining items to next run via cursor. + Constraints: + - Only dispatch allowlisted workflows. + - Keep within the dispatch-workflow max for this run. - **Why use deterministic order?** - Ensures predictable behavior and prevents starvation. Oldest items are processed first, ensuring fair treatment of all work items. The cursor saves progress for next run. + ### Step 4 — Report (No Writes) - ### Step 3 — Write State (Execution) [WRITES ONLY] + 9) Summarize what you dispatched, what remains, and what should run next. - 9) For each selected item, send an `update-project` request. - - Do NOT interleave reads. - - Do NOT pre-check whether the item is on the board. - - **All write semantics MUST follow Project Update Instructions**, including: - - first add → full required fields (status, campaign_id, worker_workflow, repo, priority, size, start_date, end_date) - - existing item → status-only update unless explicit backfill is required - - 10) Record per-item outcome: success/failure + error details. - - ### Step 4 — Report & Status Update - - 11) **REQUIRED: Create a project status update summarizing this run** - - Every campaign run MUST create a status update using `create-project-status-update` safe output. This is the primary communication mechanism for conveying campaign progress to stakeholders. - - **Required Sections:** - - - **Most Important Findings**: Highlight the 2-3 most critical discoveries, insights, or blockers from this run - - **What Was Learned**: Document key learnings, patterns observed, or insights gained during this run - - **Campaign Progress**: Report on campaign metrics and trends with baseline → current → target format, including direction and velocity - - **Campaign Summary**: Tasks completed, in progress, blocked, and overall completion percentage - - **Next Steps**: Clear action items and priorities for the next run - - **Configuration:** - - Set appropriate status: ON_TRACK, AT_RISK, OFF_TRACK, or COMPLETE - - Use today's date for start_date and target_date (or appropriate future date for target) - - Body must be comprehensive yet concise (target: 200-400 words) - - Example status update: - ```yaml - create-project-status-update: - project: "https://github.com/orgs/githubnext/projects/134" - status: "ON_TRACK" - start_date: "2026-01-06" - target_date: "2026-01-31" - body: | - ## Campaign Run Summary + If a status update is required on the GitHub Project, dispatch a dedicated reporting/sync worker to perform that write. **Discovered:** 25 items (15 issues, 10 PRs) **Processed:** 10 items added to project, 5 updated @@ -1506,7 +1047,8 @@ jobs: ## 0) Hard Requirements (Do Not Deviate) - - Writes MUST use only the `update-project` safe-output. + - Orchestrators are dispatch-only and MUST NOT perform project writes directly. + - Worker workflows performing project writes MUST use only the `update-project` safe-output. - All writes MUST target exactly: - **Project URL**: `https://github.com/orgs/githubnext/projects/134` - Every item MUST include: @@ -1531,6 +1073,8 @@ jobs: | `start_date` | date | `YYYY-MM-DD` | | `end_date` | date | `YYYY-MM-DD` | + PROMPT_EOF + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" Field names are case-sensitive. --- @@ -1748,13 +1292,13 @@ jobs: 1. Read State (no writes) 2. Make Decisions (no writes) - 3. Write State (update-project only) + 3. Dispatch Workers (dispatch-workflow only) 4. Report The following rules are mandatory and override inferred behavior: - The GitHub Project board is the single source of truth. - - All project writes MUST comply with `project_update_instructions.md`. + - All project writes MUST comply with the Project Update Instructions (in workers). - State reads and state writes MUST NOT be interleaved. - Do NOT infer missing data or invent values. - Do NOT reorganize hierarchy. @@ -2355,18 +1899,13 @@ jobs: if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') runs-on: ubuntu-slim permissions: - contents: read - discussions: write - issues: write - pull-requests: write + actions: write timeout-minutes: 15 env: GH_AW_ENGINE_ID: "claude" GH_AW_WORKFLOW_ID: "security-alert-burndown.campaign.g" GH_AW_WORKFLOW_NAME: "Security Alert Burndown" outputs: - process_project_safe_outputs_processed_count: ${{ steps.process_project_safe_outputs.outputs.processed_count }} - process_project_safe_outputs_temporary_project_map: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: @@ -2391,27 +1930,12 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process Project-Related Safe Outputs - id: process_project_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":10}}" - GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_PROJECT_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/safe_output_project_handler_manager.cjs'); - await main(); - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_TEMPORARY_PROJECT_MAP: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3},\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"dispatch_workflow\":{\"max\":3,\"workflows\":[\"code-scanning-fixer\",\"security-fix-pr\",\"dependabot-bundler\",\"secret-scanning-triage\"]},\"missing_data\":{},\"missing_tool\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/security-alert-burndown.campaign.md b/.github/workflows/security-alert-burndown.campaign.md index 65ef362ce3..086e57ae36 100644 --- a/.github/workflows/security-alert-burndown.campaign.md +++ b/.github/workflows/security-alert-burndown.campaign.md @@ -8,6 +8,8 @@ workflows: - security-fix-pr - dependabot-bundler - secret-scanning-triage +scope: + - githubnext/gh-aw governance: max-new-items-per-run: 3 max-discovery-items-per-run: 100 diff --git a/actions/setup/js/campaign_discovery.cjs b/actions/setup/js/campaign_discovery.cjs index f48ce495a6..79132d52a4 100644 --- a/actions/setup/js/campaign_discovery.cjs +++ b/actions/setup/js/campaign_discovery.cjs @@ -411,7 +411,7 @@ async function main() { // RUNTIME GUARD: Campaigns MUST be scoped if (!config.repos?.length && !config.orgs?.length) { - throw new Error("campaigns MUST be scoped: GH_AW_DISCOVERY_REPOS or GH_AW_DISCOVERY_ORGS is required. Configure allowed-repos or allowed-orgs in the campaign spec."); + throw new Error("campaigns MUST be scoped: GH_AW_DISCOVERY_REPOS or GH_AW_DISCOVERY_ORGS is required. Configure scope in the campaign spec."); } if (!config.workflows?.length && !config.trackerLabel) { diff --git a/docs/campaign-discovery-budgets.md b/docs/campaign-discovery-budgets.md index 5e74b2d07a..214b50dcf0 100644 --- a/docs/campaign-discovery-budgets.md +++ b/docs/campaign-discovery-budgets.md @@ -114,7 +114,7 @@ When **either budget** is exhausted: Possible reasons: 1. **No worker workflows have run yet** - Campaign may be newly created 2. **Worker outputs lack required labels** - Items not tagged with `z_campaign_security-alert-burndown` -3. **Items outside discovery scope** - Workers created items in repos not in `discovery-repos` +3. **Items outside campaign scope** - Workers created items in repos not in `scope` 4. **Search query too restrictive** - Label search didn't match existing items 5. **Budget exhausted before reaching relevant items** - Items exist but are on page 4+ (beyond 3-page budget) @@ -124,7 +124,7 @@ Possible reasons: ```yaml # From .github/workflows/security-alert-burndown.campaign.md -discovery-repos: +scope: - githubnext/gh-aw # Only searching this repo workflows: diff --git a/docs/src/content/docs/examples/campaigns/security-audit.campaign.md b/docs/src/content/docs/examples/campaigns/security-audit.campaign.md index f12d160528..2cacf80f48 100644 --- a/docs/src/content/docs/examples/campaigns/security-audit.campaign.md +++ b/docs/src/content/docs/examples/campaigns/security-audit.campaign.md @@ -78,7 +78,7 @@ Generates weekly security status reports: The campaign orchestrator will: -1. **Discover** security issues created by worker workflows via tracker-id +1. **Discover** security issues created by worker workflows via tracker-label 2. **Coordinate** by adding discovered items to the project board 3. **Track Progress** using KPIs and project board status fields 4. **Dispatch** worker workflows as needed to maintain campaign momentum diff --git a/docs/src/content/docs/examples/campaigns/security-scanner.md b/docs/src/content/docs/examples/campaigns/security-scanner.md index 6290a8efb1..ce824ff6d0 100644 --- a/docs/src/content/docs/examples/campaigns/security-scanner.md +++ b/docs/src/content/docs/examples/campaigns/security-scanner.md @@ -3,8 +3,16 @@ title: Security Scanner Workflow Example name: Security Scanner description: Scan repositories for security vulnerabilities on: - schedule: - - cron: "0 9 * * 1" # Every Monday at 9 AM + workflow_dispatch: + inputs: + campaign_id: + description: "Campaign identifier" + required: true + type: string + payload: + description: "JSON payload with work details" + required: true + type: string permissions: contents: read security-events: write @@ -33,29 +41,18 @@ Scan the repository for security vulnerabilities and create issues for any findi - CVE ID (if available) - Recommended fix - References and resources - - Labels: security, - - Body can optionally include tracker-id marker for campaign discovery + - Labels: security, , plus the campaign tracker label (defaults to `z_campaign_`) 4. For medium and low-severity findings: - Group similar findings into a single issue - Include all details in the issue description 5. Add comments to existing security issues if new information is discovered -## Output Format (Optional) - -When creating issues, you can optionally include the tracker-id in the issue body to help campaign orchestrators discover and track work items: - -``` -tracker-id: security-scanner -``` - -Note: This is optional. Campaign orchestrators can also discover worker items using labels, so tracker-id is not required. - ## Example Issue **Title**: [Security] SQL Injection vulnerability in user authentication **Body**: -```markdown +````markdown ## Vulnerability Details **Severity**: High @@ -71,16 +68,14 @@ SQL injection vulnerability in user authentication logic allows attackers to byp Use parameterized queries instead of string concatenation: -\```javascript +```javascript const query = 'SELECT * FROM users WHERE username = ? AND password = ?'; db.query(query, [username, hashedPassword]); -\``` +``` ## References - https://cwe.mitre.org/data/definitions/89.html - https://owasp.org/www-community/attacks/SQL_Injection ---- -tracker-id: security-scanner -``` +```` diff --git a/docs/src/content/docs/guides/campaigns/creating-campaigns.md b/docs/src/content/docs/guides/campaigns/creating-campaigns.md index f0dc3c679d..0140f9744d 100644 --- a/docs/src/content/docs/guides/campaigns/creating-campaigns.md +++ b/docs/src/content/docs/guides/campaigns/creating-campaigns.md @@ -75,7 +75,7 @@ Here's what a campaign spec looks like after creation: **Generated [campaign spec](/gh-aw/guides/campaigns/specs/)**: -```yaml +```markdown --- id: security-alert-burndown name: "Security Alert Burndown" @@ -83,16 +83,6 @@ description: "Drive the code security alerts backlog to zero" # GitHub Project for tracking project-url: "https://github.com/orgs/ORG/projects/1" -tracker-label: "campaign:security-alert-burndown" - -# Strategic goals -objective: "Reduce open code security alerts to zero without breaking CI." -kpis: - - id: open_alerts - name: "Open alerts" - priority: primary - direction: "decrease" - target: 0 # Worker workflows to dispatch workflows: @@ -102,9 +92,30 @@ workflows: governance: max-project-updates-per-run: 10 max-comments-per-run: 10 + +owners: + - "@security-team" --- + +# Security Alert Burndown + +## Objective + +Reduce open code security alerts to zero without breaking CI. + +## Key Performance Indicators (KPIs) + +### Primary KPI: Open alerts +- **Baseline**: (fill in) +- **Target**: 0 +- **Time Window**: (fill in) +- **Direction**: Decrease ``` +Notes: +- `tracker-label` is optional; when omitted it defaults to `z_campaign_`. +- Campaign narrative (objective, KPIs, timeline) belongs in the markdown body. + The spec compiles into a campaign orchestrator workflow (`.campaign.lock.yml`) that GitHub Actions executes on schedule. The orchestrator [dispatches workers, tracks outputs, updates the Project board, and reports progress](/gh-aw/guides/campaigns/lifecycle/). ## Next steps diff --git a/docs/src/content/docs/guides/campaigns/getting-started.md b/docs/src/content/docs/guides/campaigns/getting-started.md index a687b3b6cd..18f1efa847 100644 --- a/docs/src/content/docs/guides/campaigns/getting-started.md +++ b/docs/src/content/docs/guides/campaigns/getting-started.md @@ -33,7 +33,7 @@ The pull request creates three components: **Project board** - GitHub Project for tracking campaign progress with custom fields and views. -**Campaign spec** - Configuration file at `.github/workflows/.campaign.md` defining goals, workers, and governance. +**Campaign spec** - Configuration file at `.github/workflows/.campaign.md` defining campaign configuration (project URL, workflows, scope, governance). The markdown body contains narrative goals and success criteria. **Orchestrator workflow** - Compiled workflow at `.github/workflows/.campaign.lock.yml` that executes the campaign logic. diff --git a/docs/src/content/docs/guides/campaigns/lifecycle.md b/docs/src/content/docs/guides/campaigns/lifecycle.md index 3fff82f05e..f27ea78024 100644 --- a/docs/src/content/docs/guides/campaigns/lifecycle.md +++ b/docs/src/content/docs/guides/campaigns/lifecycle.md @@ -50,7 +50,7 @@ Worker workflows in the campaign's `workflows` list must: - Accept `workflow_dispatch` as the **only** trigger - Remove all other triggers (`schedule`, `push`, `pull_request`) -- Label created items with `campaign:` +- Label created items with the campaign tracker label (defaults to `z_campaign_`) - Accept standardized inputs: `campaign_id` (string) and `payload` (string JSON) ```yaml @@ -74,12 +74,13 @@ Workflows not in the `workflows` list can keep their original triggers. The camp ```yaml # Campaign spec -tracker-label: "campaign:security-audit" workflows: - vulnerability-scanner # Orchestrator controls this one # dependency-check runs independently with its cron schedule ``` +`tracker-label` is optional; when omitted it defaults to `z_campaign_`. + ## Discovery and governance Discovery finds items created by workers based on tracker labels. Governance limits control the pace of work. diff --git a/docs/src/content/docs/guides/campaigns/specs.md b/docs/src/content/docs/guides/campaigns/specs.md index 2021603999..b6f6cb0679 100644 --- a/docs/src/content/docs/guides/campaigns/specs.md +++ b/docs/src/content/docs/guides/campaigns/specs.md @@ -9,9 +9,9 @@ Campaign specs are YAML frontmatter configuration files at `.github/workflows/.campaign.md` file with YAML frontmatter plus a markdown body. Most fields have sensible defaults. -```yaml +```markdown --- id: framework-upgrade name: "Framework Upgrade" @@ -53,6 +53,7 @@ Upgrade all services to Framework vNext with zero downtime. - Format: lowercase letters, digits, hyphens only - Example: `security-audit-2025` - Auto-generates defaults for: tracker-label, memory-paths, metrics-glob, cursor-glob +- If omitted, defaults to the filename basename (e.g. `security-audit.campaign.md` → `security-audit`) **name** - Human-friendly display name - Example: `"Security Audit 2025"` @@ -66,6 +67,8 @@ Upgrade all services to Framework vNext with zero downtime. - Format: List of workflow IDs (file names without .md extension) - Example: `["security-scanner", "dependency-fixer"]` +`workflows` is strongly recommended for most campaigns (and `gh aw campaign validate` will flag empty workflows). It can be omitted for campaigns that only do coordination/discovery work. + ## Fields with defaults Many fields have automatic defaults based on the campaign ID: @@ -87,25 +90,15 @@ Many fields have automatic defaults based on the campaign ID: **cursor-glob** - Glob for durable cursor/checkpoint file - Default: `memory/campaigns/{id}/cursor.json` -**allowed-repos** - Repositories campaign can operate on +**scope** - Repositories and organizations this campaign can operate on - Default: Current repository (where campaign is defined) -**discovery-repos** - Repositories to search for worker outputs -- Default: Same as `allowed-repos` - -### Discovery scope (optional) - -Override discovery scope when operating across multiple repositories: - -**discovery-repos** - Specific repositories to search -- Format: List of `owner/repo` strings -- Example: `["myorg/api", "myorg/web"]` -- Default: Same as `allowed-repos` (current repository) +Campaign scope is defined once and used for both discovery and execution: -**discovery-orgs** - Organizations to search (all repos) -- Format: List of organization names -- Example: `["myorg"]` -- Overrides `discovery-repos` when specified +**scope** - Scope selectors +- Repository selector: `owner/repo` +- Organization selector: `org:` +- Example: `["myorg/api", "myorg/web", "org:myorg"]` ## Optional fields @@ -123,13 +116,12 @@ Override discovery scope when operating across multiple repositories: **governance** - Pacing and safety limits - See [Governance fields](#governance-fields) below -**allowed-orgs** - Organizations campaign can modify -- Format: List of organization names -- Alternative to specifying individual `allowed-repos` +No other scope fields are needed; use `scope`. -**project-github-token** - Custom token for Projects API -- Format: Token expression like `${{ secrets.TOKEN_NAME }}` -- Use when default `GITHUB_TOKEN` lacks Projects permissions +Campaign orchestrators are **dispatch-only by design**: +- The orchestrator can make decisions and coordinate work. +- The orchestrator may only *act* by dispatching allowlisted worker workflows via `safe-outputs.dispatch-workflow`. +- All side effects (Projects, issues/PRs, comments) happen in worker workflows with their own safe-outputs. ## Markdown body content @@ -195,34 +187,14 @@ governance: - Default: `["no-bot", "no-campaign"]` ## Discovery configuration - -### Repository-scoped discovery +Campaign discovery uses the same `scope` as execution. ```yaml -discovery-repos: +scope: - "myorg/frontend" - "myorg/backend" - "myorg/api" -``` - -Searches only specified repositories for issues and pull requests with tracker labels. - -### Organization-scoped discovery - -```yaml -discovery-orgs: - - "myorg" -``` - -Searches all repositories in the organization. Use carefully - can be expensive for large organizations. - -### Hybrid approach - -```yaml -discovery-repos: - - "myorg/critical-service" # Always scan this one -discovery-orgs: - - "myorg" # Scan all others + - "org:myorg" # optional org-wide scope ``` ## Validation @@ -243,7 +215,7 @@ Common validation errors: The simplest possible campaign: -```yaml +```markdown --- id: security-audit-q1 name: "Security Audit Q1 2025" @@ -265,11 +237,11 @@ This automatically gets: - `memory-paths: ["memory/campaigns/security-audit-q1/**"]` - `metrics-glob: memory/campaigns/security-audit-q1/metrics/*.json` - `cursor-glob: memory/campaigns/security-audit-q1/cursor.json` -- `allowed-repos` and `discovery-repos`: current repository +- `scope`: current repository ## Full example -With governance and multi-org scope: +With governance and org scope: ```yaml --- @@ -278,8 +250,8 @@ name: "Security Audit Q1 2025" description: "Quarterly security review and remediation" project-url: "https://github.com/orgs/myorg/projects/5" -discovery-orgs: - - "myorg" +scope: + - "org:myorg" workflows: - security-scanner diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index 585b346866..d99e661c4d 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -285,7 +285,7 @@ See the [ProjectOps Guide](/gh-aw/examples/issue-pr-events/projectops/) for impl A finite, enterprise-scale initiative with explicit ownership, approval gates, and executive visibility. Agentic campaigns orchestrate business outcomes (security remediation, dependency updates, compliance enforcement) across multiple repositories with governance, accountability, and ROI tracking. -Campaigns use `.campaign.md` specification files to define objectives, KPIs, and governance. The orchestrator discovers and tracks work, executes workflows sequentially, and can create missing workflows if needed. +Campaigns use `.campaign.md` files where YAML frontmatter defines configuration (project URL, workflows, scope, governance) and the markdown body defines narrative context (objective, KPIs, timeline). The orchestrator discovers and tracks work, executes workflows sequentially, and can create missing workflows if needed. ```yaml # Campaign example @@ -294,10 +294,19 @@ project-url: "https://github.com/orgs/ORG/projects/1" workflows: - framework-scanner # Will be created if missing - framework-upgrader # Will be created if missing -objective: "Upgrade all services to Framework vNext" -kpis: - - name: "Services upgraded" - target: 50 +``` + +```markdown +# Framework Upgrade + +## Objective + +Upgrade all services to Framework vNext. + +## KPIs + +### Primary KPI: Services upgraded +- **Target**: 50 ``` **Key characteristics:** diff --git a/pkg/campaign/campaign_test.go b/pkg/campaign/campaign_test.go index e7ef872a6b..4c3570f6a1 100644 --- a/pkg/campaign/campaign_test.go +++ b/pkg/campaign/campaign_test.go @@ -121,12 +121,11 @@ func TestRunCampaignStatus_JSON(t *testing.T) { // (like version) is applied. func TestValidateCampaignSpec_Basic(t *testing.T) { spec := &CampaignSpec{ - ID: "go-file-size-reduction", - Name: "Go File Size Reduction", - ProjectURL: "https://github.com/orgs/githubnext/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"org/repo1"}, - Workflows: []string{"daily-file-diet"}, + ID: "go-file-size-reduction", + Name: "Go File Size Reduction", + ProjectURL: "https://github.com/orgs/githubnext/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"daily-file-diet"}, } problems := ValidateSpec(spec) @@ -143,12 +142,12 @@ func TestValidateCampaignSpec_Basic(t *testing.T) { // values are reported by validation. func TestValidateCampaignSpec_InvalidState(t *testing.T) { spec := &CampaignSpec{ - ID: "rollout-q1-2025", - Name: "Rollout", - ProjectURL: "https://github.com/orgs/githubnext/projects/1", - AllowedRepos: []string{"org/repo1"}, - Workflows: []string{"daily-file-diet"}, - State: "launching", // invalid + ID: "rollout-q1-2025", + Name: "Rollout", + ProjectURL: "https://github.com/orgs/githubnext/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"daily-file-diet"}, + State: "launching", // invalid } problems := ValidateSpec(spec) @@ -187,9 +186,9 @@ func TestComputeCompiledState_LockFilePath(t *testing.T) { } spec := CampaignSpec{ - ID: "test-campaign", - AllowedRepos: []string{"org/repo1"}, - Workflows: []string{workflowID}, + ID: "test-campaign", + Scope: []string{"org/repo1"}, + Workflows: []string{workflowID}, } // This should find the lock file and return "Yes" diff --git a/pkg/campaign/interactive.go b/pkg/campaign/interactive.go index cddeee81ea..93627ca67a 100644 --- a/pkg/campaign/interactive.go +++ b/pkg/campaign/interactive.go @@ -20,10 +20,7 @@ type InteractiveCampaignConfig struct { Name string Description string Scope string // "current", "multiple", "org-wide" - AllowedRepos []string - AllowedOrgs []string - DiscoveryRepos []string - DiscoveryOrgs []string + ScopeSelectors []string Workflows []string Owners []string RiskLevel string @@ -60,32 +57,27 @@ func RunInteractiveCampaignCreation(rootDir string, force bool, verbose bool) er return err } - // Step 3: Workflow discovery (EARLY) - optional step - if err := promptForWorkflowDiscovery(config, rootDir); err != nil { - return err - } - - // Step 4: Repository scope + // Step 3: Repository scope if err := promptForRepositoryScope(config); err != nil { return err } - // Step 5: Workflow selection (after discovery) + // Step 4: Workflow selection if err := promptForWorkflows(config); err != nil { return err } - // Step 6: Owners/stakeholders + // Step 5: Owners/stakeholders if err := promptForOwners(config); err != nil { return err } - // Step 7: Risk level + // Step 6: Risk level if err := promptForRiskLevel(config); err != nil { return err } - // Step 8: Project board creation + // Step 7: Project board creation if err := promptForProjectCreation(config); err != nil { return err } @@ -171,107 +163,6 @@ func promptForObjective(config *InteractiveCampaignConfig) error { return nil } -func promptForWorkflowDiscovery(config *InteractiveCampaignConfig, rootDir string) error { - var expandDiscovery bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Expand workflow discovery?"). - Description("Scan additional repositories or organizations for worker workflows (optional)"). - Value(&expandDiscovery), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("workflow discovery prompt failed: %w", err) - } - - if !expandDiscovery { - interactiveLog.Print("User opted to skip expanded workflow discovery") - return nil - } - - // Ask for discovery scope - var discoveryType string - discoveryForm := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Where should we discover workflows?"). - Options( - huh.NewOption("Specific repositories", "repos"), - huh.NewOption("Organization-wide", "orgs"), - huh.NewOption("Both", "both"), - ). - Value(&discoveryType), - ), - ) - - if err := discoveryForm.Run(); err != nil { - return fmt.Errorf("discovery type selection failed: %w", err) - } - - switch discoveryType { - case "repos", "both": - var reposInput string - reposForm := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Discovery repositories"). - Description("Enter repositories to scan (comma-separated, e.g., 'owner/repo1, owner/repo2')"). - Placeholder("owner/repo1, owner/repo2"). - Value(&reposInput), - ), - ) - - if err := reposForm.Run(); err != nil { - return fmt.Errorf("discovery repos input failed: %w", err) - } - - if strings.TrimSpace(reposInput) != "" { - repos := strings.Split(reposInput, ",") - for _, repo := range repos { - repo = strings.TrimSpace(repo) - if repo != "" { - config.DiscoveryRepos = append(config.DiscoveryRepos, repo) - } - } - } - - if discoveryType == "repos" { - break - } - fallthrough - case "orgs": - var orgsInput string - orgsForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Discovery organizations"). - Description("Enter organizations to scan (comma-separated, e.g., 'org1, org2')"). - Placeholder("myorg"). - Value(&orgsInput), - ), - ) - - if err := orgsForm.Run(); err != nil { - return fmt.Errorf("discovery orgs input failed: %w", err) - } - - if strings.TrimSpace(orgsInput) != "" { - orgs := strings.Split(orgsInput, ",") - for _, org := range orgs { - org = strings.TrimSpace(org) - if org != "" { - config.DiscoveryOrgs = append(config.DiscoveryOrgs, org) - } - } - } - } - - interactiveLog.Printf("Discovery repos: %v, orgs: %v", config.DiscoveryRepos, config.DiscoveryOrgs) - return nil -} - func promptForRepositoryScope(config *InteractiveCampaignConfig) error { var scopeType string form := huh.NewForm( @@ -294,6 +185,13 @@ func promptForRepositoryScope(config *InteractiveCampaignConfig) error { config.Scope = scopeType switch scopeType { + case "current": + currentRepo, err := getCurrentRepository() + if err != nil { + interactiveLog.Printf("Warning: could not determine current repository for scope: %v", err) + break + } + config.ScopeSelectors = []string{currentRepo} case "multiple": var reposInput string reposForm := huh.NewForm( @@ -320,7 +218,7 @@ func promptForRepositoryScope(config *InteractiveCampaignConfig) error { for _, repo := range repos { repo = strings.TrimSpace(repo) if repo != "" { - config.AllowedRepos = append(config.AllowedRepos, repo) + config.ScopeSelectors = append(config.ScopeSelectors, repo) } } case "org-wide": @@ -347,11 +245,11 @@ func promptForRepositoryScope(config *InteractiveCampaignConfig) error { org := strings.TrimSpace(orgsInput) if org != "" { - config.AllowedOrgs = append(config.AllowedOrgs, org) + config.ScopeSelectors = append(config.ScopeSelectors, "org:"+org) } } - interactiveLog.Printf("Scope: %s, allowed repos: %v, orgs: %v", config.Scope, config.AllowedRepos, config.AllowedOrgs) + interactiveLog.Printf("Scope: %s, selectors: %v", config.Scope, config.ScopeSelectors) return nil } @@ -507,22 +405,19 @@ func generateCampaignFromConfig(rootDir string, config *InteractiveCampaignConfi // Build the spec spec := CampaignSpec{ - ID: config.ID, - Name: config.Name, - Description: config.Description, - ProjectURL: "https://github.com/orgs/ORG/projects/1", // Placeholder - Version: "v1", - State: "planned", - Workflows: config.Workflows, - AllowedRepos: config.AllowedRepos, - AllowedOrgs: config.AllowedOrgs, - DiscoveryRepos: config.DiscoveryRepos, - DiscoveryOrgs: config.DiscoveryOrgs, - Owners: config.Owners, - RiskLevel: config.RiskLevel, - MemoryPaths: []string{"memory/campaigns/" + config.ID + "/**"}, - MetricsGlob: "memory/campaigns/" + config.ID + "/metrics/*.json", - CursorGlob: "memory/campaigns/" + config.ID + "/cursor.json", + ID: config.ID, + Name: config.Name, + Description: config.Description, + ProjectURL: "https://github.com/orgs/ORG/projects/1", // Placeholder + Version: "v1", + State: "planned", + Workflows: config.Workflows, + Scope: config.ScopeSelectors, + Owners: config.Owners, + RiskLevel: config.RiskLevel, + MemoryPaths: []string{"memory/campaigns/" + config.ID + "/**"}, + MetricsGlob: "memory/campaigns/" + config.ID + "/metrics/*.json", + CursorGlob: "memory/campaigns/" + config.ID + "/cursor.json", Governance: &CampaignGovernancePolicy{ MaxNewItemsPerRun: 25, MaxDiscoveryItemsPerRun: 200, diff --git a/pkg/campaign/loader.go b/pkg/campaign/loader.go index ce1185faeb..11851e34be 100644 --- a/pkg/campaign/loader.go +++ b/pkg/campaign/loader.go @@ -112,23 +112,17 @@ func LoadSpecs(rootDir string) ([]CampaignSpec, error) { log.Printf("Defaulted cursor-glob to '%s' for campaign '%s'", spec.CursorGlob, spec.ID) } - // Default allowed-repos to current repository if not specified - if len(spec.AllowedRepos) == 0 { + // Default scope to current repository if not specified + if len(spec.Scope) == 0 { currentRepo, err := getCurrentRepository() if err != nil { - log.Printf("Warning: Could not determine current repository for campaign '%s': %v. Campaign will require explicit allowed-repos.", spec.ID, err) + log.Printf("Warning: Could not determine current repository for campaign '%s': %v. Campaign will require explicit scope.", spec.ID, err) } else { - spec.AllowedRepos = []string{currentRepo} - log.Printf("Defaulted allowed-repos to current repository for campaign '%s': %s", spec.ID, currentRepo) + spec.Scope = []string{currentRepo} + log.Printf("Defaulted scope to current repository for campaign '%s': %s", spec.ID, currentRepo) } } - // Default discovery-repos to allowed-repos if not specified - if len(spec.DiscoveryRepos) == 0 && len(spec.DiscoveryOrgs) == 0 { - spec.DiscoveryRepos = spec.AllowedRepos - log.Printf("Defaulted discovery-repos to allowed-repos for campaign '%s'", spec.ID) - } - spec.ConfigPath = filepath.ToSlash(filepath.Join(".github", "workflows", name)) specs = append(specs, spec) } @@ -233,7 +227,7 @@ func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { buf.WriteString("Describe this campaign's goals, guardrails, stakeholders, and playbook.\n\n") buf.WriteString("## Quick Start\n\n") buf.WriteString("By default, this campaign will target the current repository. To target additional repositories:\n\n") - buf.WriteString("1. **Add allowed-repos** (optional): Specify repositories to target\n") + buf.WriteString("1. **Add scope** (optional): Specify repositories and/or organizations to target\n") buf.WriteString("2. **Define workflows**: List workflows to execute (e.g., `vulnerability-scanner`)\n") buf.WriteString("3. **Add narrative context**: Define campaign goals, workflows, and timeline in the markdown body\n") buf.WriteString("4. **Set owners**: Specify who is responsible for this campaign\n") @@ -242,37 +236,19 @@ func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { buf.WriteString("```yaml\n") buf.WriteString("# Add to the frontmatter above:\n") buf.WriteString("\n") - buf.WriteString("# Optional: Target specific repositories (defaults to current repo)\n") - buf.WriteString("# allowed-repos:\n") + buf.WriteString("# Optional: Target specific repositories and/or organizations (defaults to current repo)\n") + buf.WriteString("# scope:\n") buf.WriteString("# - myorg/backend\n") buf.WriteString("# - myorg/frontend\n") - buf.WriteString("# Or use allowed-orgs for organization-wide scope:\n") - buf.WriteString("# allowed-orgs:\n") - buf.WriteString("# - myorg\n") + buf.WriteString("# - org:myorg\n") buf.WriteString("\n") - buf.WriteString("# Where to discover worker workflows (required for campaigns with workflows)\n") - buf.WriteString("discovery-repos:\n") - buf.WriteString(" - myorg/backend\n") - buf.WriteString(" - myorg/frontend\n") - buf.WriteString("# Or use discovery-orgs for organization-wide discovery:\n") - buf.WriteString("# discovery-orgs:\n") - buf.WriteString("# - myorg\n") + buf.WriteString("# Campaigns with workflows MUST be scoped via scope\n") buf.WriteString("\n") - buf.WriteString("objective: \"Reduce security vulnerabilities across all repositories\"\n") buf.WriteString("workflows:\n") buf.WriteString(" - vulnerability-scanner\n") buf.WriteString(" - dependency-updater\n") buf.WriteString("owners:\n") buf.WriteString(" - @security-team\n") - buf.WriteString("kpis:\n") - buf.WriteString(" - name: \"Critical vulnerabilities resolved\"\n") - buf.WriteString(" priority: primary\n") - buf.WriteString(" unit: count\n") - buf.WriteString(" baseline: 0\n") - buf.WriteString(" target: 50\n") - buf.WriteString(" time-window-days: 30\n") - buf.WriteString(" direction: increase\n") - buf.WriteString(" source: code_security\n") buf.WriteString("```\n") // Use restrictive permissions (0644) for proper git tracking diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index a30d5ccddf..4c58dd90ac 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -93,16 +93,21 @@ func buildDiscoverySteps(spec *CampaignSpec) []map[string]any { "GH_AW_CURSOR_PATH": getCursorPath(spec), } - // Add GH_AW_DISCOVERY_REPOS from spec.DiscoveryRepos - if len(spec.DiscoveryRepos) > 0 { - envVars["GH_AW_DISCOVERY_REPOS"] = strings.Join(spec.DiscoveryRepos, ",") - orchestratorLog.Printf("Setting GH_AW_DISCOVERY_REPOS from discovery-repos: %v", spec.DiscoveryRepos) + // Campaign scope uses scope selectors as the single source of truth. + // We export discovery scope to the action via GH_AW_DISCOVERY_*. + parsedScope, scopeProblems := parseScopeSelectors(spec.Scope) + if len(scopeProblems) > 0 { + orchestratorLog.Printf("Warning: invalid scope selectors for campaign '%s': %v", spec.ID, scopeProblems) } - // Add GH_AW_DISCOVERY_ORGS from spec.DiscoveryOrgs if provided - if len(spec.DiscoveryOrgs) > 0 { - envVars["GH_AW_DISCOVERY_ORGS"] = strings.Join(spec.DiscoveryOrgs, ",") - orchestratorLog.Printf("Setting GH_AW_DISCOVERY_ORGS from discovery-orgs: %v", spec.DiscoveryOrgs) + if len(parsedScope.Repos) > 0 { + envVars["GH_AW_DISCOVERY_REPOS"] = strings.Join(parsedScope.Repos, ",") + orchestratorLog.Printf("Setting GH_AW_DISCOVERY_REPOS from scope: %v", parsedScope.Repos) + } + + if len(parsedScope.Orgs) > 0 { + envVars["GH_AW_DISCOVERY_ORGS"] = strings.Join(parsedScope.Orgs, ",") + orchestratorLog.Printf("Setting GH_AW_DISCOVERY_ORGS from scope: %v", parsedScope.Orgs) } steps := []map[string]any{ @@ -370,46 +375,13 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W appendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions) } - // Enable safe outputs needed for campaign coordination. + // Campaign orchestrators are dispatch-only: they may only dispatch allowlisted + // workflows via the dispatch-workflow safe output. All side effects (Projects, + // issues/PRs, comments) must be performed by dispatched worker workflows. + // // Note: Campaign orchestrators intentionally omit explicit `permissions:` from // the generated markdown; safe-output jobs have their own scoped permissions. - maxComments := 10 - maxProjectUpdates := 10 - if spec.Governance != nil { - if spec.Governance.MaxCommentsPerRun > 0 { - maxComments = spec.Governance.MaxCommentsPerRun - } - if spec.Governance.MaxProjectUpdatesPerRun > 0 { - maxProjectUpdates = spec.Governance.MaxProjectUpdatesPerRun - } - } - safeOutputs := &workflow.SafeOutputsConfig{} - // Allow creating the Epic issue for the campaign (max: 1, only created once). - safeOutputs.CreateIssues = &workflow.CreateIssuesConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1}} - // Allow commenting on related issues/PRs as part of campaign coordination. - safeOutputs.AddComments = &workflow.AddCommentsConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxComments}} - // Allow updating the campaign's GitHub Project dashboard. - updateProjectConfig := &workflow.UpdateProjectConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxProjectUpdates}} - // If the campaign spec specifies a custom GitHub token for Projects v2 operations, - // pass it to the update-project configuration. - if strings.TrimSpace(spec.ProjectGitHubToken) != "" { - updateProjectConfig.GitHubToken = strings.TrimSpace(spec.ProjectGitHubToken) - orchestratorLog.Printf("Campaign orchestrator '%s' configured with custom GitHub token for update-project", spec.ID) - } - safeOutputs.UpdateProjects = updateProjectConfig - - // Allow creating project status updates for campaign summaries. - statusUpdateConfig := &workflow.CreateProjectStatusUpdateConfig{BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1}} - // Use the same custom GitHub token for status updates as for project operations. - if strings.TrimSpace(spec.ProjectGitHubToken) != "" { - statusUpdateConfig.GitHubToken = strings.TrimSpace(spec.ProjectGitHubToken) - orchestratorLog.Printf("Campaign orchestrator '%s' configured with custom GitHub token for create-project-status-update", spec.ID) - } - safeOutputs.CreateProjectStatusUpdates = statusUpdateConfig - - // Add dispatch_workflow if workflows are configured - // This allows the orchestrator to dispatch worker workflows for the campaign if len(spec.Workflows) > 0 { dispatchWorkflowConfig := &workflow.DispatchWorkflowConfig{ BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3}, @@ -419,7 +391,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W orchestratorLog.Printf("Campaign orchestrator '%s' configured with dispatch_workflow for %d workflows", spec.ID, len(spec.Workflows)) } - orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with safe outputs enabled", spec.ID) + orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow safe output", spec.ID) // Extract file-glob patterns from memory-paths or metrics-glob to support // multiple directory structures (e.g., both dated "campaign-id-*/**" and non-dated "campaign-id/**") @@ -438,6 +410,21 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W orchestratorLog.Printf("Campaign orchestrator '%s' using default engine: %s", spec.ID, engineID) } + tools := map[string]any{ + "repo-memory": []any{ + map[string]any{ + "id": "campaigns", + "branch-name": "memory/campaigns", + "file-glob": convertStringsToAny(fileGlobPatterns), + "campaign-id": spec.ID, + }, + }, + "bash": []any{"*"}, + "edit": nil, + } + // Deliberately omit GitHub tool access from orchestrators. All writes and GitHub + // API operations should be performed by dispatched worker workflows. + data := &workflow.WorkflowData{ Name: name, Description: description, @@ -454,21 +441,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W EngineConfig: &workflow.EngineConfig{ ID: engineID, }, - Tools: map[string]any{ - "github": map[string]any{ - "toolsets": []any{"default", "actions", "code_security"}, - }, - "repo-memory": []any{ - map[string]any{ - "id": "campaigns", - "branch-name": "memory/campaigns", - "file-glob": convertStringsToAny(fileGlobPatterns), - "campaign-id": spec.ID, - }, - }, - "bash": []any{"*"}, - "edit": nil, - }, + Tools: tools, SafeOutputs: safeOutputs, } diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index c97d94a95f..16c77561d1 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -132,6 +132,45 @@ func TestBuildOrchestrator_WorkflowsInDiscovery(t *testing.T) { }) } +func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) { + withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { + spec := &CampaignSpec{ + ID: "dispatch-only-campaign", + Name: "Dispatch Only Campaign", + Description: "Campaign orchestrator restricted to dispatch-workflow", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"worker-a", "worker-b"}, + MemoryPaths: []string{"memory/campaigns/dispatch-only-campaign/**"}, + } + + mdPath := ".github/workflows/dispatch-only-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + + if data.SafeOutputs == nil { + t.Fatalf("expected SafeOutputs to be set") + } + if data.SafeOutputs.DispatchWorkflow == nil { + t.Fatalf("expected dispatch-workflow safe output to be enabled") + } + if len(data.SafeOutputs.DispatchWorkflow.Workflows) != 2 { + t.Fatalf("expected 2 allowlisted workflows, got %d", len(data.SafeOutputs.DispatchWorkflow.Workflows)) + } + if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil { + t.Fatalf("expected dispatch-only orchestrator to omit non-dispatch safe outputs") + } + + // Dispatch-only policy should not grant GitHub tool access to the agent. + if data.Tools != nil { + if _, ok := data.Tools["github"]; ok { + t.Fatalf("expected dispatch-only orchestrator to omit github tools") + } + } + }) +} + func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) { withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { spec := &CampaignSpec{ @@ -174,15 +213,15 @@ func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) { t.Errorf("expected markdown to contain core principles section, got: %q", data.MarkdownContent) } - // Verify separation of steps (read / decide / write / report) + // Verify separation of steps (read / decide / dispatch / report) if !strings.Contains(data.MarkdownContent, "Step 1") || !strings.Contains(data.MarkdownContent, "Read State") { t.Errorf("expected markdown to contain Step 1 Read State, got: %q", data.MarkdownContent) } if !strings.Contains(data.MarkdownContent, "Step 2") || !strings.Contains(data.MarkdownContent, "Make Decisions") { t.Errorf("expected markdown to contain Step 2 Make Decisions, got: %q", data.MarkdownContent) } - if !strings.Contains(data.MarkdownContent, "Step 3") || !strings.Contains(data.MarkdownContent, "Write State") { - t.Errorf("expected markdown to contain Step 3 Write State, got: %q", data.MarkdownContent) + if !strings.Contains(data.MarkdownContent, "Step 3") || !strings.Contains(data.MarkdownContent, "Dispatch Workers") { + t.Errorf("expected markdown to contain Step 3 Dispatch Workers, got: %q", data.MarkdownContent) } if !strings.Contains(data.MarkdownContent, "Step 4") || !strings.Contains(data.MarkdownContent, "Report") { t.Errorf("expected markdown to contain Step 4 Report, got: %q", data.MarkdownContent) @@ -190,80 +229,7 @@ func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) { }) } -func TestBuildOrchestrator_GitHubToken(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - t.Run("with custom github token", func(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign-with-token", - Name: "Test Campaign", - Description: "A test campaign with custom GitHub token", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"test-workflow"}, - ProjectGitHubToken: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Verify that SafeOutputs is configured - if data.SafeOutputs == nil { - t.Fatalf("expected SafeOutputs to be configured") - } - - // Verify that UpdateProjects is configured - if data.SafeOutputs.UpdateProjects == nil { - t.Fatalf("expected UpdateProjects to be configured") - } - - // Verify that the GitHubToken is set - if data.SafeOutputs.UpdateProjects.GitHubToken != "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" { - t.Errorf("expected GitHubToken to be %q, got %q", - "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", - data.SafeOutputs.UpdateProjects.GitHubToken) - } - }) - - t.Run("without custom github token", func(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign-no-token", - Name: "Test Campaign", - Description: "A test campaign without custom GitHub token", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"test-workflow"}, - // ProjectGitHubToken is intentionally omitted - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Verify that SafeOutputs is configured - if data.SafeOutputs == nil { - t.Fatalf("expected SafeOutputs to be configured") - } - - // Verify that UpdateProjects is configured - if data.SafeOutputs.UpdateProjects == nil { - t.Fatalf("expected UpdateProjects to be configured") - } - - // Verify that the GitHubToken is empty when not specified - if data.SafeOutputs.UpdateProjects.GitHubToken != "" { - t.Errorf("expected GitHubToken to be empty when not specified, got %q", - data.SafeOutputs.UpdateProjects.GitHubToken) - } - }) - }) -} - -func TestBuildOrchestrator_GovernanceOverridesSafeOutputMaxima(t *testing.T) { +func TestBuildOrchestrator_GovernanceDoesNotGrantWriteSafeOutputs(t *testing.T) { withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { spec := &CampaignSpec{ ID: "test-campaign", @@ -281,14 +247,14 @@ func TestBuildOrchestrator_GovernanceOverridesSafeOutputMaxima(t *testing.T) { if data == nil { t.Fatalf("expected non-nil WorkflowData") } - if data.SafeOutputs == nil || data.SafeOutputs.AddComments == nil || data.SafeOutputs.UpdateProjects == nil { - t.Fatalf("expected SafeOutputs add-comment and update-project to be configured") + if data.SafeOutputs == nil || data.SafeOutputs.DispatchWorkflow == nil { + t.Fatalf("expected dispatch-workflow safe output to be enabled") } - if data.SafeOutputs.AddComments.Max != 3 { - t.Fatalf("unexpected add-comment max: got %d, want %d", data.SafeOutputs.AddComments.Max, 3) + if data.SafeOutputs.DispatchWorkflow.Max != 3 { + t.Fatalf("unexpected dispatch-workflow max: got %d, want %d", data.SafeOutputs.DispatchWorkflow.Max, 3) } - if data.SafeOutputs.UpdateProjects.Max != 4 { - t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4) + if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil { + t.Fatalf("expected orchestrator to omit non-dispatch safe outputs regardless of governance") } }) } diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json index 56a150129e..45534f0be3 100644 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -27,11 +27,6 @@ "pattern": "/projects/", "minLength": 1 }, - "project-github-token": { - "type": "string", - "description": "Optional GitHub token expression used for GitHub Projects v2 operations (e.g., ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }})", - "minLength": 1 - }, "version": { "type": "string", "description": "Spec version (e.g., v1)", @@ -47,44 +42,29 @@ }, "minItems": 1 }, - "discovery-repos": { - "type": "array", - "description": "List of repositories (in owner/repo format) where worker workflows are discovered. This controls the scope of GitHub searches for issues/PRs created by worker workflows.", - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", - "minLength": 1 - }, - "minItems": 1 - }, - "discovery-orgs": { - "type": "array", - "description": "List of GitHub organizations where worker workflows are discovered. When specified, any repository within these organizations is searched for worker-created issues/PRs.", - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_.-]+$", - "minLength": 1 - } - }, - "allowed-repos": { + "scope": { "type": "array", - "description": "Optional list of repositories (in owner/repo format) that this campaign is allowed to discover and operate on. When omitted, defaults to the current repository where the campaign is defined.", + "description": "Optional scope selectors defining which repositories and organizations this campaign is allowed to discover and operate on. When omitted, defaults to the current repository where the campaign is defined.", "items": { "type": "string", - "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", - "minLength": 1 + "minLength": 1, + "anyOf": [ + { + "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", + "description": "Repository selector: owner/repo" + }, + { + "pattern": "^org:[a-zA-Z0-9_.-]+$", + "description": "Organization selector: org:" + }, + { + "pattern": "^[a-zA-Z0-9_.-]+/\\*$", + "description": "Organization selector (sugar): /*" + } + ] }, "minItems": 1 }, - "allowed-orgs": { - "type": "array", - "description": "Optional list of GitHub organizations that this campaign is allowed to discover and operate on", - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_.-]+$", - "minLength": 1 - } - }, "memory-paths": { "type": "array", "description": "Paths where this campaign writes its repo-memory", diff --git a/pkg/campaign/scope.go b/pkg/campaign/scope.go new file mode 100644 index 0000000000..a8d0dd96e2 --- /dev/null +++ b/pkg/campaign/scope.go @@ -0,0 +1,100 @@ +package campaign + +import ( + "fmt" + "regexp" + "strings" +) + +type ParsedScope struct { + Repos []string + Orgs []string +} + +var ( + repoSelectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$`) + orgNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) +) + +func parseScopeSelectors(selectors []string) (ParsedScope, []string) { + var parsed ParsedScope + var problems []string + + for _, raw := range selectors { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + problems = append(problems, "scope must not contain empty entries - remove empty strings from the list") + continue + } + + // org: + if strings.HasPrefix(trimmed, "org:") { + org := strings.TrimSpace(strings.TrimPrefix(trimmed, "org:")) + if org == "" { + problems = append(problems, "scope entry 'org:' must include an organization name - example: 'org:github'") + continue + } + if strings.Contains(org, "/") { + problems = append(problems, fmt.Sprintf("scope entry '%s' must be 'org:' (no slashes) - example: 'org:github'", trimmed)) + continue + } + if strings.Contains(org, "*") { + problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - example: 'org:github'", trimmed)) + continue + } + if !orgNamePattern.MatchString(org) { + problems = append(problems, fmt.Sprintf("scope entry '%s' has an invalid organization name - example: 'org:github'", trimmed)) + continue + } + parsed.Orgs = appendUniqueString(parsed.Orgs, org) + continue + } + + // Optional sugar: /* + if strings.HasSuffix(trimmed, "/*") && strings.Count(trimmed, "/") == 1 && !strings.Contains(trimmed, ":") { + org := strings.TrimSuffix(trimmed, "/*") + if org == "" { + problems = append(problems, fmt.Sprintf("scope entry '%s' must include an organization name - example: 'org:github'", trimmed)) + continue + } + if strings.Contains(org, "*") { + problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - example: 'org:github'", trimmed)) + continue + } + if !orgNamePattern.MatchString(org) { + problems = append(problems, fmt.Sprintf("scope entry '%s' has an invalid organization name - example: 'org:github'", trimmed)) + continue + } + parsed.Orgs = appendUniqueString(parsed.Orgs, org) + continue + } + + if strings.Contains(trimmed, "*") { + problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - list repositories explicitly or use 'org:' for organization-wide scope", trimmed)) + continue + } + + if strings.Contains(trimmed, ":") { + problems = append(problems, fmt.Sprintf("scope entry '%s' is not recognized - valid formats: 'owner/repo' or 'org:'", trimmed)) + continue + } + + if repoSelectorPattern.MatchString(trimmed) { + parsed.Repos = appendUniqueString(parsed.Repos, trimmed) + continue + } + + problems = append(problems, fmt.Sprintf("scope entry '%s' must be 'owner/repo' or 'org:' - example: 'github/docs' or 'org:github'", trimmed)) + } + + return parsed, problems +} + +func appendUniqueString(values []string, value string) []string { + for _, v := range values { + if v == value { + return values + } + } + return append(values, value) +} diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go index 2321b01a26..d53481138c 100644 --- a/pkg/campaign/spec.go +++ b/pkg/campaign/spec.go @@ -31,25 +31,15 @@ type CampaignSpec struct { // step will search for items with this label. TrackerLabel string `yaml:"tracker-label,omitempty" json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty,maxlen:40"` - // DiscoveryRepos defines the explicit list of repositories (in owner/repo format) - // where worker workflows are discovered. This controls the scope of GitHub searches - // for issues/PRs created by worker workflows. - DiscoveryRepos []string `yaml:"discovery-repos,omitempty" json:"discovery_repos,omitempty" console:"header:Discovery Repos,omitempty,maxlen:60"` - - // DiscoveryOrgs optionally defines the list of GitHub organizations where worker - // workflows are discovered. When specified, any repository within these organizations - // is searched for worker-created issues/PRs. - DiscoveryOrgs []string `yaml:"discovery-orgs,omitempty" json:"discovery_orgs,omitempty" console:"header:Discovery Orgs,omitempty,maxlen:40"` - - // AllowedRepos defines the explicit list of repositories (in owner/repo format) - // that this campaign is allowed to discover and operate on. When omitted, defaults - // to the current repository where the campaign is defined. - AllowedRepos []string `yaml:"allowed-repos,omitempty" json:"allowed_repos,omitempty" console:"header:Allowed Repos,omitempty,maxlen:60"` - - // AllowedOrgs optionally defines the list of GitHub organizations that this - // campaign is allowed to discover and operate on. When specified, any repository - // within these organizations is considered in-scope. - AllowedOrgs []string `yaml:"allowed-orgs,omitempty" json:"allowed_orgs,omitempty" console:"header:Allowed Orgs,omitempty,maxlen:40"` + // Scope defines the explicit set of repositories and organizations that this + // campaign is allowed to discover and operate on. + // + // Supported selectors: + // - "owner/repo" (specific repository) + // - "org:" (all repositories in an organization) + // + // When omitted, it defaults to the current repository where the campaign is defined. + Scope []string `yaml:"scope,omitempty" json:"scope,omitempty" console:"header:Scope,omitempty,maxlen:60"` // MemoryPaths documents where this campaign writes its repo-memory // (for example: memory/campaigns/incident-response/**). @@ -93,12 +83,6 @@ type CampaignSpec struct { // enforced by validation in the future. AllowedSafeOutputs []string `yaml:"allowed-safe-outputs,omitempty" json:"allowed_safe_outputs,omitempty" console:"header:Allowed Safe Outputs,omitempty,maxlen:30"` - // ProjectGitHubToken is an optional GitHub token expression (e.g., - // ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}) used for GitHub Projects v2 - // operations. When specified, this token is passed to the update-project - // safe output configuration in the generated orchestrator workflow. - ProjectGitHubToken string `yaml:"project-github-token,omitempty" json:"project_github_token,omitempty" console:"header:Project Token,omitempty,maxlen:30"` - // Governance configures lightweight pacing and opt-out policies for campaign // orchestrator workflows. These guardrails are primarily enforced through // generated prompts and safe-output maxima. diff --git a/pkg/campaign/template_test.go b/pkg/campaign/template_test.go index 187d778e7d..3ad54a070d 100644 --- a/pkg/campaign/template_test.go +++ b/pkg/campaign/template_test.go @@ -28,7 +28,7 @@ func TestRenderOrchestratorInstructions(t *testing.T) { "Step 2", "Make Decisions", "Step 3", - "Write State", + "Dispatch Workers", "Step 4", "Report", }, @@ -37,7 +37,6 @@ func TestRenderOrchestratorInstructions(t *testing.T) { name: "explicit state management", data: CampaignPromptData{}, shouldContain: []string{ - "Read current GitHub Project board state", "Parse discovered items from the manifest", "Discovery cursor is maintained automatically", "Determine desired `status`", @@ -158,7 +157,7 @@ func TestRenderClosingInstructions(t *testing.T) { "Execute all four steps in strict order", "Read State (no writes)", "Make Decisions (no writes)", - "Write State (update-project only)", + "Dispatch Workers (dispatch-workflow only)", "Report", "Workers are immutable and campaign-agnostic", "GitHub Project board is the single source of truth", diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go index d5365ee858..93f77ab546 100644 --- a/pkg/campaign/validation.go +++ b/pkg/campaign/validation.go @@ -80,82 +80,12 @@ func ValidateSpec(spec *CampaignSpec) []string { } } - // Validate discovery-repos format if provided - if len(spec.DiscoveryRepos) > 0 { - // Validate each repository format - for _, repo := range spec.DiscoveryRepos { - trimmed := strings.TrimSpace(repo) - if trimmed == "" { - problems = append(problems, "discovery-repos must not contain empty entries - remove empty strings from the list") - continue - } - // Validate owner/repo format - parts := strings.Split(trimmed, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - problems = append(problems, fmt.Sprintf("discovery-repos entry '%s' must be in 'owner/repo' format - example: 'github/docs' or 'myorg/myrepo'", trimmed)) - } - // Warn about common mistakes - if strings.Contains(trimmed, "*") { - problems = append(problems, fmt.Sprintf("discovery-repos entry '%s' cannot contain wildcards - list each repository explicitly or use discovery-orgs for organization-wide scope", trimmed)) - } - } - } - - // Validate discovery-orgs if provided - if len(spec.DiscoveryOrgs) > 0 { - for _, org := range spec.DiscoveryOrgs { - trimmed := strings.TrimSpace(org) - if trimmed == "" { - problems = append(problems, "discovery-orgs must not contain empty entries - remove empty strings from the list") - continue - } - // Validate organization name format (no slashes, valid GitHub org name) - if strings.Contains(trimmed, "/") { - problems = append(problems, fmt.Sprintf("discovery-orgs entry '%s' must be an organization name only (not owner/repo format) - example: 'github' not 'github/docs'", trimmed)) - } - if strings.Contains(trimmed, "*") { - problems = append(problems, fmt.Sprintf("discovery-orgs entry '%s' cannot contain wildcards - use the organization name directly (e.g., 'myorg')", trimmed)) - } - } - } + parsedScope, scopeProblems := parseScopeSelectors(spec.Scope) + problems = append(problems, scopeProblems...) - // Validate allowed-repos format if provided (now optional - defaults to current repo) - if len(spec.AllowedRepos) > 0 { - // Validate each repository format - for _, repo := range spec.AllowedRepos { - trimmed := strings.TrimSpace(repo) - if trimmed == "" { - problems = append(problems, "allowed-repos must not contain empty entries - remove empty strings from the list") - continue - } - // Validate owner/repo format - parts := strings.Split(trimmed, "/") - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - problems = append(problems, fmt.Sprintf("allowed-repos entry '%s' must be in 'owner/repo' format - example: 'github/docs' or 'myorg/myrepo'", trimmed)) - } - // Warn about common mistakes - if strings.Contains(trimmed, "*") { - problems = append(problems, fmt.Sprintf("allowed-repos entry '%s' cannot contain wildcards - list each repository explicitly or use allowed-orgs for organization-wide scope", trimmed)) - } - } - } - - // Validate allowed-orgs if provided (optional) - if len(spec.AllowedOrgs) > 0 { - for _, org := range spec.AllowedOrgs { - trimmed := strings.TrimSpace(org) - if trimmed == "" { - problems = append(problems, "allowed-orgs must not contain empty entries - remove empty strings from the list") - continue - } - // Validate organization name format (no slashes, valid GitHub org name) - if strings.Contains(trimmed, "/") { - problems = append(problems, fmt.Sprintf("allowed-orgs entry '%s' must be an organization name only (not owner/repo format) - example: 'github' not 'github/docs'", trimmed)) - } - if strings.Contains(trimmed, "*") { - problems = append(problems, fmt.Sprintf("allowed-orgs entry '%s' cannot contain wildcards - use the organization name directly (e.g., 'myorg')", trimmed)) - } - } + // Campaigns that do discovery (workflows or tracker-label) must be scoped. + if (len(spec.Workflows) > 0 || strings.TrimSpace(spec.TrackerLabel) != "") && len(parsedScope.Repos) == 0 && len(parsedScope.Orgs) == 0 { + problems = append(problems, "campaigns with workflows must be scoped via scope") } if strings.TrimSpace(spec.ProjectURL) == "" { @@ -309,13 +239,9 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { Name string `json:"name"` Description string `json:"description,omitempty"` ProjectURL string `json:"project-url,omitempty"` - ProjectGitHubToken string `json:"project-github-token,omitempty"` Version string `json:"version,omitempty"` Workflows []string `json:"workflows,omitempty"` - DiscoveryRepos []string `json:"discovery-repos,omitempty"` - DiscoveryOrgs []string `json:"discovery-orgs,omitempty"` - AllowedRepos []string `json:"allowed-repos,omitempty"` - AllowedOrgs []string `json:"allowed-orgs,omitempty"` + Scope []string `json:"scope,omitempty"` MemoryPaths []string `json:"memory-paths,omitempty"` MetricsGlob string `json:"metrics-glob,omitempty"` CursorGlob string `json:"cursor-glob,omitempty"` @@ -334,13 +260,9 @@ func ValidateSpecWithSchema(spec *CampaignSpec) []string { Name: spec.Name, Description: spec.Description, ProjectURL: spec.ProjectURL, - ProjectGitHubToken: spec.ProjectGitHubToken, Version: spec.Version, Workflows: spec.Workflows, - DiscoveryRepos: spec.DiscoveryRepos, - DiscoveryOrgs: spec.DiscoveryOrgs, - AllowedRepos: spec.AllowedRepos, - AllowedOrgs: spec.AllowedOrgs, + Scope: spec.Scope, MemoryPaths: spec.MemoryPaths, MetricsGlob: spec.MetricsGlob, CursorGlob: spec.CursorGlob, diff --git a/pkg/campaign/validation_test.go b/pkg/campaign/validation_test.go index a26a6ee1f5..2a5538a6f8 100644 --- a/pkg/campaign/validation_test.go +++ b/pkg/campaign/validation_test.go @@ -7,14 +7,13 @@ import ( func TestValidateSpec_ValidSpec(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - Version: "v1", - State: "active", - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1", "workflow2"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Version: "v1", + State: "active", + Workflows: []string{"workflow1", "workflow2"}, } problems := ValidateSpec(spec) @@ -25,11 +24,10 @@ func TestValidateSpec_ValidSpec(t *testing.T) { func TestValidateSpec_MissingID(t *testing.T) { spec := &CampaignSpec{ - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) @@ -51,12 +49,11 @@ func TestValidateSpec_MissingID(t *testing.T) { func TestValidateSpec_InvalidIDCharacters(t *testing.T) { spec := &CampaignSpec{ - ID: "Test_Campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "Test_Campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) @@ -78,11 +75,10 @@ func TestValidateSpec_InvalidIDCharacters(t *testing.T) { func TestValidateSpec_MissingName(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "test-campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) @@ -104,10 +100,10 @@ func TestValidateSpec_MissingName(t *testing.T) { func TestValidateSpec_MissingWorkflows(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, } problems := ValidateSpec(spec) @@ -129,13 +125,12 @@ func TestValidateSpec_MissingWorkflows(t *testing.T) { func TestValidateSpec_InvalidState(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - State: "invalid-state", + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, + State: "invalid-state", } problems := ValidateSpec(spec) @@ -160,13 +155,12 @@ func TestValidateSpec_ValidStates(t *testing.T) { for _, state := range validStates { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - State: state, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, + State: state, } problems := ValidateSpec(spec) @@ -178,12 +172,11 @@ func TestValidateSpec_ValidStates(t *testing.T) { func TestValidateSpec_VersionDefault(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, } _ = ValidateSpec(spec) @@ -198,13 +191,12 @@ func TestValidateSpec_RiskLevel(t *testing.T) { for _, riskLevel := range validRiskLevels { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - RiskLevel: riskLevel, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, + RiskLevel: riskLevel, } problems := ValidateSpec(spec) @@ -218,12 +210,11 @@ func TestValidateSpec_RiskLevel(t *testing.T) { func TestValidateSpec_WithApprovalPolicy(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, ApprovalPolicy: &CampaignApprovalPolicy{ RequiredApprovals: 2, RequiredRoles: []string{"admin", "security"}, @@ -243,9 +234,8 @@ func TestValidateSpec_CompleteSpec(t *testing.T) { Name: "Complete Campaign", Description: "A complete campaign spec for testing", ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, + Scope: []string{"org/repo1"}, Version: "v1", - DiscoveryRepos: []string{"org/repo1"}, Workflows: []string{"workflow1", "workflow2"}, MemoryPaths: []string{"memory/campaigns/complete/**"}, MetricsGlob: "memory/campaigns/complete-*.json", @@ -268,12 +258,12 @@ func TestValidateSpec_CompleteSpec(t *testing.T) { } } -func TestValidateSpec_MissingAllowedReposIsValid(t *testing.T) { +func TestValidateSpec_MissingScopeIsValid(t *testing.T) { spec := &CampaignSpec{ ID: "test-campaign", Name: "Test Campaign", ProjectURL: "https://github.com/orgs/org/projects/1", - // No Workflows, no DiscoveryRepos - should be valid since no discovery needed + // No workflows - scope is not required } problems := ValidateSpec(spec) @@ -283,14 +273,13 @@ func TestValidateSpec_MissingAllowedReposIsValid(t *testing.T) { } } -func TestValidateSpec_InvalidAllowedReposFormat(t *testing.T) { +func TestValidateSpec_InvalidScopeRepoFormat(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - AllowedRepos: []string{"invalid-repo-format", "org/repo1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + Scope: []string{"invalid-repo-format", "org/repo1"}, } problems := ValidateSpec(spec) @@ -300,7 +289,7 @@ func TestValidateSpec_InvalidAllowedReposFormat(t *testing.T) { found := false for _, p := range problems { - if strings.Contains(p, "must be in 'owner/repo' format") { + if strings.Contains(p, "must be 'owner/repo'") { found = true break } @@ -310,12 +299,12 @@ func TestValidateSpec_InvalidAllowedReposFormat(t *testing.T) { } } -func TestValidateSpec_EmptyAllowedReposIsValid(t *testing.T) { +func TestValidateSpec_EmptyScopeIsValid(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{}, // Empty list, no workflows + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{}, // Empty list, no workflows } problems := ValidateSpec(spec) @@ -325,33 +314,28 @@ func TestValidateSpec_EmptyAllowedReposIsValid(t *testing.T) { } } -func TestValidateSpec_ValidAllowedOrgs(t *testing.T) { +func TestValidateSpec_ValidScopeWithOrgs(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - AllowedRepos: []string{"org/repo1"}, - AllowedOrgs: []string{"github", "microsoft"}, - DiscoveryOrgs: []string{"github", "microsoft"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + Scope: []string{"org/repo1", "org:github", "org:microsoft"}, } problems := ValidateSpec(spec) if len(problems) != 0 { - t.Errorf("Expected no validation problems with valid discovery-orgs, got: %v", problems) + t.Errorf("Expected no validation problems with valid scope orgs, got: %v", problems) } } -func TestValidateSpec_InvalidAllowedOrgsFormat(t *testing.T) { +func TestValidateSpec_InvalidScopeOrgFormat(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - AllowedRepos: []string{"org/repo1"}, - AllowedOrgs: []string{"github/repo"}, // Invalid - contains slash + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Workflows: []string{"workflow1"}, + Scope: []string{"org/repo1", "org:github/repo"}, // Invalid - contains slash } problems := ValidateSpec(spec) @@ -361,7 +345,7 @@ func TestValidateSpec_InvalidAllowedOrgsFormat(t *testing.T) { found := false for _, p := range problems { - if strings.Contains(p, "must be an organization name") { + if strings.Contains(p, "must be 'org:'") { found = true break } @@ -426,12 +410,11 @@ func TestSuggestValidID(t *testing.T) { func TestValidateSpec_InvalidIDWithSuggestion(t *testing.T) { spec := &CampaignSpec{ - ID: "Test Campaign 2025", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "Test Campaign 2025", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) @@ -452,19 +435,18 @@ func TestValidateSpec_InvalidIDWithSuggestion(t *testing.T) { } } -func TestValidateSpec_AllowedReposWildcard(t *testing.T) { +func TestValidateSpec_ScopeRepoWildcard(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/*"}, // Invalid - wildcard not allowed - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo*"}, // Invalid - wildcards not allowed in repo selectors + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) if len(problems) == 0 { - t.Fatal("Expected validation problems for wildcard in allowed-repos") + t.Fatal("Expected validation problems for wildcard in scope") } found := false @@ -479,20 +461,18 @@ func TestValidateSpec_AllowedReposWildcard(t *testing.T) { } } -func TestValidateSpec_AllowedOrgsWildcard(t *testing.T) { +func TestValidateSpec_ScopeOrgWildcard(t *testing.T) { spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - AllowedRepos: []string{"org/repo1"}, - AllowedOrgs: []string{"github*"}, // Invalid - wildcard not allowed - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org:github*"}, // Invalid - wildcards not allowed in org selectors + Workflows: []string{"workflow1"}, } problems := ValidateSpec(spec) if len(problems) == 0 { - t.Fatal("Expected validation problems for wildcard in allowed-orgs") + t.Fatal("Expected validation problems for wildcard in scope") } found := false @@ -503,7 +483,7 @@ func TestValidateSpec_AllowedOrgsWildcard(t *testing.T) { } } if !found { - t.Errorf("Expected wildcard validation problem for orgs, got: %v", problems) + t.Errorf("Expected wildcard validation problem, got: %v", problems) } } @@ -546,12 +526,12 @@ func TestValidateSpec_TrackerLabelFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spec := &CampaignSpec{ - ID: tt.campaignID, - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - DiscoveryRepos: []string{"test/repo"}, - Workflows: []string{"workflow1"}, - TrackerLabel: tt.trackerLabel, + ID: tt.campaignID, + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/org/projects/1", + Scope: []string{"org/repo1"}, + Workflows: []string{"workflow1"}, + TrackerLabel: tt.trackerLabel, } problems := ValidateSpec(spec) diff --git a/pkg/cli/compile_campaign_orchestrator_test.go b/pkg/cli/compile_campaign_orchestrator_test.go index 156392d60f..eda2c9d90f 100644 --- a/pkg/cli/compile_campaign_orchestrator_test.go +++ b/pkg/cli/compile_campaign_orchestrator_test.go @@ -95,6 +95,14 @@ func TestGenerateAndCompileCampaignOrchestrator(t *testing.T) { t.Errorf("expected generated markdown to set engine: claude") } + // Verify dispatch-workflow safe output is rendered (used for orchestration) + if !strings.Contains(mdStr, "dispatch-workflow:") { + t.Errorf("expected generated markdown to include dispatch-workflow safe output") + } + if !strings.Contains(mdStr, "example-workflow") { + t.Errorf("expected generated markdown to include allowlisted workflow 'example-workflow'") + } + // Verify that the Source comment exists and contains a relative path (not absolute) if !strings.Contains(mdStr, "