diff --git a/.changeset/minor-work-queue-dispatch-command.md b/.changeset/minor-work-queue-dispatch-command.md new file mode 100644 index 00000000000..80ceb340491 --- /dev/null +++ b/.changeset/minor-work-queue-dispatch-command.md @@ -0,0 +1,5 @@ +--- +"gh-aw": minor +--- + +Add `gh aw work-queue dispatch DISPATCHER` to activate a verified queue dispatcher through the GitHub CLI, with a read-only route preview and optional dispatcher wait. Preserve scheduler-selected Claims and immutable worker assignments instead of allowing direct Claim reassignment. diff --git a/.changeset/patch-work-queue-compacted-control-retry.md b/.changeset/patch-work-queue-compacted-control-retry.md new file mode 100644 index 00000000000..dbfa9fa2021 --- /dev/null +++ b/.changeset/patch-work-queue-compacted-control-retry.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix retries of accepted work queue submissions after Policy compaction when the request was published after the checkpoint. Add full trusted-control performance budgets and optional read-only tests against a captured live queue and its native Git history. diff --git a/.changeset/patch-work-queue-control-cache.md b/.changeset/patch-work-queue-control-cache.md new file mode 100644 index 00000000000..b3f2d5fd56e --- /dev/null +++ b/.changeset/patch-work-queue-control-cache.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Reuse authenticated immutable queue-history proofs throughout trusted control processing by constructing a queue client with the existing publisher token, independently from worker-dispatch credentials. diff --git a/.changeset/patch-work-queue-dispatch-snapshot.md b/.changeset/patch-work-queue-dispatch-snapshot.md new file mode 100644 index 00000000000..d4944b6b9a5 --- /dev/null +++ b/.changeset/patch-work-queue-dispatch-snapshot.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Bind trusted work-queue dispatcher activation to a verified SHA-derived lightweight tag instead of the moving default branch. Fail closed on snapshot mismatches and source/compiled worker-allowlist drift, while preserving read-only previews and native-run SHA correlation. diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 153f46427fe..0c34d78fe1c 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -2331,6 +2331,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "6082833d6d93c169164a945eeedb8210b1157d45ce39eb6cb584ba2719ec5c39" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-compiler-quality.lock.yml b/.github/workflows/daily-compiler-quality.lock.yml index 45e7952617f..562da58d5be 100644 --- a/.github/workflows/daily-compiler-quality.lock.yml +++ b/.github/workflows/daily-compiler-quality.lock.yml @@ -2549,6 +2549,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "5038ed73b4c3b73a1e6ba52bae046d512475d7ff40d475dfcc761caee6f51dda" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-evals-report.lock.yml b/.github/workflows/daily-evals-report.lock.yml index 030bc2fc60e..d323413c36b 100644 --- a/.github/workflows/daily-evals-report.lock.yml +++ b/.github/workflows/daily-evals-report.lock.yml @@ -2348,6 +2348,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "39e77feec9cf8446f25b8d4df5c25156cb1ca7c0b7f78f0ab5bed5382485e0bf" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 728ee66321a..5af0afb94c1 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -2388,6 +2388,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "34729feb3fbac0e05507fc07d8d75527e6f1780cccbffd4feb7b927b8ca71700" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index b95b423ccd1..287e35e3165 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -2702,6 +2702,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "1943ac70d2da08d22175e96ec1f758d25d08dfa7ad257c9d738d90a581f6b8d9" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-observability-report.lock.yml b/.github/workflows/daily-observability-report.lock.yml index 01633bc3c40..30fb79627de 100644 --- a/.github/workflows/daily-observability-report.lock.yml +++ b/.github/workflows/daily-observability-report.lock.yml @@ -2318,6 +2318,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "118fb55889fafac80fec7b169437bf0470e82406ffcc6be8d9d4fe28489365c0" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-regulatory.lock.yml b/.github/workflows/daily-regulatory.lock.yml index 29a30dfc1db..5555222d28d 100644 --- a/.github/workflows/daily-regulatory.lock.yml +++ b/.github/workflows/daily-regulatory.lock.yml @@ -2951,6 +2951,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "fc7778a0e306f5146f12582578216bf4ac999e5ff8b1e0e361102a2baad72bfb" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index 0847fbf9886..060723fd961 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -2281,6 +2281,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "78799541c29d883427fb7226a9aaafa9e7a523d94214cc9fce4a123841cc85e7" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-report-dispatcher.lock.yml b/.github/workflows/daily-report-dispatcher.lock.yml index 78785ff2635..f3f69facb87 100644 --- a/.github/workflows/daily-report-dispatcher.lock.yml +++ b/.github/workflows/daily-report-dispatcher.lock.yml @@ -1880,6 +1880,7 @@ jobs: GH_AW_WORK_QUEUE_POLICY_2: "\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"daily-token-consumption-report\":{\"logical_contract\":\"9dd049e2aee21f810fa7be4da3abee1a659f5b063a23416f1da344df7766a6a2\",\"workflow\":\".github/workflows/daily-token-consumption-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"deep-report\":{\"logical_contract\":\"099f49b1f233e2c5ebfe89e8fb87697affeb622b76156d375591191c6b28872c\",\"workflow\":\".github/workflows/deep-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-agy\":{\"logical_contract\":\"7f5cb738b2b7c47ff9f918062c4de9159b33421bc8321c5d99b688b7e3ca2eb8\",\"workflow\":\".github/workflows/engine-conformance-agy.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-aider\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-aider.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-claude\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-claude.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-codex\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-codex.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-copilot\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-copilot.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-crush\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-crush.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-cursor\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-cursor.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-deepseek-harness\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-deepseek-harness.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-gemini\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-gemini.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-goose\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-goose.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-kiro\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-kiro.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-opencode\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-opencode.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pi\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-pi.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pydantic-ai\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-pydantic-ai.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-miner\":{\"logical_contract\":\"e1ae579a2beb08ebce029363f9f59ec3bb85b84fa066210be66fd41b013b7a9c\",\"workflow\":\".github/workflows/eslint-miner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-monster\":{\"logical_contract\":\"ed27c8f7b06c56b27a284332f5674e71e8822fed6fd61603d0ade616be76ecbd\",\"workflow\":\".github/workflows/eslint-monster.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-refiner\":{\"logical_contract\":\"e25a866c9c9c5518ada0e1e45dffdce7925bad3463b3f47f34a861f534353928\",\"workflow\":\".github/workflows/eslint-refiner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"repo-tree-map\":{\"logical_contract\":\"07d113d7757ea868f42209419497afe38ef74eacb73f45ea12dae0cfe9c5d9ba\",\"workflow\":\".github/workflows/repo-tree-map.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false}},\"logical_limit\":3,\"native_limit\":3,\"per_account_limit\":1,\"allowed_repositories\":[\"${{ github.repository }}\"],\"max_observation_age_ms\":60000,\"retry\":{\"max_attempts\":3,\"backoff_ms\":30000},\"reconciliation\":{\"max_attempts\":5,\"deadline_ms\":300000}}},\"limits\":{\"ledger_bytes\":67108864,\"recovery_bytes\":16777216,\"payload_bytes\":16384,\"graph_nodes\":4096,\"predecessors\":64,\"pending_nodes\":30,\"operations\":256,\"assignment_bytes\":49152,\"result_bytes\":4096,\"evidence_bytes\":1024,\"observation_writes\":4096}}" GH_AW_WORK_QUEUE_DISPATCH_BUDGET: "3" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"allowed_refs\":[\"refs/heads/${{ github.event.repository.default_branch }}\"],\"aw_context_workflows\":[\"daily-compiler-quality\",\"daily-evals-report\",\"daily-firewall-report\",\"daily-issues-report\",\"daily-observability-report\",\"daily-regulatory\",\"daily-repo-chronicle\",\"daily-secrets-analysis\",\"daily-team-evolution-insights\",\"daily-token-consumption-report\",\"deep-report\",\"artifacts-summary\",\"repo-tree-map\"],\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"max\":3,\"target-ref\":\"${{ github.event.repository.default_branch }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":[\"daily-compiler-quality\",\"daily-evals-report\",\"daily-firewall-report\",\"daily-issues-report\",\"daily-observability-report\",\"daily-regulatory\",\"daily-repo-chronicle\",\"daily-secrets-analysis\",\"daily-team-evolution-insights\",\"daily-token-consumption-report\",\"deep-report\",\"artifacts-summary\",\"repo-tree-map\"],\"workflow_files\":{\"artifacts-summary\":\".lock.yml\",\"daily-compiler-quality\":\".lock.yml\",\"daily-evals-report\":\".lock.yml\",\"daily-firewall-report\":\".lock.yml\",\"daily-issues-report\":\".lock.yml\",\"daily-observability-report\":\".lock.yml\",\"daily-regulatory\":\".lock.yml\",\"daily-repo-chronicle\":\".lock.yml\",\"daily-secrets-analysis\":\".lock.yml\",\"daily-team-evolution-insights\":\".lock.yml\",\"daily-token-consumption-report\":\".lock.yml\",\"deep-report\":\".lock.yml\",\"repo-tree-map\":\".lock.yml\"},\"workflows\":[\"daily-compiler-quality\",\"daily-evals-report\",\"daily-firewall-report\",\"daily-issues-report\",\"daily-observability-report\",\"daily-regulatory\",\"daily-repo-chronicle\",\"daily-secrets-analysis\",\"daily-team-evolution-insights\",\"daily-token-consumption-report\",\"deep-report\",\"artifacts-summary\",\"repo-tree-map\"]}" with: diff --git a/.github/workflows/daily-secrets-analysis.lock.yml b/.github/workflows/daily-secrets-analysis.lock.yml index 0d17ccac1ac..d089ebc714c 100644 --- a/.github/workflows/daily-secrets-analysis.lock.yml +++ b/.github/workflows/daily-secrets-analysis.lock.yml @@ -2295,6 +2295,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "5c8f0d20d9fbf1659c01a7081b875d8576f263e1d39f5283358af0392777fc11" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-team-evolution-insights.lock.yml b/.github/workflows/daily-team-evolution-insights.lock.yml index 6c9912346f4..9d5173cb07d 100644 --- a/.github/workflows/daily-team-evolution-insights.lock.yml +++ b/.github/workflows/daily-team-evolution-insights.lock.yml @@ -1953,6 +1953,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "035f691a3127a730d127bc4a99ca668d838a54828da86fafae9ba39c7b19b5da" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/daily-token-consumption-report.lock.yml b/.github/workflows/daily-token-consumption-report.lock.yml index 696f5c70890..4c0c7df6ec6 100644 --- a/.github/workflows/daily-token-consumption-report.lock.yml +++ b/.github/workflows/daily-token-consumption-report.lock.yml @@ -2552,6 +2552,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "9dd049e2aee21f810fa7be4da3abee1a659f5b063a23416f1da344df7766a6a2" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index e7f9847d6c5..1840b8d0c2b 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -3188,6 +3188,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "099f49b1f233e2c5ebfe89e8fb87697affeb622b76156d375591191c6b28872c" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-agy.lock.yml b/.github/workflows/engine-conformance-agy.lock.yml index f1d3b627ddf..08146a75247 100644 --- a/.github/workflows/engine-conformance-agy.lock.yml +++ b/.github/workflows/engine-conformance-agy.lock.yml @@ -1590,6 +1590,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "7f5cb738b2b7c47ff9f918062c4de9159b33421bc8321c5d99b688b7e3ca2eb8" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-aider.lock.yml b/.github/workflows/engine-conformance-aider.lock.yml index aed3686ea21..6b1526dc8c3 100644 --- a/.github/workflows/engine-conformance-aider.lock.yml +++ b/.github/workflows/engine-conformance-aider.lock.yml @@ -2032,6 +2032,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-claude.lock.yml b/.github/workflows/engine-conformance-claude.lock.yml index 8e2705d056c..d35c33a736a 100644 --- a/.github/workflows/engine-conformance-claude.lock.yml +++ b/.github/workflows/engine-conformance-claude.lock.yml @@ -1552,6 +1552,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-codex.lock.yml b/.github/workflows/engine-conformance-codex.lock.yml index eba49cbf06f..90beb8a88a5 100644 --- a/.github/workflows/engine-conformance-codex.lock.yml +++ b/.github/workflows/engine-conformance-codex.lock.yml @@ -1544,6 +1544,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-copilot.lock.yml b/.github/workflows/engine-conformance-copilot.lock.yml index b08b7539489..7ad0f899c94 100644 --- a/.github/workflows/engine-conformance-copilot.lock.yml +++ b/.github/workflows/engine-conformance-copilot.lock.yml @@ -1551,6 +1551,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-crush.lock.yml b/.github/workflows/engine-conformance-crush.lock.yml index 1d7463b15a3..5ba4b3ca4be 100644 --- a/.github/workflows/engine-conformance-crush.lock.yml +++ b/.github/workflows/engine-conformance-crush.lock.yml @@ -1914,6 +1914,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-cursor.lock.yml b/.github/workflows/engine-conformance-cursor.lock.yml index 04e6ce7af31..84e2648a289 100644 --- a/.github/workflows/engine-conformance-cursor.lock.yml +++ b/.github/workflows/engine-conformance-cursor.lock.yml @@ -1791,6 +1791,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-deepseek-harness.lock.yml b/.github/workflows/engine-conformance-deepseek-harness.lock.yml index 69f4191cdb2..8e74e1418cd 100644 --- a/.github/workflows/engine-conformance-deepseek-harness.lock.yml +++ b/.github/workflows/engine-conformance-deepseek-harness.lock.yml @@ -1674,6 +1674,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-dispatcher.lock.yml b/.github/workflows/engine-conformance-dispatcher.lock.yml index 0651c62d06c..574bf681edc 100644 --- a/.github/workflows/engine-conformance-dispatcher.lock.yml +++ b/.github/workflows/engine-conformance-dispatcher.lock.yml @@ -1882,6 +1882,7 @@ jobs: GH_AW_WORK_QUEUE_POLICY_2: "\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"daily-token-consumption-report\":{\"logical_contract\":\"9dd049e2aee21f810fa7be4da3abee1a659f5b063a23416f1da344df7766a6a2\",\"workflow\":\".github/workflows/daily-token-consumption-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"deep-report\":{\"logical_contract\":\"099f49b1f233e2c5ebfe89e8fb87697affeb622b76156d375591191c6b28872c\",\"workflow\":\".github/workflows/deep-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-agy\":{\"logical_contract\":\"7f5cb738b2b7c47ff9f918062c4de9159b33421bc8321c5d99b688b7e3ca2eb8\",\"workflow\":\".github/workflows/engine-conformance-agy.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-aider\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-aider.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-claude\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-claude.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-codex\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-codex.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-copilot\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-copilot.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-crush\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-crush.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-cursor\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-cursor.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-deepseek-harness\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-deepseek-harness.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-gemini\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-gemini.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-goose\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-goose.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-kiro\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-kiro.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-opencode\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-opencode.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pi\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-pi.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pydantic-ai\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-pydantic-ai.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-miner\":{\"logical_contract\":\"e1ae579a2beb08ebce029363f9f59ec3bb85b84fa066210be66fd41b013b7a9c\",\"workflow\":\".github/workflows/eslint-miner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-monster\":{\"logical_contract\":\"ed27c8f7b06c56b27a284332f5674e71e8822fed6fd61603d0ade616be76ecbd\",\"workflow\":\".github/workflows/eslint-monster.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-refiner\":{\"logical_contract\":\"e25a866c9c9c5518ada0e1e45dffdce7925bad3463b3f47f34a861f534353928\",\"workflow\":\".github/workflows/eslint-refiner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"repo-tree-map\":{\"logical_contract\":\"07d113d7757ea868f42209419497afe38ef74eacb73f45ea12dae0cfe9c5d9ba\",\"workflow\":\".github/workflows/repo-tree-map.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false}},\"logical_limit\":3,\"native_limit\":3,\"per_account_limit\":1,\"allowed_repositories\":[\"${{ github.repository }}\"],\"max_observation_age_ms\":60000,\"retry\":{\"max_attempts\":3,\"backoff_ms\":30000},\"reconciliation\":{\"max_attempts\":5,\"deadline_ms\":300000}}},\"limits\":{\"ledger_bytes\":67108864,\"recovery_bytes\":16777216,\"payload_bytes\":16384,\"graph_nodes\":4096,\"predecessors\":64,\"pending_nodes\":30,\"operations\":256,\"assignment_bytes\":49152,\"result_bytes\":4096,\"evidence_bytes\":1024,\"observation_writes\":4096}}" GH_AW_WORK_QUEUE_DISPATCH_BUDGET: "3" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"allowed_refs\":[\"refs/heads/${{ github.event.repository.default_branch }}\"],\"aw_context_workflows\":[\"engine-conformance-agy\",\"engine-conformance-aider\",\"engine-conformance-claude\",\"engine-conformance-codex\",\"engine-conformance-copilot\",\"engine-conformance-crush\",\"engine-conformance-cursor\",\"engine-conformance-deepseek-harness\",\"engine-conformance-gemini\",\"engine-conformance-goose\",\"engine-conformance-kiro\",\"engine-conformance-opencode\",\"engine-conformance-pi\",\"engine-conformance-pydantic-ai\"],\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"max\":3,\"target-ref\":\"${{ github.event.repository.default_branch }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":[\"engine-conformance-agy\",\"engine-conformance-aider\",\"engine-conformance-claude\",\"engine-conformance-codex\",\"engine-conformance-copilot\",\"engine-conformance-crush\",\"engine-conformance-cursor\",\"engine-conformance-deepseek-harness\",\"engine-conformance-gemini\",\"engine-conformance-goose\",\"engine-conformance-kiro\",\"engine-conformance-opencode\",\"engine-conformance-pi\",\"engine-conformance-pydantic-ai\"],\"workflow_files\":{\"engine-conformance-agy\":\".lock.yml\",\"engine-conformance-aider\":\".lock.yml\",\"engine-conformance-claude\":\".lock.yml\",\"engine-conformance-codex\":\".lock.yml\",\"engine-conformance-copilot\":\".lock.yml\",\"engine-conformance-crush\":\".lock.yml\",\"engine-conformance-cursor\":\".lock.yml\",\"engine-conformance-deepseek-harness\":\".lock.yml\",\"engine-conformance-gemini\":\".lock.yml\",\"engine-conformance-goose\":\".lock.yml\",\"engine-conformance-kiro\":\".lock.yml\",\"engine-conformance-opencode\":\".lock.yml\",\"engine-conformance-pi\":\".lock.yml\",\"engine-conformance-pydantic-ai\":\".lock.yml\"},\"workflows\":[\"engine-conformance-agy\",\"engine-conformance-aider\",\"engine-conformance-claude\",\"engine-conformance-codex\",\"engine-conformance-copilot\",\"engine-conformance-crush\",\"engine-conformance-cursor\",\"engine-conformance-deepseek-harness\",\"engine-conformance-gemini\",\"engine-conformance-goose\",\"engine-conformance-kiro\",\"engine-conformance-opencode\",\"engine-conformance-pi\",\"engine-conformance-pydantic-ai\"]}" with: diff --git a/.github/workflows/engine-conformance-gemini.lock.yml b/.github/workflows/engine-conformance-gemini.lock.yml index a752a4aff58..2d9086eea65 100644 --- a/.github/workflows/engine-conformance-gemini.lock.yml +++ b/.github/workflows/engine-conformance-gemini.lock.yml @@ -1514,6 +1514,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-goose.lock.yml b/.github/workflows/engine-conformance-goose.lock.yml index 5dbf99d27da..dc96135a5e8 100644 --- a/.github/workflows/engine-conformance-goose.lock.yml +++ b/.github/workflows/engine-conformance-goose.lock.yml @@ -1648,6 +1648,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-kiro.lock.yml b/.github/workflows/engine-conformance-kiro.lock.yml index 610531a0c11..b735321df0d 100644 --- a/.github/workflows/engine-conformance-kiro.lock.yml +++ b/.github/workflows/engine-conformance-kiro.lock.yml @@ -1814,6 +1814,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-opencode.lock.yml b/.github/workflows/engine-conformance-opencode.lock.yml index 1a4e7f22f8e..551a6e000fe 100644 --- a/.github/workflows/engine-conformance-opencode.lock.yml +++ b/.github/workflows/engine-conformance-opencode.lock.yml @@ -1698,6 +1698,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-pi.lock.yml b/.github/workflows/engine-conformance-pi.lock.yml index 8099dd390c3..43d6bba941f 100644 --- a/.github/workflows/engine-conformance-pi.lock.yml +++ b/.github/workflows/engine-conformance-pi.lock.yml @@ -1505,6 +1505,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/engine-conformance-pydantic-ai.lock.yml b/.github/workflows/engine-conformance-pydantic-ai.lock.yml index edde90daef4..29ab7b2cec9 100644 --- a/.github/workflows/engine-conformance-pydantic-ai.lock.yml +++ b/.github/workflows/engine-conformance-pydantic-ai.lock.yml @@ -2100,6 +2100,7 @@ jobs: GH_AW_WORK_QUEUE_CONTRACT: "f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8" GH_AW_SAFE_OUTPUTS_STAGED: "true" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/eslint-factory-dispatcher.lock.yml b/.github/workflows/eslint-factory-dispatcher.lock.yml index 24842b211c7..33360c47b21 100644 --- a/.github/workflows/eslint-factory-dispatcher.lock.yml +++ b/.github/workflows/eslint-factory-dispatcher.lock.yml @@ -1878,6 +1878,7 @@ jobs: GH_AW_WORK_QUEUE_POLICY_2: "\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"daily-token-consumption-report\":{\"logical_contract\":\"9dd049e2aee21f810fa7be4da3abee1a659f5b063a23416f1da344df7766a6a2\",\"workflow\":\".github/workflows/daily-token-consumption-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"deep-report\":{\"logical_contract\":\"099f49b1f233e2c5ebfe89e8fb87697affeb622b76156d375591191c6b28872c\",\"workflow\":\".github/workflows/deep-report.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-agy\":{\"logical_contract\":\"7f5cb738b2b7c47ff9f918062c4de9159b33421bc8321c5d99b688b7e3ca2eb8\",\"workflow\":\".github/workflows/engine-conformance-agy.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-aider\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-aider.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-claude\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-claude.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-codex\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-codex.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-copilot\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-copilot.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-crush\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-crush.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-cursor\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-cursor.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-deepseek-harness\":{\"logical_contract\":\"3822eab0a946e83d62d52e55f17e889ee4db367b40a63e53978b52963f89cbf2\",\"workflow\":\".github/workflows/engine-conformance-deepseek-harness.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-gemini\":{\"logical_contract\":\"baad6e27d6d5b3c826d5aa25d6ba2f3cf8b5818bcafb8e420a137a50e7dffe0e\",\"workflow\":\".github/workflows/engine-conformance-gemini.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-goose\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-goose.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-kiro\":{\"logical_contract\":\"14639a000869e40bfa9e9cb312ab60e13cc3039f2df1297776e21487769cb127\",\"workflow\":\".github/workflows/engine-conformance-kiro.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-opencode\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-opencode.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pi\":{\"logical_contract\":\"22f605b30efc2472f3b2f346e24b54dc7b4bbccb18fa930ebeeecc4b922ecab3\",\"workflow\":\".github/workflows/engine-conformance-pi.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"engine-conformance-pydantic-ai\":{\"logical_contract\":\"f6c16dcd5e57ca69b78091e6062bfa4e6129a60d47e21ade47668b71e02feba8\",\"workflow\":\".github/workflows/engine-conformance-pydantic-ai.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-miner\":{\"logical_contract\":\"e1ae579a2beb08ebce029363f9f59ec3bb85b84fa066210be66fd41b013b7a9c\",\"workflow\":\".github/workflows/eslint-miner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-monster\":{\"logical_contract\":\"ed27c8f7b06c56b27a284332f5674e71e8822fed6fd61603d0ade616be76ecbd\",\"workflow\":\".github/workflows/eslint-monster.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"eslint-refiner\":{\"logical_contract\":\"e25a866c9c9c5518ada0e1e45dffdce7925bad3463b3f47f34a861f534353928\",\"workflow\":\".github/workflows/eslint-refiner.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false},\"repo-tree-map\":{\"logical_contract\":\"07d113d7757ea868f42209419497afe38ef74eacb73f45ea12dae0cfe9c5d9ba\",\"workflow\":\".github/workflows/repo-tree-map.lock.yml\",\"ref\":\"${{ github.sha }}\",\"trust_domain\":\"default\",\"credential_scope\":\"repository\",\"effect_scope\":\"${{ github.repository }}\",\"max_claims\":1,\"share_keys\":false}},\"logical_limit\":3,\"native_limit\":3,\"per_account_limit\":1,\"allowed_repositories\":[\"${{ github.repository }}\"],\"max_observation_age_ms\":60000,\"retry\":{\"max_attempts\":3,\"backoff_ms\":30000},\"reconciliation\":{\"max_attempts\":5,\"deadline_ms\":300000}}},\"limits\":{\"ledger_bytes\":67108864,\"recovery_bytes\":16777216,\"payload_bytes\":16384,\"graph_nodes\":4096,\"predecessors\":64,\"pending_nodes\":30,\"operations\":256,\"assignment_bytes\":49152,\"result_bytes\":4096,\"evidence_bytes\":1024,\"observation_writes\":4096}}" GH_AW_WORK_QUEUE_DISPATCH_BUDGET: "3" GH_AW_GITHUB_TOKEN_SOURCE: "github_actions" + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"allowed_refs\":[\"refs/heads/${{ github.event.repository.default_branch }}\"],\"aw_context_workflows\":[\"eslint-miner\",\"eslint-refiner\",\"eslint-monster\"],\"github-token\":\"${{ secrets.GITHUB_TOKEN }}\",\"max\":3,\"target-ref\":\"${{ github.event.repository.default_branch }}\",\"work_queue_dispatch_credential\":{\"kind\":\"github_token\"},\"work_queue_enabled\":true,\"work_queue_workflows\":[\"eslint-miner\",\"eslint-refiner\",\"eslint-monster\"],\"workflow_files\":{\"eslint-miner\":\".lock.yml\",\"eslint-monster\":\".lock.yml\",\"eslint-refiner\":\".lock.yml\"},\"workflows\":[\"eslint-miner\",\"eslint-refiner\",\"eslint-monster\"]}" with: diff --git a/.github/workflows/eslint-miner.lock.yml b/.github/workflows/eslint-miner.lock.yml index 960c13fd4be..5ebce7d84b8 100644 --- a/.github/workflows/eslint-miner.lock.yml +++ b/.github/workflows/eslint-miner.lock.yml @@ -2376,6 +2376,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "e1ae579a2beb08ebce029363f9f59ec3bb85b84fa066210be66fd41b013b7a9c" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/eslint-monster.lock.yml b/.github/workflows/eslint-monster.lock.yml index 49d9171aa66..0e4c8d10028 100644 --- a/.github/workflows/eslint-monster.lock.yml +++ b/.github/workflows/eslint-monster.lock.yml @@ -2475,6 +2475,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "ed27c8f7b06c56b27a284332f5674e71e8822fed6fd61603d0ade616be76ecbd" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/eslint-refiner.lock.yml b/.github/workflows/eslint-refiner.lock.yml index 0f64315236a..3d058947c23 100644 --- a/.github/workflows/eslint-refiner.lock.yml +++ b/.github/workflows/eslint-refiner.lock.yml @@ -2520,6 +2520,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "e25a866c9c9c5518ada0e1e45dffdce7925bad3463b3f47f34a861f534353928" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index 29c987116f4..23681ad11dd 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -1804,6 +1804,7 @@ jobs: GH_AW_WORK_QUEUE_CHECKED_TRANSPORT: "graphql" GH_AW_WORK_QUEUE_CONTRACT: "07d113d7757ea868f42209419497afe38ef74eacb73f45ea12dae0cfe9c5d9ba" GH_AW_GITHUB_TOKEN_SOURCE: ${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'pat' || 'github_actions' }} + GH_AW_WORK_QUEUE_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_WORK_QUEUE_INTENTS: /tmp/gh-aw/work-queue.intents.jsonl GH_AW_WORK_QUEUE_CONTROL_CONFIG: "{\"aw_context_workflows\":null,\"github-token\":\"${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}\",\"work_queue_dispatch_credential\":{\"kind\":\"${{ secrets.GH_AW_GITHUB_TOKEN != '' && 'authenticated' || 'github_token' }}\"},\"work_queue_enabled\":true,\"work_queue_workflows\":null}" with: diff --git a/actions/setup/js/eslint_factory_native.test_helpers.cjs b/actions/setup/js/eslint_factory_native.test_helpers.cjs new file mode 100644 index 00000000000..f93135efd66 --- /dev/null +++ b/actions/setup/js/eslint_factory_native.test_helpers.cjs @@ -0,0 +1,95 @@ +// @ts-check +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { ESLINT_WORKERS } = require("./eslint_factory_portfolio.cjs"); +const { replayTransactions } = require("./work_queue_replay.cjs"); +const { REF, REPOSITORY } = require("./work_queue_lifecycle.test_helpers.cjs"); + +function compiledFactoryPolicyEnvironments(compiled) { + return [...compiled.matchAll(/(?:^ +GH_AW_WORK_QUEUE_POLICY(?:_PARTS|_[0-9]+)?: "[^\n]+"\n)+/gm)].map(([block]) => + Object.fromEntries([...block.matchAll(/(GH_AW_WORK_QUEUE_POLICY(?:_PARTS|_[0-9]+)?): ("[^\n]+")/g)].map(([, name, value]) => [name, JSON.parse(value)])) + ); +} + +function installFactoryNativeAPI(fake, fixture) { + const runs = new Map(); + const tags = new Map(); + const tagCreations = []; + const posts = []; + const fileReads = []; + fake.githubClient.rest.repos.get = async () => ({ + status: 200, + data: { full_name: REPOSITORY, id: 7, default_branch: "main", size: 1, permissions: { push: true } }, + }); + const getRef = fake.githubClient.rest.git.getRef; + fake.githubClient.rest.git.getRef = async parameters => { + if (parameters.ref.startsWith("tags/")) { + const sha = tags.get(parameters.ref); + if (!sha) throw Object.assign(new Error("Not found"), { status: 404 }); + return { status: 200, data: { ref: `refs/${parameters.ref}`, object: { type: "commit", sha } } }; + } + const response = await getRef(parameters); + return { ...response, data: { ...response.data, ref: `refs/${parameters.ref}` } }; + }; + const createRef = fake.githubClient.rest.git.createRef; + fake.githubClient.rest.git.createRef = async parameters => { + if (!parameters.ref.startsWith("refs/tags/")) return createRef(parameters); + assert.equal(parameters.ref, `refs/tags/gh-aw/work-queue/${parameters.sha}`); + assert.ok([REF, fake.state.defaultRevision].includes(parameters.sha)); + tagCreations.push(parameters); + tags.set(parameters.ref.slice("refs/".length), parameters.sha); + return { status: 201, data: {} }; + }; + fake.githubClient.rest.users = { + ...fixture.githubClient.rest.users, + getByUsername: async ({ username }) => { + assert.equal(username, "github-actions[bot]"); + return { status: 200, data: { id: "12", type: "Bot", login: username } }; + }, + }; + Object.assign(fake.githubClient, { auth: async () => ({ type: "token", token: "fixture-token" }) }); + const workerFiles = ESLINT_WORKERS.flatMap(profile => [`.github/workflows/${profile}.md`, `.github/workflows/${profile}.lock.yml`]); + fake.githubClient.rest.repos.getContent = async ({ path: filename, ref }) => { + assert.ok([REF, fake.state.defaultRevision].includes(ref)); + assert.ok(workerFiles.includes(filename)); + fileReads.push(filename); + const content = fs.readFileSync(path.resolve(__dirname, "../../..", filename)); + return { data: { type: "file", path: filename, encoding: "base64", content: content.toString("base64") } }; + }; + fake.githubClient.rest.actions.getWorkflow = async ({ workflow_id }) => { + assert.ok(ESLINT_WORKERS.some(worker => workflow_id === `${worker}.lock.yml`)); + return { data: { path: `.github/workflows/${workflow_id}`, state: "active" } }; + }; + const getRun = async ({ run_id }) => { + const run = String(run_id) === "15" ? fixture.nativeRun(fixture.dispatcherContext) : runs.get(String(run_id)); + assert.ok(run, "only fixture workflow runs may be read"); + return { status: 200, data: run }; + }; + fake.githubClient.rest.actions.getWorkflowRun = getRun; + fake.githubClient.rest.actions.getWorkflowRunAttempt = getRun; + fake.githubClient.rest.actions.createWorkflowDispatch = async parameters => { + const assignment = JSON.parse(parameters.inputs.work_queue_assignment); + const state = replayTransactions(fake.log()); + assert.equal(state.dispatches.get(assignment.dispatch_id).state, "started"); + const revision = tags.get(parameters.ref.slice("refs/".length)); + assert.ok(revision, "worker dispatch must use a verified fixture tag"); + assert.equal(assignment.claims.length, 1); + assert.equal(parameters.workflow_id, `${assignment.worker_profile}.lock.yml`); + const id = String(42 + posts.length); + posts.push(parameters); + runs.set(id, { + ...fixture.nativeRun(fixture.workerContext), + id, + path: `.github/workflows/${parameters.workflow_id}`, + head_sha: revision, + display_title: `gh-aw work-queue ${assignment.dispatch_id}`, + }); + return { status: 200, data: { workflow_run_id: id, run_url: `https://api.github.com/repos/${REPOSITORY}/actions/runs/${id}`, html_url: `https://github.com/${REPOSITORY}/actions/runs/${id}` } }; + }; + return { runs, tags, tagCreations, posts, fileReads, workerFiles }; +} + +module.exports = { compiledFactoryPolicyEnvironments, installFactoryNativeAPI }; diff --git a/actions/setup/js/eslint_factory_portfolio.test.cjs b/actions/setup/js/eslint_factory_portfolio.test.cjs index 71ec3ce4754..fba06441a08 100644 --- a/actions/setup/js/eslint_factory_portfolio.test.cjs +++ b/actions/setup/js/eslint_factory_portfolio.test.cjs @@ -16,18 +16,13 @@ import { readWorkQueuePolicyConfig } from "./work_queue_policy_config.cjs"; import { bindWorkerAssignment } from "./work_queue_binding.cjs"; import { authorizeWorkerClaim, reconcileWorkerClaim } from "./finish_work_queue_claim.cjs"; import { reconcileDispatch } from "./work_queue_reconciler.cjs"; +import { compiledFactoryPolicyEnvironments, installFactoryNativeAPI } from "./eslint_factory_native.test_helpers.cjs"; const repository = "owner/repo"; const planOptions = { date: "2026-10-08", repository, repositoryId: "7" }; const settings = JSON.parse(fs.readFileSync(new URL("../../../.github/workflows/aw.json", import.meta.url), "utf8")).work_queue; const policyOptions = { repository, ref: "a".repeat(40), settings }; -function compiledFactoryPolicyEnvironments(compiled) { - return [...compiled.matchAll(/(?:^ +GH_AW_WORK_QUEUE_POLICY(?:_PARTS|_[0-9]+)?: "[^\n]+"\n)+/gm)].map(([block]) => - Object.fromEntries([...block.matchAll(/(GH_AW_WORK_QUEUE_POLICY(?:_PARTS|_[0-9]+)?): ("[^\n]+")/g)].map(([, name, value]) => [name, JSON.parse(value)])) - ); -} - function ledger() { let state = newState(); const transactions = []; @@ -174,10 +169,7 @@ describe("ESLint factory producer and dispatcher", () => { fs.mkdirSync(directory); const fixture = queueFixture({ granted: false, workerPrincipal: "12" }); const fake = fakeGitHub(); - const runs = new Map(); - const tags = new Map(); - const tagCreations = []; - const posts = []; + const { runs, tagCreations, posts, fileReads, workerFiles } = installFactoryNativeAPI(fake, fixture); const failures = []; const compiled = fs.readFileSync(new URL("../../../.github/workflows/eslint-factory-dispatcher.lock.yml", import.meta.url), "utf8"); const configMatch = compiled.match(/GH_AW_WORK_QUEUE_CONTROL_CONFIG: ("[^\n]+")/); @@ -186,64 +178,6 @@ describe("ESLint factory producer and dispatcher", () => { const [environment] = compiledFactoryPolicyEnvironments(compiled); expect(environment).toBeDefined(); const policy = JSON.parse(readWorkQueuePolicyConfig(environment).replaceAll("${{ github.repository }}", REPOSITORY).replaceAll("${{ github.sha }}", REF)); - fake.githubClient.rest.repos.get = async () => ({ - status: 200, - data: { full_name: REPOSITORY, id: 7, default_branch: "main", size: 1, permissions: { push: true } }, - }); - const getRef = fake.githubClient.rest.git.getRef; - fake.githubClient.rest.git.getRef = async parameters => { - if (parameters.ref.startsWith("tags/")) { - const sha = tags.get(parameters.ref); - if (!sha) throw Object.assign(new Error("Not found"), { status: 404 }); - return { status: 200, data: { ref: `refs/${parameters.ref}`, object: { type: "commit", sha } } }; - } - const response = await getRef(parameters); - return { ...response, data: { ...response.data, ref: `refs/${parameters.ref}` } }; - }; - const createRef = fake.githubClient.rest.git.createRef; - fake.githubClient.rest.git.createRef = async parameters => { - if (!parameters.ref.startsWith("refs/tags/")) return createRef(parameters); - expect(parameters.ref).toBe(`refs/tags/gh-aw/work-queue/${REF}`); - expect(parameters.sha).toBe(REF); - tagCreations.push(parameters); - tags.set(parameters.ref.slice("refs/".length), parameters.sha); - return { status: 201, data: {} }; - }; - fake.githubClient.rest.users = fixture.githubClient.rest.users; - fake.githubClient.rest.users.getByUsername = async ({ username }) => { - expect(username).toBe("github-actions[bot]"); - return { status: 200, data: { id: "12", type: "Bot", login: username } }; - }; - fake.githubClient.auth = async () => ({ type: "token", token: "fixture-token" }); - const workerFiles = ESLINT_WORKERS.flatMap(profile => [`.github/workflows/${profile}.md`, `.github/workflows/${profile}.lock.yml`]); - const fileReads = []; - fake.githubClient.rest.repos.getContent = async ({ path: filename, ref }) => { - expect(ref).toBe(REF); - expect(workerFiles).toContain(filename); - fileReads.push(filename); - const content = fs.readFileSync(new URL(`../../../${filename}`, import.meta.url)); - return { data: { type: "file", path: filename, encoding: "base64", content: content.toString("base64") } }; - }; - fake.githubClient.rest.actions.getWorkflow = async ({ workflow_id }) => ({ data: { path: `.github/workflows/${workflow_id}`, state: "active" } }); - fake.githubClient.rest.actions.getWorkflowRun = async ({ run_id }) => ({ status: 200, data: String(run_id) === "15" ? fixture.nativeRun(fixture.dispatcherContext) : runs.get(String(run_id)) }); - fake.githubClient.rest.actions.getWorkflowRunAttempt = async ({ run_id }) => fake.githubClient.rest.actions.getWorkflowRun({ run_id }); - fake.githubClient.rest.actions.createWorkflowDispatch = async parameters => { - const assignment = JSON.parse(parameters.inputs.work_queue_assignment); - const state = replayTransactions(fake.log()); - expect(state.dispatches.get(assignment.dispatch_id).state).toBe("started"); - expect(parameters.ref).toBe(`refs/tags/gh-aw/work-queue/${REF}`); - expect(assignment.claims).toHaveLength(1); - expect(parameters.workflow_id).toBe(`${assignment.worker_profile}.lock.yml`); - const id = String(42 + posts.length); - posts.push(parameters); - runs.set(id, { - ...fixture.nativeRun(fixture.workerContext), - id, - path: `.github/workflows/${parameters.workflow_id}`, - display_title: `gh-aw work-queue ${assignment.dispatch_id}`, - }); - return { status: 200, data: { workflow_run_id: id, run_url: `https://api.github.com/repos/${REPOSITORY}/actions/runs/${id}`, html_url: `https://github.com/${REPOSITORY}/actions/runs/${id}` } }; - }; const core = { setOutput: () => {}, info: () => {}, setFailed: message => failures.push(message) }; const options = { githubClient: fake.githubClient, diff --git a/actions/setup/js/package.json b/actions/setup/js/package.json index e9d4fa77936..6188f0368dc 100644 --- a/actions/setup/js/package.json +++ b/actions/setup/js/package.json @@ -36,6 +36,7 @@ "typecheck": "tsc --noEmit", "test": "npm run typecheck && npm run schema:session:check && vitest run --no-file-parallelism", "test:js": "vitest run", + "test:work-queue-performance": "vitest run --no-file-parallelism --silent=false work_queue_performance.test.cjs work_queue_control_performance.test.cjs work_queue_live_performance.test.cjs", "test:js-integration-live-api": "vitest run --config vitest.integration.config.mjs --no-file-parallelism", "test:js-integration-artifact": "vitest run --config vitest.artifact-integration.config.mjs --no-file-parallelism", "test:js-watch": "vitest", diff --git a/actions/setup/js/work_queue_control_adapter.cjs b/actions/setup/js/work_queue_control_adapter.cjs index 79534151ec4..dc86e4b3039 100644 --- a/actions/setup/js/work_queue_control_adapter.cjs +++ b/actions/setup/js/work_queue_control_adapter.cjs @@ -17,6 +17,7 @@ const { normalizeDispatchCredential, createDispatchCredentialValidator } = requi * core?: {setOutput: (name: string, value: unknown) => unknown, info: (message: string) => unknown, setFailed?: (message: string) => unknown}, * github?: object, * githubClient?: object, + * queueClient?: object, * dispatchClient?: object, * getOctokit?: (token: string, options?: {baseUrl?: string, credentialSource?: string}) => object | Promise, * config?: Record & {"github-token"?: string, work_queue_dispatch_credential?: unknown, work_queue_enabled?: boolean}, @@ -150,6 +151,15 @@ async function main(options = {}) { throw new Error("work_queue_preview_cannot_validate_launch_credential"); }; } + const queueToken = (configured.env || process.env).GH_AW_WORK_QUEUE_TOKEN; + if (!preview && configured.queueClient === undefined && queueToken !== undefined) { + if (typeof queueToken !== "string" || !queueToken || queueToken.length > 16384 || /[\x00-\x1f\x7f]/.test(queueToken)) throw new Error("work_queue_publisher_credential_token_invalid"); + const factory = configured.getOctokit || global.getOctokit; + if (typeof factory !== "function") throw new Error("work_queue_publisher_credential_client_unavailable"); + const credentialSource = (configured.env || process.env).GH_AW_GITHUB_TOKEN_SOURCE || "unknown"; + // The publisher token is distinct from the action-dispatch credential. + configured.queueClient = await factory(queueToken, { credentialSource }); + } if (intents.some(intent => ["submit", "dispatch_next"].includes(intent.kind)) && !preview) { const latest = await loadQueue({ ...configured, policyProposal: undefined, initializationContext: undefined }); configured.dependencyResolver = createCompilerDependencyResolver({ ...configured, state: dependencyStateForIntents(latest, intents, configured) }); diff --git a/actions/setup/js/work_queue_control_adapter_checks.cjs b/actions/setup/js/work_queue_control_adapter_checks.cjs index e231170bcaa..d92b059303a 100644 --- a/actions/setup/js/work_queue_control_adapter_checks.cjs +++ b/actions/setup/js/work_queue_control_adapter_checks.cjs @@ -162,20 +162,35 @@ function registerTests({ describe, it }) { fixture.githubClient.rest.actions.createWorkflowDispatch = async () => { throw new Error("publisher credential must not dispatch"); }; + const publisherClient = { ...fixture.githubClient }; + let queueReads = 0; + let queueWrites = 0; try { const intentPath = path.join(root, "intents.jsonl"); fs.writeFileSync(intentPath, `${JSON.stringify({ version: 3, intent_id: "launch-credential", kind: "dispatch_next", parameters: { pool: "default", max_claims: 1, max_dispatches: 1 } })}\n`); const result = await main({ assignment: null, - env: { GH_AW_GITHUB_TOKEN_SOURCE: entry.credentialSource }, + env: { GH_AW_GITHUB_TOKEN_SOURCE: entry.credentialSource, GH_AW_WORK_QUEUE_TOKEN: "protected-publisher-token" }, intentPath, githubClient: fixture.githubClient, context: fixture.dispatcherContext, workflowRef: `${REPOSITORY}/${DISPATCHER}@${REF}`, intentOrigin: fixture.dispatcher, - readWorkQueueLog: fixture.readWorkQueueLog, - publishWorkQueueRequest: fixture.publishWorkQueueRequest, + readWorkQueueLog: async parameters => { + assert.equal(parameters.githubClient, publisherClient); + queueReads++; + return fixture.readWorkQueueLog(); + }, + publishWorkQueueRequest: async parameters => { + assert.equal(parameters.githubClient, publisherClient); + queueWrites++; + return fixture.publishWorkQueueRequest(parameters); + }, getOctokit: (token, options) => { + if (token === "protected-publisher-token") { + assert.deepEqual(options, { credentialSource: entry.credentialSource }); + return publisherClient; + } assert.equal(token, "protected-selected-token"); assert.deepEqual(options, entry.credentialSource === "unknown" ? undefined : { credentialSource: entry.credentialSource }); return dispatchClient; @@ -189,6 +204,8 @@ function registerTests({ describe, it }) { assert.equal(posts, 1); assert.equal(workerReads, 1); assert.equal(identityReads, 1); + assert.ok(queueReads > 0); + assert.ok(queueWrites > 0); const dispatch = [...fixture.state.dispatches.values()][0]; assert.equal(dispatch.state, "bound"); assert.equal(dispatch.run.principal, entry.principal); @@ -270,7 +287,7 @@ function registerTests({ describe, it }) { fs.writeFileSync(intentPath, `${JSON.stringify({ version: 3, intent_id: "credential-preview", kind: "dispatch_next", parameters: { pool: "default", max_claims: 1, max_dispatches: 1 } })}\n`); const options = { assignment: null, - env: {}, + env: { GH_AW_WORK_QUEUE_TOKEN: "protected-publisher-token" }, intentPath, githubClient: fixture.githubClient, dispatchClient: fixture.githubClient, @@ -298,10 +315,13 @@ function registerTests({ describe, it }) { for (const credential of [null, { kind: "unsupported" }, { kind: "github_app" }]) { await assert.rejects(main({ ...options, config: { ...options.config, "github-token": "protected-selected-token", work_queue_dispatch_credential: credential } }), /dispatch_(credential_kind_invalid|app_metadata_missing)/); } + for (const token of ["", "publisher\ninjection", "x".repeat(16385)]) { + await assert.rejects(main({ ...options, env: { GH_AW_WORK_QUEUE_TOKEN: token }, config: { ...options.config, work_queue_dispatch_credential: { kind: "github_token" } } }), /publisher_credential_token_invalid/); + } assert.equal(reads, 0, "invalid protected metadata must fail before queue or dependency reads"); assert.equal(credentialCalls, 0, "invalid protected metadata must fail before client construction"); assert.equal(writes, 0); - const result = await main({ ...options, staged: true, config: { ...options.config, "github-token": "", work_queue_dispatch_credential: { kind: "agent-spoof" } } }); + const result = await main({ ...options, env: { GH_AW_WORK_QUEUE_TOKEN: "" }, staged: true, config: { ...options.config, "github-token": "", work_queue_dispatch_credential: { kind: "agent-spoof" } } }); assert.equal(result.receipts[0].status, "staged_preview", JSON.stringify(result)); assert.equal(credentialCalls, 0); assert.equal(writes, 0); @@ -799,6 +819,7 @@ async function verifyCompiledDispatchIdentity(input) { }, }; fixture.githubClient.hook = { before() {} }; + const publisherClient = { ...fixture.githubClient, auth: async () => ({ type: "token", token: "protected-publisher-token" }) }; fixture.githubClient.rest.actions.createWorkflowDispatch = async () => { throw new Error("The ledger publisher credential must not launch workers"); }; @@ -851,10 +872,16 @@ async function verifyCompiledDispatchIdentity(input) { main: async options => { result = await main({ ...options, - env: {}, + env: { GH_AW_WORK_QUEUE_TOKEN: "protected-publisher-token" }, workflowRef: dispatcherWorkflowRef, - readWorkQueueLog: fixture.readWorkQueueLog, - publishWorkQueueRequest: fixture.publishWorkQueueRequest, + readWorkQueueLog: async parameters => { + assert.equal(parameters.githubClient.auth, publisherClient.auth); + return fixture.readWorkQueueLog(); + }, + publishWorkQueueRequest: async parameters => { + assert.equal(parameters.githubClient.auth, publisherClient.auth); + return fixture.publishWorkQueueRequest(parameters); + }, now: fixture.at, sleepFn: async () => {}, }); @@ -865,6 +892,7 @@ async function verifyCompiledDispatchIdentity(input) { throw new Error(`Unexpected compiled runtime module: ${module}`); }; const getOctokit = token => { + if (token === "protected-publisher-token") return publisherClient; assert.equal(token, "protected-selected-token"); return dispatchClient; }; diff --git a/actions/setup/js/work_queue_control_performance.test.cjs b/actions/setup/js/work_queue_control_performance.test.cjs new file mode 100644 index 00000000000..484d18ce819 --- /dev/null +++ b/actions/setup/js/work_queue_control_performance.test.cjs @@ -0,0 +1,283 @@ +// @ts-check +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { createRequire } from "node:module"; +import { ESLINT_WORKERS, buildESLintFactoryPlan } from "./eslint_factory_portfolio.cjs"; +import { compiledFactoryPolicyEnvironments, installFactoryNativeAPI } from "./eslint_factory_native.test_helpers.cjs"; +import { canonical } from "./work_queue_codec.cjs"; +import { stagePlanIntents } from "./work_queue_intents.cjs"; +import { prepareCheckpoint, replayTransactions } from "./work_queue_replay.cjs"; +import { fakeGitHub } from "./work_queue_store_checks.cjs"; +import { queueFixture, DISPATCHER, REF, REPOSITORY } from "./work_queue_lifecycle.test_helpers.cjs"; +import { main as processControls } from "./work_queue_control_adapter.cjs"; +import { readWorkQueuePolicyConfig } from "./work_queue_policy_config.cjs"; + +const require = createRequire(import.meta.url); +const { setupGlobals } = require("./setup_globals.cjs"); +const { queueAuthenticationScope } = require("./work_queue_auth_cache.cjs"); +const GLOBAL_NAMES = ["core", "github", "context", "exec", "io", "getOctokit", "runtimeFeatures", "hasRuntimeFeature", "getRuntimeFeatureValue"]; +const savedGlobals = new Map(GLOBAL_NAMES.map(name => [name, Object.getOwnPropertyDescriptor(global, name)])); +const directories = []; +const compiled = fs.readFileSync(new URL("../../../.github/workflows/eslint-factory-dispatcher.lock.yml", import.meta.url), "utf8"); +const configMatch = compiled.match(/GH_AW_WORK_QUEUE_CONTROL_CONFIG: ("[^\n]+")/); +if (!configMatch) throw new Error("Compiled ESLint factory control configuration is missing"); +const config = { ...JSON.parse(JSON.parse(configMatch[1])), "github-token": "fixture-token" }; +const [policyEnvironment] = compiledFactoryPolicyEnvironments(compiled); +if (!policyEnvironment) throw new Error("Compiled ESLint factory Policy is missing"); +const compiledPolicy = readWorkQueuePolicyConfig(policyEnvironment).replaceAll("${{ github.repository }}", REPOSITORY).replaceAll("${{ github.sha }}", REF); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + for (const name of GLOBAL_NAMES) { + const descriptor = savedGlobals.get(name); + if (descriptor) Object.defineProperty(global, name, descriptor); + else delete global[name]; + } + for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); +}); + +async function fixture({ depth = 32, profiles = 3, works = 15, retained = 100, auth = "constructed", deploymentsChanged = false, transport = "graphql", publisherToken = false } = {}) { + const root = fs.mkdtempSync(path.join(process.cwd(), ".gh-aw-control-performance-")); + directories.push(root); + const workers = ESLINT_WORKERS.slice(0, profiles); + const plan = buildESLintFactoryPlan({ date: "2026-10-08", repository: REPOSITORY, repositoryId: "7" }); + plan.nodes = plan.nodes.filter(node => workers.includes(node.worker_profile)); + plan.dispatch.max_claims = profiles; + plan.dispatch.max_dispatches = profiles; + const policy = JSON.parse(compiledPolicy); + policy.pools.default.profiles = Object.fromEntries(workers.map(worker => [worker, policy.pools.default.profiles[worker]])); + policy.pools.default.default_profile = workers[0]; + const queue = queueFixture({ + count: works, + granted: false, + workerPrincipal: "12", + configurePolicy: value => Object.assign(value, policy), + configureWork: (work, index) => { + const node = plan.nodes[index % profiles]; + work.worker_profile = node.worker_profile; + work.payload = { ...node.payload, historical_context: "Original ESLint factory task context. ".repeat(350) }; + }, + }); + expect(queue.state.deployments.get("default").size).toBe(profiles); + for (let index = 0; index < retained; index++) { + queue.append("control", { operations: [{ kind: "Control", control: "admission_paused", value: index % 2 === 0, reason: "performance_fixture" }] }, queue.administrator); + } + const fake = fakeGitHub(queue.transactions, { immutableIds: true }); + const native = installFactoryNativeAPI(fake, queue); + const git = fake.githubClient.rest.git; + const original = fake.refs.get("work-queue"); + const prior = "c".repeat(40); + const getCommit = git.getCommit; + git.getCommit = parameters => getCommit({ ...parameters, commit_sha: parameters.commit_sha === prior ? original : parameters.commit_sha }); + const checkpoint = canonical(prepareCheckpoint(queue.transactions, prior, queue.administrator, queue.at)[0]) + "\n"; + expect(Buffer.byteLength(checkpoint)).toBeGreaterThanOrEqual(200 * 1024); + const blob = await git.createBlob({ content: checkpoint, encoding: "utf-8" }); + const tree = await git.createTree({ tree: [{ path: "work-queue.jsonl", mode: "100644", type: "blob", sha: blob.data.sha }] }); + const history = new Set([prior]); + fake.refs.set("work-queue", prior); + for (let index = 0; index < depth; index++) { + const commit = await git.createCommit({ tree: tree.data.sha, parents: [fake.refs.get("work-queue")] }); + fake.refs.set("work-queue", commit.data.sha); + history.add(commit.data.sha); + } + if (deploymentsChanged) fake.state.defaultRevision = "b".repeat(40); + + // Model one atomic GraphQL publication, not four REST requests hidden inside + // the measurement. Both transports still execute the real checked publisher. + const rawGit = { ...git }; + fake.githubClient.graphql = async (query, variables) => { + expect(query).toMatch(/^mutation CheckedWorkQueuePublication/); + const input = variables.input; + expect(input.branch).toEqual({ repositoryNameWithOwner: REPOSITORY, branchName: "work-queue" }); + expect(input.expectedHeadOid).toBe(fake.refs.get("work-queue")); + expect(input.fileChanges.additions).toHaveLength(1); + const file = input.fileChanges.additions[0]; + expect(file.path).toBe("work-queue.jsonl"); + const blob = await rawGit.createBlob({ content: Buffer.from(file.contents, "base64").toString("utf8"), encoding: "utf-8" }); + const tree = await rawGit.createTree({ tree: [{ path: file.path, mode: "100644", type: "blob", sha: blob.data.sha }] }); + const commit = await rawGit.createCommit({ tree: tree.data.sha, parents: [input.expectedHeadOid] }); + await rawGit.updateRef({ ref: "heads/work-queue", sha: commit.data.sha, force: false }); + return { createCommitOnBranch: { commit: { oid: commit.data.sha } } }; + }; + const calls = []; + let sleepMs = 0; + for (const [namespace, client] of Object.entries(fake.githubClient.rest)) { + for (const [method, implementation] of Object.entries(client)) { + client[method] = parameters => { + calls.push({ method: `${namespace}.${method}`, write: /^(?:create|update|delete)/.test(method), history: namespace === "git" && method === "getCommit" && history.has(parameters.commit_sha) }); + return implementation(parameters); + }; + } + } + const graphql = fake.githubClient.graphql; + fake.githubClient.graphql = (query, variables) => { + calls.push({ method: "graphql.publication", write: true, history: false }); + return graphql(query, variables); + }; + const failures = []; + const core = { setOutput: () => {}, info: () => {}, setFailed: message => failures.push(message) }; + const createClient = token => ({ + rest: fake.githubClient.rest, + graphql: fake.githubClient.graphql, + auth: async () => ({ type: "token", token }), + hook: { before: () => {} }, + }); + const injected = createClient("fixture-token"); + setupGlobals(core, injected, queue.dispatcherContext, {}, {}, createClient, "github_actions"); + expect(await queueAuthenticationScope(injected)).toBeUndefined(); + const client = auth === "injected" ? injected : global.getOctokit("fixture-token", auth === "dynamic" ? { authStrategy: () => {} } : {}); + expect(Boolean(await queueAuthenticationScope(client))).toBe(auth === "constructed"); + const intentPath = path.join(root, "intents.jsonl"); + stagePlanIntents(intentPath, plan); + vi.stubEnv("GH_AW_WORK_QUEUE_CHECKED_TRANSPORT", transport); + vi.stubGlobal("fetch", () => { + throw new Error("Performance tests must not access the network"); + }); + const options = { + github: client, + context: queue.dispatcherContext, + workflowRef: `${REPOSITORY}/${DISPATCHER}@${REF}`, + intentOrigin: queue.dispatcher, + role: "dispatcher", + policyProposal: policy, + core, + env: publisherToken ? { GH_AW_WORK_QUEUE_TOKEN: "publisher-token", GH_AW_GITHUB_TOKEN_SOURCE: "github_actions" } : {}, + intentPath, + config: { ...config, work_queue_workflows: workers, aw_context_workflows: workers }, + sleepFn: async milliseconds => { + sleepMs += milliseconds; + }, + }; + const run = async phase => { + calls.length = 0; + sleepMs = 0; + const start = performance.now(); + const result = await processControls(options); + const cpuMs = performance.now() - start; + expect(result.success, JSON.stringify({ result, failures })).toBe(true); + expect(result.receipts.map(receipt => receipt.status)).toEqual(["durable", "durable"]); + expect(failures).toEqual([]); + const writes = calls.filter(call => call.write).length; + const metrics = { + benchmark: "trusted-work-queue-controls", + phase, + success: result.success, + auth, + publisher_token: publisherToken, + transport, + profiles, + works, + retained_requests: retained, + checkpoint_bytes: Buffer.byteLength(checkpoint), + ancestry_commits: depth, + api_calls: calls.length, + reads: calls.length - writes, + writes, + history_reads: calls.filter(call => call.history).length, + // Each ancestry commit is loaded and its parent proof checked once. + within_single_traversal_budget: calls.filter(call => call.history).length <= 2 * depth + 1, + // Serial cost model only; this is not a measured GitHub wall-clock time. + simulated_network_ms: (calls.length - writes) * 250 + writes * 500 + sleepMs, + cpu_ms: Math.round(cpuMs), + methods: Object.fromEntries([...new Set(calls.map(call => call.method))].sort().map(method => [method, calls.filter(call => call.method === method).length])), + }; + console.info(JSON.stringify(metrics)); + return metrics; + }; + return { fake, native, run, works, profiles, depth, workers }; +} + +function assertBoundClaims(fixture) { + const state = replayTransactions(fixture.fake.log()); + expect(state.works.size).toBe(fixture.works + fixture.profiles); + expect(state.claims.size).toBe(fixture.profiles); + expect([...state.works.values()].filter(work => work.state === "claimed")).toHaveLength(fixture.profiles); + expect(state.dispatches.size).toBe(fixture.profiles); + expect(fixture.native.posts).toHaveLength(fixture.profiles); + expect(fixture.native.tagCreations).toHaveLength(1); + expect([...state.dispatches.values()].every(dispatch => dispatch.state === "bound")).toBe(true); + expect(new Set([...state.dispatches.values()].map(dispatch => dispatch.run.run_id))).toEqual(new Set(fixture.native.runs.keys())); +} + +describe("trusted work queue control step performance", () => { + it.each([ + { profiles: 1, depth: 8 }, + { profiles: 3, depth: 8 }, + { profiles: 3, depth: 32 }, + { profiles: 3, depth: 64 }, + { profiles: 3, depth: 8, works: 27, retained: 300 }, + ])( + "bounds cold history verification and full-batch idempotent recovery: %j", + async parameters => { + const scenario = await fixture(parameters); + const cold = await scenario.run("cold"); + assertBoundClaims(scenario); + expect(cold.history_reads).toBe(2 * scenario.depth + 1); + expect(cold.api_calls).toBeLessThanOrEqual(4 * scenario.depth + 40 * scenario.profiles + 48); + const head = scenario.fake.refs.get("work-queue"); + const warm = await scenario.run("idempotent"); + assertBoundClaims(scenario); + expect(scenario.fake.refs.get("work-queue")).toBe(head); + expect(warm.history_reads).toBe(0); + expect(warm.writes).toBe(0); + expect(warm.api_calls).toBeLessThanOrEqual(20 + 8 * scenario.profiles); + expect(warm.simulated_network_ms).toBeLessThanOrEqual((20 + 8 * scenario.profiles) * 250); + }, + 30000 + ); + + it.each(["graphql", "rest"])( + "refreshes three worker deployments without multiplying history traversals (%s)", + async transport => { + const scenario = await fixture({ deploymentsChanged: true, transport }); + const metrics = await scenario.run("deployment-refresh"); + assertBoundClaims(scenario); + const deployments = scenario.fake + .log() + .flatMap(commit => commit.operations) + .filter(operation => operation.kind === "Deployment"); + expect(deployments).toHaveLength(3); + expect(deployments.every(deployment => deployment.profile.ref === "b".repeat(40))).toBe(true); + expect([...scenario.native.runs.values()].every(run => run.head_sha === "b".repeat(40))).toBe(true); + expect(metrics.history_reads).toBe(2 * scenario.depth + 1); + expect(metrics.api_calls).toBeLessThanOrEqual(4 * scenario.depth + 60 * scenario.profiles + 64); + }, + 30000 + ); + + it.each(["graphql", "rest"])( + "constructs a cache-eligible publisher client for compiled injected-client controls (%s)", + async transport => { + const scenario = await fixture({ auth: "injected", publisherToken: true, deploymentsChanged: true, transport }); + const metrics = await scenario.run("compiled-publisher-token"); + assertBoundClaims(scenario); + expect(metrics.history_reads).toBe(2 * scenario.depth + 1); + expect(metrics.api_calls).toBeLessThanOrEqual(4 * scenario.depth + 60 * scenario.profiles + 64); + const head = scenario.fake.refs.get("work-queue"); + const retry = await scenario.run("compiled-publisher-retry"); + assertBoundClaims(scenario); + expect(scenario.fake.refs.get("work-queue")).toBe(head); + expect(retry.writes).toBe(0); + expect(retry.history_reads).toBeLessThanOrEqual(2 * (scenario.depth + 1) + 8 * scenario.profiles); + }, + 30000 + ); + + it.each(["injected", "dynamic"])( + "detects uncached-history amplification for the %s client at the real compiler adapter boundary", + async auth => { + const scenario = await fixture({ auth, deploymentsChanged: true }); + const metrics = await scenario.run("uncached-auth-negative-control"); + assertBoundClaims(scenario); + // Negative controls must fail the same one-traversal budget as the cached + // cases when no explicit publisher token is supplied. + expect(metrics.within_single_traversal_budget).toBe(false); + expect(metrics.history_reads).toBeGreaterThan(5 * (scenario.depth + 1)); + expect(metrics.simulated_network_ms).toBeGreaterThan(120000); + }, + 30000 + ); +}); diff --git a/actions/setup/js/work_queue_dispatch.cjs b/actions/setup/js/work_queue_dispatch.cjs index 026338323ba..ef2d079a609 100644 --- a/actions/setup/js/work_queue_dispatch.cjs +++ b/actions/setup/js/work_queue_dispatch.cjs @@ -353,8 +353,7 @@ function inheritWorkerSubmission(state, trustedContext, parameters) { function acceptedSubmissionParameters(state, trustedContext, parameters, prior) { if (prior.request.kind !== "submit" || canonical(prior.actor) !== canonical(actorFromContext(trustedContext))) throw new Error("work_queue_request_reused"); const policy = - state.transactions.flatMap(commit => commit.operations).find(operation => operation.kind === "Policy" && operation.epoch === prior.policy_epoch)?.policy ?? - (prior.compacted_parameters_digest && prior.policy_epoch === state.policy_epoch ? state.policy : undefined); + state.transactions.flatMap(commit => commit.operations).find(operation => operation.kind === "Policy" && operation.epoch === prior.policy_epoch)?.policy ?? (prior.policy_epoch === state.policy_epoch ? state.policy : undefined); if (!policy) throw new Error("work_queue_policy_missing"); let inherited = parameters; if (trustedContext.role === "worker") { diff --git a/actions/setup/js/work_queue_live_performance.test.cjs b/actions/setup/js/work_queue_live_performance.test.cjs new file mode 100644 index 00000000000..61896f59333 --- /dev/null +++ b/actions/setup/js/work_queue_live_performance.test.cjs @@ -0,0 +1,119 @@ +// @ts-check +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import { digest } from "./work_queue_codec.cjs"; +import { replayTransactionLog, serializeProjection } from "./work_queue_replay.cjs"; +import { readWorkQueueLog } from "./work_queue_store.cjs"; + +const require = createRequire(import.meta.url); +const { registerStaticQueueAuthentication } = require("./work_queue_auth_cache.cjs"); +const head = process.env.GH_AW_WORK_QUEUE_PERF_LIVE_HEAD; + +function projectionDigest(state) { + return digest(JSON.parse(JSON.stringify(serializeProjection(state)))); +} + +function gitObject(type, sha) { + expect(sha).toMatch(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/); + return execFileSync("git", ["cat-file", type, sha], { timeout: 5000, maxBuffer: 80 * 1024 * 1024, env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1" } }); +} + +function recordedClient(repository, registered) { + const calls = []; + const token = "offline-performance-token"; + const record = (name, implementation) => async parameters => { + calls.push(name); + return implementation(parameters); + }; + const client = { + auth: async () => ({ type: "token", token }), + rest: { + repos: { + get: record("repos.get", () => ({ data: { full_name: repository, default_branch: "main", size: 1 } })), + }, + git: { + getRef: record("git.getRef", ({ ref }) => { + expect(ref).toBe("heads/work-queue"); + return { data: { ref: `refs/${ref}`, object: { type: "commit", sha: head } } }; + }), + getCommit: record("git.getCommit", ({ commit_sha }) => { + const headers = gitObject("commit", commit_sha).toString("utf8").split("\n\n")[0].split("\n"); + const tree = headers.find(line => line.startsWith("tree ")); + if (!tree) throw new Error("Captured Git commit has no tree"); + return { data: { sha: commit_sha, tree: { sha: tree.slice(5) }, parents: headers.filter(line => line.startsWith("parent ")).map(line => ({ sha: line.slice(7) })) } }; + }), + getTree: record("git.getTree", ({ tree_sha }) => { + expect(tree_sha).toMatch(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/); + const contents = execFileSync("git", ["ls-tree", "--full-tree", "-z", tree_sha], { timeout: 5000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1" } }).toString("utf8"); + const tree = contents + .split("\0") + .filter(Boolean) + .map(line => { + const separator = line.indexOf("\t"); + const [mode, type, sha] = line.slice(0, separator).split(" "); + return { mode, type, sha, path: line.slice(separator + 1) }; + }); + return { data: { sha: tree_sha, truncated: false, tree } }; + }), + getBlob: record("git.getBlob", ({ file_sha }) => { + const content = gitObject("blob", file_sha); + return { data: { sha: file_sha, encoding: "base64", content: content.toString("base64"), size: content.length } }; + }), + }, + }, + }; + if (registered) registerStaticQueueAuthentication(client, token); + return { client, calls }; +} + +describe.runIf(Boolean(head))("read-only captured live work queue performance", () => { + it.each([false, true])( + "verifies actual Git checkpoint history and measures repeated reads (static cache registered: %s)", + async registered => { + const filename = process.env.GH_AW_WORK_QUEUE_PERF_LEDGER; + if (!filename) throw new Error("GH_AW_WORK_QUEUE_PERF_LEDGER must accompany GH_AW_WORK_QUEUE_PERF_LIVE_HEAD"); + const contents = fs.readFileSync(filename, "utf8"); + const expected = replayTransactionLog(contents); + const repository = expected.repository; + expect(repository).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/); + const [owner, repo] = repository.split("/"); + const { client, calls } = recordedClient(repository, registered); + const options = { githubClient: client, owner, repo }; + const cold = await readWorkQueueLog(options); + expect(cold.sha).toBe(head); + expect(projectionDigest(cold.state)).toBe(projectionDigest(expected)); + const coldCalls = calls.length; + expect(coldCalls).toBeGreaterThan(2); + let warmCalls = 0; + for (let iteration = 0; iteration < 3; iteration++) { + calls.length = 0; + const warm = await readWorkQueueLog(options); + expect(projectionDigest(warm.state)).toBe(projectionDigest(expected)); + if (registered) expect(calls).toEqual(["repos.get", "git.getRef"]); + else expect(calls.length).toBe(coldCalls); + warmCalls = calls.length; + warm.state.works.clear(); + warm.transactions.length = 0; + } + console.info( + JSON.stringify({ + benchmark: "captured-live-work-queue", + head, + registered_static_cache: registered, + bytes: Buffer.byteLength(contents), + works: expected.works.size, + claims: expected.claims.size, + dispatches: expected.dispatches.size, + requests: expected.requests.size, + cold_api_calls: coldCalls, + warm_api_calls: warmCalls, + simulated_warm_network_ms: warmCalls * 250, + mutations: 0, + }) + ); + }, + 120000 + ); +}); diff --git a/actions/setup/js/work_queue_store_checks.cjs b/actions/setup/js/work_queue_store_checks.cjs index c2efc6e6977..df00d069244 100644 --- a/actions/setup/js/work_queue_store_checks.cjs +++ b/actions/setup/js/work_queue_store_checks.cjs @@ -12,7 +12,7 @@ const { administrator, bind, commit, context, dispatcher, evidence, finish, gene const resourceFixture = require("../../../specs/work-queue/fixtures/resource-scope.json"); const contractFixture = require("../../../specs/work-queue/fixtures/contract.json"); -function fakeGitHub(initial = []) { +function fakeGitHub(initial = [], { immutableIds = false } = {}) { let serial = 0; const blobs = new Map(); const trees = new Map(); @@ -36,7 +36,10 @@ function fakeGitHub(initial = []) { emptyLog: false, updates: 0, }; - const id = prefix => `${prefix}-${++serial}`; + const id = prefix => { + const value = `${prefix}-${++serial}`; + return immutableIds ? createHash("sha1").update(value).digest("hex") : value; + }; const missing = () => Object.assign(new Error("Not found"), { status: 404 }); function install(log, branch = "work-queue") { const blob = id("blob"); diff --git a/docs/adr/68827-trusted-work-queue-dispatch-and-cached-control-client.md b/docs/adr/68827-trusted-work-queue-dispatch-and-cached-control-client.md new file mode 100644 index 00000000000..b9cebb75ef5 --- /dev/null +++ b/docs/adr/68827-trusted-work-queue-dispatch-and-cached-control-client.md @@ -0,0 +1,64 @@ +# ADR-68827: Route Trusted Work-Queue Dispatch Through the Controller and Build the Control Queue Client From the Publisher Token + +**Date**: 2026-10-14 +**Status**: Proposed (implemented in PR #68827; maintainer acceptance pending) +**Decision owner**: @pelikhan, the PR author. Formal architecture approval is not implied by this record. + +--- + +### Context + +Operators need a supported CLI entry point to start a *dispatcher* without selecting a Work, assigning workers directly, or rebinding immutable Claims. Dispatcher activation must use the same revision whose source and compiled controls were validated, even if the default branch moves between validation and launch. + +Separately, the control step for trusted queue operations used a queue client without registered static authentication, so repeated queue loads within one invocation re-verified the full checkpoint history. On a full-step fake-transport fixture (32 ancestors, 3 deployment updates, 15 Work items, 100 retained requests), the baseline required 4,946 API calls and 8,181 ms locally, dominated by repeated Git commit/tree/blob reads. A related defect affected same-epoch compacted-Policy recovery for accepted control requests without a compacted parameters digest. + +### Decision + +Add a first-class `gh aw work-queue --repo OWNER/REPO dispatch DISPATCHER` command that resolves a dispatcher route at the repository's verified default revision (`pkg/workqueue/dispatcher.go`, `pkg/cli/work_command_dispatch.go`) and activates the dispatcher whose trusted controls select and launch assignments. Validate the dispatcher source and its compiled `.lock.yml` and correlate the native run rather than assigning workers or rebinding immutable Claims. + +Construct the control-step queue client from the protected publisher token before the first queue load (`actions/setup/js/work_queue_control_adapter.cjs` reading `GH_AW_WORK_QUEUE_TOKEN`). The compiler binds this environment variable to the same publisher credential used by the control step, using the existing shared publisher-token resolution. Registered static-auth clients become eligible for checkpoint-history proof reuse within the invocation. Registration is scoped to the exact client object and unchanged authentication; injected or dynamic-auth clients remain uncached unless explicitly registered at a known static-token constructor. Worker-launch credentials remain a separate credential path, and control-step preview constructs no credential client. + +The dispatcher validates both compiled control allowlists against the source worker-name set, then creates or reuses the SHA-derived lightweight tag `refs/tags/gh-aw/work-queue/`. Dispatch targets that verified tag, not the moving default branch; mismatched tags and ambiguous creation failures stop before activation. Tags are never overwritten or deleted by the command. Repository writers remain trusted not to mutate these tags while in use; returned native-run identity checks detect mismatches but cannot undo an activation. Preview performs only read-only route validation. + +### Alternatives Considered + +The following alternatives describe the tradeoffs assessed for this record, not a claim that each was previously implemented or formally reviewed. + +#### Alternative 1: Use worker-assignment / Claim operations for dispatch + +Expose dispatcher activation through worker-assignment or Claim-manipulation operations. This conflates launching the scheduler with selecting or assigning work. A dedicated dispatcher command preserves the trusted controller's authority over assignment and leaves immutable Claims unchanged. + +#### Alternative 2: Keep injecting an arbitrary queue client and add a dedicated history cache + +Instead of constructing the client from the publisher token, add a separate memoization layer for ancestry reads to an arbitrary injected or dynamic-auth client. This would duplicate the existing static-auth cache mechanism and require additional safeguards against reusing verification proofs after authorization changes. Using a known static-token constructor enables the existing exact-client-scoped mechanism without broadening cache eligibility. + +#### Alternative 3: Reduce history depth (shallower checkpoint verification) + +Verifying fewer ancestors could reduce cold-read cost, but changes checkpoint verification semantics. Preserve full verification, fresh authorization/ref probes, and compare-and-swap behavior; optimize repeated reads instead. + +#### Alternative 4: Dispatch the moving default branch or a bare commit SHA + +Dispatching the default branch after validation can activate different code if it moves. A SHA-derived tag supplies a named Actions dispatch ref tied to the verified commit without relying on acceptance of a bare SHA. Persistent tags and the repository-writer trust assumption are the cost of this choice. + +### Consequences + +#### Positive +- Dispatch becomes a supported, validated operation (name pattern, frontmatter, compiled lock, verified default revision) rather than an ad-hoc workaround. +- The full-step fake-transport fixture drops from 4,946 to 305 API calls, 2,080 to 65 ancestry commit reads, and 8,181 ms to 705 ms local elapsed time. Offline replay of a captured live ledger preserves the 413-request cold read and reduces repeated reads from 413 requests to 2. +- Regression coverage exercises source/compiled allowlist parity, snapshot activation, native-run identity, credential separation, retries, and control-step performance. +- Same-epoch compacted-Policy recovery now works for accepted control requests without a compacted parameters digest, while retaining actor and request-fingerprint checks. + +#### Negative +- A new credential binding (`GH_AW_WORK_QUEUE_TOKEN`) is threaded into compiled workflows, requiring affected lock files to be regenerated and reviewed for publisher-token correctness. It reuses the existing publisher credential rather than granting additional token authority. +- The dispatcher CLI adds a new trusted entry point that must be kept in lockstep with queue protocol changes and validated on every protocol upgrade. +- Snapshot tags persist and require `contents: write` when created, in addition to workflow-dispatch permission. Privileged tag mutation remains outside the ordinary branch-movement guarantee. +- Repeated-read performance depends on static-auth registration of the exact client: unregistered or dynamic-auth clients retain the uncached path, even if they currently resolve to the same token. + +#### Neutral +- Cache eligibility rules, checkpoint verification, fresh authorization/ref probes, and compare-and-swap semantics are unchanged; proof reuse is scoped to one invocation of a registered, unchanged static-auth client. +- CLI preview validates the route through read-only repository API calls but does not create a snapshot tag or activate a workflow. Control-step preview does not construct publisher or launch credential clients and cannot validate launch credentials. +- Cold history traversal is unchanged. Reported elapsed timings use a fake local transport; no production elapsed-time improvement has been verified. + +--- + +Implementation: [PR #68827](https://github.com/github/gh-aw/pull/68827). diff --git a/docs/src/content/docs/reference/work-queue.md b/docs/src/content/docs/reference/work-queue.md index 0a0d88ac189..f81a4bd782e 100644 --- a/docs/src/content/docs/reference/work-queue.md +++ b/docs/src/content/docs/reference/work-queue.md @@ -466,6 +466,50 @@ safe-output job cannot be recovered from agent logs. See [Claim diagnostic artifacts](/gh-aw/patterns/daily-report-portfolio/#claim-diagnostic-artifacts) for file purposes and redaction limits. +## Performance regression tests + +The offline performance suite exercises the trusted control adapter, checked +publication, deployment synchronization, reconciliation, and native worker +binding with an in-memory GitHub API: + +```bash +npm --prefix actions/setup/js run test:work-queue-performance +``` + +Fixtures contain at least 200 KiB of checkpoint data, 15–27 Work items, +100–300 retained requests, one or three worker profiles, and 8–64 Git ancestry +commits. Assertions cover API-call budgets, bounded history verification, +full-batch retries without writes or duplicate launches, and both REST and +GraphQL publication. JSON metrics include per-method counts and measured local +execution time. Simulated network cost assumes 250 ms per read and 500 ms per +mutation; it is not measured GitHub elapsed time. + +Compiled controls construct a separate queue client from the protected publisher +token, not the worker-dispatch credential. Within one control invocation, this +permits reuse of immutable checkpoint-history proofs under registered, unchanged +static authentication while retaining fresh repository/ref probes and checked +publication. Both publication transports have full-step regression budgets for +this compiled-token path. + +Arbitrary injected-client and custom-auth cases without an explicit publisher +token remain uncached negative controls. They must report +`within_single_traversal_budget: false`; caching is not enabled by inspecting an +unknown client's `auth()` result. + +Optional live-data tests use a privately captured ledger and the exact queue +head's Git objects already fetched into the current checkout: + +```bash +GH_AW_WORK_QUEUE_PERF_LEDGER=/path/to/private/work-queue.jsonl \ +GH_AW_WORK_QUEUE_PERF_LIVE_HEAD=IMMUTABLE_QUEUE_SHA \ +npm --prefix actions/setup/js run test:work-queue-performance +``` + +These tests verify native checkpoint ancestry, compare reconstructed state with +the captured ledger, and measure cold and repeated reads with and without static +authentication caching. They make no network requests, publish no queue records, +and launch no workers. Missing live-data settings skip the optional cases. + ## Operator commands `gh aw work-queue` defaults to branch `work-queue` and supports Git storage only. @@ -494,6 +538,7 @@ initialize Policy over an invalid log. | `submit-work --file work.json` | Admit an immutable payload with default priority 3 and shared accounting key `""`. On an absent queue, atomically install verified scheduling and AW routes with Work without administrator seeding. | | `submit-graph` | Atomically admit a normalized graph within size limits, including issue and pull request dependency nodes. Bootstraps an absent queue under the same rules as `submit-work`. | | `dispatch-next --pool POOL --max-claims N --max-dispatches N` | Commit the next assignments selected by deterministic fair scheduling and their reservations. Does not send a workflow-dispatch POST. | +| `dispatch DISPATCHER [--wait] [--dry-run]` | Use the GitHub CLI to start one verified dispatcher at the repository's default-branch snapshot revision. Its trusted controls grant and launch eligible assignments; it does not directly assign a Claim to a worker. | | `control`, `cancel-work` | Apply authorized pause, cutover, or cancellation decisions without refunding accounted service or force-releasing a reservation for a possible native run. | | `cancel-claim --claim-id ID[,ID...] --reason CODE` | Atomically revoke the exact current Claims' authority and terminally cancel their Work. Does not retry work, impersonate a worker, or stop a native run. | | `reprioritize --work-id ID[,ID...] --priority 1..5 --reason CODE` | Apply an administrator-only, compare-and-set priority override to future grants of available Work. Preserves admitted definitions, FIFO positions, retry boundaries, and accumulated fairness charges. | @@ -505,6 +550,48 @@ describes validated inputs. Reservation, native launch, verified run binding, Completion, Result, and Release are distinct; successful `dispatch-next` is not proof of launch. +To activate the ESLint factory through its trusted queue dispatcher: + +```bash +gh aw work-queue --repo github/gh-aw dispatch eslint-factory-dispatcher --wait --json +``` + +`dispatch` verifies the dispatcher source and compiled lock at the immutable +default-branch revision, then creates or reuses the lightweight snapshot tag +`refs/tags/gh-aw/work-queue/`. It reads back the exact tag name, +commit object type and SHA before invoking the existing workflow-run transport +with that tag, never the moving default branch. Existing mismatched tags, +permission errors and uncertain tag creation fail before dispatch; the command +never overwrites or deletes a tag. The operator's authenticated GitHub credential +needs `actions: write` and, when creating the tag, `contents: write`. +Snapshot tags are retained. This prevents ordinary default-branch updates from +changing the activated revision, but repository writers able to move or delete +tags remain trusted: they must not mutate these tags while in use. Post-dispatch +SHA verification cannot undo execution if a privileged writer changes the tag. +The command requires the activation's returned run URL and checks that run's +repository, workflow, event, and SHA against the verified snapshot. Missing +identity or a mismatch fails explicitly after activation; it never selects the +latest run or automatically dispatches again. Inspect the accepted activation +before retrying. +It accepts only an unassigned dispatcher with a declared worker allowlist, never +a worker requiring `work_queue_assignment`. Both compiled control allowlists +must match the source worker-name set exactly; missing, malformed, drifted or +ambiguous control configurations fail before tag creation or dispatch. Recompile +the dispatcher after changing its source allowlist. +It does not enable workflows, upload +files, or supply assignment inputs. `--dry-run` previews the verified route only; +it does not create tags, compile or execute a workflow. Activated JSON output's +`ref` is the snapshot tag; preview output's `ref` is the default branch. +`--wait` waits for the dispatcher, not +its workers' independently verified Results. A successful activation is not proof +of Claims, worker launches, or consumption; inspect the ledger afterward. + +Direct `--claim-id`, `--work-id`, `--worker-profile`, `--ref`, alternate queue +branches, and `--request-id` overrides are unsupported for `dispatch`. The +dispatcher owns its native run identity and compiled queue authority. Existing +Claims retain their original immutable worker route; this command does not +reassign them or retry uncertain effects. + `submit-work --execution-ref IMMUTABLE_SHA` explicitly pins execution to a registered, compatible revision. Retries retain the original admission contract and pin; an explicit pin change is rejected. Omit the flag for compatible diff --git a/pkg/cli/run_workflow_execution.go b/pkg/cli/run_workflow_execution.go index c2bbdd6f486..af3749c6975 100644 --- a/pkg/cli/run_workflow_execution.go +++ b/pkg/cli/run_workflow_execution.go @@ -47,6 +47,7 @@ type RunOptions struct { RepeatCount int // Number of times to repeat (0 = run once) Inputs []string // Workflow inputs in key=value format JSON bool // Output results in JSON format + ExpectedRevision string // Require the returned native run to match this verified SHA without latest-run fallback. } // WorkflowRunResult contains the result of a single workflow run trigger for JSON output @@ -127,7 +128,8 @@ func validateRunInputs(inputs []string) error { if !strings.Contains(input, "=") { return fmt.Errorf("input '%s' is not in key=value format. Expected each --input value as key=value. Example: --input environment=staging", input) } - if parts := strings.SplitN(input, "=", 2); parts[0] == "" { + key, _, _ := strings.Cut(input, "=") + if key == "" { return fmt.Errorf("input '%s' has an empty key before '='. Expected a non-empty key in key=value format. Example: --input environment=staging", input) } } @@ -415,6 +417,14 @@ func executeWorkflowRun(ctx context.Context, lockFileName string, args []string, fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Triggered workflow: "+lockFileName)) executionLog.Printf("Workflow triggered successfully: %s", lockFileName) runInfo, runErr := resolveWorkflowRunInfo(lockFileName, output, opts) + if opts.ExpectedRevision != "" { + if runErr != nil { + return nil, fmt.Errorf("workflow activation was accepted but its run identity is unresolved; do not redispatch: %w", runErr) + } + if err := verifyExpectedWorkflowRun(ctx, lockFileName, runInfo, opts); err != nil { + return nil, fmt.Errorf("workflow activation was accepted but run verification failed; do not redispatch: %w", err) + } + } return &workflowRunExecutionResult{ runInfo: runInfo, runInfoErr: runErr, @@ -441,6 +451,9 @@ func resolveWorkflowRunInfo(lockFileName, output string, opts RunOptions) (*Work executionLog.Printf("Parsed run info from gh output: id=%d, url=%s", parsedRunInfo.DatabaseID, parsedRunInfo.URL) return parsedRunInfo, nil } + if opts.ExpectedRevision != "" { + return nil, errors.New("GitHub CLI did not return the activated run URL; refusing to select the latest workflow run") + } return getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose) } @@ -461,9 +474,15 @@ func waitForWorkflowRunCompletion(ctx context.Context, opts RunOptions, runInfo return nil } if runErr != nil { + if opts.ExpectedRevision != "" { + return runErr + } printWorkflowRunInfoWarning(opts, runErr) return nil } + if opts.ExpectedRevision != "" && (opts.RepoOverride == "" || runInfo == nil || runInfo.DatabaseID <= 0) { + return errors.New("cannot wait without an exact verified dispatcher run identity and repository") + } targetRepo := resolveWorkflowTargetRepo(opts) if targetRepo == "" || runInfo == nil { return nil @@ -471,6 +490,9 @@ func waitForWorkflowRunCompletion(ctx context.Context, opts RunOptions, runInfo printWorkflowWaitMessage(opts.AutoMergePRs) runIDStr := strconv.FormatInt(runInfo.DatabaseID, 10) if err := WaitForWorkflowCompletion(ctx, targetRepo, runIDStr, workflowCompletionWaitTimeoutMinutes, opts.Verbose); err != nil { + if opts.ExpectedRevision != "" { + return fmt.Errorf("dispatcher wait failed; inspect the accepted activation before redispatching: %w", err) + } if ctx.Err() != nil || errors.Is(err, ErrInterrupted) { return err } @@ -661,12 +683,12 @@ func parseRunInfoFromOutput(output string) *WorkflowRunInfo { if len(matches) < 2 { return nil } - runID, err := strconv.ParseInt(matches[1], 10, 64) + runID, err := strconv.ParseInt(matches[1], 10, 64) //nolint:uncheckedsliceindex // len(matches) is checked above. if err != nil { return nil } return &WorkflowRunInfo{ - URL: matches[0], + URL: matches[0], //nolint:uncheckedsliceindex // len(matches) is checked above. DatabaseID: runID, } } diff --git a/pkg/cli/run_workflow_revision.go b/pkg/cli/run_workflow_revision.go new file mode 100644 index 00000000000..2f1d60cdd39 --- /dev/null +++ b/pkg/cli/run_workflow_revision.go @@ -0,0 +1,45 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-aw/pkg/githubapi" +) + +func verifyExpectedWorkflowRun(ctx context.Context, lockFile string, info *WorkflowRunInfo, opts RunOptions) error { + if info == nil || info.DatabaseID <= 0 || opts.RepoOverride == "" { + return errors.New("exact native run identity and repository are required") + } + client, err := api.NewRESTClient(githubapi.ClientOptions("", "")) + if err != nil { + return err + } + var run struct { + ID json.Number `json:"id"` + HeadSHA string `json:"head_sha"` + Path string `json:"path"` + Event string `json:"event"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } + endpoint := fmt.Sprintf("repos/%s/actions/runs/%d", opts.RepoOverride, info.DatabaseID) + if err := client.DoWithContext(ctx, http.MethodGet, endpoint, nil, &run); err != nil { + return err + } + workflowPath, _, _ := strings.Cut(run.Path, "@") + if run.ID.String() != strconv.FormatInt(info.DatabaseID, 10) || + !strings.EqualFold(run.Repository.FullName, opts.RepoOverride) || + run.Event != "workflow_dispatch" || workflowPath != logsWorkflowsPathSegment+lockFile || + run.HeadSHA != opts.ExpectedRevision { + return errors.New("native dispatcher run does not match the verified repository, workflow, and revision") + } + return nil +} diff --git a/pkg/cli/work_command.go b/pkg/cli/work_command.go index 49a669b4b91..e6f1e44d938 100644 --- a/pkg/cli/work_command.go +++ b/pkg/cli/work_command.go @@ -34,7 +34,8 @@ func NewWorkCommandWithNativeDeliveryHost(host *workqueue.NativeDeliveryHost) *c Use: "work-queue", Aliases: []string{"work"}, Short: "Inspect and publish the current fair Git-backed DAG work queue", Long: "Inspect and publish the current fair Git-backed DAG work queue.\n\n" + "Direct claim and operator finish are unsupported. Use dispatch-next for mandatory\n" + - "scheduler grants; only the originally bound worker may stage work_queue_claim_finish.\n" + + "scheduler grants, or dispatch to run a trusted dispatcher and launch assignments.\n" + + "Only the originally bound worker may stage work_queue_claim_finish.\n" + "Use trace and historical explain for read-only provenance. compact normalizes\n" + "representation without dropping history, resetting debt, or adopting legacy queues.", Args: cobra.NoArgs, @@ -56,7 +57,7 @@ func NewWorkCommandWithNativeDeliveryHost(host *workqueue.NativeDeliveryHost) *c cmd.PersistentFlags().String("request-id", "", "Stable publication handle, or committed request to inspect with explain/trace") cmd.PersistentFlags().BoolP("json", "j", false, "Output results in JSON format") cmd.AddCommand(workReplayCommand(), workStatsCommand(), workExplainCommand(), - workSubmitCommand(), workSubmitGraphCommand(), workDispatchNextCommand(), + workSubmitCommand(), workSubmitGraphCommand(), workDispatchNextCommand(), workDispatchCommand(), workPolicyCommand(), workDeployCommand(), workControlCommand(), workCancelCommand(), workReconcileCommand(), workEvidenceCommand(), workCompactCommand(), workTraceCommand(), workStateCommand(), workInspectCommand(), workCancelClaimCommand(), workPriorityCommand(), workTUICommand()) diff --git a/pkg/cli/work_command_dispatch.go b/pkg/cli/work_command_dispatch.go new file mode 100644 index 00000000000..1711ee91885 --- /dev/null +++ b/pkg/cli/work_command_dispatch.go @@ -0,0 +1,68 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/github/gh-aw/pkg/workqueue" + "github.com/spf13/cobra" +) + +func workDispatchCommand() *cobra.Command { + return workDispatchCommandWithRunner(RunWorkflowOnGitHub) +} + +func workDispatchCommandWithRunner(run func(context.Context, string, RunOptions) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "dispatch ", + Short: "Run a trusted dispatcher to grant and launch fair queue assignments", + Long: "Run a verified work-queue dispatcher through the GitHub CLI on the target\n" + + "repository's verified default revision using a SHA-derived snapshot tag.\n" + + "Its trusted controls select and launch assignments.\n" + + "This command cannot choose a Work, reassign a Claim, or replay worker effects.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("dispatcher_invalid: expected exactly one dispatcher basename") + } + branch := workBranch(cmd) + requestID, _ := cmd.Flags().GetString("request-id") + if branch.Name != workqueue.DefaultBranch || requestID != "" { + return errors.New("dispatcher_invalid: --branch and --request-id cannot override the dispatcher's compiled queue authority or native run identity") + } + route, err := branch.DispatcherFromConfig(cmd.Context(), args[0]) //nolint:uncheckedsliceindex // Exactly one argument is checked above. + if err != nil { + return err + } + result := struct { + workqueue.DispatcherRoute + Status string `json:"status"` + }{route, "preview"} + preview, _ := cmd.Flags().GetBool("dry-run") + if preview { + return workPrint(cmd, result, fmt.Sprintf("Dispatcher preview: %s at %s (%s); no Claims granted or workflows launched", + route.Workflow, route.Ref, route.Revision)) + } + activationRef, err := branch.PrepareDispatcherActivation(cmd.Context(), route) + if err != nil { + return err + } + wait, _ := cmd.Flags().GetBool("wait") + if err := run(cmd.Context(), args[0], RunOptions{ //nolint:uncheckedsliceindex // Exactly one argument is checked above. + WorkflowRunFlags: WorkflowRunFlags{RepoOverride: route.Repository, RefOverride: activationRef}, + WaitForCompletion: wait, + ExpectedRevision: route.Revision, + }); err != nil { + return err + } + result.Ref = activationRef + result.Status = "activated" + return workPrint(cmd, result, fmt.Sprintf("Dispatcher activated: %s at %s; inspect the ledger for Claims and Results", + route.Workflow, route.Revision)) + }, + } + cmd.Flags().Bool("dry-run", false, "Preview the verified dispatcher route without compiling, granting Claims, or launching workflows") + cmd.Flags().Bool("wait", false, "Wait for the dispatcher run; worker Results still require independent verification") + return cmd +} diff --git a/pkg/cli/work_command_dispatch_test.go b/pkg/cli/work_command_dispatch_test.go new file mode 100644 index 00000000000..a7ca3a65229 --- /dev/null +++ b/pkg/cli/work_command_dispatch_test.go @@ -0,0 +1,272 @@ +package cli + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestWorkQueueDispatchPinsVerifiedRevisionWhenDefaultBranchMoves(t *testing.T) { + for _, preview := range []bool{false, true} { + t.Run(fmt.Sprintf("preview=%t", preview), func(t *testing.T) { + readCalls := mockWorkDispatcherAPI(t) + calls := 0 + command := newWorkDispatchTestCommand(func(ctx context.Context, name string, options RunOptions) error { + calls++ + if name != "dispatcher" || options.RepoOverride != "owner/repo" || + options.RefOverride != "refs/tags/gh-aw/work-queue/"+strings.Repeat("f", 40) || + options.ExpectedRevision != strings.Repeat("f", 40) || + options.JSON || !options.WaitForCompletion || options.Push || options.Enable || options.Approve || + len(options.Inputs) != 0 || options.RepeatCount != 0 { + t.Fatalf("dispatch changed trusted run options: %s %+v", name, options) + } + return nil + }) + var output bytes.Buffer + command.SetOut(&output) + args := []string{"--repo", "owner/repo", "--json", "dispatch", "dispatcher", "--wait"} + if preview { + args = append(args, "--dry-run") + } + command.SetArgs(args) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + expectedAPICalls, expectedRef := 7, "refs/tags/gh-aw/work-queue/"+strings.Repeat("f", 40) + if preview { + expectedAPICalls, expectedRef = 4, "main" + } + if *readCalls != expectedAPICalls { + t.Fatalf("unexpected API calls: got %d, want %d", *readCalls, expectedAPICalls) + } + var result struct { + Status, Revision, Ref, Workflow, Repository string + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + expectedCalls, expectedStatus := 1, "activated" + if preview { + expectedCalls, expectedStatus = 0, "preview" + } + if calls != expectedCalls || result.Status != expectedStatus || result.Revision != strings.Repeat("f", 40) || + result.Repository != "owner/repo" || result.Ref != expectedRef || result.Workflow != "dispatcher.lock.yml" { + t.Fatalf("incorrect activation count or result: calls=%d output=%s", calls, output.String()) + } + }) + } +} + +func TestWorkQueueDispatchPropagatesRunFailure(t *testing.T) { + mockWorkDispatcherAPI(t) + denied := errors.New("workflow dispatch denied") + calls := 0 + command := newWorkDispatchTestCommand(func(context.Context, string, RunOptions) error { + calls++ + return denied + }) + command.SetArgs([]string{"--repo", "owner/repo", "dispatch", "dispatcher"}) + if err := command.Execute(); !errors.Is(err, denied) || calls != 1 { + t.Fatalf("dispatch failure was hidden or retried: %v calls=%d", err, calls) + } +} + +func TestWorkQueueDispatchRejectsAuthorityOverrides(t *testing.T) { + for _, args := range [][]string{ + {"dispatch"}, + {"dispatch", "dispatcher", "another"}, + {"dispatch", "dispatcher", "--claim-id", "chosen"}, + {"dispatch", "dispatcher", "--work-id", "chosen"}, + {"dispatch", "dispatcher", "--worker-profile", "worker"}, + {"dispatch", "dispatcher", "--ref", "unreviewed"}, + {"--branch", "other-queue", "dispatch", "dispatcher"}, + {"--request-id", "forged-origin", "dispatch", "dispatcher"}, + } { + command := newWorkDispatchTestCommand(func(context.Context, string, RunOptions) error { + t.Fatal("invalid command reached workflow dispatch") + return nil + }) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(args) + if err := command.Execute(); err == nil { + t.Fatalf("unsafe assignment override was accepted: %v", args) + } + } +} + +func TestWorkQueueDispatchRequiresCorrelatedRun(t *testing.T) { + info, err := resolveWorkflowRunInfo("dispatcher.lock.yml", "", RunOptions{ExpectedRevision: strings.Repeat("f", 40)}) + if err == nil || info != nil || !strings.Contains(err.Error(), "refusing to select the latest") { + t.Fatalf("unidentified activation selected another workflow run: %+v %v", info, err) + } +} + +func TestWorkQueueDispatchWaitRequiresIdentity(t *testing.T) { + opts := RunOptions{WaitForCompletion: true, ExpectedRevision: strings.Repeat("f", 40)} + unresolved := errors.New("unresolved native run") + if err := waitForWorkflowRunCompletion(context.Background(), opts, nil, unresolved, time.Time{}, "main"); !errors.Is(err, unresolved) { + t.Fatalf("unresolved activation wait succeeded: %v", err) + } + if err := waitForWorkflowRunCompletion(context.Background(), opts, nil, nil, time.Time{}, "main"); err == nil { + t.Fatal("missing run identity wait succeeded") + } + if err := waitForWorkflowRunCompletion(context.Background(), opts, &WorkflowRunInfo{DatabaseID: 202}, nil, time.Time{}, "main"); err == nil { + t.Fatal("missing repository wait succeeded") + } +} + +func TestWorkQueueDispatchVerifiesNativeRevision(t *testing.T) { + for _, mismatch := range []string{"", "revision", "repository", "workflow", "event", "id", "denied"} { + t.Run(mismatch, func(t *testing.T) { + t.Setenv("GH_HOST", "github.com") + t.Setenv("GH_TOKEN", "test-token") + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + http.DefaultTransport = workAPIRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodGet || request.URL.Path != "/repos/owner/repo/actions/runs/202" { + t.Fatalf("unexpected native run verification: %s %s", request.Method, request.URL.Path) + } + run := map[string]any{"id": 202, "head_sha": strings.Repeat("f", 40), + "path": ".github/workflows/dispatcher.lock.yml", "event": "workflow_dispatch", + "repository": map[string]string{"full_name": "owner/repo"}} + switch mismatch { + case "revision": + run["head_sha"] = strings.Repeat("a", 40) + case "repository": + run["repository"] = map[string]string{"full_name": "other/repo"} + case "workflow": + run["path"] = ".github/workflows/worker.lock.yml" + case "event": + run["event"] = "push" + case "id": + run["id"] = 203 + } + status := http.StatusOK + if mismatch == "denied" { + status = http.StatusForbidden + } + data, _ := json.Marshal(run) + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil + }) + err := verifyExpectedWorkflowRun(context.Background(), "dispatcher.lock.yml", &WorkflowRunInfo{DatabaseID: 202}, + RunOptions{WorkflowRunFlags: WorkflowRunFlags{RepoOverride: "owner/repo"}, ExpectedRevision: strings.Repeat("f", 40)}) + if (err != nil) != (mismatch != "") { + t.Fatalf("native revision mismatch=%q error=%v", mismatch, err) + } + }) + } +} + +func newWorkDispatchTestCommand(run func(context.Context, string, RunOptions) error) *cobra.Command { + command := NewWorkCommand() + existing, _, _ := command.Find([]string{"dispatch"}) + command.RemoveCommand(existing) + command.AddCommand(workDispatchCommandWithRunner(run)) + return command +} + +func mockWorkDispatcherAPI(t *testing.T) *int { + return mockWorkDispatcherActivationAPI(t, false) +} + +func TestWorkQueueDispatchRejectsSnapshotMismatch(t *testing.T) { + mockWorkDispatcherActivationAPI(t, true) + command := newWorkDispatchTestCommand(func(context.Context, string, RunOptions) error { + t.Fatal("snapshot mismatch reached workflow dispatch") + return nil + }) + command.SetArgs([]string{"--repo", "owner/repo", "dispatch", "dispatcher"}) + if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "snapshot tag does not match") { + t.Fatalf("snapshot mismatch did not fail closed: %v", err) + } +} + +func mockWorkDispatcherActivationAPI(t *testing.T, mismatch bool) *int { + t.Helper() + t.Setenv("GH_HOST", "github.com") + t.Setenv("GH_TOKEN", "test-token") + original := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = original }) + calls := new(int) + tagCreated := false + defaultRevision := strings.Repeat("f", 40) + http.DefaultTransport = workAPIRoundTripper(func(request *http.Request) (*http.Response, error) { + if request.Header.Get("Authorization") != "token test-token" { + t.Fatal("dispatcher request was not authenticated") + } + *calls++ + var value any + status := http.StatusOK + tag := "refs/tags/gh-aw/work-queue/" + strings.Repeat("f", 40) + if request.Method == http.MethodPost { + if request.URL.Path != "/repos/owner/repo/git/refs" || tagCreated { + t.Fatalf("unexpected or repeated mutation: %s", request.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 2 || body["ref"] != tag || body["sha"] != strings.Repeat("f", 40) { + t.Fatalf("snapshot creation changed verified route: %v", body) + } + tagCreated = true + defaultRevision = strings.Repeat("a", 40) + status = http.StatusCreated + } else if request.Method != http.MethodGet { + t.Fatalf("unexpected dispatcher mutation: %s", request.Method) + } + switch request.URL.Path { + case "/repos/owner/repo/git/refs": + value = map[string]any{} + case "/repos/owner/repo/git/ref/" + strings.TrimPrefix(tag, "refs/"): + if !tagCreated && !mismatch { + status = http.StatusNotFound + value = map[string]string{"message": "Not Found"} + } else { + sha := strings.Repeat("f", 40) + if mismatch { + sha = strings.Repeat("a", 40) + } + value = map[string]any{"ref": tag, "object": map[string]string{"type": "commit", "sha": sha}} + } + case "/repos/owner/repo": + value = map[string]any{"full_name": "owner/repo", "default_branch": "main"} + case "/repos/owner/repo/git/ref/heads/main": + value = map[string]any{"ref": "refs/heads/main", "object": map[string]string{"sha": defaultRevision}} + case "/repos/owner/repo/contents/.github/workflows/dispatcher.md", + "/repos/owner/repo/contents/.github/workflows/dispatcher.lock.yml": + if request.URL.Query().Get("ref") != strings.Repeat("f", 40) { + t.Fatal("dispatcher artifacts were read at a moving ref") + } + content := "---\ntools:\n work-queue: true\nsafe-outputs:\n dispatch-workflow:\n workflows: [worker]\n---\nDispatch" + if strings.HasSuffix(request.URL.Path, ".yml") { + content = "on:\n workflow_dispatch:\njobs:\n agent:\n env:\n GH_AW_WORK_QUEUE_ROLE: dispatcher\n" + + " steps:\n - id: work_queue_controls\n env:\n" + + " GH_AW_WORK_QUEUE_CONTROL_CONFIG: '{\"work_queue_workflows\":[\"worker\"],\"aw_context_workflows\":[\"worker\"]}'\n" + } + value = map[string]string{"type": "file", "path": strings.TrimPrefix(request.URL.Path, "/repos/owner/repo/contents/"), + "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content))} + default: + t.Fatalf("unexpected dispatcher read: %s", request.URL.Path) + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil + }) + return calls +} diff --git a/pkg/cli/work_command_test.go b/pkg/cli/work_command_test.go index 7a5228caec2..54077aaa73b 100644 --- a/pkg/cli/work_command_test.go +++ b/pkg/cli/work_command_test.go @@ -34,7 +34,7 @@ func mustWorkQueueOperation(t testing.TB, value any) workqueue.Operation { func TestWorkCommandClosedOperatorSurfaces(t *testing.T) { command := NewWorkCommand() - for _, required := range []string{"compact", "trace", "explain", "state", "inspect", "cancel-claim", "reprioritize", "tui"} { + for _, required := range []string{"compact", "trace", "explain", "state", "inspect", "cancel-claim", "reprioritize", "tui", "dispatch"} { found := false for _, subcommand := range command.Commands() { if subcommand.Name() == required { diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index acb47d890f3..98bebf3edc7 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -406,6 +406,7 @@ func (c *Compiler) buildWorkQueueControlProcessingStep(data *WorkflowData) ([]st env = append(env, " GH_AW_SAFE_OUTPUTS_STAGED: "+fmt.Sprintf("%q", *value)+"\n") } env = append(env, fmt.Sprintf(" GH_AW_GITHUB_TOKEN_SOURCE: %s\n", c.safeOutputGitHubTokenSource(data))) + env = append(env, fmt.Sprintf(" GH_AW_WORK_QUEUE_TOKEN: %s\n", resolveSafeOutputGitHubTokenForConfig(data, "", resolveSafeOutputGitHubToken, true))) env = append(env, " GH_AW_WORK_QUEUE_INTENTS: "+constants.WorkQueueIntentPath+"\n") env = append(env, " GH_AW_WORK_QUEUE_CONTROL_CONFIG: "+fmt.Sprintf("%q", string(encoded))+"\n") steps = append(steps, env...) diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 681f2d68c68..96eef132995 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -273,6 +273,10 @@ func injectProcessorStepEnv(steps []string, env map[string]string) []string { } func (c *Compiler) addResolvedSafeOutputGitHubTokenForConfig(steps *[]string, data *WorkflowData, configToken string, resolver func(string) string, allowGitHubApp bool) { + *steps = append(*steps, fmt.Sprintf(" github-token: %s\n", resolveSafeOutputGitHubTokenForConfig(data, configToken, resolver, allowGitHubApp))) +} + +func resolveSafeOutputGitHubTokenForConfig(data *WorkflowData, configToken string, resolver func(string) string, allowGitHubApp bool) string { var safeOutputsToken string var githubApp *GitHubAppConfig if data.SafeOutputs != nil { @@ -291,15 +295,12 @@ func (c *Compiler) addResolvedSafeOutputGitHubTokenForConfig(steps *[]string, da // fallback in that case so github-token never resolves to an empty value. if githubApp.shouldIgnoreMissingKey() || !safeOutputsGlobalAppTokenMinted(data.SafeOutputs) { fallbackToken := resolver(resolvedCustomToken) - *steps = append(*steps, fmt.Sprintf(" github-token: %s\n", combineTokenExpressions("${{ steps.safe-outputs-app-token.outputs.token }}", fallbackToken))) - return + return combineTokenExpressions("${{ steps.safe-outputs-app-token.outputs.token }}", fallbackToken) } - *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") - return + return "${{ steps.safe-outputs-app-token.outputs.token }}" } - resolvedToken := resolver(resolvedCustomToken) - *steps = append(*steps, fmt.Sprintf(" github-token: %s\n", resolvedToken)) + return resolver(resolvedCustomToken) } func (c *Compiler) safeOutputGitHubTokenSource(data *WorkflowData) string { diff --git a/pkg/workflow/work_queue_dispatch_credentials_test.go b/pkg/workflow/work_queue_dispatch_credentials_test.go index 2125aeebaec..929e7977703 100644 --- a/pkg/workflow/work_queue_dispatch_credentials_test.go +++ b/pkg/workflow/work_queue_dispatch_credentials_test.go @@ -39,6 +39,25 @@ func TestWorkQueueDispatchCredentialConfiguration(t *testing.T) { require.NotContains(t, credential, "app_slug") } require.NotContains(t, config, "github.actor_id") + data := &WorkflowData{ + Tools: map[string]any{"work-queue": map[string]any{"worker": true}}, + SafeOutputs: entry.outputs, + } + require.NoError(t, validateWorkQueueConfiguration(data)) + lines, err := NewCompiler().buildWorkQueueControlProcessingStep(data) + require.NoError(t, err) + var steps []struct { + ID string `yaml:"id"` + With map[string]string `yaml:"with"` + Env map[string]string `yaml:"env"` + } + require.NoError(t, yaml.Unmarshal([]byte(strings.Join(lines, "")), &steps)) + control := steps[len(steps)-1] + require.Equal(t, "work_queue_controls", control.ID) + require.Equal(t, control.With["github-token"], control.Env["GH_AW_WORK_QUEUE_TOKEN"], "queue cache client must use exactly the publisher credential") + if strings.HasPrefix(entry.name, "handler-") { + require.NotEqual(t, entry.token, control.Env["GH_AW_WORK_QUEUE_TOKEN"], "dispatch credentials must not be used for queue access") + } }) } } @@ -58,6 +77,7 @@ func TestWorkQueueControlStepPassesGitHubTokenProvenance(t *testing.T) { require.NoError(t, yaml.Unmarshal([]byte(strings.Join(steps, "")), &generated)) require.Len(t, generated, 1) require.Equal(t, "github_actions", generated[0].Env["GH_AW_GITHUB_TOKEN_SOURCE"]) + require.Equal(t, "${{ secrets.GITHUB_TOKEN }}", generated[0].Env["GH_AW_WORK_QUEUE_TOKEN"]) require.Contains(t, generated[0].With["script"], "setupGlobals(core, github, context, exec, io, getOctokit, process.env.GH_AW_GITHUB_TOKEN_SOURCE)") } diff --git a/pkg/workflow/work_queue_dispatch_identity_integration_test.go b/pkg/workflow/work_queue_dispatch_identity_integration_test.go index 574eaef60b1..b6d4fe14238 100644 --- a/pkg/workflow/work_queue_dispatch_identity_integration_test.go +++ b/pkg/workflow/work_queue_dispatch_identity_integration_test.go @@ -89,6 +89,7 @@ Dispatch only approved workers using the protected launch credential. continue } found = true + require.Equal(t, step.With["github-token"], step.Env["GH_AW_WORK_QUEUE_TOKEN"]) require.Equal(t, "dispatcher", step.Env["GH_AW_WORK_QUEUE_ROLE"]) proposal := strings.ReplaceAll(step.Env["GH_AW_WORK_QUEUE_POLICY"], "${{ github.repository }}", "owner/repo") require.NotEmpty(t, proposal) diff --git a/pkg/workqueue/dispatcher.go b/pkg/workqueue/dispatcher.go new file mode 100644 index 00000000000..ccb9790785c --- /dev/null +++ b/pkg/workqueue/dispatcher.go @@ -0,0 +1,187 @@ +package workqueue + +import ( + "context" + "regexp" + "slices" + + "github.com/github/gh-aw/pkg/parser" + "gopkg.in/yaml.v3" +) + +var dispatcherNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`) + +type DispatcherRoute struct { + Repository string `json:"repository"` + Workflow string `json:"workflow"` + Ref string `json:"ref"` + Revision string `json:"revision"` +} + +// DispatcherFromConfig resolves a dispatcher, never a worker assignment, at the +// repository's verified default revision. It does not grant or launch Claims. +func (b Branch) DispatcherFromConfig(ctx context.Context, name string) (DispatcherRoute, error) { + if !dispatcherNamePattern.MatchString(name) { + return DispatcherRoute{}, queueError("dispatcher_invalid", "expected a dispatcher basename, for example eslint-factory-dispatcher") + } + b, err := b.withClient() + if err != nil { + return DispatcherRoute{}, err + } + repository, ref, revision, err := b.verifiedDefaultRoute(ctx) + if err != nil { + return DispatcherRoute{}, err + } + b.Remote = repository + source, err := b.configFile(ctx, workerWorkflowDirectory+name+".md", revision) + if err != nil { + return DispatcherRoute{}, err + } + workers, err := dispatcherSourceWorkflows(source) + if err != nil { + return DispatcherRoute{}, err + } + workflow := name + ".lock.yml" + compiled, err := b.configFile(ctx, workerWorkflowDirectory+workflow, revision) + if err != nil { + return DispatcherRoute{}, err + } + if err := validateDispatcherLock(compiled, workers); err != nil { + return DispatcherRoute{}, err + } + return DispatcherRoute{Repository: repository, Workflow: workflow, Ref: ref, Revision: revision}, nil +} + +func dispatcherSourceWorkflows(source []byte) ([]string, error) { + frontmatter, err := parser.ExtractFrontmatterFromContent(string(source)) + if err != nil { + return nil, err + } + if frontmatter == nil { + return nil, queueError("dispatcher_invalid", "dispatcher source requires agentic workflow frontmatter") + } + tools, ok := frontmatter.Frontmatter["tools"].(map[string]any) + if !ok { + return nil, queueError("dispatcher_invalid", "dispatcher must declare work-queue tools") + } + queue := tools["work-queue"] + enabled := queue == true + if options, ok := queue.(map[string]any); ok { + enabled = options["worker"] != true && options["require-assignment"] != true + } + if !enabled { + return nil, queueError("dispatcher_invalid", "expected an unassigned work-queue dispatcher; workers require authenticated Claims") + } + outputs, ok := frontmatter.Frontmatter["safe-outputs"].(map[string]any) + if !ok { + return nil, queueError("dispatcher_invalid", "dispatcher must declare safe-outputs") + } + dispatch, ok := outputs["dispatch-workflow"].(map[string]any) + if !ok { + return nil, queueError("dispatcher_invalid", "dispatcher must declare safe-outputs.dispatch-workflow") + } + return dispatcherWorkflowAllowlist(dispatch["workflows"]) +} + +func dispatcherWorkflowAllowlist(value any) ([]string, error) { + workers, ok := value.([]any) + if !ok || len(workers) == 0 { + return nil, queueError("dispatcher_invalid", "dispatcher worker allowlist must be a nonempty array of workflow basenames") + } + names := make([]string, 0, len(workers)) + for _, worker := range workers { + name, ok := worker.(string) + if !ok || !dispatcherNamePattern.MatchString(name) { + return nil, queueError("dispatcher_invalid", "dispatcher worker allowlist must contain workflow basenames") + } + names = append(names, name) + } + slices.Sort(names) + return slices.Compact(names), nil +} + +func validateDispatcherLock(compiled []byte, workers []string) error { + var workflow struct { + On map[string]any `yaml:"on"` + Env map[string]any `yaml:"env"` + Jobs map[string]struct { + Env map[string]any `yaml:"env"` + Steps []struct { + ID string `yaml:"id"` + Env map[string]any `yaml:"env"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(compiled, &workflow); err != nil { + return err + } + trigger, ok := workflow.On["workflow_dispatch"] + if !ok { + return queueError("dispatcher_invalid", "compiled dispatcher must accept workflow_dispatch") + } + if dispatch, ok := trigger.(map[string]any); ok { + if inputs, ok := dispatch["inputs"].(map[string]any); ok { + if _, reserved := inputs["work_queue_assignment"]; reserved { + return queueError("dispatcher_invalid", "cannot directly dispatch a Claim-assigned worker") + } + } + } + const controlConfig = "GH_AW_WORK_QUEUE_CONTROL_CONFIG" + if _, exists := workflow.Env[controlConfig]; exists { + return queueError("dispatcher_invalid", "compiled dispatcher control configuration must be scoped to its trusted control step") + } + hasRole, controls := false, 0 + for _, job := range workflow.Jobs { + if job.Env["GH_AW_WORK_QUEUE_ROLE"] == "dispatcher" { + hasRole = true + } + if _, exists := job.Env[controlConfig]; exists { + return queueError("dispatcher_invalid", "compiled dispatcher control configuration must be scoped to its trusted control step") + } + for _, step := range job.Steps { + value, exists := step.Env[controlConfig] + if step.ID != "work_queue_controls" && !exists { + continue + } + controls++ + if step.ID != "work_queue_controls" || !exists { + return queueError("dispatcher_invalid", "compiled dispatcher requires a protected work_queue_controls configuration") + } + if err := validateDispatcherControlConfig(value, workers); err != nil { + return err + } + } + } + if !hasRole { + return queueError("dispatcher_invalid", "compiled workflow must contain the trusted work-queue dispatcher role") + } + if controls != 1 { + return queueError("dispatcher_invalid", "compiled dispatcher must contain exactly one trusted work_queue_controls configuration") + } + return nil +} + +func validateDispatcherControlConfig(value any, workers []string) error { + encoded, ok := value.(string) + if !ok { + return queueError("dispatcher_invalid", "compiled dispatcher control configuration must be a JSON object") + } + decoded, err := decodeStrict([]byte(encoded)) + if err != nil { + return queueError("dispatcher_invalid", "invalid compiled dispatcher control configuration: %v", err) + } + config, ok := decoded.(map[string]any) + if !ok { + return queueError("dispatcher_invalid", "compiled dispatcher control configuration must be a JSON object") + } + for _, field := range []string{"work_queue_workflows", "aw_context_workflows"} { + names, err := dispatcherWorkflowAllowlist(config[field]) + if err != nil { + return queueError("dispatcher_invalid", "compiled dispatcher %s: %v", field, err) + } + if !slices.Equal(names, workers) { + return queueError("dispatcher_invalid", "compiled dispatcher %s must match source dispatch-workflow.workflows; recompile the dispatcher", field) + } + } + return nil +} diff --git a/pkg/workqueue/dispatcher_activation.go b/pkg/workqueue/dispatcher_activation.go new file mode 100644 index 00000000000..66929f2d6f3 --- /dev/null +++ b/pkg/workqueue/dispatcher_activation.go @@ -0,0 +1,57 @@ +package workqueue + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/cli/go-gh/v2/pkg/api" +) + +// PrepareDispatcherActivation binds a previously verified dispatcher route to a +// named snapshot ref. It never moves or deletes tags, including on collisions. +// Repository writers must not mutate the snapshot tag while it is in use. +func (b Branch) PrepareDispatcherActivation(ctx context.Context, route DispatcherRoute) (string, error) { + b, err := b.withClient() + if err != nil { + return "", err + } + if !strings.EqualFold(route.Repository, b.Remote) || !revisionPattern.MatchString(route.Revision) || + strings.Trim(route.Revision, "0") == "" { + return "", queueError("dispatcher_invalid", "activation requires the verified repository and immutable revision") + } + b.Remote = route.Repository + ref := "refs/tags/gh-aw/work-queue/" + route.Revision + endpoint := "git/ref/" + strings.TrimPrefix(ref, "refs/") + var snapshot struct { + Ref string `json:"ref"` + Object struct { + Type string `json:"type"` + SHA string `json:"sha"` + } `json:"object"` + } + err = b.request(ctx, http.MethodGet, endpoint, nil, &snapshot) + if hasStatus(err, http.StatusNotFound) { + err = b.request(ctx, http.MethodPost, "git/refs", map[string]string{ + "ref": ref, "sha": route.Revision, + }, nil) + if err != nil { + var failure *api.HTTPError + // Only a definitive concurrent creation permits readback, not a + // permission denial, other validation failure, or ambiguous write. + if !errors.As(err, &failure) || failure.StatusCode != http.StatusUnprocessableEntity || + failure.Message != "Reference already exists" { + return "", err + } + } + err = b.request(ctx, http.MethodGet, endpoint, nil, &snapshot) + } + if err != nil { + return "", err + } + if snapshot.Ref != ref || snapshot.Object.Type != "commit" || snapshot.Object.SHA != route.Revision { + return "", queueError("dispatcher_invalid", "activation snapshot tag does not match the verified commit; refusing to dispatch") + } + return ref, nil +} diff --git a/pkg/workqueue/dispatcher_activation_test.go b/pkg/workqueue/dispatcher_activation_test.go new file mode 100644 index 00000000000..847a0c28f63 --- /dev/null +++ b/pkg/workqueue/dispatcher_activation_test.go @@ -0,0 +1,127 @@ +package workqueue + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" +) + +type dispatcherActivationTransport func(*http.Request) (*http.Response, error) + +func (transport dispatcherActivationTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestPrepareDispatcherActivation(t *testing.T) { + sha := strings.Repeat("f", 40) + tag := "refs/tags/gh-aw/work-queue/" + sha + for _, test := range []struct { + name string + initial int + create int + message string + readback int + ref, kind string + revision string + wantError bool + wantReads int + wantWrites int + }{ + {name: "existing", initial: 200, ref: tag, kind: "commit", revision: sha, wantReads: 1}, + {name: "created", initial: 404, create: 201, readback: 200, ref: tag, kind: "commit", revision: sha, wantReads: 2, wantWrites: 1}, + {name: "concurrent creator", initial: 404, create: 422, message: "Reference already exists", readback: 200, ref: tag, kind: "commit", revision: sha, wantReads: 2, wantWrites: 1}, + {name: "collision wrong target", initial: 404, create: 422, message: "Reference already exists", readback: 200, ref: tag, kind: "commit", revision: strings.Repeat("a", 40), wantError: true, wantReads: 2, wantWrites: 1}, + {name: "wrong ref", initial: 200, ref: tag + "-other", kind: "commit", revision: sha, wantError: true, wantReads: 1}, + {name: "wrong revision", initial: 200, ref: tag, kind: "commit", revision: strings.Repeat("a", 40), wantError: true, wantReads: 1}, + {name: "annotated tag", initial: 200, ref: tag, kind: "tag", revision: sha, wantError: true, wantReads: 1}, + {name: "missing type", initial: 200, ref: tag, revision: sha, wantError: true, wantReads: 1}, + {name: "read denied", initial: 403, wantError: true, wantReads: 1}, + {name: "read throttled", initial: 429, wantError: true, wantReads: 1}, + {name: "create denied", initial: 404, create: 403, wantError: true, wantReads: 1, wantWrites: 1}, + {name: "other validation failure", initial: 404, create: 422, message: "Validation Failed", wantError: true, wantReads: 1, wantWrites: 1}, + {name: "ambiguous create response", initial: 404, create: 500, wantError: true, wantReads: 1, wantWrites: 1}, + {name: "ambiguous create transport", initial: 404, create: -1, wantError: true, wantReads: 1, wantWrites: 1}, + {name: "missing readback", initial: 404, create: 201, readback: 404, wantError: true, wantReads: 2, wantWrites: 1}, + {name: "readback denied", initial: 404, create: 201, readback: 403, wantError: true, wantReads: 2, wantWrites: 1}, + } { + t.Run(test.name, func(t *testing.T) { + reads, writes := 0, 0 + transport := dispatcherActivationTransport(func(request *http.Request) (*http.Response, error) { + if request.Header.Get("Authorization") != "token test-token" { + t.Fatal("activation request was not authenticated") + } + status := test.initial + var value any + switch { + case request.Method == http.MethodGet && request.URL.Path == "/repos/owner/repo/git/ref/"+strings.TrimPrefix(tag, "refs/"): + reads++ + if reads > 1 { + status = test.readback + } + value = map[string]any{"ref": test.ref, "object": map[string]string{"type": test.kind, "sha": test.revision}} + case request.Method == http.MethodPost && request.URL.Path == "/repos/owner/repo/git/refs": + writes++ + var payload map[string]string + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if len(payload) != 2 || payload["ref"] != tag || payload["sha"] != sha { + t.Fatalf("unexpected snapshot target: %v", payload) + } + if test.create == -1 { + return nil, errors.New("ambiguous transport failure") + } + status = test.create + value = map[string]string{"message": test.message} + default: + t.Fatalf("unexpected request or ref overwrite: %s %s", request.Method, request.URL.Path) + } + if status >= 400 && request.Method == http.MethodGet { + value = map[string]string{"message": http.StatusText(status)} + } + body, _ := json.Marshal(value) + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), Request: request}, nil + }) + client, err := api.NewRESTClient(api.ClientOptions{Host: "github.com", AuthToken: "test-token", Transport: transport}) + if err != nil { + t.Fatal(err) + } + branch := Branch{Remote: "owner/repo", Name: DefaultBranch, client: client} + ref, err := branch.PrepareDispatcherActivation(context.Background(), DispatcherRoute{Repository: "owner/repo", Revision: sha}) + if (err != nil) != test.wantError || (!test.wantError && ref != tag) || (test.wantError && ref != "") { + t.Fatalf("unexpected activation snapshot: ref=%q err=%v", ref, err) + } + if reads != test.wantReads || writes != test.wantWrites { + t.Fatalf("unexpected request count or retry: reads=%d writes=%d", reads, writes) + } + }) + } +} + +func TestPrepareDispatcherActivationRejectsInvalidRoute(t *testing.T) { + client, err := api.NewRESTClient(api.ClientOptions{Host: "github.com", AuthToken: "test-token", + Transport: dispatcherActivationTransport(func(*http.Request) (*http.Response, error) { + t.Fatal("invalid route reached the API") + return nil, nil + })}) + if err != nil { + t.Fatal(err) + } + for _, route := range []DispatcherRoute{ + {Repository: "other/repo", Revision: strings.Repeat("f", 40)}, + {Repository: "owner/repo", Revision: "main"}, + {Repository: "owner/repo", Revision: strings.Repeat("0", 40)}, + } { + if ref, err := (Branch{Remote: "owner/repo", Name: DefaultBranch, client: client}).PrepareDispatcherActivation(context.Background(), route); err == nil || ref != "" { + t.Fatalf("invalid route accepted: %v %q %v", route, ref, err) + } + } +} diff --git a/pkg/workqueue/dispatcher_test.go b/pkg/workqueue/dispatcher_test.go new file mode 100644 index 00000000000..b980e0a4148 --- /dev/null +++ b/pkg/workqueue/dispatcher_test.go @@ -0,0 +1,172 @@ +package workqueue + +import ( + "context" + "net/http" + "os" + "strings" + "testing" +) + +const dispatcherSource = "---\ntools:\n work-queue: true\nsafe-outputs:\n dispatch-workflow:\n workflows: [worker]\n---\nDispatch queued work." +const dispatcherControlStep = " - id: work_queue_controls\n env:\n GH_AW_WORK_QUEUE_CONTROL_CONFIG: '{\"work_queue_workflows\":[\"worker\"],\"aw_context_workflows\":[\"worker\"]}'\n" +const dispatcherLock = "on:\n workflow_dispatch:\njobs:\n agent:\n env:\n GH_AW_WORK_QUEUE_ROLE: dispatcher\n steps:\n" + dispatcherControlStep + +func TestDispatcherAcceptsESLintFactoryArtifacts(t *testing.T) { + source, err := os.ReadFile("../../.github/workflows/eslint-factory-dispatcher.md") + if err != nil { + t.Fatal(err) + } + compiled, err := os.ReadFile("../../.github/workflows/eslint-factory-dispatcher.lock.yml") + if err != nil { + t.Fatal(err) + } + workers, err := dispatcherSourceWorkflows(source) + if err != nil { + t.Fatal(err) + } + if err := validateDispatcherLock(compiled, workers); err != nil { + t.Fatal(err) + } +} + +func TestDispatcherFromConfig(t *testing.T) { + branch, mock := newQueueAPI(t) + mock.sources = map[string]string{"dispatcher": dispatcherSource} + mock.workerRoutes = map[string]string{".github/workflows/dispatcher.lock.yml": dispatcherLock} + route, err := branch.DispatcherFromConfig(context.Background(), "dispatcher") + if err != nil { + t.Fatal(err) + } + if route.Repository != testRepository || route.Workflow != "dispatcher.lock.yml" || + route.Ref != "main" || route.Revision != strings.Repeat("f", 40) { + t.Fatalf("unexpected dispatcher route: %+v", route) + } + for _, ref := range mock.configRefs { + if ref != route.Revision { + t.Fatalf("source read used a moving ref: %s", ref) + } + } + if mock.workerRef != route.Revision || mock.refWrites != 0 || mock.nativeReads != 0 { + t.Fatal("dispatcher route inspection mutated the queue or launched a worker") + } +} + +func TestDispatcherFromConfigRejectsInvalidRoutes(t *testing.T) { + for _, tc := range []struct { + name, source, lock string + status int + }{ + {name: "missing queue", source: "---\ntools: {}\n---\nOrdinary workflow", lock: dispatcherLock}, + {name: "disabled queue", source: strings.ReplaceAll(dispatcherSource, "work-queue: true", "work-queue: false"), lock: dispatcherLock}, + {name: "worker", source: strings.ReplaceAll(dispatcherSource, "work-queue: true", "work-queue:\n worker: true"), lock: dispatcherLock}, + {name: "requires assignment", source: strings.ReplaceAll(dispatcherSource, "work-queue: true", "work-queue:\n require-assignment: true"), lock: dispatcherLock}, + {name: "no allowlist", source: strings.ReplaceAll(dispatcherSource, "[worker]", "[]"), lock: dispatcherLock}, + {name: "invalid allowlist", source: strings.ReplaceAll(dispatcherSource, "[worker]", "[../worker]"), lock: dispatcherLock}, + {name: "no frontmatter", source: "Ordinary workflow", lock: dispatcherLock}, + {name: "not dispatchable", source: dispatcherSource, lock: strings.ReplaceAll(dispatcherLock, "workflow_dispatch:", "push:")}, + {name: "wrong compiled role", source: dispatcherSource, lock: strings.ReplaceAll(dispatcherLock, "ROLE: dispatcher", "ROLE: worker")}, + {name: "assignment input", source: dispatcherSource, lock: strings.ReplaceAll(dispatcherLock, "workflow_dispatch:", "workflow_dispatch:\n inputs:\n work_queue_assignment:\n type: string")}, + {name: "stale broadened lock", source: dispatcherSource, lock: strings.ReplaceAll(dispatcherLock, `["worker"]`, `["worker","other"]`)}, + {name: "stale narrowed lock", source: strings.ReplaceAll(dispatcherSource, "[worker]", "[worker, other]"), lock: dispatcherLock}, + {name: "denied source", source: dispatcherSource, lock: dispatcherLock, status: http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + branch, mock := newQueueAPI(t) + mock.sources = map[string]string{"dispatcher": tc.source} + mock.workerRoutes = map[string]string{".github/workflows/dispatcher.lock.yml": tc.lock} + mock.sourceStatuses = map[string]int{"dispatcher": tc.status} + if _, err := branch.DispatcherFromConfig(context.Background(), "dispatcher"); err == nil { + t.Fatal("invalid dispatcher route was accepted") + } + if mock.refWrites != 0 { + t.Fatal("invalid dispatcher route mutated the queue") + } + }) + } + for _, name := range []string{"", "../worker", "worker.md", "worker.lock.yml", "worker/name", "--worker", strings.Repeat("a", 129)} { + branch, _ := newQueueAPI(t) + if _, err := branch.DispatcherFromConfig(context.Background(), name); err == nil { + t.Fatalf("invalid dispatcher name was accepted: %q", name) + } + } +} + +func TestDispatcherLockAllowlistParity(t *testing.T) { + configLock := func(config string) string { + return strings.Replace(dispatcherLock, `{"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]}`, config, 1) + } + for _, tc := range []struct { + name, lock string + }{ + {"missing controls", strings.Replace(dispatcherLock, dispatcherControlStep, "", 1)}, + {"missing config", strings.Replace(dispatcherLock, dispatcherControlStep, " - id: work_queue_controls\n", 1)}, + {"wrong control id", strings.Replace(dispatcherLock, "id: work_queue_controls", "id: other", 1)}, + {"multiple control steps", dispatcherLock + dispatcherControlStep}, + {"other step config", dispatcherLock + strings.Replace(dispatcherControlStep, "id: work_queue_controls", "id: other", 1)}, + {"multiple control jobs", dispatcherLock + " other:\n steps:\n" + dispatcherControlStep}, + {"workflow env config", "env:\n GH_AW_WORK_QUEUE_CONTROL_CONFIG: '{}'\n" + dispatcherLock}, + {"job env config", strings.Replace(dispatcherLock, "ROLE: dispatcher", "ROLE: dispatcher\n GH_AW_WORK_QUEUE_CONTROL_CONFIG: '{}'", 1)}, + {"malformed YAML", dispatcherLock + " - ["}, + {"duplicate YAML config", strings.Replace(dispatcherLock, " GH_AW_WORK_QUEUE_CONTROL_CONFIG:", " GH_AW_WORK_QUEUE_CONTROL_CONFIG: '{}'\n GH_AW_WORK_QUEUE_CONTROL_CONFIG:", 1)}, + {"malformed JSON", configLock("{")}, + {"trailing JSON", configLock(`{"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]} {}`)}, + {"duplicate JSON key", configLock(`{"work_queue_workflows":["other"],"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]}`)}, + {"nonobject JSON", configLock(`[]`)}, + {"expression config", configLock(`${{ needs.activation.outputs.config }}`)}, + {"YAML object config", strings.Replace(dispatcherLock, `'{"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]}'`, `{work_queue_workflows: [worker], aw_context_workflows: [worker]}`, 1)}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := validateDispatcherLock([]byte(tc.lock), []string{"worker"}); err == nil { + t.Fatal("invalid compiled dispatcher configuration was accepted") + } + }) + } + for _, field := range []string{"work_queue_workflows", "aw_context_workflows"} { + for _, tc := range []struct { + name, value string + }{ + {"broadened", `["worker","other"]`}, + {"narrowed", `[]`}, + {"substituted", `["other"]`}, + {"wrong case", `["Worker"]`}, + {"not basenames", `["worker.lock.yml"]`}, + {"path", `["../worker"]`}, + {"nonstring member", `["worker",false]`}, + {"object", `{}`}, + {"string", `"worker"`}, + {"null", `null`}, + {"missing", ""}, + } { + t.Run(field+"/"+tc.name, func(t *testing.T) { + config := `{"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]}` + if tc.value == "" { + if field == "work_queue_workflows" { + config = `{"aw_context_workflows":["worker"]}` + } else { + config = `{"work_queue_workflows":["worker"]}` + } + } else { + config = strings.Replace(config, `"`+field+`":["worker"]`, `"`+field+`":`+tc.value, 1) + } + if err := validateDispatcherLock([]byte(configLock(config)), []string{"worker"}); err == nil { + t.Fatal("compiled dispatcher allowlist drift was accepted") + } + }) + } + } +} + +func TestDispatcherAllowlistCanonicalEquality(t *testing.T) { + branch, mock := newQueueAPI(t) + mock.sources = map[string]string{"dispatcher": strings.Replace(dispatcherSource, "[worker]", "[beta, alpha, beta]", 1)} + mock.workerRoutes = map[string]string{".github/workflows/dispatcher.lock.yml": strings.Replace(dispatcherLock, + `{"work_queue_workflows":["worker"],"aw_context_workflows":["worker"]}`, + `{"work_queue_workflows":["alpha","beta"],"aw_context_workflows":["beta","alpha","alpha"]}`, 1)} + if _, err := branch.DispatcherFromConfig(context.Background(), "dispatcher"); err != nil { + t.Fatalf("equivalent allowlist sets were rejected: %v", err) + } + if mock.refWrites != 0 || mock.nativeReads != 0 { + t.Fatal("dispatcher parity validation mutated the queue") + } +} diff --git a/pkg/workqueue/provisioning_deployment.go b/pkg/workqueue/provisioning_deployment.go index f0576a586c0..5b0e4691db2 100644 --- a/pkg/workqueue/provisioning_deployment.go +++ b/pkg/workqueue/provisioning_deployment.go @@ -12,26 +12,31 @@ import ( ) func (b Branch) verifiedDefaultReference(ctx context.Context) (string, string, error) { + repository, _, revision, err := b.verifiedDefaultRoute(ctx) + return repository, revision, err +} + +func (b Branch) verifiedDefaultRoute(ctx context.Context) (string, string, string, error) { var repository struct { FullName string `json:"full_name"` DefaultBranch string `json:"default_branch"` } if err := b.request(ctx, http.MethodGet, "", nil, &repository); err != nil { - return "", "", err + return "", "", "", err } if !strings.EqualFold(repository.FullName, b.Remote) || repository.DefaultBranch == "" || !branchPattern.MatchString(repository.DefaultBranch) || strings.Contains(repository.DefaultBranch, "..") { - return "", "", queueError("policy_invalid", "AW configuration requires a verified repository and immutable approved default worker revision") + return "", "", "", queueError("policy_invalid", "AW configuration requires a verified repository and immutable approved default worker revision") } b.Remote = repository.FullName revision, err := b.resolveHeadRef(ctx, repository.DefaultBranch) if err != nil { if errors.Is(err, errInvalidHeadReference) { - return "", "", queueError("policy_invalid", "default worker revision is not immutable") + return "", "", "", queueError("policy_invalid", "default worker revision is not immutable") } - return "", "", err + return "", "", "", err } - return repository.FullName, revision, nil + return repository.FullName, repository.DefaultBranch, revision, nil } // DeploymentOperationsFromConfig proposes route-only changes for existing AW