diff --git a/.changeset/patch-skip-write-permissions-staged-safe-outputs.md b/.changeset/patch-skip-write-permissions-staged-safe-outputs.md new file mode 100644 index 0000000000..b8c59b5056 --- /dev/null +++ b/.changeset/patch-skip-write-permissions-staged-safe-outputs.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Skip write permissions for staged safe output handlers so staged-only safe outputs compile with explicit empty permissions and avoid unnecessary checkout/setup steps. diff --git a/.github/workflows/daily-choice-test.lock.yml b/.github/workflows/daily-choice-test.lock.yml index 2bef28cf96..6cc82db0d3 100644 --- a/.github/workflows/daily-choice-test.lock.yml +++ b/.github/workflows/daily-choice-test.lock.yml @@ -909,6 +909,7 @@ jobs: - test_environment if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) runs-on: ubuntu-slim + permissions: {} concurrency: group: "gh-aw-conclusion-daily-choice-test" cancel-in-progress: false @@ -1017,6 +1018,7 @@ jobs: needs: agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim + permissions: {} timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/daily-choice-test" diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index 91ba0ca85d..bdce432d71 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -748,6 +748,7 @@ jobs: - safe_outputs if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) runs-on: ubuntu-slim + permissions: {} concurrency: group: "gh-aw-conclusion-notion-issue-summary" cancel-in-progress: false @@ -981,6 +982,7 @@ jobs: needs: agent if: (!cancelled()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim + permissions: {} timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/notion-issue-summary" diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index c474fb2489..cf2746e865 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1158,7 +1158,6 @@ jobs: /tmp/gh-aw/agent/ /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json - /tmp/gh-aw/aw-*.patch if-no-files-found: ignore # --- Threat Detection (inline) --- - name: Check if detection needed @@ -1294,11 +1293,7 @@ jobs: - upload_assets if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) runs-on: ubuntu-slim - permissions: - contents: write - discussions: write - issues: write - pull-requests: write + permissions: {} concurrency: group: "gh-aw-conclusion-poem-bot" cancel-in-progress: false @@ -1487,16 +1482,10 @@ jobs: await main(); safe_outputs: - needs: - - activation - - agent + needs: agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim - permissions: - contents: write - discussions: write - issues: write - pull-requests: write + permissions: {} timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/poem-bot" @@ -1548,34 +1537,6 @@ jobs: mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Checkout repository - if: (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))) || (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch'))) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} - token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - persist-credentials: false - fetch-depth: 1 - - name: Configure Git credentials - if: (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request'))) || (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch'))) - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - name: Configure GH_HOST for enterprise compatibility shell: bash run: | @@ -1594,7 +1555,6 @@ jobs: GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3,\"target\":\"*\"},\"add_labels\":{\"allowed\":[\"poetry\",\"creative\",\"automation\",\"ai-generated\",\"epic\",\"haiku\",\"sonnet\",\"limerick\"],\"max\":5},\"close_pull_request\":{\"max\":2,\"required_labels\":[\"poetry\",\"automation\"],\"required_title_prefix\":\"[🎨 POETRY]\",\"target\":\"*\"},\"create_agent_session\":{\"base\":\"main\",\"max\":1},\"create_discussion\":{\"category\":\"audits\",\"close_older_discussions\":true,\"expires\":24,\"fallback_to_issue\":true,\"labels\":[\"poetry\",\"automation\",\"ai-generated\"],\"max\":2,\"title_prefix\":\"[📜 POETRY] \"},\"create_issue\":{\"expires\":48,\"group\":true,\"labels\":[\"poetry\",\"automation\",\"ai-generated\"],\"max\":2,\"title_prefix\":\"[🎭 POEM-BOT] \"},\"create_pull_request\":{\"draft\":false,\"expires\":48,\"labels\":[\"poetry\",\"automation\",\"creative-writing\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"],\"reviewers\":[\"copilot\"],\"title_prefix\":\"[🎨 POETRY] \"},\"create_pull_request_review_comment\":{\"max\":2,\"side\":\"RIGHT\"},\"link_sub_issue\":{\"max\":3,\"parent_required_labels\":[\"poetry\",\"epic\"],\"parent_title_prefix\":\"[🎭 POEM-BOT]\",\"sub_required_labels\":[\"poetry\"],\"sub_title_prefix\":\"[🎭 POEM-BOT]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"]},\"update_issue\":{\"allow_body\":true,\"allow_status\":true,\"allow_title\":true,\"max\":2,\"target\":\"*\"},\"upload_asset\":{\"allowed-exts\":[\".png\",\".jpg\",\".jpeg\"],\"branch\":\"assets/${{ github.workflow }}\",\"max-size\":10240}}" GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-call-workflow.lock.yml b/.github/workflows/smoke-call-workflow.lock.yml index d6a8b43ebc..35a462c19c 100644 --- a/.github/workflows/smoke-call-workflow.lock.yml +++ b/.github/workflows/smoke-call-workflow.lock.yml @@ -865,6 +865,7 @@ jobs: - safe_outputs if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) runs-on: ubuntu-slim + permissions: {} concurrency: group: "gh-aw-conclusion-smoke-call-workflow" cancel-in-progress: false @@ -1004,6 +1005,7 @@ jobs: needs: agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim + permissions: {} timeout-minutes: 15 env: GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-call-workflow" diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 782262dffc..74fb9203af 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -2221,7 +2221,6 @@ jobs: /tmp/gh-aw/agent/ /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json - /tmp/gh-aw/aw-*.patch if-no-files-found: ignore # --- Threat Detection (inline) --- - name: Check if detection needed @@ -2368,7 +2367,7 @@ jobs: if: (always()) && ((needs.agent.result != 'skipped') || (needs.activation.outputs.lockdown_check_failed == 'true')) runs-on: ubuntu-slim permissions: - contents: write + contents: read discussions: write issues: write pull-requests: write @@ -2530,13 +2529,11 @@ jobs: await main(); safe_outputs: - needs: - - activation - - agent + needs: agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim permissions: - contents: write + contents: read discussions: write issues: write pull-requests: write @@ -2586,34 +2583,6 @@ jobs: mkdir -p /tmp/gh-aw/ find "/tmp/gh-aw/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Download patch artifact - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Checkout repository - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} - token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - persist-credentials: false - fetch-depth: 1 - - name: Configure Git credentials - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - name: Configure GH_HOST for enterprise compatibility shell: bash run: | @@ -2655,7 +2624,6 @@ jobs: GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUT_SCRIPTS: "{\"post_slack_message\":\"safe_output_script_post_slack_message.cjs\"}" GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"add_reviewer\":{\"max\":2,\"target\":\"*\"},\"close_pull_request\":{\"max\":1,\"staged\":true},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\",\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CLAUDE.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\",\".claude/\"],\"staged\":true,\"target\":\"*\"},\"resolve_pull_request_review_thread\":{\"max\":5},\"submit_pull_request_review\":{\"footer\":\"always\",\"max\":1},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":true,\"max\":1,\"target\":\"*\"}}" - GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/docs/src/content/docs/blog/2026-01-12-welcome-to-pelis-agent-factory.md b/docs/src/content/docs/blog/2026-01-12-welcome-to-pelis-agent-factory.md index d9885be43b..0d00172198 100644 --- a/docs/src/content/docs/blog/2026-01-12-welcome-to-pelis-agent-factory.md +++ b/docs/src/content/docs/blog/2026-01-12-welcome-to-pelis-agent-factory.md @@ -115,4 +115,4 @@ Want to start with automated agentic workflows on GitHub? See our [Quick Start]( ## Factory Status -[Current Factory Status](/gh-aw/agent-factory-status) +[Current Factory Status](/gh-aw/agent-factory-status/) diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-advanced-analytics.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-advanced-analytics.md index 8f3d2ea0f4..0a221bdd2a 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-advanced-analytics.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-advanced-analytics.md @@ -73,7 +73,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-campaigns.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-campaigns.md index 667aacf7ea..89411a28b9 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-campaigns.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-campaigns.md @@ -57,7 +57,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-improvement.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-improvement.md index d0622b0767..7aacfcc833 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-improvement.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-improvement.md @@ -142,7 +142,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Next Up: Continuous Documentation diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-refactoring.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-refactoring.md index da456a7265..38bea6ad96 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-refactoring.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-refactoring.md @@ -80,7 +80,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Next Up: Continuous Style diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-simplicity.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-simplicity.md index b6ce54fac3..0b1a980fdb 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-simplicity.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-simplicity.md @@ -76,7 +76,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Next Up: Continuous Refactoring diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-style.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-style.md index 3f956fd8b8..80ea1fa793 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-style.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-continuous-style.md @@ -56,7 +56,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specification to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Next Up: Continuous Improvement diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-creative-culture.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-creative-culture.md index e55119086c..179451dc88 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-creative-culture.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-creative-culture.md @@ -86,7 +86,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-documentation.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-documentation.md index dc97f75dc0..210a0857f3 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-documentation.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-documentation.md @@ -100,7 +100,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-interactive-chatops.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-interactive-chatops.md index 0ecacd352d..bdb41d08fb 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-interactive-chatops.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-interactive-chatops.md @@ -66,7 +66,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-issue-management.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-issue-management.md index 8f2a461aef..55b6bb7a8a 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-issue-management.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-issue-management.md @@ -73,7 +73,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-metrics-analytics.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-metrics-analytics.md index 540c123db2..cbdf0caad2 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-metrics-analytics.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-metrics-analytics.md @@ -64,7 +64,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-multi-phase.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-multi-phase.md index a7e19b4d70..2365c86431 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-multi-phase.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-multi-phase.md @@ -74,7 +74,7 @@ gh aw add-wizard githubnext/agentics/workflows/pr-fix.md Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-operations-release.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-operations-release.md index 59f5468abc..df68110eba 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-operations-release.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-operations-release.md @@ -47,7 +47,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-organization.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-organization.md index bbf564f389..b9b6274eb7 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-organization.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-organization.md @@ -64,7 +64,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-quality-hygiene.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-quality-hygiene.md index 1bd2e62272..3d715577e2 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-quality-hygiene.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-quality-hygiene.md @@ -68,7 +68,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-security-compliance.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-security-compliance.md index 52a367dbdd..996d8e8f00 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-security-compliance.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-security-compliance.md @@ -80,7 +80,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-testing-validation.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-testing-validation.md index ec36f85605..0ed9216a43 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-testing-validation.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-testing-validation.md @@ -102,7 +102,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-tool-infrastructure.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-tool-infrastructure.md index 48c93ca5d9..ce935c0ac5 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-tool-infrastructure.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows-tool-infrastructure.md @@ -66,7 +66,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specifications to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Learn More diff --git a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows.md b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows.md index c053e1a0a2..ebdc4a1219 100644 --- a/docs/src/content/docs/blog/2026-01-13-meet-the-workflows.md +++ b/docs/src/content/docs/blog/2026-01-13-meet-the-workflows.md @@ -92,7 +92,7 @@ gh aw add-wizard https://github.com/github/gh-aw/blob/v0.45.5/.github/workflows/ Then edit and remix the workflow specification to meet your needs, regenerate the lock file using `gh aw compile`, and push to your repository. See our [Quick Start](https://github.github.com/gh-aw/setup/quick-start/) for further installation and setup instructions. -You can also [create your own workflows](/gh-aw/setup/creating-workflows). +You can also [create your own workflows](/gh-aw/setup/creating-workflows/). ## Next Up: Code Quality & Refactoring Workflows diff --git a/docs/src/content/docs/guides/agentic-authoring.mdx b/docs/src/content/docs/guides/agentic-authoring.mdx index 78c513f598..cfdde6054a 100644 --- a/docs/src/content/docs/guides/agentic-authoring.mdx +++ b/docs/src/content/docs/guides/agentic-authoring.mdx @@ -8,7 +8,7 @@ import CopyEntireFileButton from '../../../components/CopyEntireFileButton.astro import Video from '../../../components/Video.astro'; Using our authoring agent is an effective way to create, debug, optimize your agentic workflows. -This is a continuation of the [Create Agentic Workflows](/gh-aw/setup/creating-workflows) page. +This is a continuation of the [Create Agentic Workflows](/gh-aw/setup/creating-workflows/) page. ## Configuring Your Repository diff --git a/docs/src/content/docs/setup/creating-workflows.mdx b/docs/src/content/docs/setup/creating-workflows.mdx index 16f40cb7d8..9e292eef22 100644 --- a/docs/src/content/docs/setup/creating-workflows.mdx +++ b/docs/src/content/docs/setup/creating-workflows.mdx @@ -148,4 +148,4 @@ To add a workflow from another repository, see [Reusing Workflows](/gh-aw/guides ## Learn More About Agentic Authoring -The [Agentic Authoring](/gh-aw/guides/agentic-authoring) contains additional techniques to leverage agents to help build better agentic workflows. \ No newline at end of file +The [Agentic Authoring](/gh-aw/guides/agentic-authoring/) contains additional techniques to leverage agents to help build better agentic workflows. \ No newline at end of file diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go index 659ea65eea..2d447c8fb1 100644 --- a/pkg/cli/compile_integration_test.go +++ b/pkg/cli/compile_integration_test.go @@ -1036,8 +1036,185 @@ Verify staged safe-outputs with multiple handler types. } } -// TestCompileFromSubdirectoryCreatesActionsLockAtRoot tests that actions-lock.json -// is created at the repository root when compiling from a subdirectory +// TestCompileStagedSafeOutputsPermissionsGlobal verifies that when safe-outputs has +// global staged: true, the compiled safe_outputs job has no job-level permissions block +// (staged mode emits only preview output; no GitHub API writes are performed). +func TestCompileStagedSafeOutputsPermissionsGlobal(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Staged Global Permissions +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + staged: true + create-issue: + title-prefix: "[staged] " + max: 1 + add-labels: + max: 3 + create-discussion: + max: 1 +--- + +Verify that global staged mode removes all write permissions from the safe_outputs job. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "staged-global-perms.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "staged-global-perms.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Global staged means no write API calls are made, so the safe_outputs job must + // have no job-level permissions block (permissions come from the workflow level). + if strings.Contains(lockContentStr, "issues: write") { + t.Errorf("Staged lock file should NOT contain 'issues: write' in safe_outputs job\nLock file content:\n%s", lockContentStr) + } + if strings.Contains(lockContentStr, "discussions: write") { + t.Errorf("Staged lock file should NOT contain 'discussions: write' in safe_outputs job\nLock file content:\n%s", lockContentStr) + } + if strings.Contains(lockContentStr, "pull-requests: write") { + t.Errorf("Staged lock file should NOT contain 'pull-requests: write' in safe_outputs job\nLock file content:\n%s", lockContentStr) + } + if strings.Contains(lockContentStr, "contents: write") { + t.Errorf("Staged lock file should NOT contain 'contents: write' in safe_outputs job\nLock file content:\n%s", lockContentStr) + } + + // Staged env var must still be present + if !strings.Contains(lockContentStr, `GH_AW_SAFE_OUTPUTS_STAGED: "true"`) { + t.Errorf("Lock file should contain GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\nLock file content:\n%s", lockContentStr) + } +} + +// TestCompileStagedSafeOutputsPermissionsPerHandler verifies that when only specific +// safe-output handlers have staged: true, only those handlers' write permissions are +// omitted. Non-staged handlers still contribute their required permissions. +func TestCompileStagedSafeOutputsPermissionsPerHandler(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Staged Per-Handler Permissions +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + create-issue: + staged: true + title-prefix: "[staged] " + max: 1 + add-labels: + max: 3 +--- + +Verify that per-handler staged mode removes only that handler's write permissions. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "staged-perhandler-perms.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "staged-perhandler-perms.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // add-labels is not staged and needs issues: write and pull-requests: write + if !strings.Contains(lockContentStr, "issues: write") { + t.Errorf("Lock file should contain 'issues: write' for non-staged add-labels\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, "pull-requests: write") { + t.Errorf("Lock file should contain 'pull-requests: write' for non-staged add-labels\nLock file content:\n%s", lockContentStr) + } + + // create-issue is staged so it must NOT add issues: write on its own. + // However add-labels already contributes issues: write, so we can only verify + // that discussions and contents: write are absent (which create-issue does not add + // anyway). The key behaviour is verified via the unit tests in safe_outputs_permissions_test.go. + if strings.Contains(lockContentStr, "discussions: write") { + t.Errorf("Lock file should NOT contain 'discussions: write' when only add-labels and staged create-issue are configured\nLock file content:\n%s", lockContentStr) + } + if strings.Contains(lockContentStr, "contents: write") { + t.Errorf("Lock file should NOT contain 'contents: write'\nLock file content:\n%s", lockContentStr) + } +} + +// TestCompileStagedSafeOutputsPermissionsAllHandlersStaged verifies that when all +// handlers are per-handler staged, the safe_outputs job has no write permissions. +func TestCompileStagedSafeOutputsPermissionsAllHandlersStaged(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: All Handlers Staged +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + create-issue: + staged: true + max: 1 + create-discussion: + staged: true + max: 1 +--- + +Verify that when all handlers are per-handler staged, no write permissions appear. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "staged-all-handlers.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "staged-all-handlers.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // All handlers are staged — no write permissions should appear in safe_outputs job + for _, perm := range []string{"issues: write", "discussions: write", "pull-requests: write", "contents: write"} { + if strings.Contains(lockContentStr, perm) { + t.Errorf("Staged lock file should NOT contain %q\nLock file content:\n%s", perm, lockContentStr) + } + } +} + func TestCompileFromSubdirectoryCreatesActionsLockAtRoot(t *testing.T) { setup := setupIntegrationTest(t) defer setup.cleanup() diff --git a/pkg/cli/workflows/test-staged-permissions-global.md b/pkg/cli/workflows/test-staged-permissions-global.md new file mode 100644 index 0000000000..b1d8a5aa1a --- /dev/null +++ b/pkg/cli/workflows/test-staged-permissions-global.md @@ -0,0 +1,22 @@ +--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + staged: true + create-issue: + title-prefix: "[staged] " + max: 1 + add-labels: + max: 3 + create-discussion: + max: 1 +--- + +# Test Staged Permissions (Global) + +Verify that when `staged: true` is set globally, the compiled safe_outputs job +has **no** job-level `permissions:` block (all handlers are staged, so no write +permissions are needed). diff --git a/pkg/cli/workflows/test-staged-permissions-per-handler.md b/pkg/cli/workflows/test-staged-permissions-per-handler.md new file mode 100644 index 0000000000..4f3421116a --- /dev/null +++ b/pkg/cli/workflows/test-staged-permissions-per-handler.md @@ -0,0 +1,22 @@ +--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + create-issue: + staged: true + title-prefix: "[staged] " + max: 1 + add-labels: + max: 3 +--- + +# Test Staged Permissions (Per-Handler) + +Verify that when only specific handlers have `staged: true`, the compiled +safe_outputs job only includes permissions required by the non-staged handlers. + +Here `create-issue` is staged (no write permissions for it), and `add-labels` +is not staged (needs `issues: write` and `pull-requests: write`). diff --git a/pkg/workflow/safe_outputs_config_helpers_test.go b/pkg/workflow/safe_outputs_config_helpers_test.go index 6c0700bbf1..8690a193a4 100644 --- a/pkg/workflow/safe_outputs_config_helpers_test.go +++ b/pkg/workflow/safe_outputs_config_helpers_test.go @@ -84,6 +84,62 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { }, expected: true, }, + { + name: "returns false when CreatePullRequests is globally staged", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expected: false, + }, + { + name: "returns false when PushToPullRequestBranch is globally staged", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, + }, + expected: false, + }, + { + name: "returns false when both PR handlers are globally staged", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreatePullRequests: &CreatePullRequestsConfig{}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, + }, + expected: false, + }, + { + name: "returns false when CreatePullRequests is per-handler staged", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + }, + expected: false, + }, + { + name: "returns false when both PR handlers are per-handler staged", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + }, + expected: false, + }, + { + name: "returns true when CreatePullRequests is not staged but PushToPullRequestBranch is staged", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + }, + expected: true, + }, + { + name: "returns true when PushToPullRequestBranch is not staged but CreatePullRequests is staged", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, + }, + expected: true, + }, } for _, tt := range tests { diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index 833eea6493..7df2a668b2 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -43,6 +43,14 @@ func stepsRequireIDToken(steps []any) bool { return false } +// isHandlerStaged returns true when a safe output handler is effectively staged +// (i.e., it will only emit preview output, not make real API calls). A handler is +// staged when either the global safe-outputs staged flag is true, or the +// per-handler staged flag is true. Staged handlers do not require write permissions. +func isHandlerStaged(globalStaged, handlerStaged bool) bool { + return globalStaged || handlerStaged +} + // ComputePermissionsForSafeOutputs computes the minimal required permissions // based on the configured safe-outputs. This function is used by both the // consolidated safe outputs job and the conclusion job to ensure they only @@ -50,6 +58,8 @@ func stepsRequireIDToken(steps []any) bool { // // This implements the principle of least privilege by only including // permissions that are required by the configured safe outputs. +// Handlers that are staged (globally or per-handler) are skipped because +// staged mode only emits preview output and does not make any API calls. func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissions { if safeOutputs == nil { safeOutputsPermissionsLog.Print("No safe outputs configured, returning empty permissions") @@ -58,57 +68,60 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio permissions := NewPermissions() - // Merge permissions for all handler-managed types - if safeOutputs.CreateIssues != nil { + // Merge permissions for all handler-managed types. + // Staged handlers are skipped because they do not make real API calls. + if safeOutputs.CreateIssues != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateIssues.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-issue") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.CreateDiscussions != nil { + if safeOutputs.CreateDiscussions != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateDiscussions.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-discussion") permissions.Merge(NewPermissionsContentsReadIssuesWriteDiscussionsWrite()) } - if safeOutputs.AddComments != nil { + if safeOutputs.AddComments != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AddComments.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for add-comment") permissions.Merge(buildAddCommentPermissions(safeOutputs.AddComments)) } - if safeOutputs.CloseIssues != nil { + if safeOutputs.CloseIssues != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CloseIssues.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for close-issue") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.CloseDiscussions != nil { + if safeOutputs.CloseDiscussions != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CloseDiscussions.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for close-discussion") permissions.Merge(NewPermissionsContentsReadDiscussionsWrite()) } - if safeOutputs.AddLabels != nil { + if safeOutputs.AddLabels != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AddLabels.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for add-labels") permissions.Merge(NewPermissionsContentsReadIssuesWritePRWrite()) } - if safeOutputs.RemoveLabels != nil { + if safeOutputs.RemoveLabels != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.RemoveLabels.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for remove-labels") permissions.Merge(NewPermissionsContentsReadIssuesWritePRWrite()) } - if safeOutputs.UpdateIssues != nil { + if safeOutputs.UpdateIssues != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdateIssues.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-issue") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.UpdateDiscussions != nil { + if safeOutputs.UpdateDiscussions != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdateDiscussions.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-discussion") permissions.Merge(NewPermissionsContentsReadDiscussionsWrite()) } - if safeOutputs.LinkSubIssue != nil { + if safeOutputs.LinkSubIssue != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.LinkSubIssue.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for link-sub-issue") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.UpdateRelease != nil { + if safeOutputs.UpdateRelease != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdateRelease.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-release") permissions.Merge(NewPermissionsContentsWrite()) } - if safeOutputs.CreatePullRequestReviewComments != nil || safeOutputs.SubmitPullRequestReview != nil || - safeOutputs.ReplyToPullRequestReviewComment != nil || safeOutputs.ResolvePullRequestReviewThread != nil { + if (safeOutputs.CreatePullRequestReviewComments != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreatePullRequestReviewComments.Staged)) || + (safeOutputs.SubmitPullRequestReview != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.SubmitPullRequestReview.Staged)) || + (safeOutputs.ReplyToPullRequestReviewComment != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.ReplyToPullRequestReviewComment.Staged)) || + (safeOutputs.ResolvePullRequestReviewThread != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.ResolvePullRequestReviewThread.Staged)) { safeOutputsPermissionsLog.Print("Adding permissions for PR review operations") permissions.Merge(NewPermissionsContentsReadPRWrite()) } - if safeOutputs.CreatePullRequests != nil { + if safeOutputs.CreatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreatePullRequests.Staged) { // Check fallback-as-issue setting to determine permissions if getFallbackAsIssue(safeOutputs.CreatePullRequests) { safeOutputsPermissionsLog.Print("Adding permissions for create-pull-request with fallback-as-issue") @@ -118,23 +131,23 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio permissions.Merge(NewPermissionsContentsWritePRWrite()) } } - if safeOutputs.PushToPullRequestBranch != nil { + if safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.PushToPullRequestBranch.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for push-to-pull-request-branch") permissions.Merge(NewPermissionsContentsWritePRWrite()) } - if safeOutputs.UpdatePullRequests != nil { + if safeOutputs.UpdatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdatePullRequests.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-pull-request") permissions.Merge(NewPermissionsContentsReadPRWrite()) } - if safeOutputs.ClosePullRequests != nil { + if safeOutputs.ClosePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.ClosePullRequests.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for close-pull-request") permissions.Merge(NewPermissionsContentsReadPRWrite()) } - if safeOutputs.MarkPullRequestAsReadyForReview != nil { + if safeOutputs.MarkPullRequestAsReadyForReview != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.MarkPullRequestAsReadyForReview.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for mark-pull-request-as-ready-for-review") permissions.Merge(NewPermissionsContentsReadPRWrite()) } - if safeOutputs.HideComment != nil { + if safeOutputs.HideComment != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.HideComment.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for hide-comment") // Check if discussions permission should be excluded (discussions: false) // Default (nil or true) includes discussions:write for GitHub Apps with Discussions permission @@ -145,60 +158,60 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio permissions.Merge(NewPermissionsContentsReadIssuesWriteDiscussionsWrite()) } } - if safeOutputs.DispatchWorkflow != nil { + if safeOutputs.DispatchWorkflow != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.DispatchWorkflow.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for dispatch-workflow") permissions.Merge(NewPermissionsActionsWrite()) } // Project-related types - if safeOutputs.CreateProjects != nil { + if safeOutputs.CreateProjects != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateProjects.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-project") permissions.Merge(NewPermissionsContentsReadProjectsWrite()) } - if safeOutputs.UpdateProjects != nil { + if safeOutputs.UpdateProjects != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdateProjects.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-project") permissions.Merge(NewPermissionsContentsReadProjectsWrite()) } - if safeOutputs.CreateProjectStatusUpdates != nil { + if safeOutputs.CreateProjectStatusUpdates != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateProjectStatusUpdates.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-project-status-update") permissions.Merge(NewPermissionsContentsReadProjectsWrite()) } - if safeOutputs.AssignToAgent != nil { + if safeOutputs.AssignToAgent != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AssignToAgent.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for assign-to-agent") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.CreateAgentSessions != nil { + if safeOutputs.CreateAgentSessions != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateAgentSessions.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-agent-session") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.CreateCodeScanningAlerts != nil { + if safeOutputs.CreateCodeScanningAlerts != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreateCodeScanningAlerts.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for create-code-scanning-alert") permissions.Merge(NewPermissionsContentsReadSecurityEventsWrite()) } - if safeOutputs.AutofixCodeScanningAlert != nil { + if safeOutputs.AutofixCodeScanningAlert != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AutofixCodeScanningAlert.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for autofix-code-scanning-alert") permissions.Merge(NewPermissionsContentsReadSecurityEventsWriteActionsRead()) } - if safeOutputs.AssignToUser != nil { + if safeOutputs.AssignToUser != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AssignToUser.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for assign-to-user") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.UnassignFromUser != nil { + if safeOutputs.UnassignFromUser != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UnassignFromUser.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for unassign-from-user") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.AssignMilestone != nil { + if safeOutputs.AssignMilestone != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AssignMilestone.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for assign-milestone") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.SetIssueType != nil { + if safeOutputs.SetIssueType != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.SetIssueType.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for set-issue-type") permissions.Merge(NewPermissionsContentsReadIssuesWrite()) } - if safeOutputs.AddReviewer != nil { + if safeOutputs.AddReviewer != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.AddReviewer.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for add-reviewer") permissions.Merge(NewPermissionsContentsReadPRWrite()) } - if safeOutputs.UploadAssets != nil { + if safeOutputs.UploadAssets != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UploadAssets.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for upload-asset") permissions.Merge(NewPermissionsContentsWrite()) } @@ -219,6 +232,15 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio permissions.Set(PermissionIdToken, PermissionWrite) } + // If safeOutputs is configured but no permissions were accumulated (all handlers staged), + // return explicit empty permissions so the compiled safe_outputs job renders + // "permissions: {}" rather than omitting the block and inheriting workflow-level permissions. + // This makes the security posture self-documenting in the generated YAML. + if len(permissions.permissions) == 0 { + safeOutputsPermissionsLog.Print("All handlers staged; returning explicit empty permissions (permissions: {})") + return NewPermissionsEmpty() + } + safeOutputsPermissionsLog.Printf("Computed permissions with %d scopes", len(permissions.permissions)) return permissions } diff --git a/pkg/workflow/safe_outputs_permissions_test.go b/pkg/workflow/safe_outputs_permissions_test.go index 993542c0fb..93ea41634c 100644 --- a/pkg/workflow/safe_outputs_permissions_test.go +++ b/pkg/workflow/safe_outputs_permissions_test.go @@ -570,3 +570,172 @@ func TestComputePermissionsForSafeOutputs_IDToken(t *testing.T) { }) } } + +func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expected map[PermissionScope]PermissionLevel + }{ + { + name: "global staged=true - no permissions for any handler", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreateIssues: &CreateIssuesConfig{}, + CreateDiscussions: &CreateDiscussionsConfig{}, + AddLabels: &AddLabelsConfig{}, + }, + expected: map[PermissionScope]PermissionLevel{}, + }, + { + name: "per-handler staged=true - staged handler contributes no permissions", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + }, + AddLabels: &AddLabelsConfig{}, + }, + // create-issue is staged so it contributes nothing; add-labels is not staged + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionRead, + PermissionIssues: PermissionWrite, + PermissionPullRequests: PermissionWrite, + }, + }, + { + name: "all handlers per-handler staged - no permissions", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + }, + CreateDiscussions: &CreateDiscussionsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + }, + }, + expected: map[PermissionScope]PermissionLevel{}, + }, + { + name: "global staged=true overrides per-handler staged=false", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: false}, + }, + DispatchWorkflow: &DispatchWorkflowConfig{}, + }, + expected: map[PermissionScope]PermissionLevel{}, + }, + { + name: "global staged=false, one handler staged=true", + safeOutputs: &SafeOutputsConfig{ + Staged: false, + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + }, + CloseIssues: &CloseIssuesConfig{}, + }, + // create-pull-request is staged; close-issue is not + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionRead, + PermissionIssues: PermissionWrite, + }, + }, + { + name: "global staged=true - upload-asset staged, no contents:write", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + UploadAssets: &UploadAssetsConfig{}, + }, + expected: map[PermissionScope]PermissionLevel{}, + }, + { + name: "pr review operations - all staged via global flag", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{}, + SubmitPullRequestReview: &SubmitPullRequestReviewConfig{}, + }, + expected: map[PermissionScope]PermissionLevel{}, + }, + { + name: "pr review operations - one staged, one not", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + }, + SubmitPullRequestReview: &SubmitPullRequestReviewConfig{}, + }, + // submit-pull-request-review is not staged, so PR write permissions are added + expected: map[PermissionScope]PermissionLevel{ + PermissionContents: PermissionRead, + PermissionPullRequests: PermissionWrite, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + permissions := ComputePermissionsForSafeOutputs(tt.safeOutputs) + require.NotNil(t, permissions, "Permissions should not be nil") + + // Check that all expected permissions are present + for scope, expectedLevel := range tt.expected { + actualLevel, exists := permissions.Get(scope) + assert.True(t, exists, "Permission scope %s should exist", scope) + assert.Equal(t, expectedLevel, actualLevel, "Permission level for %s should match", scope) + } + + // Check that no unexpected permissions are present + for scope := range permissions.permissions { + _, expected := tt.expected[scope] + assert.True(t, expected, "Unexpected permission scope: %s", scope) + } + }) + } +} + +// TestComputePermissionsForSafeOutputs_StagedYAMLRendering validates that fully-staged +// safe output configurations produce explicit "permissions: {}" in YAML rendering, +// rather than an empty string that would cause the job to inherit workflow-level permissions. +func TestComputePermissionsForSafeOutputs_StagedYAMLRendering(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expectedRendered string + }{ + { + name: "globally staged - renders permissions: {}", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreateIssues: &CreateIssuesConfig{}, + AddLabels: &AddLabelsConfig{}, + }, + expectedRendered: "permissions: {}", + }, + { + name: "all per-handler staged - renders permissions: {}", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + AddLabels: &AddLabelsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + }, + expectedRendered: "permissions: {}", + }, + { + name: "staged PR handlers - renders permissions: {}", + safeOutputs: &SafeOutputsConfig{ + Staged: true, + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectedRendered: "permissions: {}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + permissions := ComputePermissionsForSafeOutputs(tt.safeOutputs) + require.NotNil(t, permissions, "Permissions should not be nil") + rendered := permissions.RenderToYAML() + assert.Equal(t, tt.expectedRendered, rendered, "Fully-staged safe-outputs must render explicit empty permissions block") + }) + } +} diff --git a/pkg/workflow/safe_outputs_runtime.go b/pkg/workflow/safe_outputs_runtime.go index 81da2659cf..ce07fd36f0 100644 --- a/pkg/workflow/safe_outputs_runtime.go +++ b/pkg/workflow/safe_outputs_runtime.go @@ -28,13 +28,19 @@ func (c *Compiler) formatSafeOutputsRunsOn(safeOutputs *SafeOutputsConfig) strin } // usesPatchesAndCheckouts checks if the workflow uses safe outputs that require -// git patches and checkouts (create-pull-request or push-to-pull-request-branch) +// git patches and checkouts (create-pull-request or push-to-pull-request-branch). +// Staged handlers are excluded because they only emit preview output and do not +// perform real git operations or API calls. func usesPatchesAndCheckouts(safeOutputs *SafeOutputsConfig) bool { if safeOutputs == nil { return false } - result := safeOutputs.CreatePullRequests != nil || safeOutputs.PushToPullRequestBranch != nil - safeOutputsRuntimeLog.Printf("usesPatchesAndCheckouts: createPR=%v, pushToPRBranch=%v, result=%v", - safeOutputs.CreatePullRequests != nil, safeOutputs.PushToPullRequestBranch != nil, result) + createPRNeedsCheckout := safeOutputs.CreatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreatePullRequests.Staged) + pushToPRNeedsCheckout := safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.PushToPullRequestBranch.Staged) + result := createPRNeedsCheckout || pushToPRNeedsCheckout + safeOutputsRuntimeLog.Printf("usesPatchesAndCheckouts: createPR=%v(needsCheckout=%v), pushToPRBranch=%v(needsCheckout=%v), result=%v", + safeOutputs.CreatePullRequests != nil, createPRNeedsCheckout, + safeOutputs.PushToPullRequestBranch != nil, pushToPRNeedsCheckout, + result) return result }