diff --git a/.github/aw/github-agentic-workflows.md b/.github/aw/github-agentic-workflows.md index 4ca304d69e..9759db520c 100644 --- a/.github/aw/github-agentic-workflows.md +++ b/.github/aw/github-agentic-workflows.md @@ -66,10 +66,23 @@ gh aw compile --poutine # Supply chain security analyzer # Strict mode with all scanners gh aw compile --actionlint --zizmor --poutine + +# Output validation results as JSON (includes labels referenced in safe-outputs) +gh aw compile --json --no-emit ``` **Best Practice**: Always run `gh aw compile` after every workflow change to ensure the GitHub Actions YAML is up to date. +**Agentic Maintenance Workflow Operations:** + +The generated `agentics-maintenance.yml` workflow supports these `workflow_dispatch` operations: + +- `disable` / `enable` — Disable or re-enable all agentic workflows +- `update` — Update workflow metadata (opens a PR for changed files) +- `upgrade` — Upgrade gh-aw version and dependencies (opens a PR) +- `safe_outputs` — Replay safe outputs from a previous run (provide `run_url`) +- `create_labels` — Compile all workflows and create any labels referenced in `safe-outputs` that are missing from the repository + ## Complete Frontmatter Schema The YAML frontmatter supports these fields: diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index de967ee673..68c22092c3 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -49,6 +49,7 @@ on: - 'update' - 'upgrade' - 'safe_outputs' + - 'create_labels' run_url: description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' required: false @@ -106,7 +107,7 @@ jobs: await main(); run_operation: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && github.event.inputs.operation != 'create_labels' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: write @@ -201,6 +202,54 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); await main(); + create_labels: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'create_labels' && !github.event.repository.fork }} + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Build gh-aw + run: make build + + - name: Create missing labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_CMD_PREFIX: ./gh-aw + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); + await main(); + compile-workflows: if: ${{ !github.event.repository.fork && (github.event_name != 'workflow_dispatch' || github.event.inputs.operation == '') }} runs-on: ubuntu-slim diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs new file mode 100644 index 0000000000..1e659bf527 --- /dev/null +++ b/actions/setup/js/create_labels.cjs @@ -0,0 +1,139 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { ERR_SYSTEM } = require("./error_codes.cjs"); + +/** + * Generate a deterministic pastel hex color string from a label name. + * Produces colors in the pastel range (128–191 per channel) for readability. + * + * @param {string} name + * @returns {string} Six-character hex color (no leading #) + */ +function deterministicLabelColor(name) { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash * 31 + name.charCodeAt(i)) >>> 0; + } + // Map to pastel range: 128–223 per channel + const r = 128 + (hash & 0x3f); + const g = 128 + ((hash >> 6) & 0x3f); + const b = 128 + ((hash >> 12) & 0x3f); + return ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); +} + +/** + * Compile all agentic workflows, collect the labels referenced in safe-outputs + * configurations, and create any labels that are missing from the repository. + * + * Required environment variables: + * GH_AW_CMD_PREFIX - Command prefix: './gh-aw' (dev) or 'gh aw' (release) + * + * @returns {Promise} + */ +async function main() { + const cmdPrefixStr = process.env.GH_AW_CMD_PREFIX || "gh aw"; + const [bin, ...prefixArgs] = cmdPrefixStr.split(" ").filter(Boolean); + + // Run compile --json --no-emit to collect labels without writing lock files. + // Use ignoreReturnCode because compile exits non-zero when some workflows have errors, + // but still produces valid JSON output for all (valid and invalid) workflows. + const compileArgs = [...prefixArgs, "compile", "--json", "--no-emit"]; + core.info(`Running: ${bin} ${compileArgs.join(" ")}`); + + let compileOutput; + try { + const result = await exec.getExecOutput(bin, compileArgs, { ignoreReturnCode: true }); + compileOutput = result.stdout; + // Only treat as a fatal error when the exit is non-zero AND there is no stdout at all. + // A non-zero exit with JSON on stdout means some workflows failed validation but we + // still have label data from the successfully-parsed ones — continue processing. + if (result.exitCode !== 0 && !compileOutput.trim()) { + throw new Error(`${ERR_SYSTEM}: compile exited with code ${result.exitCode}: ${result.stderr}`); + } + } catch (err) { + core.setFailed(`Failed to run compile: ${getErrorMessage(err)}`); + return; + } + + // Parse JSON output + let validationResults; + try { + validationResults = JSON.parse(compileOutput); + } catch (err) { + core.setFailed(`Failed to parse compile JSON output: ${getErrorMessage(err)}`); + return; + } + + // Collect all unique labels across all workflows + /** @type {Set} */ + const allLabels = new Set(); + for (const result of validationResults) { + if (Array.isArray(result.labels)) { + for (const label of result.labels) { + if (typeof label === "string" && label.trim()) { + allLabels.add(label.trim()); + } + } + } + } + + if (allLabels.size === 0) { + core.info("No labels found in safe-outputs configurations — nothing to create"); + return; + } + + core.info(`Found ${allLabels.size} unique label(s) in safe-outputs: ${[...allLabels].join(", ")}`); + + // Fetch all existing labels from the repository + const { owner, repo } = context.repo; + let existingLabels; + try { + existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + } catch (err) { + core.setFailed(`Failed to list repository labels: ${getErrorMessage(err)}`); + return; + } + + const existingLabelNames = new Set(existingLabels.map(l => l.name.toLowerCase())); + + // Create missing labels + let created = 0; + let skipped = 0; + + for (const labelName of allLabels) { + if (existingLabelNames.has(labelName.toLowerCase())) { + core.info(`ℹ️ Label already exists: ${labelName}`); + skipped++; + } else { + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: labelName, + color: deterministicLabelColor(labelName), + description: "", + }); + core.info(`✅ Created label: ${labelName}`); + created++; + } catch (err) { + // 422 means label already exists (race condition) — treat as success + if (err && typeof err === "object" && /** @type {any} */ err.status === 422) { + core.info(`ℹ️ Label already exists (concurrent): ${labelName}`); + skipped++; + } else { + core.warning(`Failed to create label '${labelName}': ${getErrorMessage(err)}`); + } + } + } + } + + core.info(`Done: ${created} label(s) created, ${skipped} already existed`); +} + +module.exports = { main }; diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index ea711b5d9a..c58c942b82 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -59,4 +59,5 @@ type ValidationResult struct { Errors []CompileValidationError `json:"errors"` Warnings []CompileValidationError `json:"warnings"` CompiledFile string `json:"compiled_file,omitempty"` + Labels []string `json:"labels,omitempty"` // Labels referenced in safe-outputs configurations } diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index 915d93ee69..4148001188 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -239,6 +239,7 @@ func sanitizeValidationResults(results []ValidationResult) []ValidationResult { CompiledFile: result.CompiledFile, Errors: sliceutil.Map(result.Errors, sanitizeError), Warnings: sliceutil.Map(result.Warnings, sanitizeError), + Labels: result.Labels, } }) } diff --git a/pkg/cli/compile_workflow_processor.go b/pkg/cli/compile_workflow_processor.go index 7844ebd850..a425d9329e 100644 --- a/pkg/cli/compile_workflow_processor.go +++ b/pkg/cli/compile_workflow_processor.go @@ -142,6 +142,67 @@ func compileWorkflowFile( if !noEmit { result.validationResult.CompiledFile = lockFile } + + // Collect labels from safe-outputs for JSON output (used by create-labels maintenance operation) + result.validationResult.Labels = extractSafeOutputLabels(workflowData) + compileWorkflowProcessorLog.Printf("Successfully processed workflow file: %s", resolvedFile) return result } + +// extractSafeOutputLabels collects all unique labels referenced in safe-outputs configurations. +// These are labels that should exist in the repository for the workflow to function correctly. +// Scans: create-issue.labels/allowed-labels, create-discussion.labels/allowed-labels, +// create-pull-request.labels/allowed-labels, and add-labels.allowed. +func extractSafeOutputLabels(data *workflow.WorkflowData) []string { + if data == nil || data.SafeOutputs == nil { + return nil + } + + seen := make(map[string]bool) + var labels []string + + addLabel := func(label string) { + if label != "" && !seen[label] { + seen[label] = true + labels = append(labels, label) + } + } + + so := data.SafeOutputs + + if so.CreateIssues != nil { + for _, l := range so.CreateIssues.Labels { + addLabel(l) + } + for _, l := range so.CreateIssues.AllowedLabels { + addLabel(l) + } + } + + if so.CreateDiscussions != nil { + for _, l := range so.CreateDiscussions.Labels { + addLabel(l) + } + for _, l := range so.CreateDiscussions.AllowedLabels { + addLabel(l) + } + } + + if so.CreatePullRequests != nil { + for _, l := range so.CreatePullRequests.Labels { + addLabel(l) + } + for _, l := range so.CreatePullRequests.AllowedLabels { + addLabel(l) + } + } + + if so.AddLabels != nil { + for _, l := range so.AddLabels.Allowed { + addLabel(l) + } + } + + return labels +} diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index bceef12a29..c4a4ec588d 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -220,6 +220,7 @@ on: - 'update' - 'upgrade' - 'safe_outputs' + - 'create_labels' run_url: description: 'Run URL or run ID to replay safe outputs from (e.g. https://github.com/owner/repo/actions/runs/12345 or 12345). Required when operation is safe_outputs.' required: false @@ -300,10 +301,10 @@ jobs: await main(); `) - // Add unified run_operation job for all dispatch operations except safe_outputs + // Add unified run_operation job for all dispatch operations except safe_outputs and create_labels yaml.WriteString(` run_operation: - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && !github.event.repository.fork }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && github.event.inputs.operation != 'create_labels' && !github.event.repository.fork }} runs-on: ubuntu-slim permissions: actions: write @@ -396,6 +397,51 @@ jobs: await main(); `) + // Add create_labels job for workflow_dispatch with operation == 'create_labels' + yaml.WriteString(` + create_labels: + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'create_labels' && !github.event.repository.fork }} + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: ` + GetActionPin("actions/checkout") + ` + with: + persist-credentials: false + + - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + + - name: Check admin/maintainer permissions + uses: ` + GetActionPin("actions/github-script") + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) + + yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver)) + yaml.WriteString(` - name: Create missing labels + uses: ` + GetActionPin("actions/github-script") + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); + await main(); +`) + // Add compile-workflows and zizmor-scan jobs only in dev mode // These jobs are specific to the gh-aw repository and require go.mod, make build, etc. // User repositories won't have these dependencies, so we skip them in release mode diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index 3947d0b928..8c1677d865 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -281,11 +281,12 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { yaml := string(content) operationSkipCondition := `github.event_name != 'workflow_dispatch' || github.event.inputs.operation == ''` - operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs'` + operationRunCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation != '' && github.event.inputs.operation != 'safe_outputs' && github.event.inputs.operation != 'create_labels'` applySafeOutputsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'safe_outputs'` + createLabelsCondition := `github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'create_labels'` const jobSectionSearchRange = 300 - const runOpSectionSearchRange = 200 + const runOpSectionSearchRange = 400 // Jobs that should be disabled when operation is set disabledJobs := []string{"close-expired-entities:", "compile-workflows:", "zizmor-scan:", "secret-validation:"} @@ -329,6 +330,22 @@ func TestGenerateMaintenanceWorkflow_OperationJobConditions(t *testing.T) { } } + // create_labels job should be triggered when operation == 'create_labels' + createLabelsIdx := strings.Index(yaml, "\n create_labels:") + if createLabelsIdx == -1 { + t.Errorf("Job create_labels not found in generated workflow") + } else { + createLabelsSection := yaml[createLabelsIdx : createLabelsIdx+runOpSectionSearchRange] + if !strings.Contains(createLabelsSection, createLabelsCondition) { + t.Errorf("Job create_labels should have the activation condition %q in:\n%s", createLabelsCondition, createLabelsSection) + } + } + + // Verify create_labels is an option in the operation choices + if !strings.Contains(yaml, "- 'create_labels'") { + t.Error("workflow_dispatch operation choices should include 'create_labels'") + } + // Verify safe_outputs is an option in the operation choices if !strings.Contains(yaml, "- 'safe_outputs'") { t.Error("workflow_dispatch operation choices should include 'safe_outputs'") @@ -572,12 +589,12 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) { t.Fatalf("Expected maintenance workflow to be generated: %v", err) } yaml := string(content) - // Both run_operation and compile_workflows should use the same setup-go version - // (both use GetActionPin, not hardcoded pins). Exactly 2 occurrences expected. + // run_operation, create_labels, and compile_workflows should use the same setup-go version + // (all use GetActionPin, not hardcoded pins). Exactly 3 occurrences expected. setupGoPin := GetActionPin("actions/setup-go") occurrences := strings.Count(yaml, setupGoPin) - if occurrences != 2 { - t.Errorf("Expected exactly 2 occurrences of pinned setup-go ref %q (run_operation + compile_workflows), got %d in:\n%s", + if occurrences != 3 { + t.Errorf("Expected exactly 3 occurrences of pinned setup-go ref %q (run_operation + create_labels + compile_workflows), got %d in:\n%s", setupGoPin, occurrences, yaml) } }) diff --git a/scratchpad/labels.md b/scratchpad/labels.md index 0f9ea020ff..6e07c1ab71 100644 --- a/scratchpad/labels.md +++ b/scratchpad/labels.md @@ -39,6 +39,10 @@ Labels help organize and triage issues for better project management. Use labels - **ai-inspected** - Issue reviewed by AI workflow - **smoke-copilot** - Smoke test results +### Domain Labels (used by scheduled workflows) +- **constraint-solving** - Constraint solving problems and algorithms (used by Constraint Solving POTD workflow) +- **problem-of-the-day** - Problem of the day (used by Constraint Solving POTD workflow) + ### Status Labels - **good first issue** - Suitable for new contributors - **dependencies** - Dependency updates @@ -108,6 +112,7 @@ Type: bug, enhancement, documentation, question, testing Priority: priority-high, priority-medium, priority-low Component: cli, workflow, mcp, actions, engine, automation Workflow: ai-generated, plan, ai-inspected, smoke-copilot +Domain: constraint-solving, problem-of-the-day Status: good first issue, dependencies ```