diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index 42a2424239..9404c3fc57 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -25,9 +25,10 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"298055babdc6d453ead986983230edf4cbb2c74f82c5a110850af9b8a64e89c2","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"d30dc29a4e3303a626094750fe8c3efecfbbe25ab6b39a475ab3217871047ed4","strict":true} name: "Breaking Change Checker" "on": @@ -167,6 +168,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/breaking-change-checker.md b/.github/workflows/breaking-change-checker.md index bc272600d1..61843fca16 100644 --- a/.github/workflows/breaking-change-checker.md +++ b/.github/workflows/breaking-change-checker.md @@ -34,6 +34,7 @@ safe-outputs: run-failure: "🔬 Analysis interrupted! [{workflow_name}]({run_url}) {status}. Compatibility status unknown..." timeout-minutes: 10 imports: + - shared/activation-app.md - shared/reporting.md features: copilot-requests: true diff --git a/.github/workflows/code-scanning-fixer.lock.yml b/.github/workflows/code-scanning-fixer.lock.yml index 9005ab4f23..a931a29de6 100644 --- a/.github/workflows/code-scanning-fixer.lock.yml +++ b/.github/workflows/code-scanning-fixer.lock.yml @@ -23,7 +23,11 @@ # # Automatically fixes code scanning alerts by creating pull requests with remediation # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"356d127ca6b12cd898b6897498aa23822d2a75188b7e7564bc8b833190056a4b","strict":true} +# Resolved workflow manifest: +# Imports: +# - shared/activation-app.md +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c86a8c8936d098ce2da4bf71678186f60eb9aaed93086ab0692137208bd21398","strict":true} name: "Code Scanning Fixer" "on": @@ -166,6 +170,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/code-scanning-fixer.md}} GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" diff --git a/.github/workflows/code-scanning-fixer.md b/.github/workflows/code-scanning-fixer.md index a8deaa3ee5..9291478ae9 100644 --- a/.github/workflows/code-scanning-fixer.md +++ b/.github/workflows/code-scanning-fixer.md @@ -9,6 +9,8 @@ permissions: pull-requests: read security-events: read engine: copilot +imports: + - shared/activation-app.md tools: github: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/code-simplifier.lock.yml b/.github/workflows/code-simplifier.lock.yml index 09c37ead1a..6d4d0b0fff 100644 --- a/.github/workflows/code-simplifier.lock.yml +++ b/.github/workflows/code-simplifier.lock.yml @@ -25,9 +25,10 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"6ba60c66818393095f34e20338d7b05c7e2cf5f3cc398105e210b2d12622b7fa","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"46fcb0db5d0eb563835594c8aa08fa38761aa25be0bc7d3dae4293d504a5754e","strict":true} name: "Code Simplifier" "on": @@ -177,6 +178,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/code-simplifier.md b/.github/workflows/code-simplifier.md index 2adc3f3ce8..cfe59a43a1 100644 --- a/.github/workflows/code-simplifier.md +++ b/.github/workflows/code-simplifier.md @@ -13,6 +13,7 @@ permissions: tracker-id: code-simplifier imports: + - shared/activation-app.md - shared/reporting.md safe-outputs: diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index a42dd95fb4..af2f611797 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -25,11 +25,12 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/mcp/serena-go.md # - shared/reporting.md # - shared/safe-output-app.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"70afbdd1e3c59b27fde620365bdd2f0f14030571674bb7ed196cb3c56bf34979","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"2bf96ef660042f766ad92ab43f4a2b2e74bc1b62ad74c95679bed28e6fe4ce75","strict":true} name: "Daily File Diet" "on": @@ -169,6 +170,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-file-diet.md b/.github/workflows/daily-file-diet.md index 85c9164df7..5077120101 100644 --- a/.github/workflows/daily-file-diet.md +++ b/.github/workflows/daily-file-diet.md @@ -16,6 +16,7 @@ tracker-id: daily-file-diet engine: copilot imports: + - shared/activation-app.md - shared/reporting.md - shared/safe-output-app.md - shared/mcp/serena-go.md diff --git a/.github/workflows/daily-rendering-scripts-verifier.lock.yml b/.github/workflows/daily-rendering-scripts-verifier.lock.yml index 8a2fa00a2d..dded9b85f1 100644 --- a/.github/workflows/daily-rendering-scripts-verifier.lock.yml +++ b/.github/workflows/daily-rendering-scripts-verifier.lock.yml @@ -25,9 +25,10 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"381a6c01f344b342056507311653cad014f3159ed676431b41d1357e1c9fd3be","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"a7011f09224b79475d25a6867214db655a751d141f5c99b9344f89befa74976a","strict":true} name: "Daily Rendering Scripts Verifier" "on": @@ -178,6 +179,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-rendering-scripts-verifier.md b/.github/workflows/daily-rendering-scripts-verifier.md index 7f3e69538e..f87ed1a512 100644 --- a/.github/workflows/daily-rendering-scripts-verifier.md +++ b/.github/workflows/daily-rendering-scripts-verifier.md @@ -45,6 +45,7 @@ safe-outputs: timeout-minutes: 30 imports: + - shared/activation-app.md - shared/reporting.md --- diff --git a/.github/workflows/daily-safe-output-optimizer.lock.yml b/.github/workflows/daily-safe-output-optimizer.lock.yml index c63a241358..886fa8b9db 100644 --- a/.github/workflows/daily-safe-output-optimizer.lock.yml +++ b/.github/workflows/daily-safe-output-optimizer.lock.yml @@ -25,10 +25,11 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/jqschema.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"34459ba98cad0356b507423708958b0455022e2797d063650cc338a06efe8309","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"4dfd985f4be0de9f2d4f273608bccc24442362bc90d685918b77fe649be45793","strict":true} name: "Daily Safe Output Tool Optimizer" "on": @@ -176,6 +177,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/jqschema.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-safe-output-optimizer.md b/.github/workflows/daily-safe-output-optimizer.md index c56c918c95..f25f9963c8 100644 --- a/.github/workflows/daily-safe-output-optimizer.md +++ b/.github/workflows/daily-safe-output-optimizer.md @@ -35,6 +35,7 @@ timeout-minutes: 30 strict: true imports: + - shared/activation-app.md - shared/jqschema.md - shared/reporting.md --- diff --git a/.github/workflows/daily-testify-uber-super-expert.lock.yml b/.github/workflows/daily-testify-uber-super-expert.lock.yml index 28a56c8249..56d6975bf2 100644 --- a/.github/workflows/daily-testify-uber-super-expert.lock.yml +++ b/.github/workflows/daily-testify-uber-super-expert.lock.yml @@ -25,11 +25,12 @@ # # Resolved workflow manifest: # Imports: +# - shared/activation-app.md # - shared/mcp/serena-go.md # - shared/reporting.md # - shared/safe-output-app.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"0935d96e21c4e3fcee9b2a941f983c92b12d0ea27c07d196b6d43a60eb7e482f","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"395963d5f4f9f6fd0b3bcfde48ac88bd0e446e310cb08788e1a59c20bf43ff44","strict":true} name: "Daily Testify Uber Super Expert" "on": @@ -172,6 +173,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' diff --git a/.github/workflows/daily-testify-uber-super-expert.md b/.github/workflows/daily-testify-uber-super-expert.md index 9397631cde..b3da4c376a 100644 --- a/.github/workflows/daily-testify-uber-super-expert.md +++ b/.github/workflows/daily-testify-uber-super-expert.md @@ -15,6 +15,7 @@ tracker-id: daily-testify-uber-super-expert engine: copilot imports: + - shared/activation-app.md - shared/reporting.md - shared/safe-output-app.md - shared/mcp/serena-go.md diff --git a/.github/workflows/dead-code-remover.lock.yml b/.github/workflows/dead-code-remover.lock.yml index 1d0907a159..534a9f3678 100644 --- a/.github/workflows/dead-code-remover.lock.yml +++ b/.github/workflows/dead-code-remover.lock.yml @@ -23,7 +23,11 @@ # # Daily dead code assessment and removal — identifies unreachable Go functions using static analysis and creates a PR to remove a batch each day # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"fa086fa48d23515e37fdf92ef825e11e376fcedf5ffe99f7e5d9ce5164deb071","strict":true} +# Resolved workflow manifest: +# Imports: +# - shared/activation-app.md +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"00f650dd3f0d6abf021fc2e0e683c25aa13ee283334637072f1d6def43026a6a","strict":true} name: "Dead Code Removal Agent" "on": @@ -169,6 +173,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/dead-code-remover.md}} GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" diff --git a/.github/workflows/dead-code-remover.md b/.github/workflows/dead-code-remover.md index 725588a9b1..871c47015b 100644 --- a/.github/workflows/dead-code-remover.md +++ b/.github/workflows/dead-code-remover.md @@ -9,6 +9,8 @@ permissions: pull-requests: read issues: read engine: copilot +imports: + - shared/activation-app.md network: allowed: - defaults diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 158b97b89e..c92c3c5000 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -23,7 +23,11 @@ # # The Cookie Monster of issues - assigns issues to Copilot coding agent one at a time # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"2ababe1bdc7d094c401b9491ec0f362786fabd31e1e5fc1025db33681912136a","strict":true} +# Resolved workflow manifest: +# Imports: +# - shared/activation-app.md +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"429c9553c1b8706ab97bd48fe931129d4fdfe815c78402277377062529800d8a","strict":true} name: "Issue Monster" "on": @@ -181,6 +185,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/issue-monster.md}} GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" diff --git a/.github/workflows/issue-monster.md b/.github/workflows/issue-monster.md index 0615f91726..da0c5212fc 100644 --- a/.github/workflows/issue-monster.md +++ b/.github/workflows/issue-monster.md @@ -18,6 +18,9 @@ engine: id: copilot model: gpt-5.1-codex-mini +imports: + - shared/activation-app.md + timeout-minutes: 30 tools: diff --git a/.github/workflows/shared/activation-app.md b/.github/workflows/shared/activation-app.md new file mode 100644 index 0000000000..7f3d19321a --- /dev/null +++ b/.github/workflows/shared/activation-app.md @@ -0,0 +1,62 @@ +--- +#on: +# github-app: +# app-id: ${{ vars.APP_ID }} +# private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + + diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 86626eacd9..5323d3dae7 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -23,7 +23,11 @@ # # Maintains the gh-aw slide deck by scanning repository content and detecting layout issues using Playwright # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"a11988b356c426b5f0adcc819e558e7758398d328b7f90aa5173a4bd639bfdc9","strict":true} +# Resolved workflow manifest: +# Imports: +# - shared/activation-app.md +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"417e7c5f2ba13e6dcc483c5e726130f0b838707263a6dd784ad55a3fb80f1e83","strict":true} name: "Slide Deck Maintainer" "on": @@ -181,6 +185,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/slide-deck-maintainer.md}} GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" diff --git a/.github/workflows/slide-deck-maintainer.md b/.github/workflows/slide-deck-maintainer.md index 3e5c77a856..0ce343ff1c 100644 --- a/.github/workflows/slide-deck-maintainer.md +++ b/.github/workflows/slide-deck-maintainer.md @@ -19,6 +19,8 @@ concurrency: job-discriminator: ${{ inputs.focus || github.run_id }} tracker-id: slide-deck-maintainer engine: copilot +imports: + - shared/activation-app.md timeout-minutes: 45 tools: cache-memory: true diff --git a/.github/workflows/ubuntu-image-analyzer.lock.yml b/.github/workflows/ubuntu-image-analyzer.lock.yml index 3b8bb9bd23..cf49fdd546 100644 --- a/.github/workflows/ubuntu-image-analyzer.lock.yml +++ b/.github/workflows/ubuntu-image-analyzer.lock.yml @@ -23,7 +23,11 @@ # # Weekly analysis of the default Ubuntu Actions runner image and guidance for creating Docker mimics # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"c193dd6ba034f16860806d18b40a9d2afbe981db46a99a273e4b1f0ab4c7e182","strict":true} +# Resolved workflow manifest: +# Imports: +# - shared/activation-app.md +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"262aa2c38c59cd857d3031db4ff424e381cd83850ea0b9170f07e52e5fa75e70","strict":true} name: "Ubuntu Actions Image Analyzer" "on": @@ -173,6 +177,9 @@ jobs: GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/activation-app.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' {{#runtime-import .github/workflows/ubuntu-image-analyzer.md}} GH_AW_PROMPT_EOF } > "$GH_AW_PROMPT" diff --git a/.github/workflows/ubuntu-image-analyzer.md b/.github/workflows/ubuntu-image-analyzer.md index 1adbb01083..600b9509a4 100644 --- a/.github/workflows/ubuntu-image-analyzer.md +++ b/.github/workflows/ubuntu-image-analyzer.md @@ -14,6 +14,8 @@ permissions: tracker-id: ubuntu-image-analyzer engine: copilot +imports: + - shared/activation-app.md strict: true network: diff --git a/actions/setup/js/check_skip_if_helpers.cjs b/actions/setup/js/check_skip_if_helpers.cjs new file mode 100644 index 0000000000..1ede805da6 --- /dev/null +++ b/actions/setup/js/check_skip_if_helpers.cjs @@ -0,0 +1,21 @@ +// @ts-check +/// + +/** + * Builds the GitHub search query, optionally scoping it to the current repository. + * @param {string} skipQuery - The base query string + * @param {string|undefined} skipScope - The scope setting ('none' to disable repo scoping) + * @returns {string} The final search query + */ +function buildSearchQuery(skipQuery, skipScope) { + if (skipScope === "none") { + core.info(`Using raw query (scope: none): ${skipQuery}`); + return skipQuery; + } + const { owner, repo } = context.repo; + const searchQuery = `${skipQuery} repo:${owner}/${repo}`; + core.info(`Scoped query: ${searchQuery}`); + return searchQuery; +} + +module.exports = { buildSearchQuery }; diff --git a/actions/setup/js/check_skip_if_match.cjs b/actions/setup/js/check_skip_if_match.cjs index d646d03c93..6385262ac5 100644 --- a/actions/setup/js/check_skip_if_match.cjs +++ b/actions/setup/js/check_skip_if_match.cjs @@ -3,11 +3,13 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); +const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); async function main() { const skipQuery = process.env.GH_AW_SKIP_QUERY; const workflowName = process.env.GH_AW_WORKFLOW_NAME; const maxMatchesStr = process.env.GH_AW_SKIP_MAX_MATCHES ?? "1"; + const skipScope = process.env.GH_AW_SKIP_SCOPE; if (!skipQuery) { core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`); @@ -28,14 +30,11 @@ async function main() { core.info(`Checking skip-if-match query: ${skipQuery}`); core.info(`Maximum matches threshold: ${maxMatches}`); - const { owner, repo } = context.repo; - const scopedQuery = `${skipQuery} repo:${owner}/${repo}`; - - core.info(`Scoped query: ${scopedQuery}`); + const searchQuery = buildSearchQuery(skipQuery, skipScope); try { const response = await github.rest.search.issuesAndPullRequests({ - q: scopedQuery, + q: searchQuery, per_page: 1, }); diff --git a/actions/setup/js/check_skip_if_match.test.cjs b/actions/setup/js/check_skip_if_match.test.cjs index 92713d1446..88faea8bf7 100644 --- a/actions/setup/js/check_skip_if_match.test.cjs +++ b/actions/setup/js/check_skip_if_match.test.cjs @@ -174,3 +174,68 @@ const mockCore = { })); })); })); + +describe("check_skip_if_match.cjs - scope support", () => { + const { main } = require("./check_skip_if_match.cjs"); + const { ERR_CONFIG } = require("./error_codes.cjs"); + + let mockCoreScope; + let mockGithubScope; + let mockContextScope; + + beforeEach(() => { + vi.clearAllMocks(); + mockCoreScope = { + info: vi.fn(), + warning: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + }; + mockGithubScope = { rest: { search: { issuesAndPullRequests: vi.fn() } } }; + mockContextScope = { repo: { owner: "testowner", repo: "testrepo" } }; + global.core = mockCoreScope; + global.github = mockGithubScope; + global.context = mockContextScope; + }); + + afterEach(() => { + delete process.env.GH_AW_SKIP_QUERY; + delete process.env.GH_AW_WORKFLOW_NAME; + delete process.env.GH_AW_SKIP_MAX_MATCHES; + delete process.env.GH_AW_SKIP_SCOPE; + }); + + it("should use raw query when GH_AW_SKIP_SCOPE is 'none'", async () => { + process.env.GH_AW_SKIP_QUERY = "org:myorg label:blocked is:issue is:open"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + process.env.GH_AW_SKIP_SCOPE = "none"; + + let capturedQuery; + mockGithubScope.rest.search.issuesAndPullRequests.mockImplementation(async ({ q }) => { + capturedQuery = q; + return { data: { total_count: 0 } }; + }); + + await main(); + + expect(capturedQuery).toBe("org:myorg label:blocked is:issue is:open"); + expect(mockCoreScope.info).toHaveBeenCalledWith("Using raw query (scope: none): org:myorg label:blocked is:issue is:open"); + expect(mockCoreScope.setOutput).toHaveBeenCalledWith("skip_check_ok", "true"); + }); + + it("should scope query to repo when GH_AW_SKIP_SCOPE is not set", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue is:open label:bug"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + + let capturedQuery; + mockGithubScope.rest.search.issuesAndPullRequests.mockImplementation(async ({ q }) => { + capturedQuery = q; + return { data: { total_count: 0 } }; + }); + + await main(); + + expect(capturedQuery).toBe("is:issue is:open label:bug repo:testowner/testrepo"); + expect(mockCoreScope.info).toHaveBeenCalledWith("Scoped query: is:issue is:open label:bug repo:testowner/testrepo"); + }); +}); diff --git a/actions/setup/js/check_skip_if_no_match.cjs b/actions/setup/js/check_skip_if_no_match.cjs index 083ea68eb1..8d08906832 100644 --- a/actions/setup/js/check_skip_if_no_match.cjs +++ b/actions/setup/js/check_skip_if_no_match.cjs @@ -3,9 +3,10 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); +const { buildSearchQuery } = require("./check_skip_if_helpers.cjs"); async function main() { - const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MIN_MATCHES: minMatchesStr = "1" } = process.env; + const { GH_AW_SKIP_QUERY: skipQuery, GH_AW_WORKFLOW_NAME: workflowName, GH_AW_SKIP_MIN_MATCHES: minMatchesStr = "1", GH_AW_SKIP_SCOPE: skipScope } = process.env; if (!skipQuery) { core.setFailed(`${ERR_CONFIG}: Configuration error: GH_AW_SKIP_QUERY not specified.`); @@ -26,16 +27,13 @@ async function main() { core.info(`Checking skip-if-no-match query: ${skipQuery}`); core.info(`Minimum matches threshold: ${minMatches}`); - const { owner, repo } = context.repo; - const scopedQuery = `${skipQuery} repo:${owner}/${repo}`; - - core.info(`Scoped query: ${scopedQuery}`); + const searchQuery = buildSearchQuery(skipQuery, skipScope); try { const { data: { total_count: totalCount }, } = await github.rest.search.issuesAndPullRequests({ - q: scopedQuery, + q: searchQuery, per_page: 1, }); diff --git a/actions/setup/js/check_skip_if_no_match.test.cjs b/actions/setup/js/check_skip_if_no_match.test.cjs index 449010078a..8d89d7bfbe 100644 --- a/actions/setup/js/check_skip_if_no_match.test.cjs +++ b/actions/setup/js/check_skip_if_no_match.test.cjs @@ -215,4 +215,41 @@ describe("check_skip_if_no_match", () => { expect(mockCore.infos).toContain("Scoped query: is:open is:issue label:enhancement repo:test-owner/test-repo"); expect(mockCore.infos).toContain("Search found 8 matching items"); }); + + it("should use raw query when GH_AW_SKIP_SCOPE is 'none'", async () => { + process.env.GH_AW_SKIP_QUERY = "org:myorg label:agent-fix is:issue is:open"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + process.env.GH_AW_SKIP_SCOPE = "none"; + delete process.env.GH_AW_SKIP_MIN_MATCHES; + + let capturedQuery; + mockGithub.rest.search.issuesAndPullRequests = async ({ q }) => { + capturedQuery = q; + return { data: { total_count: 3 } }; + }; + + await main(); + + expect(capturedQuery).toBe("org:myorg label:agent-fix is:issue is:open"); + expect(mockCore.infos).toContain("Using raw query (scope: none): org:myorg label:agent-fix is:issue is:open"); + expect(mockCore.outputs["skip_no_match_check_ok"]).toBe("true"); + }); + + it("should scope query to repo when GH_AW_SKIP_SCOPE is not set", async () => { + process.env.GH_AW_SKIP_QUERY = "is:issue is:open label:bug"; + process.env.GH_AW_WORKFLOW_NAME = "test-workflow"; + delete process.env.GH_AW_SKIP_SCOPE; + delete process.env.GH_AW_SKIP_MIN_MATCHES; + + let capturedQuery; + mockGithub.rest.search.issuesAndPullRequests = async ({ q }) => { + capturedQuery = q; + return { data: { total_count: 1 } }; + }; + + await main(); + + expect(capturedQuery).toBe("is:issue is:open label:bug repo:test-owner/test-repo"); + expect(mockCore.infos).toContain("Scoped query: is:issue is:open label:bug repo:test-owner/test-repo"); + }); }); diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index ba038de022..9f3853e0a7 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -64,6 +64,16 @@ metadata: # (optional) imports: [] +# Optional list of additional workflow or action files that should be fetched +# alongside this workflow when running 'gh aw add'. Entries are relative paths +# (from the same directory as this workflow in the source repository) to agentic +# workflow .md files or GitHub Actions .yml/.yaml files. GitHub Actions expression +# syntax (${{) is not allowed in resource paths. +# (optional) +resources: [] + # Array of Relative path to a workflow .md file or action .yml/.yaml file. Must be + # a static path; GitHub Actions expression syntax (${{) is not allowed. + # If true, inline all imports (including those without inputs) at compilation time # in the generated lock.yml instead of using runtime-import macros. When enabled, # the frontmatter hash covers the entire markdown body so any change to the @@ -547,8 +557,9 @@ on: stop-after: "example-value" # Conditionally skip workflow execution when a GitHub search query has matches. - # Can be a string (query only, implies max=1) or an object with 'query' and - # optional 'max' fields. + # Can be a string (query only, implies max=1) or an object with 'query', optional + # 'max', and 'scope' fields. Use top-level on.github-token or on.github-app for + # custom authentication. # (optional) # This field supports multiple formats (oneOf): @@ -558,7 +569,9 @@ on: # label:bug' skip-if-match: "example-value" - # Option 2: Skip-if-match configuration object with query and maximum match count + # Option 2: Skip-if-match configuration object with query, maximum match count, + # and optional scope. For custom authentication use the top-level on.github-token + # or on.github-app fields. skip-if-match: # GitHub search query string to check before running workflow. Query is # automatically scoped to the current repository. @@ -576,9 +589,15 @@ on: # Option 2: GitHub Actions expression that resolves to an integer at runtime max: "example-value" + # Scope for the search query. Set to 'none' to disable the automatic + # 'repo:owner/repo' scoping, enabling org-wide or cross-repo queries. + # (optional) + scope: "none" + # Conditionally skip workflow execution when a GitHub search query has no matches # (or fewer than minimum). Can be a string (query only, implies min=1) or an - # object with 'query' and optional 'min' fields. + # object with 'query', optional 'min', and 'scope' fields. Use top-level + # on.github-token or on.github-app for custom authentication. # (optional) # This field supports multiple formats (oneOf): @@ -588,8 +607,9 @@ on: # label:ready-to-deploy' skip-if-no-match: "example-value" - # Option 2: Skip-if-no-match configuration object with query and minimum match - # count + # Option 2: Skip-if-no-match configuration object with query, minimum match count, + # and optional scope. For custom authentication use the top-level on.github-token + # or on.github-app fields. skip-if-no-match: # GitHub search query string to check before running workflow. Query is # automatically scoped to the current repository. @@ -600,6 +620,11 @@ on: # (optional) min: 1 + # Scope for the search query. Set to 'none' to disable the automatic + # 'repo:owner/repo' scoping, enabling org-wide or cross-repo queries. + # (optional) + scope: "none" + # Skip workflow execution for users with specific repository roles. Useful for # workflows that should only run for external contributors or specific permission # levels. @@ -682,15 +707,17 @@ on: # (optional) status-comment: true - # Custom GitHub token to use for pre-activation reactions and activation status - # comments. When specified, overrides the default GITHUB_TOKEN for these - # operations. + # Custom GitHub token for pre-activation reactions, activation status comments, + # and skip-if search queries. When specified, overrides the default GITHUB_TOKEN + # for these operations. # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" - # GitHub App configuration for minting a token used in pre-activation reactions - # and activation status comments. When configured, a GitHub App installation - # access token is minted and used instead of the default GITHUB_TOKEN. + # GitHub App configuration for minting a token used in pre-activation reactions, + # activation status comments, and skip-if search queries. When configured, a + # single GitHub App installation access token is minted and shared across all + # these operations instead of using the default GITHUB_TOKEN. Can be defined in a + # shared agentic workflow and inherited by importing workflows. # (optional) github-app: # GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token. @@ -895,6 +922,7 @@ concurrency: # Concurrency group name. Workflows in the same group cannot run simultaneously. # Supports GitHub Actions expressions for dynamic group names based on branch, # workflow, or other context. + # (optional) group: "example-value" # Whether to cancel in-progress workflows in the same concurrency group when a new @@ -905,13 +933,14 @@ concurrency: cancel-in-progress: true # Additional discriminator expression appended to compiler-generated job-level - # concurrency groups (agent, output jobs). Use this in fan-out patterns where - # multiple workflow instances are dispatched concurrently with different inputs, - # to prevent job-level concurrency groups from colliding and causing cancellations. - # Supports GitHub Actions expressions. Stripped from the compiled lock file - # (gh-aw extension, not a GitHub Actions field). + # concurrency groups (agent, output jobs). Use this when multiple workflow + # instances are dispatched concurrently with different inputs (fan-out pattern) to + # prevent job-level concurrency groups from colliding. For example, '${{ + # inputs.finding_id }}' ensures each dispatched run gets a unique job-level group. + # Supports GitHub Actions expressions. This field is stripped from the compiled + # lock file (it is a gh-aw extension, not a GitHub Actions field). # (optional) - job-discriminator: "${{ inputs.finding_id }}" + job-discriminator: "example-value" # Environment variables for the workflow # (optional) @@ -1306,16 +1335,16 @@ post-steps: [] # (optional) # This field supports multiple formats (oneOf): -# Option 1: Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub -# Copilot CLI), 'codex' (OpenAI Codex CLI), or 'gemini' (Google Gemini CLI) -engine: "claude" +# Option 1: Engine name: built-in ('claude', 'codex', 'copilot', 'gemini') or a +# named catalog entry +engine: "example-value" # Option 2: Extended engine configuration object with advanced options for model # selection, turn limiting, environment variables, and custom steps engine: - # AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), - # 'copilot' (GitHub Copilot CLI), or 'gemini' (Google Gemini CLI) - id: "claude" + # AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini') or a + # named catalog entry + id: "example-value" # Optional version of the AI engine action (e.g., 'beta', 'stable', 20). Has # sensible defaults and can typically be omitted. Numeric values are automatically @@ -1422,9 +1451,9 @@ engine: agent: "example-value" # Custom API endpoint hostname for the agentic engine. Used for GitHub Enterprise - # Cloud (GHEC), GitHub Enterprise Server (GHES), or custom AI endpoints. - # Accepts a hostname only (no protocol or path). - # Examples: "api.acme.ghe.com" (GHEC), "api.enterprise.githubcopilot.com" (GHES) + # Cloud (GHEC), GitHub Enterprise Server (GHES), or custom AI endpoints. Example: + # 'api.acme.ghe.com' for GHEC, 'api.enterprise.githubcopilot.com' for GHES, or + # custom endpoint hostnames. # (optional) api-target: "example-value" @@ -1434,6 +1463,186 @@ engine: args: [] # Array of strings +# Option 3: Inline engine definition: specifies a runtime adapter and optional +# provider settings directly in the workflow frontmatter, without requiring a +# named catalog entry +engine: + # Runtime adapter reference for the inline engine definition + runtime: + # Runtime adapter identifier (e.g. 'codex', 'claude', 'copilot', 'gemini') + id: "example-value" + + # Optional version of the runtime adapter (e.g. '0.105.0', 'beta') + # (optional) + version: null + + # Optional provider configuration for the inline engine definition + # (optional) + provider: + # Provider identifier (e.g. 'openai', 'anthropic', 'github', 'google') + # (optional) + id: "example-value" + + # Optional specific LLM model to use (e.g. 'gpt-5', 'claude-3-5-sonnet-20241022') + # (optional) + model: "example-value" + + # Authentication configuration for the provider + # (optional) + auth: + # Name of the GitHub Actions secret that contains the API key for this provider + # (optional) + secret: "example-value" + + # Authentication strategy for the provider (default: api-key when secret is set) + # (optional) + strategy: "api-key" + + # OAuth 2.0 token endpoint URL. Required when strategy is + # 'oauth-client-credentials'. + # (optional) + token-url: "example-value" + + # GitHub Actions secret name that holds the OAuth client ID. Required when + # strategy is 'oauth-client-credentials'. + # (optional) + client-id: "example-value" + + # GitHub Actions secret name that holds the OAuth client secret. Required when + # strategy is 'oauth-client-credentials'. + # (optional) + client-secret: "example-value" + + # JSON field name in the token response that contains the access token. Defaults + # to 'access_token'. + # (optional) + token-field: "example-value" + + # HTTP header name to inject the API key or token into (e.g. 'api-key', + # 'x-api-key'). Required when strategy is not 'bearer'. + # (optional) + header-name: "example-value" + + # Request shaping configuration for non-standard provider URL and body + # transformations + # (optional) + request: + # URL path template with {model} and other variable placeholders (e.g. + # '/openai/deployments/{model}/chat/completions') + # (optional) + path-template: "example-value" + + # Static or template query-parameter values appended to every request + # (optional) + query: + {} + + # Key/value pairs injected into the JSON request body before sending + # (optional) + body-inject: + {} + +# Option 4: Engine definition: full declarative metadata for a named engine entry +# (used in builtin engine shared workflow files such as @builtin:engines/*.md) +engine: + # Unique engine identifier (e.g. 'copilot', 'claude', 'codex', 'gemini') + id: "example-value" + + # Human-readable display name for the engine + display-name: "example-value" + + # Human-readable description of the engine + # (optional) + description: "Description of the workflow" + + # Runtime adapter identifier. Maps to the CodingAgentEngine registered in the + # engine registry. Defaults to id when omitted. + # (optional) + runtime-id: "example-value" + + # Provider metadata for the engine + # (optional) + provider: + # Provider name (e.g. 'anthropic', 'github', 'google', 'openai') + # (optional) + name: "My Workflow" + + # Default authentication configuration for the provider + # (optional) + auth: + # Name of the GitHub Actions secret that contains the API key + # (optional) + secret: "example-value" + + # Authentication strategy + # (optional) + strategy: "api-key" + + # OAuth 2.0 token endpoint URL + # (optional) + token-url: "example-value" + + # GitHub Actions secret name for the OAuth client ID + # (optional) + client-id: "example-value" + + # GitHub Actions secret name for the OAuth client secret + # (optional) + client-secret: "example-value" + + # JSON field name in the token response containing the access token + # (optional) + token-field: "example-value" + + # HTTP header name to inject the API key or token into + # (optional) + header-name: "example-value" + + # Request shaping configuration + # (optional) + request: + # URL path template with variable placeholders + # (optional) + path-template: "example-value" + + # Static query parameters + # (optional) + query: + {} + + # Key/value pairs injected into the JSON request body + # (optional) + body-inject: + {} + + # Model selection configuration for the engine + # (optional) + models: + # Default model identifier + # (optional) + default: "example-value" + + # List of supported model identifiers + # (optional) + supported: [] + # Array of strings + + # Authentication bindings — maps logical roles (e.g. 'api-key') to GitHub Actions + # secret names + # (optional) + auth: [] + # Array items: + # Logical authentication role (e.g. 'api-key', 'token') + role: "example-value" + + # Name of the GitHub Actions secret that provides credentials for this role + secret: "example-value" + + # Additional engine-specific options + # (optional) + options: + {} + # MCP server definitions # (optional) mcp-servers: @@ -1512,22 +1721,26 @@ tools: # Array of Mount specification in format 'host:container:mode' # Guard policy: repository access configuration. Restricts which repositories the - # agent can access. Use 'all' to allow all repos or an array of 'owner/repo' - # strings. + # agent can access. Use 'all' to allow all repos, 'public' for public repositories + # only, or an array of repository patterns (e.g., 'owner/repo', 'owner/*', + # 'owner/prefix*'). # (optional) # This field supports multiple formats (oneOf): - # Option 1: Allow access to all repositories + # Option 1: Allow access to all repositories ('all') or only public repositories + # ('public') repos: "all" - # Option 2: Allow access to specific repositories + # Option 2: Allow access to specific repositories using patterns (e.g., + # 'owner/repo', 'owner/*', 'owner/prefix*') repos: [] - # Array items: Repository slug in the format 'owner/repo' + # Array items: Repository pattern in the format 'owner/repo', 'owner/*' (all repos + # under owner), or 'owner/prefix*' (repos with name prefix) # Guard policy: minimum required integrity level for repository access. Restricts # the agent to users with at least the specified permission level. # (optional) - min-integrity: "unapproved" + min-integrity: "none" # GitHub App configuration for token minting. When configured, a GitHub App # installation access token is minted at workflow start and used instead of the @@ -1873,6 +2086,11 @@ tools: # (optional) max-file-count: 1 + # Maximum total patch size in bytes (default: 10240 = 10KB, max: 102400 = 100KB). + # The total size of the git diff must not exceed this value. + # (optional) + max-patch-size: 1 + # Optional description for the memory that will be shown in the agent prompt # (optional) description: "Description of the workflow" @@ -1881,11 +2099,11 @@ tools: # (optional) create-orphan: true - # Use the GitHub Wiki git repository instead of the regular repository. When enabled, - # files are stored in and read from the wiki, and the agent will be instructed to - # follow GitHub Wiki markdown syntax (default: false) + # Use the GitHub Wiki git repository instead of the regular repository. When + # enabled, files are stored in and read from the wiki, and the agent will be + # instructed to follow GitHub Wiki markdown syntax (default: false) # (optional) - wiki: false + wiki: true # List of allowed file extensions (e.g., [".json", ".txt"]). Default: [".json", # ".jsonl", ".txt", ".md", ".csv"] @@ -2964,19 +3182,23 @@ safe-outputs: # (optional) github-token-for-extra-empty-commit: "example-value" - # Controls protected-file protection policy for this safe output. blocked - # (default): hard-block any patch that modifies package manifests (e.g. - # package.json, go.mod), engine instruction files (e.g. AGENTS.md, CLAUDE.md) or - # .github/ files. allowed: allow all changes. fallback-to-issue: push the branch - # but create a review issue instead of a PR so a human can review before merging. + # Controls protected-file protection. blocked (default): hard-block any patch that + # modifies package manifests (e.g. package.json, go.mod), engine instruction files + # (e.g. AGENTS.md, CLAUDE.md) or .github/ files. allowed: allow all changes. + # fallback-to-issue: push the branch but create a review issue instead of a PR, so + # a human can review the manifest changes before merging. # (optional) protected-files: "blocked" - # List of glob patterns for files the workflow is allowed to modify. Acts as a - # strict allowlist: every file in the patch must match at least one pattern. Runs - # independently of protected-files; both checks must pass. To modify a protected - # file it must both match allowed-files and have protected-files set to 'allowed'. - # Supports * (any characters except /) and ** (any characters including /). + # Exclusive allowlist of glob patterns. When set, every file in the patch must + # match at least one pattern — files outside the list are always refused, + # including normal source files. This is a restriction, not an exception: setting + # allowed-files: [".github/workflows/*"] blocks all other files. To allow multiple + # sets of files, list all patterns explicitly. Acts independently of the + # protected-files policy; both checks must pass. To modify a protected file, it + # must both match allowed-files and be permitted by protected-files (e.g. + # protected-files: allowed). Supports * (any characters except /) and ** (any + # characters including /). # (optional) allowed-files: [] # Array of strings @@ -3085,6 +3307,19 @@ safe-outputs: # (optional) target: "example-value" + # Target repository in format 'owner/repo' for cross-repository PR review + # submission. Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # List of additional repositories in format 'owner/repo' that PR reviews can be + # submitted in. When specified, the agent can use a 'repo' field in the output to + # specify which repository to submit the review in. The target repository (current + # or target-repo) is always implicitly allowed. + # (optional) + allowed-repos: [] + # Array of strings + # GitHub token to use for this specific output type. Overrides global github-token # if specified. # (optional) @@ -3906,19 +4141,23 @@ safe-outputs: allowed-repos: [] # Array of strings - # Controls protected-file protection policy for this safe output. blocked - # (default): hard-block any patch that modifies package manifests (e.g. - # package.json, go.mod), engine instruction files (e.g. AGENTS.md, CLAUDE.md) or - # .github/ files. allowed: allow all changes. fallback-to-issue: create a review - # issue instead of pushing so a human can review before applying the changes. + # Controls protected-file protection. blocked (default): hard-block any patch that + # modifies package manifests (e.g. package.json, go.mod), engine instruction files + # (e.g. AGENTS.md, CLAUDE.md) or .github/ files. allowed: allow all changes. + # fallback-to-issue: create a review issue instead of pushing to the PR branch, so + # a human can review the changes before applying. # (optional) protected-files: "blocked" - # List of glob patterns for files the workflow is allowed to modify. Acts as a - # strict allowlist: every file in the patch must match at least one pattern. Runs - # independently of protected-files; both checks must pass. To modify a protected - # file it must both match allowed-files and have protected-files set to 'allowed'. - # Supports * (any characters except /) and ** (any characters including /). + # Exclusive allowlist of glob patterns. When set, every file in the patch must + # match at least one pattern — files outside the list are always refused, + # including normal source files. This is a restriction, not an exception: setting + # allowed-files: [".github/workflows/*"] blocks all other files. To allow multiple + # sets of files, list all patterns explicitly. Acts independently of the + # protected-files policy; both checks must pass. To modify a protected file, it + # must both match allowed-files and be permitted by protected-files (e.g. + # protected-files: allowed). Supports * (any characters except /) and ** (any + # characters including /). # (optional) allowed-files: [] # Array of strings @@ -4043,6 +4282,18 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # Target repository in format 'owner/repo' for cross-repository workflow dispatch. + # When specified, the workflow will be dispatched to the target repository instead + # of the current one. + # (optional) + target-repo: "example-value" + + # Git ref (branch, tag, or SHA) to use when dispatching the workflow. For + # workflow_call relay scenarios this is auto-injected by the compiler from + # needs.activation.outputs.target_ref. Overrides the caller's GITHUB_REF. + # (optional) + target-ref: "example-value" + # Option 2: Shorthand array format: list of workflow names (without .md extension) # to allow dispatching dispatch-workflow: [] @@ -4497,6 +4748,18 @@ safe-outputs: # (optional) group-reports: true + # When false, disables creating failure tracking issues when workflows fail. + # Useful for workflows where failures are expected or handled elsewhere. Defaults + # to true. + # (optional) + report-failure-as-issue: true + + # Repository to create failure tracking issues in, in the format 'owner/repo'. + # Useful when the current repository has issues disabled. Defaults to the current + # repository. + # (optional) + failure-issue-repo: "example-value" + # Maximum number of bot trigger references (e.g. 'fixes #123', 'closes #456') # allowed in output before all of them are neutralized. Default: 10. Supports # integer or GitHub Actions expression (e.g. '${{ inputs.max-bot-mentions }}'). @@ -4526,6 +4789,25 @@ safe-outputs: # (optional) concurrency-group: "example-value" + # Override the GitHub deployment environment for the safe-outputs job. When set, + # this environment is used instead of the top-level environment: field. When not + # set, the top-level environment: field is propagated automatically so that + # environment-scoped secrets are accessible in the safe-outputs job. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Environment name as a string + environment: "example-value" + + # Option 2: Environment object with name and optional URL + environment: + # The name of the environment configured in the repo + name: "My Workflow" + + # A deployment URL + # (optional) + url: "example-value" + # Runner specification for all safe-outputs jobs (activation, create-issue, # add-comment, etc.). Single runner label (e.g., 'ubuntu-slim', 'ubuntu-latest', # 'windows-latest', 'self-hosted'). Defaults to 'ubuntu-slim'. See @@ -4638,6 +4920,27 @@ runtimes: # Option 2: Multiple checkout configurations checkout: [] # Array items: undefined + +# APM package references to install. Supports array format (list of package slugs) +# or object format with packages and isolated fields. +# (optional) +# This field supports multiple formats (oneOf): + +# Option 1: Simple array of APM package references. +dependencies: [] + # Array items: APM package reference in the format 'org/repo' or + # 'org/repo/path/to/skill' + +# Option 2: Object format with packages and optional isolated flag. +dependencies: + # List of APM package references to install. + packages: [] + # Array of APM package reference in the format 'org/repo' or + # 'org/repo/path/to/skill' + + # If true, agent restore step clears primitive dirs before unpacking. + # (optional) + isolated: true --- ``` diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 73c0233cd1..2800250c44 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -35,8 +35,10 @@ The `on:` section uses standard GitHub Actions syntax to define workflow trigger - `forks:` - Configure fork filtering for pull_request triggers - `skip-roles:` - Skip workflow execution for specific repository roles - `skip-bots:` - Skip workflow execution for specific GitHub actors -- `github-token:` - Custom token for activation job reactions and status comments -- `github-app:` - GitHub App for minting a short-lived token used by the activation job +- `skip-if-match:` - Skip execution when a search query has matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth) +- `skip-if-no-match:` - Skip execution when a search query has no matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth) +- `github-token:` - Custom token for activation job reactions, status comments, and skip-if search queries +- `github-app:` - GitHub App for minting a short-lived token used by the activation job and all skip-if search steps See [Trigger Events](/gh-aw/reference/triggers/) for complete documentation. diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index b6450f9b20..b1fead53ad 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -320,7 +320,7 @@ The reaction is added to the triggering item. For issues/PRs, a comment with the ### Activation Token (`on.github-token:`, `on.github-app:`) -Configure a custom GitHub token or GitHub App for the activation job. The activation job posts the initial reaction and status comment on the triggering item. By default it uses the workflow's `GITHUB_TOKEN`. +Configure a custom GitHub token or GitHub App for the activation job **and all skip-if search checks**. The activation job posts the initial reaction and status comment on the triggering item, and skip-if checks use the same token to query the GitHub Search API. By default all of these operations use the workflow's `GITHUB_TOKEN`. Use `github-token:` to supply a PAT or custom token: @@ -344,10 +344,34 @@ on: private-key: ${{ secrets.APP_KEY }} ``` -The `github-app` object accepts the same fields as the GitHub App configuration used elsewhere in the framework (`app-id`, `private-key`, and optionally `owner` and `repositories`). The token is minted once for the activation job and covers both the reaction step and the status comment step. +The `github-app` object accepts the same fields as the GitHub App configuration used elsewhere in the framework (`app-id`, `private-key`, and optionally `owner` and `repositories`). The token is minted once in the pre-activation job and is shared across the reaction step, the status comment step, and any skip-if search steps. + +Both `github-token` and `github-app` can be defined in a **shared agentic workflow** and will be automatically inherited by any workflow that imports it (first-wins strategy). This means a central CentralRepoOps shared workflow can define the app config once and all importing workflows benefit automatically: + +```yaml wrap +# shared-ops.md - define app config once +on: + workflow_call: + github-app: + app-id: ${{ secrets.ORG_APP_ID }} + private-key: ${{ secrets.ORG_APP_PRIVATE_KEY }} + owner: myorg +``` + +```yaml wrap +# any-workflow.md - inherits github-app from the import +imports: + - .github/workflows/shared/shared-ops.md +on: + schedule: + - cron: "*/30 * * * *" + skip-if-no-match: + query: "org:myorg label:agent-fix is:issue is:open" + scope: none +``` > [!NOTE] -> `github-token` and `github-app` affect only the activation job. For the agent job, configure tokens via `tools.github.github-token`/`tools.github.github-app` or `safe-outputs.github-token`/`safe-outputs.github-app`. See [Authentication](/gh-aw/reference/auth/) for a full overview. +> `github-token` and `github-app` affect only the activation job (reactions, status comments, and skip-if searches). For the agent job, configure tokens via `tools.github.github-token`/`tools.github.github-app` or `safe-outputs.github-token`/`safe-outputs.github-app`. See [Authentication](/gh-aw/reference/auth/) for a full overview. ### Stop After Configuration (`stop-after:`) @@ -390,6 +414,31 @@ on: weekly on monday A pre-activation check runs the search query against the current repository. If matches reach or exceed the threshold (default `max: 1`), the workflow is skipped. The query is automatically scoped to the current repository and supports all standard GitHub search qualifiers (`is:`, `label:`, `in:title`, `author:`, etc.). +#### Cross-Repo and Org-Wide Queries + +By default the query is scoped to the current repository. Use `scope: none` to disable this and search across an entire org. For cross-repo or org-wide searches that require elevated permissions, configure `github-token` or `github-app` at the top-level `on:` section — the same token is shared across all skip-if checks and the activation job: + +```yaml wrap +on: + schedule: + - cron: "*/15 * * * *" + skip-if-match: + query: "org:myorg label:ops:in-progress is:issue is:open" + scope: none + github-app: + app-id: ${{ secrets.WORKFLOW_APP_ID }} + private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }} + owner: myorg +``` + +| Field | Location | Description | +|-------|----------|-------------| +| `scope: none` | inside `skip-if-match` | Disables the automatic `repo:owner/repo` qualifier | +| `github-token` | top-level `on:` | Custom PAT or token for all skip-if searches (e.g. `${{ secrets.CROSS_ORG_TOKEN }}`) | +| `github-app` | top-level `on:` | Mints a short-lived installation token shared across all skip-if steps; requires `app-id` and `private-key` | + +`github-token` and `github-app` are mutually exclusive. String shorthand always uses the default `GITHUB_TOKEN` scoped to the current repository. + ### Skip-If-No-Match Condition (`skip-if-no-match:`) Conditionally skip workflow execution when a GitHub search query has **no matches** (or fewer than the minimum required). This is the opposite of `skip-if-match`. @@ -409,6 +458,21 @@ on: A pre-activation check runs the search query against the current repository. If matches are below the threshold (default `min: 1`), the workflow is skipped. Can be combined with `skip-if-match` for complex conditions. +The same `scope: none` field available on `skip-if-match` works identically here. Authentication (`github-token` / `github-app`) is configured at the top-level `on:` section and is shared across all skip-if checks — a single mint step is emitted for both: + +```yaml wrap +on: + schedule: + - cron: "*/15 * * * *" + skip-if-no-match: + query: "org:myorg label:agent-fix -label:ops:agentic is:issue is:open" + scope: none + github-app: + app-id: ${{ secrets.WORKFLOW_APP_ID }} + private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }} + owner: myorg +``` + ## Trigger Shorthands Instead of writing full YAML trigger configurations, you can use natural-language shorthand strings with `on:`. The compiler expands these into standard GitHub Actions trigger syntax and automatically includes `workflow_dispatch` so the workflow can also be run manually. diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 887256f76e..01944ff92b 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -676,6 +676,10 @@ const CheckRateLimitStepID StepID = "check_rate_limit" const CheckSkipRolesStepID StepID = "check_skip_roles" const CheckSkipBotsStepID StepID = "check_skip_bots" +// PreActivationAppTokenStepID is the step ID for the unified GitHub App token mint step +// emitted in the pre-activation job when on.github-app is configured alongside skip-if checks. +const PreActivationAppTokenStepID StepID = "pre-activation-app-token" + // Output names for pre-activation job steps const IsTeamMemberOutput = "is_team_member" const StopTimeOkOutput = "stop_time_ok" diff --git a/pkg/parser/content_extractor.go b/pkg/parser/content_extractor.go index f71addd6d7..4cd08ca98a 100644 --- a/pkg/parser/content_extractor.go +++ b/pkg/parser/content_extractor.go @@ -213,3 +213,37 @@ func extractOnSectionField(content, fieldName string) (string, error) { contentExtractorLog.Printf("Successfully extracted field %s from on: section: %d bytes", fieldName, len(jsonData)) return string(jsonData), nil } + +// extractOnSectionAnyField extracts a specific field from the on: section in frontmatter as +// a JSON string, handling any value type (string, object, array, etc.). +// Returns "" when the field is absent or an error occurs. +func extractOnSectionAnyField(content, fieldName string) (string, error) { + contentExtractorLog.Printf("Extracting on: section field (any): %s", fieldName) + result, err := ExtractFrontmatterFromContent(content) + if err != nil { + return "", nil + } + + onValue, exists := result.Frontmatter["on"] + if !exists { + return "", nil + } + + onMap, ok := onValue.(map[string]any) + if !ok { + return "", nil + } + + fieldValue, exists := onMap[fieldName] + if !exists { + return "", nil + } + + jsonData, err := json.Marshal(fieldValue) + if err != nil { + return "", nil + } + + contentExtractorLog.Printf("Successfully extracted on.%s: %d bytes", fieldName, len(jsonData)) + return string(jsonData), nil +} diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index 8b868c5931..5621d44101 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -47,6 +47,9 @@ type importAccumulator struct { agentImportSpec string repositoryImports []string importInputs map[string]any + // First on.github-token / on.github-app found across all imported files (first-wins strategy) + activationGitHubToken string + activationGitHubApp string // JSON-encoded GitHubAppConfig } // newImportAccumulator creates and initializes a new importAccumulator. @@ -209,6 +212,22 @@ func (acc *importAccumulator) extractAllImportFields(content []byte, item import } } + // Extract on.github-token from imported file (first-wins: only set if not yet populated) + if acc.activationGitHubToken == "" { + if token := extractOnGitHubToken(string(content)); token != "" { + acc.activationGitHubToken = token + log.Printf("Extracted on.github-token from import: %s", item.fullPath) + } + } + + // Extract on.github-app from imported file (first-wins: only set if not yet populated) + if acc.activationGitHubApp == "" { + if appJSON := extractOnGitHubApp(string(content)); appJSON != "" { + acc.activationGitHubApp = appJSON + log.Printf("Extracted on.github-app from import: %s", item.fullPath) + } + } + // Extract and merge plugins from imported file (merge into set to avoid duplicates). // Handles both simple string format and object format with MCP configs. pluginsContent, err := extractFrontmatterField(string(content), "plugins", "[]") @@ -282,34 +301,36 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import log.Printf("Building ImportsResult: importedFiles=%d, importPaths=%d, engines=%d, bots=%d, plugins=%d, labels=%d", len(topologicalOrder), len(acc.importPaths), len(acc.engines), len(acc.bots), len(acc.plugins), len(acc.labels)) return &ImportsResult{ - MergedTools: acc.toolsBuilder.String(), - MergedMCPServers: acc.mcpServersBuilder.String(), - MergedEngines: acc.engines, - MergedSafeOutputs: acc.safeOutputs, - MergedMCPScripts: acc.mcpScripts, - MergedMarkdown: acc.markdownBuilder.String(), - ImportPaths: acc.importPaths, - MergedSteps: acc.stepsBuilder.String(), - CopilotSetupSteps: acc.copilotSetupStepsBuilder.String(), - MergedRuntimes: acc.runtimesBuilder.String(), - MergedServices: acc.servicesBuilder.String(), - MergedNetwork: acc.networkBuilder.String(), - MergedPermissions: acc.permissionsBuilder.String(), - MergedSecretMasking: acc.secretMaskingBuilder.String(), - MergedBots: acc.bots, - MergedPlugins: acc.plugins, - MergedSkipRoles: acc.skipRoles, - MergedSkipBots: acc.skipBots, - MergedPostSteps: acc.postStepsBuilder.String(), - MergedLabels: acc.labels, - MergedCaches: acc.caches, - MergedJobs: acc.jobsBuilder.String(), - MergedFeatures: acc.features, - ImportedFiles: topologicalOrder, - AgentFile: acc.agentFile, - AgentImportSpec: acc.agentImportSpec, - RepositoryImports: acc.repositoryImports, - ImportInputs: acc.importInputs, + MergedTools: acc.toolsBuilder.String(), + MergedMCPServers: acc.mcpServersBuilder.String(), + MergedEngines: acc.engines, + MergedSafeOutputs: acc.safeOutputs, + MergedMCPScripts: acc.mcpScripts, + MergedMarkdown: acc.markdownBuilder.String(), + ImportPaths: acc.importPaths, + MergedSteps: acc.stepsBuilder.String(), + CopilotSetupSteps: acc.copilotSetupStepsBuilder.String(), + MergedRuntimes: acc.runtimesBuilder.String(), + MergedServices: acc.servicesBuilder.String(), + MergedNetwork: acc.networkBuilder.String(), + MergedPermissions: acc.permissionsBuilder.String(), + MergedSecretMasking: acc.secretMaskingBuilder.String(), + MergedBots: acc.bots, + MergedPlugins: acc.plugins, + MergedSkipRoles: acc.skipRoles, + MergedSkipBots: acc.skipBots, + MergedPostSteps: acc.postStepsBuilder.String(), + MergedLabels: acc.labels, + MergedCaches: acc.caches, + MergedJobs: acc.jobsBuilder.String(), + MergedFeatures: acc.features, + ImportedFiles: topologicalOrder, + AgentFile: acc.agentFile, + AgentImportSpec: acc.agentImportSpec, + RepositoryImports: acc.repositoryImports, + ImportInputs: acc.importInputs, + MergedActivationGitHubToken: acc.activationGitHubToken, + MergedActivationGitHubApp: acc.activationGitHubApp, } } @@ -334,3 +355,37 @@ func computeImportRelPath(fullPath, importPath string) string { } return importPath } + +// extractOnGitHubToken returns the on.github-token string value from workflow content. +// Returns "" if the field is absent or not a non-empty string. +func extractOnGitHubToken(content string) string { + tokenJSON, err := extractOnSectionAnyField(content, "github-token") + if err != nil || tokenJSON == "" || tokenJSON == "null" { + return "" + } + var token string + if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil { + return "" + } + return token +} + +// extractOnGitHubApp returns the JSON-encoded on.github-app object from workflow content. +// Returns "" if the field is absent, not a valid object, or missing required fields. +func extractOnGitHubApp(content string) string { + appJSON, err := extractOnSectionAnyField(content, "github-app") + if err != nil || appJSON == "" || appJSON == "null" { + return "" + } + var appMap map[string]any + if err := json.Unmarshal([]byte(appJSON), &appMap); err != nil { + return "" + } + if _, hasID := appMap["app-id"]; !hasID { + return "" + } + if _, hasKey := appMap["private-key"]; !hasKey { + return "" + } + return appJSON +} diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 6742f59f6a..872afbf88a 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -14,33 +14,35 @@ var importLog = logger.New("parser:import_processor") // ImportsResult holds the result of processing imports from frontmatter type ImportsResult struct { - MergedTools string // Merged tools configuration from all imports - MergedMCPServers string // Merged mcp-servers configuration from all imports - MergedEngines []string // Merged engine configurations from all imports - MergedSafeOutputs []string // Merged safe-outputs configurations from all imports - MergedMCPScripts []string // Merged mcp-scripts configurations from all imports - MergedMarkdown string // Only contains imports WITH inputs (for compile-time substitution) - ImportPaths []string // List of import file paths for runtime-import macro generation (replaces MergedMarkdown) - MergedSteps string // Merged steps configuration from all imports (excluding copilot-setup-steps) - CopilotSetupSteps string // Steps from copilot-setup-steps.yml (inserted at start) - MergedRuntimes string // Merged runtimes configuration from all imports - MergedServices string // Merged services configuration from all imports - MergedNetwork string // Merged network configuration from all imports - MergedPermissions string // Merged permissions configuration from all imports - MergedSecretMasking string // Merged secret-masking steps from all imports - MergedBots []string // Merged bots list from all imports (union of bot names) - MergedPlugins []string // Merged plugins list from all imports (union of plugin repos) - MergedSkipRoles []string // Merged skip-roles list from all imports (union of role names) - MergedSkipBots []string // Merged skip-bots list from all imports (union of usernames) - MergedPostSteps string // Merged post-steps configuration from all imports (appended in order) - MergedLabels []string // Merged labels from all imports (union of label names) - MergedCaches []string // Merged cache configurations from all imports (appended in order) - MergedJobs string // Merged jobs from imported YAML workflows (JSON format) - MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures) - ImportedFiles []string // List of imported file paths (for manifest) - AgentFile string // Path to custom agent file (if imported) - AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") - RepositoryImports []string // List of repository imports (format: "owner/repo@ref") for .github folder merging + MergedTools string // Merged tools configuration from all imports + MergedMCPServers string // Merged mcp-servers configuration from all imports + MergedEngines []string // Merged engine configurations from all imports + MergedSafeOutputs []string // Merged safe-outputs configurations from all imports + MergedMCPScripts []string // Merged mcp-scripts configurations from all imports + MergedMarkdown string // Only contains imports WITH inputs (for compile-time substitution) + ImportPaths []string // List of import file paths for runtime-import macro generation (replaces MergedMarkdown) + MergedSteps string // Merged steps configuration from all imports (excluding copilot-setup-steps) + CopilotSetupSteps string // Steps from copilot-setup-steps.yml (inserted at start) + MergedRuntimes string // Merged runtimes configuration from all imports + MergedServices string // Merged services configuration from all imports + MergedNetwork string // Merged network configuration from all imports + MergedPermissions string // Merged permissions configuration from all imports + MergedSecretMasking string // Merged secret-masking steps from all imports + MergedBots []string // Merged bots list from all imports (union of bot names) + MergedPlugins []string // Merged plugins list from all imports (union of plugin repos) + MergedSkipRoles []string // Merged skip-roles list from all imports (union of role names) + MergedSkipBots []string // Merged skip-bots list from all imports (union of usernames) + MergedActivationGitHubToken string // GitHub token from on.github-token in first imported workflow that defines it + MergedActivationGitHubApp string // JSON-encoded on.github-app from first imported workflow that defines it + MergedPostSteps string // Merged post-steps configuration from all imports (appended in order) + MergedLabels []string // Merged labels from all imports (union of label names) + MergedCaches []string // Merged cache configurations from all imports (appended in order) + MergedJobs string // Merged jobs from imported YAML workflows (JSON format) + MergedFeatures []map[string]any // Merged features configuration from all imports (parsed YAML structures) + ImportedFiles []string // List of imported file paths (for manifest) + AgentFile string // Path to custom agent file (if imported) + AgentImportSpec string // Original import specification for agent file (e.g., "owner/repo/path@ref") + RepositoryImports []string // List of repository imports (format: "owner/repo@ref") for .github folder merging // ImportInputs uses map[string]any because input values can be different types (string, number, boolean). // This is parsed from YAML frontmatter where the structure is dynamic and not known at compile time. // This is an appropriate use of 'any' for dynamic YAML/JSON data. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index cad4debbfd..7c812bea09 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1339,13 +1339,18 @@ "description": "GitHub Actions expression that resolves to an integer at runtime" } ] + }, + "scope": { + "type": "string", + "enum": ["none"], + "description": "Scope for the search query. Set to 'none' to disable the automatic 'repo:owner/repo' scoping, enabling org-wide or cross-repo queries." } }, "additionalProperties": false, - "description": "Skip-if-match configuration object with query and maximum match count" + "description": "Skip-if-match configuration object with query, maximum match count, and optional scope. For custom authentication use the top-level on.github-token or on.github-app fields." } ], - "description": "Conditionally skip workflow execution when a GitHub search query has matches. Can be a string (query only, implies max=1) or an object with 'query' and optional 'max' fields." + "description": "Conditionally skip workflow execution when a GitHub search query has matches. Can be a string (query only, implies max=1) or an object with 'query', optional 'max', and 'scope' fields. Use top-level on.github-token or on.github-app for custom authentication." }, "skip-if-no-match": { "oneOf": [ @@ -1365,13 +1370,18 @@ "type": "integer", "minimum": 1, "description": "Minimum number of items that must be matched for the workflow to proceed. Defaults to 1 if not specified." + }, + "scope": { + "type": "string", + "enum": ["none"], + "description": "Scope for the search query. Set to 'none' to disable the automatic 'repo:owner/repo' scoping, enabling org-wide or cross-repo queries." } }, "additionalProperties": false, - "description": "Skip-if-no-match configuration object with query and minimum match count" + "description": "Skip-if-no-match configuration object with query, minimum match count, and optional scope. For custom authentication use the top-level on.github-token or on.github-app fields." } ], - "description": "Conditionally skip workflow execution when a GitHub search query has no matches (or fewer than minimum). Can be a string (query only, implies min=1) or an object with 'query' and optional 'min' fields." + "description": "Conditionally skip workflow execution when a GitHub search query has no matches (or fewer than minimum). Can be a string (query only, implies min=1) or an object with 'query', optional 'min', and 'scope' fields. Use top-level on.github-token or on.github-app for custom authentication." }, "skip-roles": { "oneOf": [ @@ -1464,12 +1474,12 @@ }, "github-token": { "type": "string", - "description": "Custom GitHub token to use for pre-activation reactions and activation status comments. When specified, overrides the default GITHUB_TOKEN for these operations.", + "description": "Custom GitHub token for pre-activation reactions, activation status comments, and skip-if search queries. When specified, overrides the default GITHUB_TOKEN for these operations.", "examples": ["${{ secrets.MY_GITHUB_TOKEN }}"] }, "github-app": { "type": "object", - "description": "GitHub App configuration for minting a token used in pre-activation reactions and activation status comments. When configured, a GitHub App installation access token is minted and used instead of the default GITHUB_TOKEN.", + "description": "GitHub App configuration for minting a token used in pre-activation reactions, activation status comments, and skip-if search queries. When configured, a single GitHub App installation access token is minted and shared across all these operations instead of using the default GITHUB_TOKEN. Can be defined in a shared agentic workflow and inherited by importing workflows.", "properties": { "app-id": { "type": "string", diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 0e8478e0ec..1da057ff62 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -590,8 +590,8 @@ func (c *Compiler) extractAdditionalConfigurations( workflowData.RateLimit = c.extractRateLimitConfig(frontmatter) workflowData.SkipRoles = c.mergeSkipRoles(c.extractSkipRoles(frontmatter), importsResult.MergedSkipRoles) workflowData.SkipBots = c.mergeSkipBots(c.extractSkipBots(frontmatter), importsResult.MergedSkipBots) - workflowData.ActivationGitHubToken = c.extractActivationGitHubToken(frontmatter) - workflowData.ActivationGitHubApp = c.extractActivationGitHubApp(frontmatter) + workflowData.ActivationGitHubToken = c.resolveActivationGitHubToken(frontmatter, importsResult) + workflowData.ActivationGitHubApp = c.resolveActivationGitHubApp(frontmatter, importsResult) // Use the already extracted output configuration workflowData.SafeOutputs = safeOutputs diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 0d42c7a588..8ebdbddcb6 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -88,9 +88,18 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, generateGitHubScriptWithRequire("check_stop_time.cjs")) } + // Emit a single unified GitHub App token mint step if on.github-app is configured + // and any skip-if check is present. Both checks share the same minted token. + hasSkipIfCheck := data.SkipIfMatch != nil || data.SkipIfNoMatch != nil + if hasSkipIfCheck && data.ActivationGitHubApp != nil { + steps = append(steps, c.buildPreActivationAppTokenMintStep(data.ActivationGitHubApp)...) + } + + // Resolve the token expression to use for skip-if checks (app token > custom token > default) + skipIfToken := c.resolvePreActivationSkipIfToken(data) + // Add skip-if-match check if configured if data.SkipIfMatch != nil { - // Extract workflow name for the skip-if-match check workflowName := data.Name steps = append(steps, " - name: Check skip-if-match query\n") @@ -100,14 +109,19 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfMatch.Query)) steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MAX_MATCHES: \"%d\"\n", data.SkipIfMatch.Max)) + if data.SkipIfMatch.Scope != "" { + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_SCOPE: %q\n", data.SkipIfMatch.Scope)) + } steps = append(steps, " with:\n") + if skipIfToken != "" { + steps = append(steps, fmt.Sprintf(" github-token: %s\n", skipIfToken)) + } steps = append(steps, " script: |\n") steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_match.cjs")) } // Add skip-if-no-match check if configured if data.SkipIfNoMatch != nil { - // Extract workflow name for the skip-if-no-match check workflowName := data.Name steps = append(steps, " - name: Check skip-if-no-match query\n") @@ -117,7 +131,13 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_QUERY: %q\n", data.SkipIfNoMatch.Query)) steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName)) steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_MIN_MATCHES: \"%d\"\n", data.SkipIfNoMatch.Min)) + if data.SkipIfNoMatch.Scope != "" { + steps = append(steps, fmt.Sprintf(" GH_AW_SKIP_SCOPE: %q\n", data.SkipIfNoMatch.Scope)) + } steps = append(steps, " with:\n") + if skipIfToken != "" { + steps = append(steps, fmt.Sprintf(" github-token: %s\n", skipIfToken)) + } steps = append(steps, " script: |\n") steps = append(steps, generateGitHubScriptWithRequire("check_skip_if_no_match.cjs")) } @@ -407,3 +427,54 @@ func (c *Compiler) extractPreActivationCustomFields(jobs map[string]any) ([]stri return customSteps, customOutputs, nil } + +// buildPreActivationAppTokenMintStep generates a single GitHub App token mint step for use +// by all skip-if checks in the pre-activation job. The step ID is "pre-activation-app-token". +// Auth configuration comes from the top-level on.github-app field. +func (c *Compiler) buildPreActivationAppTokenMintStep(app *GitHubAppConfig) []string { + var steps []string + tokenStepID := constants.PreActivationAppTokenStepID + + steps = append(steps, " - name: Generate GitHub App token for skip-if checks\n") + steps = append(steps, fmt.Sprintf(" id: %s\n", tokenStepID)) + steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) + steps = append(steps, " with:\n") + steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) + steps = append(steps, fmt.Sprintf(" private-key: %s\n", app.PrivateKey)) + + owner := app.Owner + if owner == "" { + owner = "${{ github.repository_owner }}" + } + steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) + + if len(app.Repositories) == 1 && app.Repositories[0] == "*" { + // Org-wide access: omit repositories field entirely + } else if len(app.Repositories) == 1 { + steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0])) + } else if len(app.Repositories) > 1 { + steps = append(steps, " repositories: |-\n") + for _, repo := range app.Repositories { + steps = append(steps, fmt.Sprintf(" %s\n", repo)) + } + } else { + steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") + } + + steps = append(steps, " github-api-url: ${{ github.api_url }}\n") + + return steps +} + +// resolvePreActivationSkipIfToken returns the GitHub token expression to use for skip-if check +// steps in the pre-activation job. Priority: App token > custom github-token > empty (default). +// When non-empty, callers should emit `with.github-token: ` in the step. +func (c *Compiler) resolvePreActivationSkipIfToken(data *WorkflowData) string { + if data.ActivationGitHubApp != nil { + return fmt.Sprintf("${{ steps.%s.outputs.token }}", constants.PreActivationAppTokenStepID) + } + if data.ActivationGitHubToken != "" { + return data.ActivationGitHubToken + } + return "" +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 802042d46f..45367b4d31 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -325,12 +325,16 @@ func (c *Compiler) GetSharedActionCache() *ActionCache { type SkipIfMatchConfig struct { Query string // GitHub search query to check before running workflow Max int // Maximum number of matches before skipping (defaults to 1) + Scope string // Scope for the query: "none" disables auto repo:owner/repo scoping + // Auth (github-token / github-app) is taken from on.github-token / on.github-app at the top level. } // SkipIfNoMatchConfig holds the configuration for skip-if-no-match conditions type SkipIfNoMatchConfig struct { Query string // GitHub search query to check before running workflow Min int // Minimum number of matches required to proceed (defaults to 1) + Scope string // Scope for the query: "none" disables auto repo:owner/repo scoping + // Auth (github-token / github-app) is taken from on.github-token / on.github-app at the top level. } // WorkflowData holds all the data needed to generate a GitHub Actions workflow diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 7d47dbd755..f129087d0b 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -368,14 +368,14 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } else if strings.HasPrefix(trimmedLine, "skip-if-match:") { shouldComment = true commentReason = " # Skip-if-match processed as search check in pre-activation job" - } else if inSkipIfMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "max:")) { + } else if inSkipIfMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "max:") || strings.HasPrefix(trimmedLine, "scope:")) { // Comment out nested fields in skip-if-match object shouldComment = true commentReason = "" } else if strings.HasPrefix(trimmedLine, "skip-if-no-match:") { shouldComment = true commentReason = " # Skip-if-no-match processed as search check in pre-activation job" - } else if inSkipIfNoMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "min:")) { + } else if inSkipIfNoMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "min:") || strings.HasPrefix(trimmedLine, "scope:")) { // Comment out nested fields in skip-if-no-match object shouldComment = true commentReason = "" diff --git a/pkg/workflow/role_checks.go b/pkg/workflow/role_checks.go index bb2611b4d2..03bdc0e8ad 100644 --- a/pkg/workflow/role_checks.go +++ b/pkg/workflow/role_checks.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "fmt" "slices" "sort" @@ -8,6 +9,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" ) var roleLog = logger.New("workflow:role_checks") @@ -662,3 +664,41 @@ func (c *Compiler) extractActivationGitHubApp(frontmatter map[string]any) *GitHu } return nil } + +// resolveActivationGitHubToken returns the GitHub token to use for activation operations +// (reactions, status comments, skip-if checks). Precedence: +// 1. Current workflow's on.github-token (explicit override wins) +// 2. First on.github-token found across imported shared workflows +// 3. Empty string (use default GITHUB_TOKEN) +func (c *Compiler) resolveActivationGitHubToken(frontmatter map[string]any, importsResult *parser.ImportsResult) string { + if token := c.extractActivationGitHubToken(frontmatter); token != "" { + return token + } + if importsResult != nil && importsResult.MergedActivationGitHubToken != "" { + roleLog.Print("Using on.github-token from imported shared workflow") + return importsResult.MergedActivationGitHubToken + } + return "" +} + +// resolveActivationGitHubApp returns the GitHub App config to use for activation operations +// (reactions, status comments, skip-if checks). Precedence: +// 1. Current workflow's on.github-app (explicit override wins) +// 2. First on.github-app found across imported shared workflows +// 3. Nil (use default GITHUB_TOKEN) +func (c *Compiler) resolveActivationGitHubApp(frontmatter map[string]any, importsResult *parser.ImportsResult) *GitHubAppConfig { + if app := c.extractActivationGitHubApp(frontmatter); app != nil { + return app + } + if importsResult != nil && importsResult.MergedActivationGitHubApp != "" { + var appMap map[string]any + if err := json.Unmarshal([]byte(importsResult.MergedActivationGitHubApp), &appMap); err == nil { + app := parseAppConfig(appMap) + if app.AppID != "" && app.PrivateKey != "" { + roleLog.Print("Using on.github-app from imported shared workflow") + return app + } + } + } + return nil +} diff --git a/pkg/workflow/skip_if_match_test.go b/pkg/workflow/skip_if_match_test.go index 51a679cfa5..2d1ffc7a64 100644 --- a/pkg/workflow/skip_if_match_test.go +++ b/pkg/workflow/skip_if_match_test.go @@ -285,4 +285,169 @@ This workflow uses object format but omits max (defaults to 1). t.Error("Expected GH_AW_SKIP_MAX_MATCHES environment variable with default value 1") } }) + + t.Run("skip_if_match_with_scope_none", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-match: + query: "org:myorg label:blocked is:issue is:open" + scope: none +engine: claude +--- + +# Skip If Match With Scope None + +This workflow uses scope:none for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-match-scope-none-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify skip-if-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + + // Verify scope is commented out in frontmatter + if !strings.Contains(lockContentStr, "# scope:") { + t.Error("Expected scope to be commented out in lock file") + } + }) + + t.Run("skip_if_match_with_github_token", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-match: + query: "org:myorg label:blocked is:issue is:open" + scope: none + github-token: ${{ secrets.CROSS_ORG_TOKEN }} +engine: claude +--- + +# Skip If Match With Custom Token + +This workflow uses a custom token for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-match-github-token-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify skip-if-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + + // Verify the custom github-token is passed via with.github-token to the skip-if step + if !strings.Contains(lockContentStr, "github-token: ${{ secrets.CROSS_ORG_TOKEN }}") { + t.Error("Expected github-token to be set in with section for skip-if-match step") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + }) + + t.Run("skip_if_match_with_github_app", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-match: + query: "org:myorg label:blocked is:issue is:open" + scope: none + github-app: + app-id: ${{ secrets.WORKFLOW_APP_ID }} + private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }} + owner: myorg +engine: claude +--- + +# Skip If Match With GitHub App + +This workflow uses a GitHub App token for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-match-github-app-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify the unified GitHub App token mint step is generated + if !strings.Contains(lockContentStr, "Generate GitHub App token for skip-if checks") { + t.Error("Expected unified GitHub App token mint step to be present") + } + + // Verify app-id and private-key are in the mint step + if !strings.Contains(lockContentStr, "app-id: ${{ secrets.WORKFLOW_APP_ID }}") { + t.Error("Expected app-id in the GitHub App token mint step") + } + if !strings.Contains(lockContentStr, "private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }}") { + t.Error("Expected private-key in the GitHub App token mint step") + } + + // Verify owner is passed + if !strings.Contains(lockContentStr, "owner: myorg") { + t.Error("Expected owner to be set in GitHub App token mint step") + } + + // Verify the minted token is used in the skip-if step via the unified step ID + if !strings.Contains(lockContentStr, "github-token: ${{ steps.pre-activation-app-token.outputs.token }}") { + t.Error("Expected minted app token (pre-activation-app-token) to be used in skip-if-match step") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + }) } diff --git a/pkg/workflow/skip_if_no_match_test.go b/pkg/workflow/skip_if_no_match_test.go index a51657d1fc..6f4abb5d61 100644 --- a/pkg/workflow/skip_if_no_match_test.go +++ b/pkg/workflow/skip_if_no_match_test.go @@ -339,4 +339,233 @@ This workflow uses both skip-if-match and skip-if-no-match. t.Error("Expected activated output to include skip_no_match_check_ok condition") } }) + + t.Run("skip_if_no_match_with_scope_none", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-no-match: + query: "org:myorg label:agent-fix is:issue is:open" + scope: none +engine: claude +--- + +# Skip If No Match With Scope None + +This workflow uses scope:none for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-no-match-scope-none-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify skip-if-no-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-no-match query") { + t.Error("Expected skip-if-no-match check to be present") + } + + // Verify the skip query environment variable is set correctly + if !strings.Contains(lockContentStr, `GH_AW_SKIP_QUERY: "org:myorg label:agent-fix is:issue is:open"`) { + t.Error("Expected GH_AW_SKIP_QUERY environment variable with correct value") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + + // Verify scope is commented out in frontmatter + if !strings.Contains(lockContentStr, "# scope:") { + t.Error("Expected scope to be commented out in lock file") + } + }) + + t.Run("skip_if_no_match_with_github_token", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-no-match: + query: "org:myorg label:agent-fix is:issue is:open" + scope: none + github-token: ${{ secrets.CROSS_ORG_TOKEN }} +engine: claude +--- + +# Skip If No Match With Custom Token + +This workflow uses a custom token for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-no-match-github-token-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify skip-if-no-match check is present + if !strings.Contains(lockContentStr, "Check skip-if-no-match query") { + t.Error("Expected skip-if-no-match check to be present") + } + + // Verify the custom github-token is passed via with.github-token to the skip-if step + if !strings.Contains(lockContentStr, "github-token: ${{ secrets.CROSS_ORG_TOKEN }}") { + t.Error("Expected github-token to be set in with section for skip-if-no-match step") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + }) + + t.Run("skip_if_no_match_with_github_app", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-no-match: + query: "org:myorg label:agent-fix is:issue is:open" + scope: none + github-app: + app-id: ${{ secrets.WORKFLOW_APP_ID }} + private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }} + owner: myorg +engine: claude +--- + +# Skip If No Match With GitHub App + +This workflow uses a GitHub App token for org-wide search. +` + workflowFile := filepath.Join(tmpDir, "skip-no-match-github-app-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify the unified GitHub App token mint step is generated before the skip check + if !strings.Contains(lockContentStr, "Generate GitHub App token for skip-if checks") { + t.Error("Expected unified GitHub App token mint step to be present") + } + + // Verify app-id and private-key are in the mint step + if !strings.Contains(lockContentStr, "app-id: ${{ secrets.WORKFLOW_APP_ID }}") { + t.Error("Expected app-id in the GitHub App token mint step") + } + if !strings.Contains(lockContentStr, "private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }}") { + t.Error("Expected private-key in the GitHub App token mint step") + } + + // Verify owner is passed + if !strings.Contains(lockContentStr, "owner: myorg") { + t.Error("Expected owner to be set in GitHub App token mint step") + } + + // Verify the minted token is used in the skip-if step via the unified step ID + if !strings.Contains(lockContentStr, "github-token: ${{ steps.pre-activation-app-token.outputs.token }}") { + t.Error("Expected minted app token (pre-activation-app-token) to be used in skip-if-no-match step") + } + + // Verify GH_AW_SKIP_SCOPE is set to "none" + if !strings.Contains(lockContentStr, `GH_AW_SKIP_SCOPE: "none"`) { + t.Error("Expected GH_AW_SKIP_SCOPE environment variable set to none") + } + }) + + t.Run("unified_app_token_step_for_both_skip_checks", func(t *testing.T) { + workflowContent := `--- +on: + schedule: + - cron: "*/15 * * * *" + skip-if-match: + query: "org:myorg label:blocked is:issue is:open" + scope: none + skip-if-no-match: + query: "org:myorg label:agent-fix is:issue is:open" + scope: none + github-app: + app-id: ${{ secrets.WORKFLOW_APP_ID }} + private-key: ${{ secrets.WORKFLOW_APP_PRIVATE_KEY }} + owner: myorg +engine: claude +--- + +# Unified App Token For Both Skip Checks + +Both skip-if-match and skip-if-no-match share one mint step. +` + workflowFile := filepath.Join(tmpDir, "unified-app-token-workflow.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Exactly ONE unified mint step should be present + mintStepCount := strings.Count(lockContentStr, "Generate GitHub App token for skip-if checks") + if mintStepCount != 1 { + t.Errorf("Expected exactly 1 unified mint step, got %d", mintStepCount) + } + + // Both skip-if checks should reference the same unified token step + if !strings.Contains(lockContentStr, "Check skip-if-match query") { + t.Error("Expected skip-if-match check to be present") + } + if !strings.Contains(lockContentStr, "Check skip-if-no-match query") { + t.Error("Expected skip-if-no-match check to be present") + } + // Both reference the same pre-activation-app-token step + if strings.Count(lockContentStr, "github-token: ${{ steps.pre-activation-app-token.outputs.token }}") != 2 { + t.Error("Expected both skip-if steps to reference the unified pre-activation-app-token step") + } + }) } diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 0f781beb50..7353297aa7 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -254,9 +254,16 @@ func (c *Compiler) extractSkipIfMatchFromOn(frontmatter map[string]any, workflow } } + // Extract scope (auth is now configured via top-level on.github-app / on.github-token) + scope, err := extractSkipIfScope(skip, "skip-if-match") + if err != nil { + return nil, err + } + return &SkipIfMatchConfig{ Query: queryStr, Max: maxVal, + Scope: scope, }, nil default: return nil, fmt.Errorf("skip-if-match value must be a string or object, got %T. Examples:\n skip-if-match: \"is:issue is:open\"\n skip-if-match:\n query: \"is:pr is:open\"\n max: 3", skipIfMatch) @@ -333,9 +340,16 @@ func (c *Compiler) extractSkipIfNoMatchFromOn(frontmatter map[string]any, workfl } } + // Extract scope (auth is now configured via top-level on.github-app / on.github-token) + scope, err := extractSkipIfScope(skip, "skip-if-no-match") + if err != nil { + return nil, err + } + return &SkipIfNoMatchConfig{ Query: queryStr, Min: minVal, + Scope: scope, }, nil default: return nil, fmt.Errorf("skip-if-no-match value must be a string or object, got %T. Examples:\n skip-if-no-match: \"is:pr is:open\"\n skip-if-no-match:\n query: \"is:pr is:open\"\n min: 3", skipIfNoMatch) @@ -386,3 +400,21 @@ func (c *Compiler) processSkipIfNoMatchConfiguration(frontmatter map[string]any, return nil } + +// extractSkipIfScope extracts the optional scope field from a skip-if-match or skip-if-no-match +// object configuration. Auth fields (github-token, github-app) are configured at the top-level +// on: section and are no longer accepted inside skip-if blocks. +// conditionName is used only for error messages (e.g. "skip-if-match"). +func extractSkipIfScope(skip map[string]any, conditionName string) (string, error) { + if scopeRaw, hasScope := skip["scope"]; hasScope { + scopeStr, ok := scopeRaw.(string) + if !ok { + return "", fmt.Errorf("%s 'scope' field must be a string, got %T. Example: scope: none", conditionName, scopeRaw) + } + if scopeStr != "none" { + return "", fmt.Errorf("%s 'scope' field must be \"none\" or omitted, got %q", conditionName, scopeStr) + } + return scopeStr, nil + } + return "", nil +}