diff --git a/.github/actions/codex-bootstrap-lite/action.yml b/.github/actions/codex-bootstrap-lite/action.yml index 9069797ef..504828a83 100644 --- a/.github/actions/codex-bootstrap-lite/action.yml +++ b/.github/actions/codex-bootstrap-lite/action.yml @@ -1,5 +1,5 @@ name: "Codex Bootstrap (Lite)" -description: "Minimal issue → branch → PR bootstrap with PAT-first auth and optional auto-ready" +description: "Minimal issue → branch → ready-for-review PR bootstrap with PAT-first auth" inputs: issue: @@ -22,11 +22,11 @@ inputs: required: false default: "" draft: - description: "Whether to create the PR as a draft (true|false)" + description: "Deprecated compatibility input; ignored because automation PRs are always ready" required: false default: "false" auto_ready: - description: "If draft=true and auto_ready=true, immediately mark ready-for-review" + description: "Deprecated compatibility input; ignored because automation PRs are always ready" required: false default: "false" post_codex_comment: @@ -80,8 +80,6 @@ runs: GITHUB_TOKEN: ${{ github.token }} CODEX_CMD: ${{ inputs.codex_command }} FORCE_BASE: ${{ inputs.base_branch }} - DRAFT_FLAG: ${{ inputs.draft }} - AUTO_READY: ${{ inputs.auto_ready }} POST_CODEX: ${{ inputs.post_codex_comment }} AGENT_KEY: ${{ inputs.agent_key }} PR_MODE: ${{ inputs.pr_mode }} @@ -159,7 +157,6 @@ runs: '—', 'PR created automatically to engage Codex.' ].join('\n'); - const asDraft = /^true$/i.test(process.env.DRAFT_FLAG || 'false'); const prMode = (process.env.PR_MODE || 'create').toLowerCase(); if (prMode === 'invite') { @@ -191,10 +188,11 @@ runs: return; } - // Create draft or ready PR + // Automation-created PRs are always ready for review. Staging and + // dependency state belong in labels, checks, and the PR body. let prNum = null; try { - const { data: pr } = await github.rest.pulls.create({ owner, repo, head: branch, base: baseBranch, draft: asDraft, title: `Codex bootstrap for #${issue_number}`, body }); + const { data: pr } = await github.rest.pulls.create({ owner, repo, head: branch, base: baseBranch, draft: false, title: `Codex bootstrap for #${issue_number}`, body }); prNum = pr.number; } catch (e) { core.setFailed(`Failed to open PR: ${e.status || '?'} ${e.message}`); @@ -246,15 +244,6 @@ runs: } catch (e) { core.warning(`Instruction comment failed: ${e.message}`); } } - // Optional: immediately mark ready for review - const auto = /^true$/i.test(process.env.AUTO_READY || 'false'); - if (asDraft && auto) { - try { await github.rest.pulls.update({ owner, repo, pull_number: prNum, draft: false }); } catch (e) { - // Some endpoints prefer the separate ready-for-review endpoint - try { await github.request('POST /repos/{owner}/{repo}/pulls/{pull_number}/ready-for-review', { owner, repo, pull_number: prNum }); } catch (e2) { core.warning(`Auto-ready failed: ${e2.status || '?'} ${e2.message}`); } - } - } - // Link back to the issue try { const executionSnippet = [ @@ -272,7 +261,7 @@ runs: '```' ].join('\n'); const keepaliveStatus = `Keepalive mode: **${keepaliveEnabled ? 'ON' : 'OFF'}**`; - await github.rest.issues.createComment({ owner, repo, issue_number, body: `Opened ${asDraft && !auto ? 'draft ' : ''}PR #${prNum} to engage Codex. Track work there.\n\n${keepaliveStatus}\n\n${executionSnippet}` }); + await github.rest.issues.createComment({ owner, repo, issue_number, body: `Opened ready-for-review PR #${prNum} to engage Codex. Track work there.\n\n${keepaliveStatus}\n\n${executionSnippet}` }); } catch {} core.setOutput('pr', String(prNum)); diff --git a/.github/scripts/agents_orchestrator_resolve.js b/.github/scripts/agents_orchestrator_resolve.js index 7aa48e256..e3a5b7b7c 100644 --- a/.github/scripts/agents_orchestrator_resolve.js +++ b/.github/scripts/agents_orchestrator_resolve.js @@ -522,7 +522,7 @@ async function resolveOrchestratorParams({ github, context, core, env = process. merged.bootstrap_issues_label ?? bootstrap.label, DEFAULTS.bootstrap_issues_label ), - draft_pr: toBoolString(merged.draft_pr, DEFAULTS.draft_pr), + draft_pr: 'false', dry_run: dryRun, options_json: finalOptionsJson, dispatcher_force_issue: dispatcherForceIssue, diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index ded6e495b..570dccedc 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -909,10 +909,51 @@ async function run({ github, context, core }) { } } + async function holdReadyStableDelivery({ owner, repo, pr }) { + let { data: current } = await withRetry((client) => client.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + })); + if (current.auto_merge) { + await withRetry((client) => client.graphql( + `mutation($id: ID!) { + disablePullRequestAutoMerge(input: {pullRequestId: $id}) { + pullRequest { id autoMergeRequest { enabledAt } } + } + }`, + { id: current.node_id }, + )); + } + if (current.draft) { + await withRetry((client) => client.graphql( + `mutation($id: ID!) { + markPullRequestReadyForReview(input: {pullRequestId: $id}) { + pullRequest { id isDraft } + } + }`, + { id: current.node_id }, + )); + } + ({ data: current } = await withRetry((client) => client.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }))); + if (current.auto_merge) { + throw new Error(`Auto-merge remains enabled for staged delivery PR #${pr.number}.`); + } + if (current.draft) { + throw new Error(`Staged delivery PR #${pr.number} remains draft.`); + } + return current; + } + async function beginStableDeliveryReview({ owner, repo, pr, record, dryRunMode }) { const reviewStartedAt = record.review_started_at || new Date().toISOString(); if (dryRunMode) return { reviewStartedAt, dryRun: true }; - const body = replaceDeliveryRecord(pr.body || '', { + const current = await holdReadyStableDelivery({ owner, repo, pr }); + const body = replaceDeliveryRecord(current.body || '', { delivery_state: 'reviewing', review_started_at: reviewStartedAt, sealed_at: '', @@ -925,16 +966,6 @@ async function run({ github, context, core }) { pull_number: pr.number, body, })); - if (pr.draft) { - await withRetry((client) => client.graphql( - `mutation($id: ID!) { - markPullRequestReadyForReview(input: {pullRequestId: $id}) { - pullRequest { id isDraft } - } - }`, - { id: pr.node_id }, - )); - } await withRetry((client) => client.rest.issues.addLabels({ owner, repo, @@ -945,30 +976,30 @@ async function run({ github, context, core }) { } async function restageStableDelivery({ owner, repo, pr, dryRunMode }) { - const body = replaceDeliveryRecord(pr.body || '', { + if (dryRunMode) { + const body = replaceDeliveryRecord(pr.body || '', { + delivery_state: 'staging', + review_started_at: '', + sealed_at: '', + sealed_head_sha: '', + review_evidence: {}, + }); + return { body, dryRun: true }; + } + const current = await holdReadyStableDelivery({ owner, repo, pr }); + const body = replaceDeliveryRecord(current.body || '', { delivery_state: 'staging', review_started_at: '', sealed_at: '', sealed_head_sha: '', review_evidence: {}, }); - if (dryRunMode) return { body, dryRun: true }; await withRetry((client) => client.rest.pulls.update({ owner, repo, pull_number: pr.number, body, })); - if (!pr.draft) { - await withRetry((client) => client.graphql( - `mutation($id: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $id}) { - pullRequest { id isDraft } - } - }`, - { id: pr.node_id }, - )); - } await withRetry((client) => client.rest.issues.addLabels({ owner, repo, @@ -2022,7 +2053,7 @@ async function run({ github, context, core }) { continue; } - // Stable candidate PRs may advance from draft -> reviewing -> sealed + // Stable candidate PRs may advance from staging -> reviewing -> sealed // without pre-merge evidence. The evidence artifact authorizes only the // irreversible merge, so lifecycle progress cannot deadlock behind the // artifact that the sealed state is responsible for producing. diff --git a/.github/workflows/agents-63-issue-intake.yml b/.github/workflows/agents-63-issue-intake.yml index e65cabeca..ec9f3b426 100644 --- a/.github/workflows/agents-63-issue-intake.yml +++ b/.github/workflows/agents-63-issue-intake.yml @@ -48,11 +48,6 @@ on: required: false type: string default: "codex" - bridge_draft_pr: - description: "Open Codex bootstrap PRs as draft (true/false)" - required: false - type: boolean - default: false apply_langchain_formatting: description: "Auto-format issues with LangChain after creation" required: false @@ -93,7 +88,7 @@ on: required: false type: string bridge_draft_pr: - description: "Open Codex bootstrap PRs as draft (true/false)" + description: "Deprecated compatibility input; ignored because automation PRs are always ready" required: false type: string apply_langchain_formatting: @@ -160,7 +155,6 @@ jobs: issue_number: ${{ steps.normalize.outputs.issue_number }} post_codex_comment: ${{ steps.normalize.outputs.post_codex_comment }} bridge_agent: ${{ steps.normalize.outputs.bridge_agent }} - bridge_draft_pr: ${{ steps.normalize.outputs.bridge_draft_pr }} apply_langchain_formatting: ${{ steps.normalize.outputs.apply_langchain_formatting }} steps: - name: Normalize inputs @@ -174,7 +168,6 @@ jobs: INPUT_ISSUE_NUMBER: ${{ inputs.issue_number }} INPUT_POST_CODEX_COMMENT: ${{ inputs.post_codex_comment }} INPUT_BRIDGE_AGENT: ${{ inputs.bridge_agent }} - INPUT_BRIDGE_DRAFT_PR: ${{ inputs.bridge_draft_pr }} INPUT_APPLY_LANGCHAIN_FORMATTING: ${{ inputs.apply_langchain_formatting }} run: | # Handle boolean inputs from workflow_dispatch vs workflow_call @@ -197,7 +190,6 @@ jobs: issue_number="${INPUT_ISSUE_NUMBER:-}" post_codex_input="${INPUT_POST_CODEX_COMMENT:-}" bridge_agent="${INPUT_BRIDGE_AGENT:-}" - draft_pr=$(normalize_bool "${INPUT_BRIDGE_DRAFT_PR:-}") apply_formatting=$(normalize_bool "${INPUT_APPLY_LANGCHAIN_FORMATTING:-}") payload_issue="" @@ -253,7 +245,6 @@ jobs: echo "issue_number=$issue_number" echo "post_codex_comment=$post_codex" echo "bridge_agent=$bridge_agent" - echo "bridge_draft_pr=$draft_pr" echo "apply_langchain_formatting=$apply_formatting" } >> "$GITHUB_OUTPUT" @@ -1732,7 +1723,6 @@ jobs: }} mode: 'invite' post_agent_comment: ${{ needs.normalize_inputs.outputs.post_codex_comment }} - agent_pr_draft: ${{ needs.normalize_inputs.outputs.bridge_draft_pr }} secrets: service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} owner_pr_pat: ${{ secrets.OWNER_PR_PAT }} diff --git a/.github/workflows/agents-70-orchestrator.yml b/.github/workflows/agents-70-orchestrator.yml index db99a6936..1bddba694 100644 --- a/.github/workflows/agents-70-orchestrator.yml +++ b/.github/workflows/agents-70-orchestrator.yml @@ -178,7 +178,6 @@ jobs: keepalive_max_retries: ${{ needs.init.outputs.keepalive_max_retries }} enable_bootstrap: ${{ needs.init.outputs.enable_bootstrap }} bootstrap_issues_label: ${{ needs.init.outputs.bootstrap_issues_label }} - draft_pr: ${{ needs.init.outputs.draft_pr }} verify_issue_valid_assignees: ${{ needs.init.outputs.verify_issue_valid_assignees }} dry_run: ${{ needs.init.outputs.dry_run }} options_json: ${{ needs.init.outputs.options_json }} diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index d7e05b432..56dc2c567 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -985,6 +985,56 @@ jobs: credential_helper="!f() { echo \"username=x-access-token\"; echo \"password=\$GH_TOKEN\"; }; f" git config credential.helper "$credential_helper" + hold_ready_pr() { + local pr_number="$1" + local expected_head="$2" + local pr_json + local pr_state + local pr_head + local pr_is_draft + local pr_has_auto_merge + + pr_json=$(gh pr view "$pr_number" \ + --json state,headRefOid,isDraft,autoMergeRequest) + pr_state=$(jq -r .state <<<"$pr_json") + pr_head=$(jq -r .headRefOid <<<"$pr_json") + pr_is_draft=$(jq -r .isDraft <<<"$pr_json") + pr_has_auto_merge=$(jq -r '.autoMergeRequest != null' <<<"$pr_json") + if [ "$pr_state" != "OPEN" ] || [ "$pr_head" != "$expected_head" ]; then + echo "::error::Existing PR #$pr_number changed before refresh; refusing to overwrite it." + return 1 + fi + + # Maint 71 is the sole merge authority. Disable any inherited + # auto-merge before readiness, body, label, or head mutations. + if [ "$pr_has_auto_merge" = "true" ]; then + if ! gh pr merge "$pr_number" --disable-auto; then + echo "::error::Could not disable auto-merge for PR #$pr_number." + return 1 + fi + fi + if [ "$pr_is_draft" = "true" ]; then + if ! gh pr ready "$pr_number"; then + echo "::error::Could not mark legacy draft PR #$pr_number ready for review." + return 1 + fi + fi + + pr_json=$(gh pr view "$pr_number" \ + --json state,headRefOid,isDraft,autoMergeRequest) + pr_state=$(jq -r .state <<<"$pr_json") + pr_head=$(jq -r .headRefOid <<<"$pr_json") + pr_is_draft=$(jq -r .isDraft <<<"$pr_json") + pr_has_auto_merge=$(jq -r '.autoMergeRequest != null' <<<"$pr_json") + if [ "$pr_state" != "OPEN" ] \ + || [ "$pr_head" != "$expected_head" ] \ + || [ "$pr_is_draft" != "false" ] \ + || [ "$pr_has_auto_merge" != "false" ]; then + echo "::error::PR #$pr_number is not safely held ready for review." + return 1 + fi + } + # Keep one generated PR per lane current. Both candidate and promoted # deliveries use stable branches; immutable plan/hash identity lives in # the delivery marker rather than in a replacement PR branch. @@ -1195,21 +1245,13 @@ jobs: if [ -n "$existing_pr" ] \ && { [ "$matching_existing" != "true" ] || [ "$migrating_legacy_lifecycle" = "true" ]; }; then # Revalidate the selected head, then close every automatic merge - # path immediately before mutating it. Draft state is GitHub's - # native hard block; the staging label is enforced by all shared - # merger lanes and by Gate until Maint 71 seals the exact head. - current_pr_json=$(gh pr view "$existing_pr" --json state,headRefOid,isDraft) - current_pr_state=$(jq -r .state <<<"$current_pr_json") - current_pr_head=$(jq -r .headRefOid <<<"$current_pr_json") - if [ "$current_pr_state" != "OPEN" ] || [ "$current_pr_head" != "$existing_head" ]; then - echo "Existing PR #$existing_pr changed before refresh; refusing to overwrite it." + # path immediately before mutating it. The staging label is + # enforced by all shared merger lanes and by Gate until Maint 71 + # seals the exact head; PRs remain ready for review throughout. + if ! hold_ready_pr "$existing_pr" "$existing_head"; then echo "status=existing_pr_changed" >> "$GITHUB_OUTPUT" exit 1 fi - gh pr merge "$existing_pr" --disable-auto >/dev/null 2>&1 || true - if [ "$(jq -r .isDraft <<<"$current_pr_json")" != "true" ]; then - gh pr ready "$existing_pr" --undo - fi gh pr edit "$existing_pr" --add-label "sync:delivery-staging" gh pr edit "$existing_pr" --remove-label "sync:delivery-ready" >/dev/null 2>&1 || true fi @@ -1286,11 +1328,7 @@ jobs: fi if [ -n "$existing_pr" ]; then - current_pr_json=$(gh pr view "$existing_pr" --json state,headRefOid) - current_pr_state=$(jq -r .state <<<"$current_pr_json") - current_pr_head=$(jq -r .headRefOid <<<"$current_pr_json") - if [ "$current_pr_state" != "OPEN" ] \ - || [ "$current_pr_head" != "$existing_head" ]; then + if ! hold_ready_pr "$existing_pr" "$existing_head"; then echo "::error::Existing PR #$existing_pr changed during signed commit preparation." echo "::error::Refusing to overwrite the changed head." exit 1 @@ -1409,7 +1447,6 @@ jobs: else pr_url=$(gh pr create \ --head "$branch_name" \ - --draft \ --title "chore: sync workflow templates" \ --body "$pr_body" \ --label "sync,automated,sync:delivery-staging") diff --git a/.github/workflows/reusable-16-agents.yml b/.github/workflows/reusable-16-agents.yml index e002e6cca..617e58d0e 100644 --- a/.github/workflows/reusable-16-agents.yml +++ b/.github/workflows/reusable-16-agents.yml @@ -96,7 +96,7 @@ on: default: 'agent:codex' type: string draft_pr: - description: 'Open bootstrap PRs as draft (true/false)' + description: 'Deprecated compatibility input; ignored because automation PRs are always ready' required: false default: 'false' type: string @@ -969,7 +969,6 @@ jobs: with: issue: ${{ fromJson(steps.ready.outputs.issue_numbers_json)[0] }} service_bot_pat: ${{ secrets.service_bot_pat || '' }} - draft: ${{ inputs.draft_pr }} watchdog: if: inputs.enable_watchdog == 'true' diff --git a/.github/workflows/reusable-70-orchestrator-init.yml b/.github/workflows/reusable-70-orchestrator-init.yml index 8da43e839..a07e2f3b3 100644 --- a/.github/workflows/reusable-70-orchestrator-init.yml +++ b/.github/workflows/reusable-70-orchestrator-init.yml @@ -84,6 +84,7 @@ on: bootstrap_issues_label: value: ${{ jobs.resolve-params.outputs.bootstrap_issues_label }} draft_pr: + description: 'Deprecated compatibility output; always `false` because automation PRs are ready for review.' value: ${{ jobs.resolve-params.outputs.draft_pr }} verify_issue_valid_assignees: value: ${{ jobs.resolve-params.outputs.verify_issue_valid_assignees }} diff --git a/.github/workflows/reusable-70-orchestrator-main.yml b/.github/workflows/reusable-70-orchestrator-main.yml index 7eada12bc..84a3186d2 100644 --- a/.github/workflows/reusable-70-orchestrator-main.yml +++ b/.github/workflows/reusable-70-orchestrator-main.yml @@ -62,6 +62,7 @@ on: type: string required: false draft_pr: + description: 'Deprecated compatibility input; ignored because automation PRs are always ready' type: string required: false verify_issue_valid_assignees: @@ -1302,7 +1303,6 @@ jobs: enable_keepalive: ${{ inputs.enable_keepalive }} enable_bootstrap: ${{ inputs.enable_bootstrap }} bootstrap_issues_label: ${{ inputs.bootstrap_issues_label }} - draft_pr: ${{ inputs.draft_pr }} verify_issue_valid_assignees: ${{ inputs.verify_issue_valid_assignees }} dry_run: ${{ inputs.dry_run }} options_json: ${{ inputs.options_json }} diff --git a/.github/workflows/reusable-agents-issue-bridge.yml b/.github/workflows/reusable-agents-issue-bridge.yml index 8663972d9..6ac523aea 100644 --- a/.github/workflows/reusable-agents-issue-bridge.yml +++ b/.github/workflows/reusable-agents-issue-bridge.yml @@ -13,7 +13,6 @@ # - issue_number: Issue number to process (required for workflow_dispatch) # - mode: PR creation mode ('create' or 'invite') # - post_agent_comment: Whether to auto-post '@ start' command -# - agent_pr_draft: Whether created PRs should be drafts # - service_bot_pat: PAT for service bot operations # - owner_pr_pat: PAT for PR creation operations @@ -47,7 +46,7 @@ on: type: string default: "true" agent_pr_draft: - description: "Force created PR to be draft (true/false)" + description: "Deprecated compatibility input; ignored because automation PRs are always ready" required: false type: string default: "false" @@ -403,14 +402,6 @@ jobs: core.setOutput('mode', mode); core.setOutput('reason', reason); - - name: Resolve draft flag - id: draft - uses: actions/github-script@v9 - with: - script: | - const val = '${{ inputs.agent_pr_draft }}' === 'true'; - core.setOutput('draft', val ? 'true' : 'false'); - - name: Resolve post-agent comment flag id: agent_comment uses: actions/github-script@v9 @@ -575,7 +566,7 @@ jobs: ]); await summary.write(); - - name: Log chosen mode & draft + - name: Log chosen PR mode env: AGENT: ${{ steps.agent_label.outputs.agent || inputs.agent }} run: | @@ -585,7 +576,11 @@ jobs: echo "Chosen PR mode: $MODE (reason: $REASON)" echo "Base branch: ${{ steps.refs.outputs.base }}" echo "Head branch: ${{ steps.refs.outputs.head || 'unresolved' }}" - echo "Resolved draft flag: ${{ steps.draft.outputs.draft }}" + if [ "$MODE" = "invite" ]; then + echo "PR readiness: not applicable (invite mode)" + else + echo "PR readiness: ready for review" + fi echo "Post agent comment: ${{ steps.agent_comment.outputs.post }}" KEEP_MODE="${{ steps.keepalive.outputs.mode || 'OFF' }}" KEEP_SOURCE="${{ steps.keepalive.outputs.source || 'default' }}" @@ -972,7 +967,6 @@ jobs: const issue_number = Number('${{ steps.ctx.outputs.issue }}'); const base = "${{ steps.refs.outputs.base }}"; const head = process.env.BRANCH; - const draftFlag = "${{ steps.draft.outputs.draft }}" === 'true'; const keepaliveMode = process.env.KEEPALIVE_MODE || 'OFF'; const { buildIssueContext } = require('./.github/scripts/issue_context_utils.js'); const agent = (process.env.AGENT || '').trim(); @@ -1027,6 +1021,27 @@ jobs: ); let pr = existing.data[0]; + // A rerun can encounter a legacy or manually-created draft on the + // automation branch. Recover it before any body, label, assignment, + // or comment handoff so the ready-for-review invariant is fail-closed. + if (pr?.draft) { + const readyResult = await withRetry(() => + github.graphql( + `mutation($id: ID!) { + markPullRequestReadyForReview(input: {pullRequestId: $id}) { + pullRequest { id isDraft } + } + }`, + { id: pr.node_id }, + ), + ); + if (readyResult.markPullRequestReadyForReview.pullRequest.isDraft) { + throw new Error(`Failed to mark reused PR #${pr.number} ready for review.`); + } + pr.draft = false; + core.info(`Marked reused PR #${pr.number} ready for review.`); + } + let issueTitle = ''; let issueBody = ''; try { @@ -1114,7 +1129,7 @@ jobs: repo, head, base, - draft: draftFlag, + draft: false, title: `${agentTitle} bootstrap for #${issue_number}`, body: prBody, }), diff --git a/AGENTS.md b/AGENTS.md index bbd96e762..4039a7675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,13 @@ Start with the current docs instead of inferring behavior from old comments: - `ci.yml` and `autofix-versions.env` remain repo-specific in consumer repos. - `pr-00-gate.yml` is distributed as a create-only starting point. Consumers should stay aligned to the standard gate unless they have an explicitly documented exception. +## Pull Request Readiness Invariant + +- Automation-created pull requests must be opened ready for review. Do not create drafts or convert ready pull requests back to draft. +- Draft state is not a staging, dependency, stack-order, or opener-cap control. Use explicit labels, PR-body lifecycle state, disabled auto-merge, required checks, and exact-head merge guards instead. +- Before handing off or ending work, verify every pull request created or changed by the run is open and has `isDraft=false`. Convert a pre-existing draft to ready as a recovery action. +- Do not close an otherwise valid pull request merely to free automation capacity; preserve its branch and route the real blocker or dependency explicitly. + ## Editing Rules - Changes to reusable workflows affect consumers immediately. diff --git a/CLAUDE.md b/CLAUDE.md index 0b1b1932c..86748534f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,13 @@ Start with the current docs instead of inferring behavior from old comments: - `ci.yml` and `autofix-versions.env` remain repo-specific in consumer repos. - `pr-00-gate.yml` is distributed as a create-only starting point. Consumers should stay aligned to the standard gate unless they have an explicitly documented exception. +## Pull Request Readiness Invariant + +- Automation-created pull requests must be opened ready for review. Do not create drafts or convert ready pull requests back to draft. +- Draft state is not a staging, dependency, stack-order, or opener-cap control. Use explicit labels, PR-body lifecycle state, disabled auto-merge, required checks, and exact-head merge guards instead. +- Before handing off or ending work, verify every pull request created or changed by the run is open and has `isDraft=false`. Convert a pre-existing draft to ready as a recovery action. +- Do not close an otherwise valid pull request merely to free automation capacity; preserve its branch and route the real blocker or dependency explicitly. + ## Editing Rules - Changes to reusable workflows affect consumers immediately. diff --git a/README.md b/README.md index 9e3f351ae..cb2bfcfdf 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ For a narrative of how the repo evolved through five development phases (bootstr Consumer delivery is coalesced into one stable PR per repository and lane: configured canaries use `sync/workflows-candidate`, while promoted consumers use -`sync/workflows-delivery`. Maint 68 updates those PRs in place; Maint 71 alone -advances them from draft staging through bounded reviewer settlement to an +`sync/workflows-delivery`. Maint 68 updates those ready-for-review PRs in place; +Maint 71 alone advances them from labeled staging through bounded reviewer settlement to an exact-head seal and merge. Review capacity cannot require all configured bots: one response is sufficient after the quiet period, and a bounded timeout keeps an unavailable reviewer from making delivery immortal. Explicit review-skip, diff --git a/config/template-drift-allowlist.txt b/config/template-drift-allowlist.txt index 23105cd30..cd10356c2 100644 --- a/config/template-drift-allowlist.txt +++ b/config/template-drift-allowlist.txt @@ -43,9 +43,9 @@ [pair.1] main = .github/workflows/agents-63-issue-intake.yml template = templates/consumer-repo/.github/workflows/agents-issue-intake.yml -main_sha256 = ec2e88c47e1d1e8b4e80094db20ce2e1cefd62ea968610a2b11b4403c6750184 -template_sha256 = 617cb594296c26ef8510b03dc43e835396a50ce84ed0d8a10d9e41b3e93f6772 -reason = Intentional divergence re-reviewed 2026-08-09: root intake now records each failed topic and fails after publishing its summary; the consumer intake template remains a pinned, minimal bridge contract. +main_sha256 = 200757cbb8d1801434ac3acb9e517a90ea37aa06e57ce925e8ac4480d829fc7f +template_sha256 = 9176b7cffc68dba50fa7ff9c6a2386383c237433ac5a656053eccdd202628c6d +reason = Intentional divergence re-reviewed 2026-08-16: root and consumer intake surfaces both remove the operator draft toggle and always hand off ready-for-review automation PRs; root retains its richer failure summary while the consumer remains a pinned, minimal bridge contract. [pair.2] main = .github/workflows/agents-71-codex-belt-dispatcher.yml diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 309e2a394..0ea83b829 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -60,13 +60,12 @@ an unchanged PR a near-free no-op. It honors the operator guardrails paused or human-blocked PR is never re-dispatched by the sweep. ### Bootstrap PR -The initial draft PR an orchestrator/opener creates for an issue before any real -agent work exists — a branch (e.g. `codex/issue-`) plus a PR body carrying the -issue context, used as the surface keepalive then iterates on. Whether bootstrap -PRs open as drafts is controlled by the `draft_pr` output of -`reusable-70-orchestrator-init.yml` (see -[`INTEGRATION_GUIDE.md`](INTEGRATION_GUIDE.md)); the lightweight bootstrap path is -the `.github/actions/codex-bootstrap-lite/` composite action. Belt and verifier +The initial ready-for-review PR an orchestrator/opener creates for an issue +before any real agent work exists — a branch (e.g. `codex/issue-`) plus a PR +body carrying the issue context, used as the surface keepalive then iterates on. +Bootstrap PRs are always ready for review; labels, checks, and explicit PR-body +state carry dependencies or staging. The lightweight bootstrap path is the +`.github/actions/codex-bootstrap-lite/` composite action. Belt and verifier tooling treat a bootstrap-only placeholder (no substantive diff) as not yet mergeable. diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 3eb2f7cc9..0f2466642 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -184,7 +184,7 @@ Coverage evidence: `tests/workflows/test_reusable_workflow_outputs_doc.py` loads | Workflow | Outputs (name → description) | |----------|-----------------------------| | `reusable-16-agents.yml` | `readiness_report` → JSON payload from the readiness probe; `readiness_table` → Markdown table summarizing assignable agents. | -| `reusable-70-orchestrator-init.yml` | `rate_limit_safe`, `has_work`, `token_source`; keepalive/run toggles (`enable_keepalive`, `keepalive_pause_label`, `keepalive_round`, `keepalive_pr`, `keepalive_max_retries`, `keepalive_trace`); readiness/diagnostic toggles (`enable_readiness`, `readiness_agents`, `readiness_custom_logins`, `require_all`, `enable_preflight`, `enable_diagnostic`, `diagnostic_attempt_branch`, `diagnostic_dry_run`, `enable_verify_issue`, `verify_issue_number`, `verify_issue_valid_assignees`); bootstrap/worker settings (`enable_bootstrap`, `bootstrap_issues_label`, `draft_pr`, `dispatcher_force_issue`, `worker_max_parallel`, `conveyor_max_merges`); misc orchestrator options (`codex_user`, `codex_command_phrase`, `enable_watchdog`, `dry_run`, `options_json`). | +| `reusable-70-orchestrator-init.yml` | `rate_limit_safe`, `has_work`, `token_source`; keepalive/run toggles (`enable_keepalive`, `keepalive_pause_label`, `keepalive_round`, `keepalive_pr`, `keepalive_max_retries`, `keepalive_trace`); readiness/diagnostic toggles (`enable_readiness`, `readiness_agents`, `readiness_custom_logins`, `require_all`, `enable_preflight`, `enable_diagnostic`, `diagnostic_attempt_branch`, `diagnostic_dry_run`, `enable_verify_issue`, `verify_issue_number`, `verify_issue_valid_assignees`); bootstrap/worker settings (`enable_bootstrap`, `bootstrap_issues_label`, deprecated always-false `draft_pr`, `dispatcher_force_issue`, `worker_max_parallel`, `conveyor_max_merges`); misc orchestrator options (`codex_user`, `codex_command_phrase`, `enable_watchdog`, `dry_run`, `options_json`). | | `reusable-10-ci-python.yml` | None (artifacts only: coverage, metrics, summaries). | | `reusable-11-ci-node.yml` | None (artifacts only: coverage + junit when enabled). | | `reusable-12-ci-docker.yml` | None (logs only). | @@ -226,7 +226,7 @@ Reusable workflows with caller-facing `workflow_call` outputs: | `reusable-70-orchestrator-init.yml` | `keepalive_max_retries` | string (number-like) | Maximum keepalive retries permitted for the run. | `needs.orchestrator-init.outputs.keepalive_max_retries` | | `reusable-70-orchestrator-init.yml` | `enable_bootstrap` | string (boolean-like) | Resolved flag for Codex bootstrap. | `needs.orchestrator-init.outputs.enable_bootstrap` | | `reusable-70-orchestrator-init.yml` | `bootstrap_issues_label` | string | Label to select issues for bootstrap. | `needs.orchestrator-init.outputs.bootstrap_issues_label` | -| `reusable-70-orchestrator-init.yml` | `draft_pr` | string (boolean-like) | Whether bootstrap PRs should be drafts. | `needs.orchestrator-init.outputs.draft_pr` | +| `reusable-70-orchestrator-init.yml` | `draft_pr` | string (boolean-like) | Deprecated compatibility output; always `false` because automation PRs are ready for review. | `needs.orchestrator-init.outputs.draft_pr` | | `reusable-70-orchestrator-init.yml` | `verify_issue_valid_assignees` | string | Comma-separated logins considered valid for issue verification. | `needs.orchestrator-init.outputs.verify_issue_valid_assignees` | | `reusable-70-orchestrator-init.yml` | `dry_run` | string (boolean-like) | Global dry-run toggle for downstream jobs. | `needs.orchestrator-init.outputs.dry_run` | | `reusable-70-orchestrator-init.yml` | `options_json` | string (JSON) | Resolved options JSON passed to the orchestrator. | `needs.orchestrator-init.outputs.options_json` | diff --git a/docs/SYNC_WORKFLOW.md b/docs/SYNC_WORKFLOW.md index 024e9be46..6be85c7ab 100644 --- a/docs/SYNC_WORKFLOW.md +++ b/docs/SYNC_WORKFLOW.md @@ -83,13 +83,13 @@ done ## Stable Delivery PRs Maint 68 coalesces canary updates into `sync/workflows-candidate` and promoted -updates into `sync/workflows-delivery`. It updates the same PR in place. Before -an actual push it disables auto-merge, converts the PR to draft, and adds -`sync:delivery-staging`; if the computed base/tree is unchanged it preserves -the existing review lifecycle. +updates into `sync/workflows-delivery`. It updates the same ready-for-review PR +in place. Before an actual push it disables auto-merge and adds +`sync:delivery-staging` without changing readiness; if the computed base/tree +is unchanged it preserves the existing review lifecycle. -Maint 71 is the sole merge/close authority. It marks staging PRs ready for -bounded review, requires one available reviewer response after seven minutes +Maint 71 is the sole merge/close authority. It starts bounded review for staged +PRs, requires one available reviewer response after seven minutes (or degrades after an all-capacity signal / fifteen-minute no-response timeout), and never waives active review threads. It seals the exact head, triggers a fresh Gate, and merges only after that Gate succeeds. The staging @@ -117,7 +117,7 @@ branches. Re-run Maint 71 after the recorded quiet-period/check timestamp. - [ ] Fix any issues found - [ ] Check for open sync PRs across all consumer repos - [ ] Refresh each stable candidate or delivery PR in place through Maint 68 -- [ ] Keep refreshed PRs draft and labeled `sync:delivery-staging` +- [ ] Keep refreshed PRs ready for review and labeled `sync:delivery-staging` - [ ] Use Maint 71 to start the review window, settle available reviewer evidence, and seal the exact head - [ ] Verify zero active non-outdated review threads and passing required checks on the sealed head - [ ] Let Maint 71 merge only the sealed stable delivery; use its reconciliation output for stale attempts diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index a07f603f7..0553a15fb 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -59,7 +59,7 @@ _Inline Gate helper_ - **`maint-62-integration-consumer.yml`** — Nightly + release-triggered integration tests that reuse `reusable-10-ci-python.yml` across multiple matrices and file/resolve the `integration-test` issue via the load-balanced API client (no extra app mint). - **`maint-65-sync-label-docs.yml`** — Syncs `docs/LABELS.md` into every registered consumer repo (plus the integration tests repo) when the source doc changes or on demand, using the shared registered-repo helper and PAT gating for cross-repo pushes. - **`maint-66-monthly-audit.yml`** — First-of-month workflow that gathers workflow-run stats, runs the API wrapper guard, and files/updates the monthly audit issue; relies on the shared API client so no extra npm installs or App-token mints are needed. -- **`maint-68-sync-consumer-repos.yml`** — Daily/manual manifest-driven consumer sync that validates template/scripts, hashes the template set, records a prospective per-repo matrix, and opens stable sync PRs. Scheduled reconciliation uses the full manifest; bounded source repairs may instead select only manifest entries changed across an exact base/head range, including transitive `requires` targets declared by the typed manifest. Promotion reconstructs the exact source commit for every plan scope, plus the immutable base for source-delta plans, from Maint 71 evidence so later `main` drift cannot join the delivery. Empty source deltas stop before consumer fan-out, while manifest changes fail closed to full scope. Normal runs are fail-closed to the configured canaries, and explicit repo filters may only narrow that canary set. Candidate corrections refresh `sync/workflows-candidate`; a later `promote` run requires same-plan, green, review-clear Maint 71 evidence and refreshes `sync/workflows-delivery` in each non-canary. Every successful write wave dispatches the matching Maint 71 candidate or campaign selector, so the generated lane does not depend on a human handoff. Before an actual head mutation, Maint 68 disables auto-merge, restores draft state, and applies the staging hold; an unchanged base/tree preserves the existing review lifecycle. Each consumer job mints a repository-scoped Workflows App token and creates the exact staged Git tree through GitHub's Git database API without custom author/committer fields, so GitHub signs the commit. Tree or signature mismatches fail before the delivery branch is published. Release publication is not a second sync trigger. +- **`maint-68-sync-consumer-repos.yml`** — Daily/manual manifest-driven consumer sync that validates template/scripts, hashes the template set, records a prospective per-repo matrix, and opens stable ready-for-review sync PRs. Scheduled reconciliation uses the full manifest; bounded source repairs may instead select only manifest entries changed across an exact base/head range, including transitive `requires` targets declared by the typed manifest. Promotion reconstructs the exact source commit for every plan scope, plus the immutable base for source-delta plans, from Maint 71 evidence so later `main` drift cannot join the delivery. Empty source deltas stop before consumer fan-out, while manifest changes fail closed to full scope. Normal runs are fail-closed to the configured canaries, and explicit repo filters may only narrow that canary set. Candidate corrections refresh `sync/workflows-candidate`; a later `promote` run requires same-plan, green, review-clear Maint 71 evidence and refreshes `sync/workflows-delivery` in each non-canary. Every successful write wave dispatches the matching Maint 71 candidate or campaign selector, so the generated lane does not depend on a human handoff. Before an actual head mutation, Maint 68 disables auto-merge and applies the staging hold without changing PR readiness; an unchanged base/tree preserves the existing review lifecycle. Each consumer job mints a repository-scoped Workflows App token and creates the exact staged Git tree through GitHub's Git database API without custom author/committer fields, so GitHub signs the commit. Tree or signature mismatches fail before the delivery branch is published. Release publication is not a second sync trigger. - **`maint-69-sync-integration-repo.yml`** — Keeps Workflows-Integration-Tests aligned with `templates/integration-repo/`, regenerates `requirements.lock`, and pushes updates using PATs; no GitHub App token mint is required because the workflow stays inside the two repos. - **`maint-69-sync-labels.yml`** — Propagates the canonical `.github/labels-core.yml` set to every registered consumer repo (or a provided subset), reusing the registered-repo helper + load-balanced API client without any additional App-token minting. - **`maint-70-fix-integration-formatting.yml`** — Manual formatter for Workflows-Integration-Tests that resolves the repo default branch, applies `black`+`ruff` fixes, and pushes via PAT only when a token is available; runs read-only otherwise. diff --git a/docs/agent-automation.md b/docs/agent-automation.md index 192f9e9c8..3a590e13a 100644 --- a/docs/agent-automation.md +++ b/docs/agent-automation.md @@ -41,7 +41,7 @@ Gate workflow_run (PRs) ───────────────▶ agents- - **Triggers:** `schedule` (every 30 minutes) and manual `workflow_dispatch` with curated inputs. - **Inputs:** `enable_readiness`, `readiness_agents`, `enable_preflight`, `codex_user`, - `enable_verify_issue`, `verify_issue_number`, `verify_issue_valid_assignees`, `enable_watchdog`, `draft_pr`, plus an extensible + `enable_verify_issue`, `verify_issue_number`, `verify_issue_valid_assignees`, `enable_watchdog`, plus an extensible `params_json` string for long tail toggles (currently `diagnostic_mode`, `readiness_custom_logins`, `codex_command_phrase`, `require_all`, `enable_keepalive`, `keepalive_idle_minutes`, `keepalive_repeat_minutes`, `keepalive_labels`, `keepalive_command`). @@ -58,7 +58,8 @@ Gate workflow_run (PRs) ───────────────▶ agents- - exposes a `workflow_call` interface so the orchestrator can exercise readiness, preflight, verification, and watchdog routines. - keeps compatibility inputs such as `readiness_custom_logins`, `require_all`, `enable_preflight`, `enable_verify_issue`, - `enable_watchdog`, `draft_pr`, and the pass-through `options_json` (embedded via `params_json`) for additional toggles. + `enable_watchdog`, and the pass-through `options_json` (embedded via `params_json`) for additional toggles. Bootstrap PRs + are always opened ready for review; dependencies and staging use labels and checks rather than draft state. - emits a Codex keepalive sweep that looks for stalled checklists on `agent:codex` PRs and republishes the `@codex plan-and-execute` command when the agent has been idle longer than the configured threshold (defaults: 10 minute idle threshold, 30 minute cooldown between nudges). diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index 20b501087..a1da362c9 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -202,7 +202,7 @@ Scheduled health jobs keep the automation ecosystem aligned: * [`health-78-backplane-contract.yml`](../../.github/workflows/health-78-backplane-contract.yml) Workflows-internal gate that runs on PRs touching the run-contract/v1 contract set (schemas, registry, validator, fixtures): asserts the three schemas load as valid draft 2020-12 JSON Schema, `config/backplane_participants.json` keeps the required shape, and the bundled valid/invalid fixtures behave (the validator self-smoke). * [`health-83-dependency-sync-efficiency.yml`](../../.github/workflows/health-83-dependency-sync-efficiency.yml) publishes a weekly, fixture-backed advisory report for dependency-bot, consumer-sync, and dev-tool-sync maintenance and also runs once for each completed immutable sync plan. It completely paginates the trailing reporting window and measures stable-delivery force pushes, draft/ready cycles, reviewer events, and review-to-seal convergence; all-time history remains explicitly incomplete. The dedicated efficiency tracker (`#2897`) changes only when the material-evidence fingerprint changes. * [`health-84-langsmith-observability.yml`](../../.github/workflows/health-84-langsmith-observability.yml) independently monitors LangSmith dashboard/conformance cadence, cloud trace freshness, and intentional pause review dates. It upserts one durable health issue and adds `needs-human` plus `agent:needs-attention` while degraded (daily schedule, manual dispatch). -* [`maint-68-sync-consumer-repos.yml`](../../.github/workflows/maint-68-sync-consumer-repos.yml) coalesces workflow-template updates into stable `sync/workflows-candidate` and `sync/workflows-delivery` PRs. Scheduled reconciliation uses the full typed manifest; a bounded source repair may use an exact base/head source-delta plan, whose immutable scope and transitive manifest-declared `requires` targets are carried through Maint 71 canary evidence into promotion. Every promotion reconstructs its exact source commit from that evidence; source-delta promotion also reconstructs the exact base, so a later `main` commit cannot join either plan scope. Manifest edits require full scope. Explicit repo filters cannot broaden the canary phase; non-canaries are written only by a plan-bound `promote` run carrying green, review-clear Maint 71 evidence. A successful candidate write wave dispatches the Maint 71 candidate selector; promotion dispatches the fleet campaign selector. Actual head changes first restore draft/staging holds, while exact base/tree no-ops preserve the current review lifecycle. Mutating jobs use a repository-scoped Workflows App token to create GitHub-verified commits and fail before publication if the API result is unsigned or its tree differs from the staged tree. +* [`maint-68-sync-consumer-repos.yml`](../../.github/workflows/maint-68-sync-consumer-repos.yml) coalesces workflow-template updates into stable ready-for-review `sync/workflows-candidate` and `sync/workflows-delivery` PRs. Scheduled reconciliation uses the full typed manifest; a bounded source repair may use an exact base/head source-delta plan, whose immutable scope and transitive manifest-declared `requires` targets are carried through Maint 71 canary evidence into promotion. Every promotion reconstructs its exact source commit from that evidence; source-delta promotion also reconstructs the exact base, so a later `main` commit cannot join either plan scope. Manifest edits require full scope. Explicit repo filters cannot broaden the canary phase; non-canaries are written only by a plan-bound `promote` run carrying green, review-clear Maint 71 evidence. A successful candidate write wave dispatches the Maint 71 candidate selector; promotion dispatches the fleet campaign selector. Actual head changes restore the staging label and disable auto-merge without changing PR readiness, while exact base/tree no-ops preserve the current review lifecycle. Mutating jobs use a repository-scoped Workflows App token to create GitHub-verified commits and fail before publication if the API result is unsigned or its tree differs from the staged tree. * [`maint-69-sync-integration-repo.yml`](../../.github/workflows/maint-69-sync-integration-repo.yml) syncs integration-repo templates to Workflows-Integration-Tests repository (template push, manual dispatch with dry-run support). * [`maint-69-sync-labels.yml`](../../.github/workflows/maint-69-sync-labels.yml) syncs core functional labels from labels-core.yml to consumer repos (push to labels-core.yml, manual dispatch with dry-run support). * [`maint-70-fix-integration-formatting.yml`](../../.github/workflows/maint-70-fix-integration-formatting.yml) applies Black and Ruff formatting fixes to Integration-Tests repository files (manual dispatch for CI formatting failures). diff --git a/docs/ci/WORKFLOW_OUTPUTS.md b/docs/ci/WORKFLOW_OUTPUTS.md index bd505d484..6be36550a 100644 --- a/docs/ci/WORKFLOW_OUTPUTS.md +++ b/docs/ci/WORKFLOW_OUTPUTS.md @@ -40,7 +40,7 @@ reusable workflow in the no-output section. | `reusable-70-orchestrator-init.yml` | `keepalive_max_retries` | string (number-like) | Maximum keepalive retries permitted for the run. | `needs.init.outputs.keepalive_max_retries` | | `reusable-70-orchestrator-init.yml` | `enable_bootstrap` | string (boolean-like) | Resolved flag for Codex bootstrap. | `needs.init.outputs.enable_bootstrap` | | `reusable-70-orchestrator-init.yml` | `bootstrap_issues_label` | string | Label to select issues for bootstrap. | `needs.init.outputs.bootstrap_issues_label` | -| `reusable-70-orchestrator-init.yml` | `draft_pr` | string (boolean-like) | Whether bootstrap PRs should be drafts. | `needs.init.outputs.draft_pr` | +| `reusable-70-orchestrator-init.yml` | `draft_pr` | string (boolean-like) | Deprecated compatibility output; always `false` because automation PRs are ready for review. | `needs.init.outputs.draft_pr` | | `reusable-70-orchestrator-init.yml` | `verify_issue_valid_assignees` | string | Comma-separated logins considered valid for issue verification. | `needs.init.outputs.verify_issue_valid_assignees` | | `reusable-70-orchestrator-init.yml` | `dry_run` | string (boolean-like) | Global dry-run toggle for downstream jobs. | `needs.init.outputs.dry_run` | | `reusable-70-orchestrator-init.yml` | `options_json` | string (JSON) | Resolved options JSON passed to the orchestrator. | `needs.init.outputs.options_json` | diff --git a/docs/ci/WORKFLOW_SYSTEM.md b/docs/ci/WORKFLOW_SYSTEM.md index ff10e0136..2d34cbe94 100644 --- a/docs/ci/WORKFLOW_SYSTEM.md +++ b/docs/ci/WORKFLOW_SYSTEM.md @@ -742,7 +742,7 @@ Keep this table handy when you are triaging automation: it confirms which workfl | **Health 83 Dependency Sync Efficiency** (`health-83-dependency-sync-efficiency.yml`, maintenance bucket) | `schedule` (weekly), `workflow_dispatch` | Publishes advisory lane, amplification, stale/replacement, agent-exception, and stable-delivery convergence evidence for dependency and generated sync work. The trailing window is fully paginated, while all-time collection remains explicitly incomplete; the durable tracker changes only on a material-evidence fingerprint change. | ⚪ Scheduled/manual | [Dependency sync efficiency runs](https://github.com/stranske/Workflows/actions/workflows/health-83-dependency-sync-efficiency.yml) | | **Health 84 LangSmith Observability** (`health-84-langsmith-observability.yml`, maintenance bucket) | `schedule` (daily), `workflow_dispatch` | Checks weekly workflow cadence, `workflows-agents` cloud trace freshness, and pause review metadata; uploads evidence and upserts a durable issue with human-attention labels while degraded. | ⚪ Scheduled/manual | [LangSmith observability health runs](https://github.com/stranske/Workflows/actions/workflows/health-84-langsmith-observability.yml) | | **Reusable Backplane Conformance** (`reusable-backplane-conformance.yml`, reusable bucket) | `workflow_call` | Validate a participating repo's emitted run-contract/v1 envelope (producer/bridge) or ingested satellite object (consumer) against the canonical Workflows-owned schemas plus the opt-in participant registry. No-op for non-participants. | ⚪ Reusable (opt-in) | [Backplane conformance runs](https://github.com/stranske/Workflows/actions/workflows/reusable-backplane-conformance.yml) | -| **Maint 68 Sync Consumer Repos** (`maint-68-sync-consumer-repos.yml`, maintenance bucket) | `schedule`, `workflow_dispatch` | Coalesce copied-file changes into stable candidate and promoted-delivery PRs. Scheduled runs use the full typed manifest; bounded repairs may select an exact base/head source delta that Maint 71 evidence preserves through promotion. Exact-plan evidence gates promotion; successful write waves dispatch the matching Maint 71 selector; draft/staging holds precede every head mutation; unchanged deliveries retain their review state. Explicit repo filters cannot bypass the canary boundary. | ⚪ Automatic/manual | [Consumer sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-68-sync-consumer-repos.yml) | +| **Maint 68 Sync Consumer Repos** (`maint-68-sync-consumer-repos.yml`, maintenance bucket) | `schedule`, `workflow_dispatch` | Coalesce copied-file changes into stable ready-for-review candidate and promoted-delivery PRs. Scheduled runs use the full typed manifest; bounded repairs may select an exact base/head source delta that Maint 71 evidence preserves through promotion. Exact-plan evidence gates promotion; successful write waves dispatch the matching Maint 71 selector; staging labels and disabled auto-merge precede every head mutation without changing PR readiness; unchanged deliveries retain their review state. Explicit repo filters cannot bypass the canary boundary. | ⚪ Automatic/manual | [Consumer sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-68-sync-consumer-repos.yml) | | **Maint 69 Sync Integration Repo** (`maint-69-sync-integration-repo.yml`, maintenance bucket) | `push` (templates), `workflow_dispatch` | Sync integration-repo templates to Workflows-Integration-Tests repository. Resolves drift detected by Health 67. Supports dry-run mode. | ⚪ Automatic/manual | [Integration sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-69-sync-integration-repo.yml) | | **Maint 69 Sync Labels** (`maint-69-sync-labels.yml`, maintenance bucket) | `push` (labels-core.yml), `workflow_dispatch` | Sync core functional labels from labels-core.yml to consumer repositories. Distinguishes functional workflow labels from informational repo-specific labels. Supports dry-run mode. | ⚪ Automatic/manual | [Label sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-69-sync-labels.yml) | | **Fix Integration Tests Formatting** (`maint-70-fix-integration-formatting.yml`, maintenance bucket) | `workflow_dispatch` | Manually triggered workflow to apply Black and Ruff formatting fixes to Python files in the Workflows-Integration-Tests repository when CI formatting checks fail. | ⚪ Manual only | [Formatting fix runs](https://github.com/stranske/Workflows/actions/workflows/maint-70-fix-integration-formatting.yml) | diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 6a8f1f647..97873fb44 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -403,10 +403,11 @@ closed if neither protection surface is visible. A successful ruleset query that returns no required checks is authoritative, so cancelled informational jobs do not become invented required failures. -Maint 68 creates stable deliveries as draft with `sync:delivery-staging` and -disables auto-merge before every real head update. If a later run computes the -same base and desired tree, it preserves the PR's current review/seal state; -metadata-only refreshes therefore cannot restart review forever. +Maint 68 creates stable deliveries ready for review with +`sync:delivery-staging` and disables auto-merge before every real head update. +If a later run computes the same base and desired tree, it preserves the PR's +current review/seal state; metadata-only refreshes therefore cannot restart +review forever. Every real head update is fail-closed on commit identity. Maint 68 mints a repository-scoped Workflows GitHub App installation token, uploads the staged @@ -424,7 +425,7 @@ same signed-commit contract. This prevents synced workflow files from reaching consumer `main` through an unsigned automation commit and avoids GitHub's subsequent workflow trust approval hold. -Maint 71 marks the draft ready and starts bounded reviewer settlement. The +Maint 71 starts bounded reviewer settlement while the PR remains ready. The policy in `config/consumer_sync_review_policy.json` requires one response, not all configured reviewers, after a seven-minute quiet period. If every reviewer reports capacity unavailability, settlement degrades after the quiet period; diff --git a/docs/ops/DEPENDENCY_SYNC_EFFICIENCY.md b/docs/ops/DEPENDENCY_SYNC_EFFICIENCY.md index d5b5a0af9..cd3830305 100644 --- a/docs/ops/DEPENDENCY_SYNC_EFFICIENCY.md +++ b/docs/ops/DEPENDENCY_SYNC_EFFICIENCY.md @@ -15,7 +15,7 @@ limit for each measure: - avoidable replacement attempts per repository/batch; and - distinct agent-exception fingerprints, based on changed heads, active review threads, or check-failure clusters rather than observation timestamps; -- force-push, draft/ready, review-request, and review-submission events on the +- force-push, unexpected draft/ready, review-request, and review-submission events on the stable `sync/workflows-candidate` and `sync/workflows-delivery` PRs; and - review-start-to-seal and observed-head-to-seal convergence from each stable PR's `sync-pr-delivery-record:v1` lifecycle marker. @@ -32,7 +32,7 @@ SLOs because its generated work originates in the administration surface. | Avoidable replacements per repository/batch | 0 | | Distinct agent-exception episodes/week | ≤ 5 | | Force pushes per stable sync PR | ≤ 1 | -| Ready-for-review transitions per stable sync PR | ≤ 1 | +| Ready-for-review transitions per stable sync PR | 0 | | Median review-start-to-seal time | ≤ 30 minutes | Security-bypass PRs remain visible in the lane report. They are not treated as @@ -54,7 +54,7 @@ Weekly `created` / `merged` / `closed` counts use event timestamps inside the reporting window. The Markdown report always shows rate numerator/denominator evidence plus any avoidable-replacement repository/batch keys that drive a breach. The stable-delivery table makes repeated head rewrites and repeated -draft-to-ready review cycles visible per PR instead of treating an updated PR as +unexpected draft-to-ready review cycles visible per PR instead of treating an updated PR as one inexpensive delivery attempt. The dedicated durable tracker diff --git a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md index 38901b7ed..28306e383 100644 --- a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md +++ b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md @@ -20,9 +20,9 @@ both `sync/workflows-*` consumer-sync branches and Consumer sync uses two stable lanes: `sync/workflows-candidate` for configured canaries and `sync/workflows-delivery` for promoted non-canaries. Maint 68 -updates those PRs in place. Before changing a head it disables auto-merge, -converts the PR to draft, and applies `sync:delivery-staging`; an exact -base/tree no-op preserves the existing lifecycle instead of restarting review. +updates those ready-for-review PRs in place. Before changing a head it disables +auto-merge and applies `sync:delivery-staging` without changing readiness; an +exact base/tree no-op preserves the existing lifecycle instead of restarting review. Maint 71 advances a stable PR from `staging` to `reviewing` and finally `sealed`. Reviewer capacity is explicitly bounded: one configured reviewer diff --git a/templates/consumer-repo/.github/scripts/agents_orchestrator_resolve.js b/templates/consumer-repo/.github/scripts/agents_orchestrator_resolve.js index 7aa48e256..e3a5b7b7c 100644 --- a/templates/consumer-repo/.github/scripts/agents_orchestrator_resolve.js +++ b/templates/consumer-repo/.github/scripts/agents_orchestrator_resolve.js @@ -522,7 +522,7 @@ async function resolveOrchestratorParams({ github, context, core, env = process. merged.bootstrap_issues_label ?? bootstrap.label, DEFAULTS.bootstrap_issues_label ), - draft_pr: toBoolString(merged.draft_pr, DEFAULTS.draft_pr), + draft_pr: 'false', dry_run: dryRun, options_json: finalOptionsJson, dispatcher_force_issue: dispatcherForceIssue, diff --git a/templates/consumer-repo/.github/workflows/agents-issue-intake.yml b/templates/consumer-repo/.github/workflows/agents-issue-intake.yml index 22e089596..6fc3bbcf2 100644 --- a/templates/consumer-repo/.github/workflows/agents-issue-intake.yml +++ b/templates/consumer-repo/.github/workflows/agents-issue-intake.yml @@ -48,11 +48,6 @@ on: required: false type: boolean default: true - bridge_draft_pr: - description: "[agent_bridge] Open bootstrap PRs as draft" - required: false - type: boolean - default: false topic_files: description: "[chatgpt_sync] Topic files to sync (e.g., topics.json, agents/*.md)" required: false @@ -193,7 +188,6 @@ jobs: ${{ github.event_name == 'workflow_dispatch' && (inputs.post_codex_comment && 'true' || 'false') || (needs.check_labels.outputs.agent != 'codex' && 'true' || 'false') }} - agent_pr_draft: ${{ inputs.bridge_draft_pr && 'true' || 'false' }} secrets: service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} owner_pr_pat: ${{ secrets.OWNER_PR_PAT }} diff --git a/templates/consumer-repo/AGENTS.md b/templates/consumer-repo/AGENTS.md index 9559a8d18..d36705ded 100644 --- a/templates/consumer-repo/AGENTS.md +++ b/templates/consumer-repo/AGENTS.md @@ -28,6 +28,13 @@ If a file is synced from Workflows, fix it in Workflows first. - `pr-00-gate.yml` is a create-only standard file. Keep it aligned with the standard gate unless this repo has a documented reason to diverge. - Synced workflows, prompts, scripts, and consumer docs are managed through `.github/sync-manifest.yml` in Workflows. +## Pull Request Readiness Invariant + +- Automation-created pull requests must be opened ready for review. Do not create drafts or convert ready pull requests back to draft. +- Draft state is not a staging, dependency, stack-order, or opener-cap control. Use explicit labels, PR-body lifecycle state, disabled auto-merge, required checks, and exact-head merge guards instead. +- Before handing off or ending work, verify every pull request created or changed by the run is open and has `isDraft=false`. Convert a pre-existing draft to ready as a recovery action. +- Do not close an otherwise valid pull request merely to free automation capacity; preserve its branch and route the real blocker or dependency explicitly. + ## Commonly Managed Files Usually edit locally only when the file is repo-specific: diff --git a/templates/consumer-repo/CLAUDE.md b/templates/consumer-repo/CLAUDE.md index 9b8426bfe..8e375c851 100644 --- a/templates/consumer-repo/CLAUDE.md +++ b/templates/consumer-repo/CLAUDE.md @@ -28,6 +28,13 @@ If a file is synced from Workflows, fix it in Workflows first. - `pr-00-gate.yml` is a create-only standard file. Keep it aligned with the standard gate unless this repo has a documented reason to diverge. - Synced workflows, prompts, scripts, and consumer docs are managed through `.github/sync-manifest.yml` in Workflows. +## Pull Request Readiness Invariant + +- Automation-created pull requests must be opened ready for review. Do not create drafts or convert ready pull requests back to draft. +- Draft state is not a staging, dependency, stack-order, or opener-cap control. Use explicit labels, PR-body lifecycle state, disabled auto-merge, required checks, and exact-head merge guards instead. +- Before handing off or ending work, verify every pull request created or changed by the run is open and has `isDraft=false`. Convert a pre-existing draft to ready as a recovery action. +- Do not close an otherwise valid pull request merely to free automation capacity; preserve its branch and route the real blocker or dependency explicitly. + ## Commonly Managed Files Usually edit locally only when the file is repo-specific: diff --git a/templates/consumer-repo/WORKFLOW_USER_GUIDE.md b/templates/consumer-repo/WORKFLOW_USER_GUIDE.md index 827371778..9563a9f61 100644 --- a/templates/consumer-repo/WORKFLOW_USER_GUIDE.md +++ b/templates/consumer-repo/WORKFLOW_USER_GUIDE.md @@ -35,7 +35,7 @@ This guide explains how to use the automated workflows in your repository. All w **What Happens:** - Issue is analyzed for clarity and completeness - Issue is formatted to standard template -- Codex agent creates a branch and opens a draft PR +- Codex agent creates a branch and opens a ready-for-review PR - Agent works through the tasks automatically - Keepalive monitors and continues work until complete - Verification runs after merge @@ -264,10 +264,10 @@ Extracts suggestions from the analysis comment and: **Assigns Codex agent to create a PR** 1. Creates branch `codex/issue-` -2. Opens draft PR linked to issue +2. Opens a ready-for-review PR linked to the issue 3. Agent begins implementing tasks 4. Keeps working through keepalive system -5. Marks PR ready when complete +5. Keeps dependency and progress state in labels, checks, and the PR body **Prerequisites:** - Issue must be formatted (`agents:formatted` label) diff --git a/templates/consumer-repo/docs/SETUP_CHECKLIST.md b/templates/consumer-repo/docs/SETUP_CHECKLIST.md index 9da54852f..0ee60e60e 100644 --- a/templates/consumer-repo/docs/SETUP_CHECKLIST.md +++ b/templates/consumer-repo/docs/SETUP_CHECKLIST.md @@ -918,7 +918,7 @@ when the `autofix` or `autofix:clean` label is added to a PR. ### 8.4 Issue Intake System **Purpose**: Automatically creates PRs from issues labeled with `agent:codex`, -bootstrapping agent work with a linked branch and draft PR. +bootstrapping agent work with a linked branch and ready-for-review PR. **Workflows involved**: | Workflow | Role | @@ -944,7 +944,7 @@ bootstrapping agent work with a linked branch and draft PR. 2. Add the `agent:codex` label 3. Verify intake workflow runs 4. Check that a branch `codex/issue-` is created -5. Verify a draft PR is opened linking to the issue +5. Verify a ready-for-review PR is opened linking to the issue **Troubleshooting**: - Intake doesn't trigger: Check label is `agent:codex` (not `codex` or `agent-codex`) diff --git a/tests/workflows/test_no_draft_pr_creation.py b/tests/workflows/test_no_draft_pr_creation.py new file mode 100644 index 000000000..b5b58c0f4 --- /dev/null +++ b/tests/workflows/test_no_draft_pr_creation.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +AUTOMATION_ROOTS = ( + REPO_ROOT / ".github" / "actions", + REPO_ROOT / ".github" / "scripts", + REPO_ROOT / ".github" / "workflows", + REPO_ROOT / "templates" / "consumer-repo" / ".github", +) + + +def _automation_sources() -> list[Path]: + suffixes = {".js", ".py", ".sh", ".yaml", ".yml"} + return sorted( + path + for root in AUTOMATION_ROOTS + for path in root.rglob("*") + if path.is_file() and path.suffix in suffixes + ) + + +DRAFT_ASSIGNMENT = re.compile(r"\bdraft[ \t]*:[ \t]*([^\r\n]*)") + + +def _has_non_false_draft_value(text: str) -> bool: + for match in DRAFT_ASSIGNMENT.finditer(text): + value = re.split(r"[,#}\]]", match.group(1), maxsplit=1)[0].strip() + if value and value != "false": + return True + return False + + +def test_non_false_draft_matcher_catches_quoted_and_dynamic_values() -> None: + for value in ("true", "'true'", '"true"', "${{ inputs.draft }}", "inputs.draft"): + assert _has_non_false_draft_value(f"draft: {value},") + + assert not _has_non_false_draft_value("draft: false,") + assert not _has_non_false_draft_value("draft: false # invariant") + assert not _has_non_false_draft_value("draft:\n description: compatibility input") + + +def test_automation_never_creates_or_restages_draft_pull_requests() -> None: + violations: list[str] = [] + + for path in _automation_sources(): + text = path.read_text(encoding="utf-8") + relative = path.relative_to(REPO_ROOT) + if "--draft" in text: + violations.append(f"{relative}: uses gh pr create --draft") + if "convertPullRequestToDraft" in text: + violations.append(f"{relative}: converts a ready PR back to draft") + if ("pulls.create" in text or "gh pr create" in text) and _has_non_false_draft_value(text): + violations.append(f"{relative}: supplies a non-false draft value") + + assert not violations, "\n".join(violations) + + +def test_pr_creators_state_the_ready_for_review_invariant() -> None: + bootstrap = ( + REPO_ROOT / ".github" / "actions" / "codex-bootstrap-lite" / "action.yml" + ).read_text(encoding="utf-8") + bridge = (REPO_ROOT / ".github" / "workflows" / "reusable-agents-issue-bridge.yml").read_text( + encoding="utf-8" + ) + sync = (REPO_ROOT / ".github" / "workflows" / "maint-68-sync-consumer-repos.yml").read_text( + encoding="utf-8" + ) + + assert "draft: false" in bootstrap + assert "inputs.draft" not in bootstrap + assert "inputs.auto_ready" not in bootstrap + assert "draft: false" in bridge + assert "sync:delivery-staging" in sync + assert 'gh pr merge "$pr_number" --disable-auto' in sync + + +def test_reused_automation_pull_requests_are_recovered_to_ready() -> None: + bridge = (REPO_ROOT / ".github" / "workflows" / "reusable-agents-issue-bridge.yml").read_text( + encoding="utf-8" + ) + sync = (REPO_ROOT / ".github" / "workflows" / "maint-68-sync-consumer-repos.yml").read_text( + encoding="utf-8" + ) + + reused_pr = bridge.index("let pr = existing.data[0];") + ready_guard = bridge.index("if (pr?.draft)", reused_pr) + ready_mutation = bridge.index("markPullRequestReadyForReview", ready_guard) + body_mutation = bridge.index("github.rest.pulls.update", ready_mutation) + assert reused_pr < ready_guard < ready_mutation < body_mutation + assert "{ id: pr.node_id }" in bridge[ready_guard:body_mutation] + assert "pullRequest.isDraft" in bridge[ready_guard:body_mutation] + + hold_function = sync.index("hold_ready_pr()") + ready_command = sync.index('gh pr ready "$pr_number"', hold_function) + first_label_mutation = sync.index('gh pr edit "$existing_pr" --add-label', ready_command) + assert hold_function < ready_command < first_label_mutation + + +def test_legacy_draft_inputs_are_inert_and_absent_from_operator_ui() -> None: + bridge = (REPO_ROOT / ".github" / "workflows" / "reusable-agents-issue-bridge.yml").read_text( + encoding="utf-8" + ) + reusable_agents = (REPO_ROOT / ".github" / "workflows" / "reusable-16-agents.yml").read_text( + encoding="utf-8" + ) + intake = (REPO_ROOT / ".github" / "workflows" / "agents-63-issue-intake.yml").read_text( + encoding="utf-8" + ) + template_intake = ( + REPO_ROOT + / "templates" + / "consumer-repo" + / ".github" + / "workflows" + / "agents-issue-intake.yml" + ).read_text(encoding="utf-8") + resolver = (REPO_ROOT / ".github" / "scripts" / "agents_orchestrator_resolve.js").read_text( + encoding="utf-8" + ) + + assert "inputs.agent_pr_draft" not in bridge + assert "inputs.draft_pr" not in reusable_agents + dispatch_inputs = intake.split("workflow_dispatch:", 1)[1].split("workflow_call:", 1)[0] + assert "bridge_draft_pr" not in dispatch_inputs + assert "bridge_draft_pr" not in template_intake + assert "merged.draft_pr" not in resolver + assert "draft_pr: 'false'" in resolver diff --git a/tests/workflows/test_no_untrusted_interpolation.py b/tests/workflows/test_no_untrusted_interpolation.py index 6d31374f9..1dab99960 100644 --- a/tests/workflows/test_no_untrusted_interpolation.py +++ b/tests/workflows/test_no_untrusted_interpolation.py @@ -388,7 +388,7 @@ ), ( ".github/workflows/reusable-agents-issue-bridge.yml", - "bridge/step-12/with.script", + "bridge/step-11/with.script", "inputs.issue_number", ), ( @@ -409,11 +409,6 @@ ( ".github/workflows/reusable-agents-issue-bridge.yml", "bridge/step-8/with.script", - "inputs.agent_pr_draft", - ), - ( - ".github/workflows/reusable-agents-issue-bridge.yml", - "bridge/step-9/with.script", "inputs.post_agent_comment", ), (".github/workflows/reusable-agents-verifier.yml", "verifier/step-11/run", "inputs.mode"), diff --git a/tests/workflows/test_sync_manifest_delivery.py b/tests/workflows/test_sync_manifest_delivery.py index 6937f81c9..d797235e3 100644 --- a/tests/workflows/test_sync_manifest_delivery.py +++ b/tests/workflows/test_sync_manifest_delivery.py @@ -326,7 +326,7 @@ def test_sync_fanout_is_canary_gated_and_promotion_is_plan_bound() -> None: assert 'branch_name="$SYNC_BRANCH"' in source assert "stable_plan_rotation" in source assert "expectedStableBranch" in source - assert "--draft" in source + assert "--draft" not in source assert "sync:delivery-staging" in source assert "sync:delivery-ready" in source continuation_names = [step.get("name") for step in continuation["steps"]] @@ -548,9 +548,13 @@ def test_maint68_reuses_stable_delivery_pr_without_resetting_an_unchanged_head() assert "migrating its legacy metadata into the staged delivery lifecycle" in source assert "preserving its review lifecycle" in source assert "delivery_state=$(jq -r" in source - assert 'current_pr_json=$(gh pr view "$existing_pr" --json state,headRefOid,isDraft)' in source - assert 'gh pr merge "$existing_pr" --disable-auto' in source - assert 'gh pr ready "$existing_pr" --undo' in source + assert "hold_ready_pr()" in source + assert "--json state,headRefOid,isDraft,autoMergeRequest" in source + assert 'gh pr ready "$pr_number"' in source + assert 'gh pr merge "$pr_number" --disable-auto' in source + assert "pr_has_auto_merge=$(jq -r '.autoMergeRequest != null'" in source + assert source.count('hold_ready_pr "$existing_pr" "$existing_head"') == 2 + assert 'gh pr ready "$existing_pr" --undo' not in source assert '--force-with-lease="refs/heads/$branch_name:$existing_head"' in source assert source.count("--json number,headRefName,isCrossRepository") == 2 assert source.count(".isCrossRepository == false") == 2 @@ -592,6 +596,23 @@ def test_maint71_anchors_review_window_to_producer_head_observation() -> None: assert "selectedHeadCommit?.committer?.date" not in source +def test_maint71_holds_ready_delivery_before_staging_mutations() -> None: + source = Path(".github/scripts/maint71_merge_sync_prs.js").read_text(encoding="utf-8") + + helper = source.index("async function holdReadyStableDelivery") + disable = source.index("disablePullRequestAutoMerge", helper) + ready = source.index("markPullRequestReadyForReview", disable) + verification = source.index("Auto-merge remains enabled", ready) + begin = source.index("async function beginStableDeliveryReview", verification) + restage = source.index("async function restageStableDelivery", begin) + assert helper < disable < ready < verification < begin < restage + assert ( + "const current = await holdReadyStableDelivery({ owner, repo, pr });" + in source[begin:restage] + ) + assert "const current = await holdReadyStableDelivery({ owner, repo, pr });" in source[restage:] + + def test_gate_and_shared_mergers_hold_mutable_stable_deliveries() -> None: gate = (REPO_ROOT / ".github" / "workflows" / "pr-00-gate.yml").read_text(encoding="utf-8") template_gate = (