diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index aabe8b46a0..f5b9453006 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -10,6 +10,7 @@ # agent["agent"] # create_issue["create_issue"] # detection["detection"] +# investigate-on-failure["investigate-on-failure"] # missing_tool["missing_tool"] # pre_activation["pre_activation"] # pre_activation --> activation @@ -17,6 +18,7 @@ # agent --> create_issue # detection --> create_issue # agent --> detection +# agent --> investigate-on-failure # agent --> missing_tool # detection --> missing_tool # ``` @@ -3704,6 +3706,23 @@ jobs: path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore + investigate-on-failure: + needs: agent + if: failure() + permissions: + actions: read + contents: read + pull-requests: read + + uses: ./.github/workflows/smoke-detector.lock.yml + with: + conclusion: failure + head_sha: ${{ github.sha }} + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + workflow_name: Smoke Claude + missing_tool: needs: - agent diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 9f388b9fe6..f7dbe35bbe 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -20,6 +20,22 @@ safe-outputs: create-issue: timeout_minutes: 10 strict: true +jobs: + investigate-on-failure: + if: failure() + needs: agent + uses: ./.github/workflows/smoke-detector.lock.yml + permissions: + contents: read + pull-requests: read + actions: read + with: + workflow_name: Smoke Claude + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + conclusion: failure + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + head_sha: ${{ github.sha }} --- Review the last 5 merged pull requests in this repository and post summary in an issue. \ No newline at end of file diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 20813ee614..b95cce4023 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -10,6 +10,7 @@ # agent["agent"] # create_issue["create_issue"] # detection["detection"] +# investigate-on-failure["investigate-on-failure"] # missing_tool["missing_tool"] # pre_activation["pre_activation"] # pre_activation --> activation @@ -17,6 +18,7 @@ # agent --> create_issue # detection --> create_issue # agent --> detection +# agent --> investigate-on-failure # agent --> missing_tool # detection --> missing_tool # ``` @@ -3331,6 +3333,23 @@ jobs: path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore + investigate-on-failure: + needs: agent + if: failure() + permissions: + actions: read + contents: read + pull-requests: read + + uses: ./.github/workflows/smoke-detector.lock.yml + with: + conclusion: failure + head_sha: ${{ github.sha }} + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + workflow_name: Smoke Codex + missing_tool: needs: - agent diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index 2938b1e151..aa1367b95b 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -19,6 +19,22 @@ safe-outputs: create-issue: timeout_minutes: 10 strict: true +jobs: + investigate-on-failure: + if: failure() + needs: agent + uses: ./.github/workflows/smoke-detector.lock.yml + permissions: + contents: read + pull-requests: read + actions: read + with: + workflow_name: Smoke Codex + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + conclusion: failure + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + head_sha: ${{ github.sha }} --- Review the last 5 merged pull requests in this repository and post summary in an issue. \ No newline at end of file diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index a3d6fcdbae..f28d2ac014 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -10,6 +10,7 @@ # agent["agent"] # create_issue["create_issue"] # detection["detection"] +# investigate-on-failure["investigate-on-failure"] # missing_tool["missing_tool"] # pre_activation["pre_activation"] # pre_activation --> activation @@ -17,6 +18,7 @@ # agent --> create_issue # detection --> create_issue # agent --> detection +# agent --> investigate-on-failure # agent --> missing_tool # detection --> missing_tool # ``` @@ -4403,6 +4405,23 @@ jobs: path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore + investigate-on-failure: + needs: agent + if: failure() + permissions: + actions: read + contents: read + pull-requests: read + + uses: ./.github/workflows/smoke-detector.lock.yml + with: + conclusion: failure + head_sha: ${{ github.sha }} + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + workflow_name: Smoke Copilot + missing_tool: needs: - agent diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index e69470643c..eae11a9607 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -23,6 +23,22 @@ safe-outputs: create-issue: timeout_minutes: 10 strict: true +jobs: + investigate-on-failure: + if: failure() + needs: agent + uses: ./.github/workflows/smoke-detector.lock.yml + permissions: + contents: read + pull-requests: read + actions: read + with: + workflow_name: Smoke Copilot + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + conclusion: failure + html_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + head_sha: ${{ github.sha }} --- Review the last 5 merged pull requests in this repository and post summary in an issue. \ No newline at end of file diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index 5c1f10e669..af623f9921 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -17,7 +17,9 @@ # create_issue["create_issue"] # detection["detection"] # missing_tool["missing_tool"] +# pre_activation["pre_activation"] # update_reaction["update_reaction"] +# pre_activation --> activation # agent --> add_comment # create_issue --> add_comment # detection --> add_comment @@ -52,17 +54,32 @@ name: "Smoke Detector - Smoke Test Failure Investigator" "on": - workflow_run: - branches: - - copilot/* - types: - - completed - workflows: - - Smoke Claude - - Smoke Codex - - Smoke Copilot - - Smoke Copilot Firewall - - Smoke Opencode + workflow_call: + inputs: + conclusion: + description: Conclusion of the workflow run (failure, success, etc.) + required: true + type: string + head_sha: + description: Commit SHA that triggered the workflow run + required: true + type: string + html_url: + description: URL to the workflow run + required: true + type: string + run_id: + description: ID of the workflow run that failed + required: true + type: string + run_number: + description: Run number of the workflow run + required: true + type: string + workflow_name: + description: Name of the workflow that failed + required: true + type: string permissions: actions: read @@ -77,8 +94,8 @@ run-name: "Smoke Detector - Smoke Test Failure Investigator" jobs: activation: - if: > - (github.event.workflow_run.conclusion == 'failure') && ((github.event_name != 'workflow_run') || (github.event.workflow_run.repository.id == github.repository_id)) + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' runs-on: ubuntu-slim permissions: discussions: write @@ -1955,13 +1972,13 @@ jobs: env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_EXPR_FD224667: ${{ github.event.workflow_run.conclusion }} - GH_AW_EXPR_948C4FD8: ${{ github.event.workflow_run.event }} - GH_AW_EXPR_9A971DC4: ${{ github.event.workflow_run.head_sha }} - GH_AW_EXPR_D611C113: ${{ github.event.workflow_run.html_url }} - GH_AW_EXPR_3906A565: ${{ github.event.workflow_run.id }} - GH_AW_EXPR_20984929: ${{ github.event.workflow_run.run_number }} GH_AW_EXPR_D892F163: ${{ github.repository }} + GH_AW_EXPR_2CBA5CE1: ${{ inputs.conclusion }} + GH_AW_EXPR_B4F8C8D5: ${{ inputs.head_sha }} + GH_AW_EXPR_475F31C3: ${{ inputs.html_url }} + GH_AW_EXPR_7172678D: ${{ inputs.run_id }} + GH_AW_EXPR_1CDB85B1: ${{ inputs.run_number }} + GH_AW_EXPR_5830D129: ${{ inputs.workflow_name }} run: | PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" mkdir -p "$PROMPT_DIR" @@ -2046,15 +2063,17 @@ jobs: ## Current Context - **Repository**: ${GH_AW_EXPR_D892F163} - - **Workflow Run**: ${GH_AW_EXPR_3906A565} - - **Conclusion**: ${GH_AW_EXPR_FD224667} - - **Run URL**: ${GH_AW_EXPR_D611C113} - - **Head SHA**: ${GH_AW_EXPR_9A971DC4} + - **Workflow Name**: ${GH_AW_EXPR_5830D129} + - **Workflow Run**: ${GH_AW_EXPR_7172678D} + - **Run Number**: ${GH_AW_EXPR_1CDB85B1} + - **Conclusion**: ${GH_AW_EXPR_2CBA5CE1} + - **Run URL**: ${GH_AW_EXPR_475F31C3} + - **Head SHA**: ${GH_AW_EXPR_B4F8C8D5} ## Investigation Protocol ### Phase 1: Initial Triage - 1. **Use gh-aw_audit Tool**: Run `gh-aw_audit` with the workflow run ID `${GH_AW_EXPR_3906A565}` to get comprehensive diagnostic information + 1. **Use gh-aw_audit Tool**: Run `gh-aw_audit` with the workflow run ID `${GH_AW_EXPR_7172678D}` to get comprehensive diagnostic information 2. **Analyze Audit Report**: Review the audit report for: - Failed jobs and their errors - Error patterns and classifications @@ -2154,7 +2173,7 @@ jobs: - Include up to 3 workflow run URLs as references at the end 3. **Determine Output Location**: - - **First, check for associated pull request**: Use the GitHub API to search for pull requests associated with the branch from the failed workflow run (commit SHA: ${GH_AW_EXPR_9A971DC4}) + - **First, check for associated pull request**: Use the GitHub API to search for pull requests associated with the branch from the failed workflow run (commit SHA: ${GH_AW_EXPR_B4F8C8D5}) - **If a pull request is found**: Post the investigation report as a comment on that pull request using the `add_comment` tool - **If no pull request is found**: Create a new issue with the investigation results using the `create_issue` tool @@ -2167,8 +2186,8 @@ jobs: ### Finding Associated Pull Request To find a pull request associated with the failed workflow run: - 1. Use the GitHub search API to search for pull requests with the commit SHA: `${GH_AW_EXPR_9A971DC4}` - 2. Query: `repo:${GH_AW_EXPR_D892F163} is:pr ${GH_AW_EXPR_9A971DC4}` + 1. Use the GitHub search API to search for pull requests with the commit SHA: `${GH_AW_EXPR_B4F8C8D5}` + 2. Query: `repo:${GH_AW_EXPR_D892F163} is:pr ${GH_AW_EXPR_B4F8C8D5}` 3. If a pull request is found, use its number for the `add_comment` tool 4. If no pull request is found, proceed with creating an issue @@ -2184,12 +2203,12 @@ jobs: **Then wrap detailed content in `
` tags:** ```markdown
- Full Investigation Report - Run #${GH_AW_EXPR_20984929} + Full Investigation Report - Run #${GH_AW_EXPR_1CDB85B1} ## Failure Details - - **Run**: [§${GH_AW_EXPR_3906A565}](${GH_AW_EXPR_D611C113}) - - **Commit**: ${GH_AW_EXPR_9A971DC4} - - **Trigger**: ${GH_AW_EXPR_948C4FD8} + - **Run**: [§${GH_AW_EXPR_7172678D}](${GH_AW_EXPR_475F31C3}) + - **Commit**: ${GH_AW_EXPR_B4F8C8D5} + - **Workflow**: ${GH_AW_EXPR_5830D129} ## Root Cause Analysis [Detailed analysis of what went wrong] @@ -2214,7 +2233,7 @@ jobs: --- **References:** - - [§${GH_AW_EXPR_3906A565}](${GH_AW_EXPR_D611C113}) + - [§${GH_AW_EXPR_7172678D}](${GH_AW_EXPR_475F31C3}) ``` ### Investigation Issue Template (for Issues) @@ -2229,12 +2248,12 @@ jobs: **Then wrap detailed content in `
` tags:** ```markdown
- Full Investigation Report - Run #${GH_AW_EXPR_20984929} + Full Investigation Report - Run #${GH_AW_EXPR_1CDB85B1} ## Failure Details - - **Run**: [§${GH_AW_EXPR_3906A565}](${GH_AW_EXPR_D611C113}) - - **Commit**: ${GH_AW_EXPR_9A971DC4} - - **Trigger**: ${GH_AW_EXPR_948C4FD8} + - **Run**: [§${GH_AW_EXPR_7172678D}](${GH_AW_EXPR_475F31C3}) + - **Commit**: ${GH_AW_EXPR_B4F8C8D5} + - **Workflow**: ${GH_AW_EXPR_5830D129} ## Root Cause Analysis [Detailed analysis of what went wrong] @@ -2259,7 +2278,7 @@ jobs: --- **References:** - - [§${GH_AW_EXPR_3906A565}](${GH_AW_EXPR_D611C113}) + - [§${GH_AW_EXPR_7172678D}](${GH_AW_EXPR_475F31C3}) ``` ## Important Guidelines @@ -4979,6 +4998,86 @@ jobs: core.setFailed(`Error processing missing-tool reports: ${error}`); }); + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + script: | + async function main() { + const { eventName } = context; + const actor = context.actor; + const { owner, repo } = context.repo; + const requiredPermissionsEnv = process.env.GH_AW_REQUIRED_ROLES; + const requiredPermissions = requiredPermissionsEnv ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") : []; + if (eventName === "workflow_dispatch") { + const hasWriteRole = requiredPermissions.includes("write"); + if (hasWriteRole) { + core.info(`✅ Event ${eventName} does not require validation (write role allowed)`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "safe_event"); + return; + } + core.info(`Event ${eventName} requires validation (write role not allowed)`); + } + const safeEvents = ["workflow_run", "schedule"]; + if (safeEvents.includes(eventName)) { + core.info(`✅ Event ${eventName} does not require validation`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "safe_event"); + return; + } + if (!requiredPermissions || requiredPermissions.length === 0) { + core.warning("❌ Configuration error: Required permissions not specified. Contact repository administrator."); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "config_error"); + core.setOutput("error_message", "Configuration error: Required permissions not specified"); + return; + } + try { + core.info(`Checking if user '${actor}' has required permissions for ${owner}/${repo}`); + core.info(`Required permissions: ${requiredPermissions.join(", ")}`); + const repoPermission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor, + }); + const permission = repoPermission.data.permission; + core.info(`Repository permission level: ${permission}`); + for (const requiredPerm of requiredPermissions) { + if (permission === requiredPerm || (requiredPerm === "maintainer" && permission === "maintain")) { + core.info(`✅ User has ${permission} access to repository`); + core.setOutput("is_team_member", "true"); + core.setOutput("result", "authorized"); + core.setOutput("user_permission", permission); + return; + } + } + core.warning(`User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}`); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "insufficient_permissions"); + core.setOutput("user_permission", permission); + core.setOutput( + "error_message", + `Access denied: User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` + ); + } catch (repoError) { + const errorMessage = repoError instanceof Error ? repoError.message : String(repoError); + core.warning(`Repository permission check failed: ${errorMessage}`); + core.setOutput("is_team_member", "false"); + core.setOutput("result", "api_error"); + core.setOutput("error_message", `Repository permission check failed: ${errorMessage}`); + return; + } + } + await main(); + update_reaction: needs: - agent diff --git a/.github/workflows/smoke-detector.md b/.github/workflows/smoke-detector.md index 02f019e78d..335987e8ae 100644 --- a/.github/workflows/smoke-detector.md +++ b/.github/workflows/smoke-detector.md @@ -1,18 +1,32 @@ --- -if: ${{ github.event.workflow_run.conclusion == 'failure' }} network: defaults on: - workflow_run: - types: - - completed - workflows: - - Smoke Claude - - Smoke Codex - - Smoke Copilot - - Smoke Copilot Firewall - - Smoke Opencode - branches: - - 'copilot/*' + workflow_call: + inputs: + workflow_name: + description: 'Name of the workflow that failed' + required: true + type: string + run_id: + description: 'ID of the workflow run that failed' + required: true + type: string + run_number: + description: 'Run number of the workflow run' + required: true + type: string + conclusion: + description: 'Conclusion of the workflow run (failure, success, etc.)' + required: true + type: string + html_url: + description: 'URL to the workflow run' + required: true + type: string + head_sha: + description: 'Commit SHA that triggered the workflow run' + required: true + type: string reaction: "eyes" permissions: contents: read @@ -45,15 +59,17 @@ You are the Smoke Detector, an expert investigative agent that analyzes failed s ## Current Context - **Repository**: ${{ github.repository }} -- **Workflow Run**: ${{ github.event.workflow_run.id }} -- **Conclusion**: ${{ github.event.workflow_run.conclusion }} -- **Run URL**: ${{ github.event.workflow_run.html_url }} -- **Head SHA**: ${{ github.event.workflow_run.head_sha }} +- **Workflow Name**: ${{ inputs.workflow_name }} +- **Workflow Run**: ${{ inputs.run_id }} +- **Run Number**: ${{ inputs.run_number }} +- **Conclusion**: ${{ inputs.conclusion }} +- **Run URL**: ${{ inputs.html_url }} +- **Head SHA**: ${{ inputs.head_sha }} ## Investigation Protocol ### Phase 1: Initial Triage -1. **Use gh-aw_audit Tool**: Run `gh-aw_audit` with the workflow run ID `${{ github.event.workflow_run.id }}` to get comprehensive diagnostic information +1. **Use gh-aw_audit Tool**: Run `gh-aw_audit` with the workflow run ID `${{ inputs.run_id }}` to get comprehensive diagnostic information 2. **Analyze Audit Report**: Review the audit report for: - Failed jobs and their errors - Error patterns and classifications @@ -153,7 +169,7 @@ You are the Smoke Detector, an expert investigative agent that analyzes failed s - Include up to 3 workflow run URLs as references at the end 3. **Determine Output Location**: - - **First, check for associated pull request**: Use the GitHub API to search for pull requests associated with the branch from the failed workflow run (commit SHA: ${{ github.event.workflow_run.head_sha }}) + - **First, check for associated pull request**: Use the GitHub API to search for pull requests associated with the branch from the failed workflow run (commit SHA: ${{ inputs.head_sha }}) - **If a pull request is found**: Post the investigation report as a comment on that pull request using the `add_comment` tool - **If no pull request is found**: Create a new issue with the investigation results using the `create_issue` tool @@ -166,8 +182,8 @@ You are the Smoke Detector, an expert investigative agent that analyzes failed s ### Finding Associated Pull Request To find a pull request associated with the failed workflow run: -1. Use the GitHub search API to search for pull requests with the commit SHA: `${{ github.event.workflow_run.head_sha }}` -2. Query: `repo:${{ github.repository }} is:pr ${{ github.event.workflow_run.head_sha }}` +1. Use the GitHub search API to search for pull requests with the commit SHA: `${{ inputs.head_sha }}` +2. Query: `repo:${{ github.repository }} is:pr ${{ inputs.head_sha }}` 3. If a pull request is found, use its number for the `add_comment` tool 4. If no pull request is found, proceed with creating an issue @@ -183,12 +199,12 @@ Brief overview of the smoke test failure and key findings. This investigation an **Then wrap detailed content in `
` tags:** ```markdown
-Full Investigation Report - Run #${{ github.event.workflow_run.run_number }} +Full Investigation Report - Run #${{ inputs.run_number }} ## Failure Details -- **Run**: [§${{ github.event.workflow_run.id }}](${{ github.event.workflow_run.html_url }}) -- **Commit**: ${{ github.event.workflow_run.head_sha }} -- **Trigger**: ${{ github.event.workflow_run.event }} +- **Run**: [§${{ inputs.run_id }}](${{ inputs.html_url }}) +- **Commit**: ${{ inputs.head_sha }} +- **Workflow**: ${{ inputs.workflow_name }} ## Root Cause Analysis [Detailed analysis of what went wrong] @@ -213,7 +229,7 @@ Brief overview of the smoke test failure and key findings. This investigation an --- **References:** -- [§${{ github.event.workflow_run.id }}](${{ github.event.workflow_run.html_url }}) +- [§${{ inputs.run_id }}](${{ inputs.html_url }}) ``` ### Investigation Issue Template (for Issues) @@ -228,12 +244,12 @@ Brief overview of the smoke test failure and key findings. This investigation an **Then wrap detailed content in `
` tags:** ```markdown
-Full Investigation Report - Run #${{ github.event.workflow_run.run_number }} +Full Investigation Report - Run #${{ inputs.run_number }} ## Failure Details -- **Run**: [§${{ github.event.workflow_run.id }}](${{ github.event.workflow_run.html_url }}) -- **Commit**: ${{ github.event.workflow_run.head_sha }} -- **Trigger**: ${{ github.event.workflow_run.event }} +- **Run**: [§${{ inputs.run_id }}](${{ inputs.html_url }}) +- **Commit**: ${{ inputs.head_sha }} +- **Workflow**: ${{ inputs.workflow_name }} ## Root Cause Analysis [Detailed analysis of what went wrong] @@ -258,7 +274,7 @@ Brief overview of the smoke test failure and key findings. This investigation an --- **References:** -- [§${{ github.event.workflow_run.id }}](${{ github.event.workflow_run.html_url }}) +- [§${{ inputs.run_id }}](${{ inputs.html_url }}) ``` ## Important Guidelines diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index fb3bf4078d..ab6ec54283 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -524,7 +524,15 @@ on: # Option 2: object workflow_call: - {} + # Input parameters that can be passed to the workflow when it is called + # (optional) + inputs: + {} + + # Secrets that can be passed to the workflow when it is called + # (optional) + secrets: + {} # Time when workflow should stop running. Supports multiple formats: absolute # dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9dcc499b74..c1dd491df0 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -857,7 +857,51 @@ }, { "type": "object", - "additionalProperties": false + "additionalProperties": false, + "properties": { + "inputs": { + "type": "object", + "description": "Input parameters that can be passed to the workflow when it is called", + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the input parameter" + }, + "required": { + "type": "boolean", + "description": "Whether the input is required" + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean"], + "description": "Type of the input parameter" + }, + "default": { + "description": "Default value for the input parameter" + } + } + } + }, + "secrets": { + "type": "object", + "description": "Secrets that can be passed to the workflow when it is called", + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description of the secret" + }, + "required": { + "type": "boolean", + "description": "Whether the secret is required" + } + } + } + } + } } ] }, @@ -1128,6 +1172,34 @@ }, "concurrency": { "$ref": "#/properties/concurrency" + }, + "uses": { + "type": "string", + "description": "Path to a reusable workflow file to call (e.g., ./.github/workflows/reusable-workflow.yml)" + }, + "with": { + "type": "object", + "description": "Input parameters to pass to the reusable workflow", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "secrets": { + "type": "object", + "description": "Secrets to pass to the reusable workflow", + "additionalProperties": { + "type": "string" + } } } } diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 245fb01f33..8fb25d6481 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/goccy/go-yaml" "github.com/githubnext/gh-aw/pkg/constants" "github.com/githubnext/gh-aw/pkg/parser" ) @@ -751,19 +752,65 @@ func (c *Compiler) buildCustomJobs(data *WorkflowData) error { } } - // Add basic steps if specified - if steps, hasSteps := configMap["steps"]; hasSteps { - if stepsList, ok := steps.([]any); ok { - for _, step := range stepsList { - if stepMap, ok := step.(map[string]any); ok { - // Apply action pinning before converting to YAML - stepMap = ApplyActionPinToStep(stepMap, data) - - stepYAML, err := c.convertStepToYAML(stepMap) - if err != nil { - return fmt.Errorf("failed to convert step to YAML for job '%s': %w", jobName, err) + // Extract permissions + if permissions, hasPermissions := configMap["permissions"]; hasPermissions { + if permsMap, ok := permissions.(map[string]any); ok { + // Use gopkg.in/yaml.v3 to marshal permissions + yamlBytes, err := yaml.Marshal(permsMap) + if err != nil { + return fmt.Errorf("failed to convert permissions to YAML for job '%s': %w", jobName, err) + } + // Indent the YAML properly for job-level permissions + permsYAML := string(yamlBytes) + lines := strings.Split(strings.TrimSpace(permsYAML), "\n") + var formattedPerms strings.Builder + formattedPerms.WriteString("permissions:\n") + for _, line := range lines { + formattedPerms.WriteString(" " + line + "\n") + } + job.Permissions = formattedPerms.String() + } + } + + // Check if this is a reusable workflow call + if uses, hasUses := configMap["uses"]; hasUses { + if usesStr, ok := uses.(string); ok { + job.Uses = usesStr + + // Extract with parameters for reusable workflow + if with, hasWith := configMap["with"]; hasWith { + if withMap, ok := with.(map[string]any); ok { + job.With = withMap + } + } + + // Extract secrets for reusable workflow + if secrets, hasSecrets := configMap["secrets"]; hasSecrets { + if secretsMap, ok := secrets.(map[string]any); ok { + job.Secrets = make(map[string]string) + for key, val := range secretsMap { + if valStr, ok := val.(string); ok { + job.Secrets[key] = valStr + } + } + } + } + } + } else { + // Add basic steps if specified (only for non-reusable workflow jobs) + if steps, hasSteps := configMap["steps"]; hasSteps { + if stepsList, ok := steps.([]any); ok { + for _, step := range stepsList { + if stepMap, ok := step.(map[string]any); ok { + // Apply action pinning before converting to YAML + stepMap = ApplyActionPinToStep(stepMap, data) + + stepYAML, err := c.convertStepToYAML(stepMap) + if err != nil { + return fmt.Errorf("failed to convert step to YAML for job '%s': %w", jobName, err) + } + job.Steps = append(job.Steps, stepYAML) } - job.Steps = append(job.Steps, stepYAML) } } } diff --git a/pkg/workflow/expression_safety.go b/pkg/workflow/expression_safety.go index efcbcf598a..489fafa0c3 100644 --- a/pkg/workflow/expression_safety.go +++ b/pkg/workflow/expression_safety.go @@ -52,10 +52,11 @@ import ( // Pre-compiled regexes for expression validation (performance optimization) var ( - expressionRegex = regexp.MustCompile(`(?s)\$\{\{(.*?)\}\}`) - needsStepsRegex = regexp.MustCompile(`^(needs|steps)\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) - inputsRegex = regexp.MustCompile(`^github\.event\.inputs\.[a-zA-Z0-9_-]+$`) - envRegex = regexp.MustCompile(`^env\.[a-zA-Z0-9_-]+$`) + expressionRegex = regexp.MustCompile(`(?s)\$\{\{(.*?)\}\}`) + needsStepsRegex = regexp.MustCompile(`^(needs|steps)\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) + inputsRegex = regexp.MustCompile(`^github\.event\.inputs\.[a-zA-Z0-9_-]+$`) + workflowCallInputsRegex = regexp.MustCompile(`^inputs\.[a-zA-Z0-9_-]+$`) + envRegex = regexp.MustCompile(`^env\.[a-zA-Z0-9_-]+$`) // comparisonExtractionRegex extracts property accesses from comparison expressions // Matches patterns like "github.workflow == 'value'" and extracts "github.workflow" comparisonExtractionRegex = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_.]*)\s*(?:==|!=|<|>|<=|>=)\s*`) @@ -92,14 +93,14 @@ func validateExpressionSafety(markdownContent string) error { if parseErr == nil { // If we can parse it, validate each literal expression in the tree validationErr := VisitExpressionTree(parsed, func(expr *ExpressionNode) error { - return validateSingleExpression(expr.Expression, needsStepsRegex, inputsRegex, envRegex, &unauthorizedExpressions) + return validateSingleExpression(expr.Expression, needsStepsRegex, inputsRegex, workflowCallInputsRegex, envRegex, &unauthorizedExpressions) }) if validationErr != nil { return validationErr } } else { // If parsing fails, fall back to validating the whole expression as a literal - err := validateSingleExpression(expression, needsStepsRegex, inputsRegex, envRegex, &unauthorizedExpressions) + err := validateSingleExpression(expression, needsStepsRegex, inputsRegex, workflowCallInputsRegex, envRegex, &unauthorizedExpressions) if err != nil { return err } @@ -128,6 +129,7 @@ func validateExpressionSafety(markdownContent string) error { allowedList.WriteString(" - needs.*\n") allowedList.WriteString(" - steps.*\n") allowedList.WriteString(" - github.event.inputs.*\n") + allowedList.WriteString(" - inputs.* (workflow_call)\n") allowedList.WriteString(" - env.*\n") return fmt.Errorf("unauthorized expressions:%s\nallowed:%s", @@ -138,7 +140,7 @@ func validateExpressionSafety(markdownContent string) error { } // validateSingleExpression validates a single literal expression -func validateSingleExpression(expression string, needsStepsRe, inputsRe, envRe *regexp.Regexp, unauthorizedExpressions *[]string) error { +func validateSingleExpression(expression string, needsStepsRe, inputsRe, workflowCallInputsRe, envRe *regexp.Regexp, unauthorizedExpressions *[]string) error { expression = strings.TrimSpace(expression) // Check if this expression is in the allowed list @@ -150,6 +152,9 @@ func validateSingleExpression(expression string, needsStepsRe, inputsRe, envRe * } else if inputsRe.MatchString(expression) { // Check if this expression matches github.event.inputs.* pattern allowed = true + } else if workflowCallInputsRe.MatchString(expression) { + // Check if this expression matches inputs.* pattern (workflow_call inputs) + allowed = true } else if envRe.MatchString(expression) { // check if this expression matches env.* pattern allowed = true @@ -179,6 +184,8 @@ func validateSingleExpression(expression string, needsStepsRe, inputsRe, envRe * propertyAllowed = true } else if inputsRe.MatchString(property) { propertyAllowed = true + } else if workflowCallInputsRe.MatchString(property) { + propertyAllowed = true } else if envRe.MatchString(property) { propertyAllowed = true } else { diff --git a/pkg/workflow/jobs.go b/pkg/workflow/jobs.go index 6390ae27b7..c4a59dc817 100644 --- a/pkg/workflow/jobs.go +++ b/pkg/workflow/jobs.go @@ -27,6 +27,11 @@ type Job struct { Steps []string Needs []string // Job dependencies (needs clause) Outputs map[string]string + + // Reusable workflow call properties + Uses string // Path to reusable workflow (e.g., ./.github/workflows/reusable.yml) + With map[string]any // Input parameters for reusable workflow + Secrets map[string]string // Secrets for reusable workflow } // JobManager manages a collection of jobs and handles dependency validation @@ -272,12 +277,59 @@ func (jm *JobManager) renderJob(job *Job) string { } } - // Add steps section - if len(job.Steps) > 0 { - yaml.WriteString(" steps:\n") - for _, step := range job.Steps { - // Each step is already formatted with proper indentation - yaml.WriteString(step) + // Check if this is a reusable workflow call + if job.Uses != "" { + // Add uses directive for reusable workflow + yaml.WriteString(fmt.Sprintf(" uses: %s\n", job.Uses)) + + // Add with parameters if present + if len(job.With) > 0 { + yaml.WriteString(" with:\n") + // Sort keys for consistent output + withKeys := make([]string, 0, len(job.With)) + for key := range job.With { + withKeys = append(withKeys, key) + } + sort.Strings(withKeys) + + for _, key := range withKeys { + value := job.With[key] + // Format the value based on its type + switch v := value.(type) { + case string: + yaml.WriteString(fmt.Sprintf(" %s: %s\n", key, v)) + case int, int64, float64: + yaml.WriteString(fmt.Sprintf(" %s: %v\n", key, v)) + case bool: + yaml.WriteString(fmt.Sprintf(" %s: %t\n", key, v)) + default: + yaml.WriteString(fmt.Sprintf(" %s: %v\n", key, v)) + } + } + } + + // Add secrets if present + if len(job.Secrets) > 0 { + yaml.WriteString(" secrets:\n") + // Sort secret keys for consistent output + secretKeys := make([]string, 0, len(job.Secrets)) + for key := range job.Secrets { + secretKeys = append(secretKeys, key) + } + sort.Strings(secretKeys) + + for _, key := range secretKeys { + yaml.WriteString(fmt.Sprintf(" %s: %s\n", key, job.Secrets[key])) + } + } + } else { + // Add steps section (only for non-reusable workflow jobs) + if len(job.Steps) > 0 { + yaml.WriteString(" steps:\n") + for _, step := range job.Steps { + // Each step is already formatted with proper indentation + yaml.WriteString(step) + } } }