diff --git a/.github/scripts/__tests__/sync_dependency_campaign.test.js b/.github/scripts/__tests__/sync_dependency_campaign.test.js index e6647eb46..ace29429a 100644 --- a/.github/scripts/__tests__/sync_dependency_campaign.test.js +++ b/.github/scripts/__tests__/sync_dependency_campaign.test.js @@ -19,6 +19,7 @@ const { normalizeDeliveryHandoff, paginateWithRetry, parseCampaignMarker, + planMaint71Continuations, replaceCampaignMarker, verboseDryRunLoggingEnabled, validateCampaignState, @@ -37,7 +38,61 @@ test('mergeDeliveryHandoffs retains one current record per generated PR', () => check_state: 'ready', review_state: 'blocked', }; assert.deepEqual(mergeDeliveryHandoffs([stale], [current], '2026-08-02T00:00:00Z'), [{ - ...current, branch: '', lane: '', observed_at: '2026-08-02T00:00:00Z', + ...current, + branch: '', + lane: '', + continuation: { class: '', lane: '', reason: '', resume_after: '' }, + observed_at: '2026-08-02T00:00:00Z', + }]); +}); + +test('plans only due transient Maint 71 lanes and suppresses candidates during delivery', () => { + const base = { + schema: 'workflows-generated-delivery-handoff/v1', + head_sha: 'abc', + delivery_generation: 'g1', + disposition: 'awaiting-checks', + blocker_owner: 'ci', + next_command: 'await-required-checks', + check_state: 'checks_pending', + review_state: 'clear', + observed_at: '2026-08-15T12:00:00Z', + }; + const candidate = { + ...base, + repository: 'stranske/Travel', + pr: 11, + branch: 'sync/workflows-candidate', + continuation: { + class: 'transient', lane: 'candidate', reason: 'checks_pending', + resume_after: '2026-08-15T12:10:00Z', + }, + }; + const delivery = { + ...base, + repository: 'stranske/Ready', + pr: 12, + branch: 'sync/workflows-delivery', + continuation: { + class: 'transient', lane: 'delivery', reason: 'review_window_pending', + resume_after: '2026-08-15T12:07:00Z', + }, + }; + const planned = planMaint71Continuations([candidate, delivery], { + now: '2026-08-15T12:11:00Z', + }); + assert.deepEqual(planned.map((item) => item.lane), ['delivery']); + assert.equal(planMaint71Continuations([candidate], { + now: '2026-08-15T12:09:59Z', + }).length, 0); + assert.deepEqual(planMaint71Continuations([candidate], { + now: '2026-08-15T12:10:00Z', + }), []); + assert.deepEqual(planMaint71Continuations([candidate], { + now: '2026-08-15T12:10:00.001Z', + }).map((item) => ({ lane: item.lane, branch: item.branch })), [{ + lane: 'candidate', + branch: 'sync/workflows-candidate', }]); }); diff --git a/.github/scripts/__tests__/sync_pr_merge_contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js index 605ced305..fcc25a44c 100644 --- a/.github/scripts/__tests__/sync_pr_merge_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -10,7 +10,11 @@ const { buildMarkdownSummary, buildDeliveryHandoff, buildMergeReport, + candidateRefreshDecision, + candidatePromotionDecision, + deliveryRefreshDecision, candidateEvidenceAllowsMutation, + classifyDeliveryContinuation, classifyGeneratedPr, classifySyncPrChecks, commitSignatureAllowsMerge, @@ -28,6 +32,7 @@ const { isTrustedSyncPr, normalizeSyncHash, parseBooleanInput, + parsePromotionEvidenceFromCommitMessage, requiresStrictGateBranchUpdate, requiredContextsFromRulesets, rulesetRefPatternMatches, @@ -44,7 +49,9 @@ const { collectReviewerEvidence, legacyStatusAsCheck, normalizeReviewPolicy, + parseReviewResolutionProofs, run, + validateReviewResolutionProof, } = require('../maint71_merge_sync_prs'); const pr = (number, ref, created_at) => ({ @@ -66,6 +73,193 @@ const checkRun = ({ started_at, }); +test('transient delivery holds carry a durable due time and lane', () => { + assert.deepEqual(classifyDeliveryContinuation({ + branch: 'sync/workflows-candidate', + status: 'review_window_pending', + review_window_eligible_at: '2026-08-15T12:07:00Z', + }, '2026-08-15T12:00:00Z'), { + class: 'transient', + lane: 'candidate', + reason: 'review_window_pending', + resume_after: '2026-08-15T12:07:00.000Z', + }); + assert.equal(classifyDeliveryContinuation({ + branch: 'sync/workflows-candidate', + status: 'review_blocked', + }).class, 'actionable'); + assert.equal(classifyDeliveryContinuation({ + branch: 'sync/workflows-delivery', + status: 'merged', + }).class, 'terminal'); + assert.equal(classifyDeliveryContinuation({ + branch: 'sync/workflows-delivery', + status: 'sealed_head_mismatch', + }).class, 'actionable'); + assert.equal(classifyDeliveryContinuation({ + branch: 'sync/workflows-delivery', + status: 'delivery_review_not_started', + }, '2026-08-15T12:00:00Z').resume_after, '2026-08-15T12:10:00.000Z'); +}); + +test('promotion requires complete exact-plan evidence and terminal candidate rows', () => { + const expectedCanaries = ['stranske/Travel', 'stranske/Portable']; + const evidence = { + results: expectedCanaries.map((repo, index) => ({ + repo, + plan_id: 'plan-abc', + source_commit: 'source-abc', + pr: index + 1, + head_sha: `head-${index + 1}`, + required_check_state: 'success', + active_review_thread_count: 0, + })), + }; + const report = { + inputs: { sync_hash: 'candidate' }, + results: expectedCanaries.map((repository, index) => { + const [owner, repo] = repository.split('/'); + return { + owner, + repo, + pr: index + 1, + branch: 'sync/workflows-candidate', + status: index ? 'evidence_recovered' : 'merged', + }; + }), + }; + assert.deepEqual(candidatePromotionDecision({ report, evidence, expectedCanaries }), { + eligible: true, + errors: [], + plan_id: 'plan-abc', + }); + report.results[0].status = 'review_window_pending'; + const blocked = candidatePromotionDecision({ report, evidence, expectedCanaries }); + assert.equal(blocked.eligible, false); + assert.match(blocked.errors.join('\n'), /stranske\/Travel/); +}); + +test('candidate base drift requests a no-filter refresh and stays transient', () => { + const result = { + owner: 'stranske', + repo: 'Travel', + branch: 'sync/workflows-candidate', + status: 'stable_base_refresh_required', + next_command: 'dispatch-maint-68-phase-canary-no-filter', + }; + assert.deepEqual(classifyDeliveryContinuation(result, '2026-08-15T12:00:00Z'), { + class: 'transient', + lane: 'candidate', + reason: 'stable_base_refresh_required', + resume_after: '2026-08-15T12:10:00.000Z', + }); + assert.deepEqual(candidateRefreshDecision({ + report: { + inputs: { sync_hash: 'candidate' }, + results: [result], + }, + }), { + eligible: true, + errors: [], + repositories: ['stranske/Travel'], + }); + assert.equal(candidateRefreshDecision({ + report: { inputs: { sync_hash: 'delivery' }, results: [result] }, + }).eligible, false); +}); + +test('delivery base drift replays only signed exact-plan promotion evidence', () => { + const expectedCanaries = ['stranske/Travel', 'stranske/Portable']; + const evidence = { + schema: 'workflows.consumer-sync-canary-evidence/v1', + results: expectedCanaries.map((repo, index) => ({ + repo, + plan_id: 'plan-abc', + source_commit: 'source-abc', + pr: index + 1, + head_sha: `head-${index + 1}`, + required_check_state: 'success', + active_review_thread_count: 0, + })), + }; + const encoded = Buffer.from(JSON.stringify(evidence), 'utf8').toString('base64'); + assert.deepEqual(parsePromotionEvidenceFromCommitMessage( + `subject\n\nCanary evidence JSON (base64): ${encoded}\n`, + ), evidence); + assert.equal(parsePromotionEvidenceFromCommitMessage( + 'Canary evidence JSON (base64): not-valid-base64', + ), null); + const decision = deliveryRefreshDecision({ + report: { + inputs: { sync_hash: 'delivery' }, + results: [{ + owner: 'stranske', + repo: 'Ready', + branch: 'sync/workflows-delivery', + plan_id: 'plan-abc', + status: 'stable_base_refresh_required', + next_command: 'rerun-maint-68-phase-promote-with-same-evidence', + promotion_evidence: evidence, + }], + }, + expectedCanaries, + }); + assert.equal(decision.eligible, true); + assert.equal(decision.plan_id, 'plan-abc'); + assert.deepEqual(decision.evidence, evidence); + assert.equal(deliveryRefreshDecision({ + report: { inputs: { sync_hash: 'candidate' }, results: [] }, + expectedCanaries, + }).eligible, false); +}); + +test('review resolution proof is exact-head, source-linked, and actor-bound', () => { + const proof = { + schema: 'workflows-sync-review-resolution/v1', + repository: 'stranske/Portable', + pr: 22, + thread_id: 'PRRT_thread', + head_sha: 'head-abc', + source_fix_sha: 'a'.repeat(40), + evidence_url: 'https://github.com/stranske/Workflows/pull/3091', + reason: 'The current generated contract contains the merged source guard.', + }; + assert.deepEqual(parseReviewResolutionProofs(JSON.stringify({ proofs: [proof] })), [proof]); + assert.throws( + () => parseReviewResolutionProofs('{not-json'), + /review resolution proof is not valid JSON/, + ); + assert.deepEqual(validateReviewResolutionProof(proof, { + owner: 'stranske', + repo: 'Portable', + prNumber: 22, + headSha: 'head-abc', + actor: 'stranske-automation-bot', + trustedActors: ['stranske-automation-bot'], + }), { ok: true, errors: [] }); + assert.equal(validateReviewResolutionProof({ + ...proof, + evidence_url: 'https://github.com/stranske/Workflows/pull/not-a-number', + }, { + owner: 'stranske', + repo: 'Portable', + prNumber: 22, + headSha: 'head-abc', + actor: 'stranske-automation-bot', + trustedActors: ['stranske-automation-bot'], + }).ok, false); + const changedHead = validateReviewResolutionProof(proof, { + owner: 'stranske', + repo: 'Portable', + prNumber: 22, + headSha: 'head-new', + actor: 'stranske-automation-bot', + trustedActors: ['stranske-automation-bot'], + }); + assert.equal(changedHead.ok, false); + assert.ok(changedHead.errors.includes('head_mismatch')); +}); + test('maint71 run writes reports and records a no-PR result with fake action clients', async () => { const originalCwd = process.cwd(); const originalEnv = { @@ -801,6 +995,15 @@ test('a sync selector ignores dev-tool deliveries instead of reporting a missing assert.equal(generatedPrsForSyncSelector(generated).length, 2); }); +test('the dev-tool selector cannot be hidden by a newer workflow-sync PR', () => { + const devTool = pr(1, 'deps/sync-dev-versions-wave', '2026-08-15T00:00:00Z'); + const candidate = pr(2, 'sync/workflows-candidate', '2026-08-15T01:00:00Z'); + assert.deepEqual(generatedPrsForSyncSelector([devTool, candidate], 'dev-tool'), [devTool]); + const selection = selectActiveSyncPr([devTool, candidate], 'dev-tool'); + assert.equal(selection.active.number, 1); + assert.equal(selection.missingExpected, false); +}); + test('stable delivery branches and strict branch-update failures are recognized', () => { assert.equal(isStableSyncBranchName('sync/workflows-candidate'), true); assert.equal(isStableSyncBranchName('sync/workflows-delivery'), true); @@ -812,6 +1015,7 @@ test('stable delivery branches and strict branch-update failures are recognized' }), true); assert.equal(isBlockingSyncSystemFailure('pr_refresh_failed'), true); assert.equal(isBlockingSyncSystemFailure('head_commit_unverified'), true); + assert.equal(isBlockingSyncSystemFailure('delivery_promotion_evidence_missing'), true); }); test('workflow sync delivery merge requires a valid cryptographic signature', () => { @@ -1080,6 +1284,7 @@ test('buildMergeReport provides machine-readable summary counts', () => { reviewer_settlement_pending: 0, delivery_review_not_started: 0, delivery_sealed_checks_pending: 0, + delivery_promotion_evidence_missing: 0, sealed_head_mismatch: 0, stable_base_refresh_required: 0, head_changed: 0, @@ -1105,12 +1310,16 @@ test('buildDeliveryHandoff preserves the restart fields for a generated PR', () head_sha: 'abc', delivery_generation: 'g2', delivery_disposition: 'review-blocked', blocker_owner: 'closer', next_command: 'resolve-active-review-threads', status: 'review_blocked', active_review_thread_count: 2, - }), { + }, '2026-08-15T12:00:00Z'), { schema: 'workflows-generated-delivery-handoff/v1', repository: 'stranske/Ready', pr: 11, branch: 'deps/sync-dev-versions-20260801', head_sha: 'abc', delivery_generation: 'g2', lane: 'dev-tool-sync', disposition: 'review-blocked', blocker_owner: 'closer', next_command: 'resolve-active-review-threads', check_state: 'ready', review_state: 'blocked', + continuation: { + class: 'actionable', lane: 'dev-tool', reason: 'review_blocked', resume_after: '', + }, + observed_at: '2026-08-15T12:00:00Z', }); }); @@ -1120,11 +1329,13 @@ test('buildDeliveryHandoff rewrites terminal merge outcomes', () => { head_sha: 'abc', delivery_generation: 'g2', delivery_disposition: 'current', blocker_owner: 'maint-71', next_command: 'merge-current-delivery', status: 'merged', - }), { + }, '2026-08-15T12:00:00Z'), { schema: 'workflows-generated-delivery-handoff/v1', repository: 'stranske/Ready', pr: 11, branch: 'sync/workflows-abc', head_sha: 'abc', delivery_generation: 'g2', lane: 'sync', disposition: 'merged', blocker_owner: 'none', next_command: 'none', check_state: 'ready', review_state: 'clear', + continuation: { class: 'terminal', lane: '', reason: 'merged', resume_after: '' }, + observed_at: '2026-08-15T12:00:00Z', }); assert.equal(buildDeliveryHandoff({ owner: 'stranske', repo: 'Ready', pr: 11, branch: 'sync/workflows-abc', diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index 95409e808..9fc382d34 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -57,6 +57,46 @@ function normalizeReviewPolicy(policy = {}) { }; } +function parseReviewResolutionProofs(raw = '') { + if (!String(raw || '').trim()) return []; + let parsed; + try { + parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch (error) { + throw new Error(`review resolution proof is not valid JSON: ${error.message}`); + } + const proofs = Array.isArray(parsed) ? parsed : parsed?.proofs; + if (!Array.isArray(proofs)) { + throw new Error('review resolution proof must be an array or contain proofs[]'); + } + return proofs; +} + +function validateReviewResolutionProof(proof = {}, { + owner, + repo, + prNumber, + headSha, + actor, + trustedActors = [], +} = {}) { + const errors = []; + if (proof.schema !== 'workflows-sync-review-resolution/v1') errors.push('unsupported_schema'); + if (proof.repository !== `${owner}/${repo}`) errors.push('repository_mismatch'); + if (Number(proof.pr) !== Number(prNumber)) errors.push('pr_mismatch'); + if (String(proof.head_sha || '') !== String(headSha || '')) errors.push('head_mismatch'); + if (!String(proof.thread_id || '').startsWith('PRRT_')) errors.push('invalid_thread_id'); + if (!/^[0-9a-f]{40,64}$/i.test(String(proof.source_fix_sha || ''))) { + errors.push('invalid_source_fix_sha'); + } + if (!/^https:\/\/github\.com\/stranske\/Workflows\/(?:pull\/[1-9]\d*|commit\/[0-9a-f]{40,64})$/i.test( + String(proof.evidence_url || ''), + )) errors.push('invalid_evidence_url'); + if (!String(proof.reason || '').trim()) errors.push('missing_reason'); + if (!new Set(trustedActors).has(String(actor || ''))) errors.push('untrusted_dispatch_actor'); + return { ok: errors.length === 0, errors }; +} + async function collectReviewerEvidence({ owner, repo, @@ -237,6 +277,7 @@ async function run({ github, context, core }) { isStableSyncBranchName, normalizeSyncHash, parseBooleanInput, + parsePromotionEvidenceFromCommitMessage, requiresStrictGateBranchUpdate, requiredContextsFromRulesets, isTrustedGeneratedDeliveryPr, @@ -266,12 +307,13 @@ async function run({ github, context, core }) { true, ); const evidenceOnly = parseBooleanInput(process.env.EVIDENCE_ONLY_INPUT, false); + const resolutionOnly = parseBooleanInput(process.env.RESOLUTION_ONLY_INPUT, false); const candidateEvidenceAuthorized = parseBooleanInput( process.env.CANDIDATE_EVIDENCE_AUTHORIZED, process.env.CANDIDATE_EVIDENCE_RESULT === 'success' && process.env.CANDIDATE_ARTIFACT_RESULT === 'success', ); - const dryRun = evidenceOnly || parseBooleanInput( + const dryRun = resolutionOnly || evidenceOnly || parseBooleanInput( process.env.DRY_RUN_INPUT || (context.payload.client_payload && context.payload.client_payload.dry_run), false, @@ -281,6 +323,19 @@ async function run({ github, context, core }) { (context.payload.client_payload && context.payload.client_payload.cleanup_branches), true, ); + let reviewResolutionProofs = []; + let reviewResolutionProofParseError = ''; + try { + reviewResolutionProofs = parseReviewResolutionProofs( + process.env.REVIEW_RESOLUTION_JSON || '', + ); + } catch (error) { + reviewResolutionProofParseError = error.message || String(error); + core.warning(reviewResolutionProofParseError); + } + const trustedResolutionActors = String( + process.env.TRUSTED_REVIEW_RESOLUTION_ACTORS || 'stranske,stranske-automation-bot', + ).split(',').map((actor) => actor.trim()).filter(Boolean); const retryHelpers = fs.existsSync(retryHelperPath) ? require(retryHelperPath) : { @@ -376,12 +431,163 @@ async function run({ github, context, core }) { ); return { contexts: requiredContexts, source: 'rulesets' }; } + + async function resolveProvenReviewDebt({ owner, repo, pr, deliveryRecord }) { + const matching = reviewResolutionProofs.filter((proof) => + proof?.repository === `${owner}/${repo}` + && Number(proof?.pr) === Number(pr.number), + ); + const resolved = []; + const wouldResolve = []; + const errors = reviewResolutionProofParseError + ? [`payload:${reviewResolutionProofParseError}`] + : []; + for (const proof of matching) { + const validation = validateReviewResolutionProof(proof, { + owner, + repo, + prNumber: pr.number, + headSha: pr.head.sha, + actor: context.actor, + trustedActors: trustedResolutionActors, + }); + if (!validation.ok) { + errors.push(`${proof.thread_id || ''}:${validation.errors.join(',')}`); + continue; + } + const sourceCommit = String(deliveryRecord?.source_commit || ''); + if (!/^[0-9a-f]{40,64}$/i.test(sourceCommit)) { + errors.push(`${proof.thread_id}:delivery_source_commit_missing`); + continue; + } + try { + const evidenceUrl = String(proof.evidence_url || ''); + const commitEvidence = evidenceUrl.match( + /^https:\/\/github\.com\/stranske\/Workflows\/commit\/([0-9a-f]{40,64})$/i, + ); + const pullEvidence = evidenceUrl.match( + /^https:\/\/github\.com\/stranske\/Workflows\/pull\/([1-9]\d*)$/, + ); + if (commitEvidence) { + if (commitEvidence[1].toLowerCase() !== String(proof.source_fix_sha).toLowerCase()) { + errors.push(`${proof.thread_id}:evidence_commit_mismatch`); + continue; + } + await withRetry((client) => client.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: proof.source_fix_sha, + })); + } else if (pullEvidence) { + const pullNumber = Number(pullEvidence[1]); + const { data: sourcePull } = await withRetry((client) => client.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + })); + if (!sourcePull?.merged_at) { + errors.push(`${proof.thread_id}:evidence_pr_not_merged`); + continue; + } + const sourceCommits = await withRetry((client) => client.paginate( + client.rest.pulls.listCommits, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + per_page: 100, + }, + )); + const evidenceShas = new Set([ + sourcePull.merge_commit_sha, + ...(sourceCommits || []).map((commit) => commit.sha), + ].map((sha) => String(sha || '').toLowerCase()).filter(Boolean)); + if (!evidenceShas.has(String(proof.source_fix_sha).toLowerCase())) { + errors.push(`${proof.thread_id}:source_fix_not_in_evidence_pr`); + continue; + } + } else { + errors.push(`${proof.thread_id}:invalid_evidence_url`); + continue; + } + const { data: comparison } = await withRetry((client) => + client.rest.repos.compareCommitsWithBasehead({ + owner: context.repo.owner, + repo: context.repo.repo, + basehead: `${proof.source_fix_sha}...${sourceCommit}`, + }), + ); + if (!['ahead', 'identical'].includes(String(comparison?.status || ''))) { + errors.push(`${proof.thread_id}:source_fix_not_in_delivery_source`); + continue; + } + const data = await withRetry((client) => client.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + headRefOid + reviewThreads(first: 100) { + pageInfo { hasNextPage } + nodes { id isResolved isOutdated } + } + } + } + }`, + { owner, repo, number: pr.number }, + )); + const fresh = data?.repository?.pullRequest; + const threads = fresh?.reviewThreads; + if (fresh?.headRefOid !== pr.head.sha) { + errors.push(`${proof.thread_id}:head_changed`); + continue; + } + if (threads?.pageInfo?.hasNextPage) { + errors.push(`${proof.thread_id}:review_thread_page_truncated`); + continue; + } + const thread = (threads?.nodes || []).find((item) => item.id === proof.thread_id); + if (!thread || thread.isResolved || thread.isOutdated) { + errors.push(`${proof.thread_id}:thread_not_active`); + continue; + } + if (dryRun && !resolutionOnly) { + wouldResolve.push(proof.thread_id); + console.log( + `Would resolve proof-bound review thread ${proof.thread_id} using ` + + `${proof.evidence_url}`, + ); + continue; + } + await withRetry((client) => client.graphql( + `mutation($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { id isResolved } + } + }`, + { threadId: proof.thread_id }, + )); + resolved.push(proof.thread_id); + console.log( + `Resolved proof-bound review thread ${proof.thread_id} using ${proof.evidence_url}`, + ); + } catch (error) { + errors.push(`${proof.thread_id}:${error.message || error}`); + } + } + return { resolved, wouldResolve, errors }; + } // Parse repos from previous step + const excludedRepos = new Set( + String(process.env.EXCLUDED_REPOS_INPUT || 'stranske/Collab-Admin') + .split(',') + .map((repo) => repo.trim()) + .filter(Boolean), + ); const registeredRepos = String(process.env.REGISTERED_REPOS_INPUT || '') .split(',') .map(r => r.trim()) - .filter(Boolean); + .filter((repo) => repo && !excludedRepos.has(repo)); const requestedSyncHash = normalizeSyncHash( process.env.ACTIVE_SYNC_HASH_INPUT || @@ -439,12 +645,13 @@ async function run({ github, context, core }) { ? expectedCanaryRepos : inputRepos === 'all' ? registeredRepos - : inputRepos.split(',').map(r => r.trim()); + : inputRepos.split(',').map(r => r.trim()).filter((repo) => repo && !excludedRepos.has(repo)); console.log(`Registered consumer repos: ${registeredRepos.join(', ')}`); console.log(`Processing repos: ${targetRepos.join(', ')}`); console.log(`Auto-merge: ${autoMerge}, Dry run: ${dryRun}`); console.log(`Evidence only: ${evidenceOnly}`); + console.log(`Resolution only: ${resolutionOnly}`); console.log(`Candidate evidence authorized: ${candidateEvidenceAuthorized}`); console.log( `Reviewer policy: minimum=${minimumReviewerResponses}, ` + @@ -518,14 +725,14 @@ async function run({ github, context, core }) { body, })); if (pr.draft) { - await github.graphql( + 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, @@ -536,6 +743,50 @@ async function run({ github, context, core }) { return { reviewStartedAt, body, dryRun: false }; } + async function restageStableDelivery({ owner, repo, pr, dryRunMode }) { + const body = replaceDeliveryRecord(pr.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, + issue_number: pr.number, + labels: ['sync:delivery-staging'], + })); + try { + await withRetry((client) => client.rest.issues.removeLabel({ + owner, + repo, + issue_number: pr.number, + name: 'sync:delivery-ready', + })); + } catch (labelError) { + if (labelError?.status !== 404) throw labelError; + } + return { body, dryRun: false }; + } + async function sealStableDelivery({ owner, repo, pr, record, settlement, dryRunMode }) { const sealedAt = new Date().toISOString(); const reviewEvidence = { @@ -1102,6 +1353,13 @@ async function run({ github, context, core }) { const checkGateMode = requiredCheckPolicy.source; const failedChecks = classification.failed; const pendingChecks = classification.pending; + let deliveryRecord = parseDeliveryRecord(pr.body || ''); + const reviewResolution = await resolveProvenReviewDebt({ + owner, + repo, + pr, + deliveryRecord, + }); const activeReviewThreads = await activeReviewThreadCount( owner, repo, @@ -1113,7 +1371,6 @@ async function run({ github, context, core }) { activeReviewThreadCount: activeReviewThreads, now: new Date().toISOString(), }); - let deliveryRecord = parseDeliveryRecord(pr.body || ''); const deliveryContext = { owner, repo, @@ -1121,10 +1378,14 @@ async function run({ github, context, core }) { branch: pr.head.ref, head_sha: pr.head.sha, delivery_generation: deliveryRecord?.generation || '', + plan_id: deliveryRecord?.plan_id || '', delivery_lane: generatedDeliveryLane(pr.head.ref), delivery_disposition: deliveryState.disposition, blocker_owner: deliveryState.blocker_owner, next_command: deliveryState.next_command, + review_resolution_thread_ids: reviewResolution.resolved, + review_resolution_would_resolve_thread_ids: reviewResolution.wouldResolve, + review_resolution_errors: reviewResolution.errors, }; console.log( @@ -1270,12 +1531,22 @@ async function run({ github, context, core }) { deliveryRecord.delivery_state !== 'sealed' || deliveryRecord.sealed_head_sha !== pr.head.sha ) { + await restageStableDelivery({ + owner, + repo, + pr, + dryRunMode: dryRun, + }); results.push({ ...deliveryContext, - delivery_disposition: 'awaiting-review-settlement', + delivery_disposition: dryRun + ? 'awaiting-review-settlement' + : 'awaiting-review-start', blocker_owner: 'maint-71', - next_command: 'restage-changed-delivery-head', - status: 'sealed_head_mismatch', + next_command: dryRun + ? 'restage-changed-delivery-head' + : 'rerun-with-auto-merge-to-start-review', + status: dryRun ? 'sealed_head_mismatch' : 'delivery_review_not_started', }); continue; } @@ -1345,44 +1616,39 @@ async function run({ github, context, core }) { if (requiresStrictGateBranchUpdate({ pr, requiredContexts, willMerge })) { try { if (stableDelivery) { - const stagingBody = replaceDeliveryRecord(pr.body || '', { - delivery_state: 'staging', - review_started_at: '', - sealed_at: '', - sealed_head_sha: '', - review_evidence: {}, - }); - await withRetry((client) => client.rest.pulls.update({ + await restageStableDelivery({ owner, repo, - pull_number: pr.number, - body: stagingBody, - })); - if (!pr.draft) { - await github.graphql( - `mutation($id: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $id}) { - pullRequest { id isDraft } - } - }`, - { id: pr.node_id }, + pr, + dryRunMode: false, + }); + let promotionEvidence = null; + if (metadata?.sync_phase === 'promote') { + const { data: signedHead } = await withRetry((client) => + client.rest.repos.getCommit({ + owner, + repo, + ref: pr.head.sha, + }), ); - } - await withRetry((client) => client.rest.issues.addLabels({ - owner, - repo, - issue_number: pr.number, - labels: ['sync:delivery-staging'], - })); - try { - await withRetry((client) => client.rest.issues.removeLabel({ - owner, - repo, - issue_number: pr.number, - name: 'sync:delivery-ready', - })); - } catch (labelError) { - if (labelError?.status !== 404) throw labelError; + const verification = signedHead?.commit?.verification || {}; + promotionEvidence = parsePromotionEvidenceFromCommitMessage( + signedHead?.commit?.message || '', + ); + if ( + verification.verified !== true + || verification.reason !== 'valid' + || !promotionEvidence + ) { + results.push({ + ...deliveryContext, + delivery_disposition: 'awaiting-promotion-evidence', + blocker_owner: 'maint-68', + next_command: 'rerun-phase-promote-from-original-evidence-artifact', + status: 'delivery_promotion_evidence_missing', + }); + continue; + } } results.push({ ...deliveryContext, @@ -1391,6 +1657,7 @@ async function run({ github, context, core }) { next_command: metadata?.sync_phase === 'canary' ? 'dispatch-maint-68-phase-canary-no-filter' : 'rerun-maint-68-phase-promote-with-same-evidence', + promotion_evidence: promotionEvidence, status: 'stable_base_refresh_required', }); continue; @@ -1804,5 +2071,7 @@ module.exports = { collectReviewerEvidence, legacyStatusAsCheck, normalizeReviewPolicy, + parseReviewResolutionProofs, run, + validateReviewResolutionProof, }; diff --git a/.github/scripts/sync_dependency_campaign.js b/.github/scripts/sync_dependency_campaign.js index c0da5cf4d..edb8234f1 100644 --- a/.github/scripts/sync_dependency_campaign.js +++ b/.github/scripts/sync_dependency_campaign.js @@ -363,6 +363,15 @@ function normalizeDeliveryHandoff(record = {}, observedAt = '') { if (!repository || !pr || !headSha || !generation) return null; // Require the full restart/routing contract so durable records can classify exceptions. if (!disposition || !blockerOwner || !nextCommand || !checkState || !reviewState) return null; + const continuationRecord = record.continuation && typeof record.continuation === 'object' + ? record.continuation + : {}; + const continuation = { + class: cleanString(continuationRecord.class), + lane: cleanString(continuationRecord.lane), + reason: cleanString(continuationRecord.reason), + resume_after: cleanString(continuationRecord.resume_after), + }; return { schema: DELIVERY_HANDOFF_SCHEMA, repository, @@ -376,10 +385,48 @@ function normalizeDeliveryHandoff(record = {}, observedAt = '') { next_command: nextCommand, check_state: checkState, review_state: reviewState, + continuation, observed_at: cleanString(observedAt || record.observed_at), }; } +function planMaint71Continuations(records = [], { now = new Date().toISOString() } = {}) { + const nowMs = Date.parse(now); + if (!Number.isFinite(nowMs)) throw new Error(`invalid continuation time: ${now}`); + const handoffs = cleanArray(records) + .map((record) => normalizeDeliveryHandoff(record)) + .filter(Boolean); + const deliveryActive = handoffs.some((record) => + record.continuation.lane === 'delivery' + && record.continuation.class !== 'terminal', + ); + const dueByLane = new Map(); + for (const record of handoffs) { + const continuation = record.continuation; + if (continuation.class !== 'transient') continue; + if (!['candidate', 'delivery', 'dev-tool'].includes(continuation.lane)) continue; + if (deliveryActive && continuation.lane === 'candidate') continue; + const dueMs = Date.parse(continuation.resume_after || record.observed_at); + if (!Number.isFinite(dueMs) || dueMs >= nowMs) continue; + const current = dueByLane.get(continuation.lane); + if (!current || dueMs < Date.parse(current.resume_after)) { + dueByLane.set(continuation.lane, { + lane: continuation.lane, + resume_after: new Date(dueMs).toISOString(), + reason: continuation.reason, + repository: record.repository, + pr: record.pr, + branch: record.branch, + head_sha: record.head_sha, + }); + } + } + const order = deliveryActive + ? ['delivery', 'dev-tool'] + : ['candidate', 'delivery', 'dev-tool']; + return order.map((lane) => dueByLane.get(lane)).filter(Boolean); +} + function mergeDeliveryHandoffs(previous = [], incoming = [], observedAt = '', limit = DEFAULT_MAX_DELIVERY_HANDOFFS) { const byKey = new Map(); for (const record of cleanArray(previous)) { @@ -1766,6 +1813,7 @@ module.exports = { normalizeDeliveryHandoff, paginateWithRetry, parseCampaignMarker, + planMaint71Continuations, replaceCampaignMarker, runCampaign, validateCampaignState, diff --git a/.github/scripts/sync_pr_merge_contract.js b/.github/scripts/sync_pr_merge_contract.js index 75a283984..22df86191 100644 --- a/.github/scripts/sync_pr_merge_contract.js +++ b/.github/scripts/sync_pr_merge_contract.js @@ -5,6 +5,7 @@ const SYNC_BRANCH_PREFIX = 'sync/workflows-'; const SYNC_CANDIDATE_BRANCH = `${SYNC_BRANCH_PREFIX}candidate`; const SYNC_DELIVERY_BRANCH = `${SYNC_BRANCH_PREFIX}delivery`; const DEV_TOOL_SYNC_BRANCH_PREFIX = 'deps/sync-dev-versions-'; +const DEV_TOOL_SYNC_SELECTOR = 'dev-tool'; const GENERATED_DELIVERY_BRANCH_PREFIXES = [SYNC_BRANCH_PREFIX, DEV_TOOL_SYNC_BRANCH_PREFIX]; const POST_PUSH_REVIEW_WINDOW_MS = 7 * 60 * 1000; const { parseDeliveryRecord, mergeEligibility } = require('./sync_pr_lease_contract'); @@ -17,6 +18,19 @@ function normalizeSyncHash(value) { return raw.startsWith(SYNC_BRANCH_PREFIX) ? raw.slice(SYNC_BRANCH_PREFIX.length) : raw; } +function parsePromotionEvidenceFromCommitMessage(message = '') { + const match = String(message || '').match( + /^Canary evidence JSON \(base64\): ([A-Za-z0-9+/]+={0,2})$/m, + ); + if (!match || match[1].length % 4 !== 0) return null; + try { + const parsed = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_) { + return null; + } +} + function syncBranchForHash(syncHash) { const normalized = normalizeSyncHash(syncHash); return normalized ? `${SYNC_BRANCH_PREFIX}${normalized}` : ''; @@ -228,6 +242,9 @@ function isReviewerNonResponseSignal(body = '', nonResponsePatterns = []) { function generatedPrsForSyncSelector(prs = [], syncHash = '') { const normalized = normalizeSyncHash(syncHash); if (!normalized) return prs; + if (normalized === DEV_TOOL_SYNC_SELECTOR) { + return prs.filter((pr) => generatedDeliveryLane(pr?.head?.ref) === 'dev-tool-sync'); + } // A sync hash names a sync/workflows-* branch. Dev-tool deliveries share // Maint 71 but are a separate generated lane; their presence must not turn // an otherwise complete workflow-delivery pass into target_missing. @@ -329,6 +346,7 @@ function requiresStrictGateBranchUpdate({ pr = {}, requiredContexts = [], willMe function isBlockingSyncSystemFailure(status) { return [ 'branch_update_failed', + 'delivery_promotion_evidence_missing', 'error', 'head_commit_unverified', 'merge_failed', @@ -517,6 +535,20 @@ function sortSyncPrs(prs) { function selectActiveSyncPr(prs, syncHash = '') { const ordered = sortSyncPrs(prs); + if (normalizeSyncHash(syncHash) === DEV_TOOL_SYNC_SELECTOR) { + const devToolPrs = ordered.filter( + (pr) => generatedDeliveryLane(pr?.head?.ref) === 'dev-tool-sync', + ); + const active = devToolPrs[devToolPrs.length - 1] || null; + return { + active, + stale: active + ? devToolPrs.filter((pr) => pr.number !== active.number) + : [], + expectedBranch: '', + missingExpected: false, + }; + } const expectedBranch = syncBranchForHash(syncHash); if (!expectedBranch) { const active = ordered[ordered.length - 1] || null; @@ -723,6 +755,7 @@ function summarizeResults(results) { reviewer_settlement_pending: 0, delivery_review_not_started: 0, delivery_sealed_checks_pending: 0, + delivery_promotion_evidence_missing: 0, sealed_head_mismatch: 0, stable_base_refresh_required: 0, head_changed: 0, @@ -798,7 +831,155 @@ function deriveHandoffReviewState(result = {}) { return 'clear'; } -function buildDeliveryHandoff(result = {}) { +function continuationLaneForBranch(value) { + const branch = branchNameFromRef(value); + if (branch === SYNC_CANDIDATE_BRANCH) return 'candidate'; + if (branch === SYNC_DELIVERY_BRANCH) return 'delivery'; + if (branch.startsWith(DEV_TOOL_SYNC_BRANCH_PREFIX)) return 'dev-tool'; + return ''; +} + +function parseResumeAfter(result = {}, observedAt = new Date().toISOString()) { + const explicit = String(result.review_window_eligible_at || '').trim(); + if (Number.isFinite(Date.parse(explicit))) return new Date(explicit).toISOString(); + const nextCommand = String(result.next_command || ''); + const commandMatch = nextCommand.match(/^rerun-after:(.+)$/); + if (commandMatch && Number.isFinite(Date.parse(commandMatch[1]))) { + return new Date(commandMatch[1]).toISOString(); + } + const observed = Number.isFinite(Date.parse(observedAt)) + ? new Date(observedAt) + : new Date(); + const delayMinutes = { + checks_pending: 10, + delivery_sealed_checks_pending: 5, + head_changed: 7, + review_window_pending: 7, + review_window_started: 7, + reviewer_settlement_pending: 7, + stable_base_refresh_required: 10, + }[String(result.status || '')] || 10; + return new Date(observed.getTime() + delayMinutes * 60 * 1000).toISOString(); +} + +function classifyDeliveryContinuation(result = {}, observedAt = new Date().toISOString()) { + const status = String(result.status || ''); + const lane = continuationLaneForBranch(result.branch); + const terminal = new Set(['merged', 'stale_closed', 'evidence_recovered']); + const transient = new Set([ + 'candidate_evidence_required', + 'checks_pending', + 'delivery_review_not_started', + 'delivery_sealed_checks_pending', + 'head_changed', + 'review_window_pending', + 'review_window_started', + 'reviewer_settlement_pending', + 'stable_base_refresh_required', + ]); + if (terminal.has(status)) { + return { class: 'terminal', lane, reason: status, resume_after: '' }; + } + if (lane && transient.has(status)) { + return { + class: 'transient', + lane, + reason: status, + resume_after: parseResumeAfter(result, observedAt), + }; + } + return { class: 'actionable', lane, reason: status || 'unknown', resume_after: '' }; +} + +function candidateRefreshDecision({ report = {} } = {}) { + const errors = []; + if (normalizeSyncHash(report?.inputs?.sync_hash) !== 'candidate') { + errors.push('merge report is not a candidate-selector report'); + } + const repositories = [...new Set((Array.isArray(report?.results) ? report.results : []) + .filter((result) => ( + branchNameFromRef(result.branch) === SYNC_CANDIDATE_BRANCH + && String(result.status || '') === 'stable_base_refresh_required' + && String(result.next_command || '') === 'dispatch-maint-68-phase-canary-no-filter' + )) + .map((result) => `${result.owner || ''}/${result.repo || ''}`.replace(/^\//, '')) + .filter(Boolean))].sort(); + if (repositories.length === 0) { + errors.push('candidate report has no stable base refresh request'); + } + return { + eligible: errors.length === 0, + errors, + repositories, + }; +} + +function deliveryRefreshDecision({ report = {}, expectedCanaries = [] } = {}) { + const errors = []; + if (normalizeSyncHash(report?.inputs?.sync_hash) !== 'delivery') { + errors.push('merge report is not a delivery-selector report'); + } + const requests = (Array.isArray(report?.results) ? report.results : []).filter((result) => ( + branchNameFromRef(result.branch) === SYNC_DELIVERY_BRANCH + && String(result.status || '') === 'stable_base_refresh_required' + && String(result.next_command || '') === 'rerun-maint-68-phase-promote-with-same-evidence' + )); + if (requests.length === 0) errors.push('delivery report has no stable base refresh request'); + const firstEvidence = requests[0]?.promotion_evidence || null; + const validation = validateCanaryEvidence( + Array.isArray(firstEvidence) ? firstEvidence : firstEvidence?.results, + expectedCanaries, + ); + errors.push(...validation.errors); + for (const request of requests) { + if (String(request.plan_id || '') !== validation.plan_id) { + errors.push(`${request.owner || ''}/${request.repo || ''}: delivery plan mismatch`); + } + const requestEvidence = request.promotion_evidence || null; + const requestValidation = validateCanaryEvidence( + Array.isArray(requestEvidence) ? requestEvidence : requestEvidence?.results, + expectedCanaries, + ); + if (!requestValidation.ok || requestValidation.plan_id !== validation.plan_id) { + errors.push(`${request.owner || ''}/${request.repo || ''}: promotion evidence mismatch`); + } + } + return { + eligible: requests.length > 0 && validation.ok && errors.length === 0, + errors, + plan_id: validation.plan_id || '', + evidence: firstEvidence, + repositories: requests + .map((result) => `${result.owner || ''}/${result.repo || ''}`.replace(/^\//, '')) + .filter(Boolean) + .sort(), + }; +} + +function candidatePromotionDecision({ report = {}, evidence = {}, expectedCanaries = [] } = {}) { + const rows = Array.isArray(evidence) ? evidence : evidence.results; + const validation = validateCanaryEvidence(rows, expectedCanaries); + const errors = [...validation.errors]; + if (normalizeSyncHash(report?.inputs?.sync_hash) !== 'candidate') { + errors.push('merge report is not a candidate-selector report'); + } + const results = Array.isArray(report?.results) ? report.results : []; + for (const repository of expectedCanaries) { + const terminal = results.some((result) => + `${result.owner || ''}/${result.repo || ''}`.replace(/^\//, '') === repository + && branchNameFromRef(result.branch) === SYNC_CANDIDATE_BRANCH + && ['merged', 'evidence_recovered'].includes(String(result.status || '')), + ); + if (!terminal) errors.push(`${repository}: candidate was not merged or recovered`); + } + return { + eligible: validation.ok && errors.length === 0, + errors, + plan_id: validation.plan_id || '', + }; +} + +function buildDeliveryHandoff(result = {}, observedAt = new Date().toISOString()) { if (!result.pr) return null; const status = String(result.status || ''); // Branch-delete rows are companions to the merged row; emit one terminal handoff only. @@ -835,6 +1016,8 @@ function buildDeliveryHandoff(result = {}) { return null; } + const continuation = classifyDeliveryContinuation(result, observedAt); + return { schema: 'workflows-generated-delivery-handoff/v1', repository: `${result.owner || ''}/${result.repo || ''}`.replace(/^\//, ''), @@ -848,6 +1031,8 @@ function buildDeliveryHandoff(result = {}) { next_command: nextCommand, check_state: checkState, review_state: reviewState, + continuation, + observed_at: observedAt, }; } @@ -876,7 +1061,9 @@ function buildMergeReport({ }, summary: summarizeResults(results), results, - handoff_records: (results || []).map(buildDeliveryHandoff).filter(Boolean), + handoff_records: (results || []) + .map((result) => buildDeliveryHandoff(result, generatedAt)) + .filter(Boolean), }; } @@ -914,6 +1101,7 @@ module.exports = { SYNC_CANDIDATE_BRANCH, SYNC_DELIVERY_BRANCH, DEV_TOOL_SYNC_BRANCH_PREFIX, + DEV_TOOL_SYNC_SELECTOR, GENERATED_DELIVERY_BRANCH_PREFIXES, branchNameFromRef, classifyGeneratedPr, @@ -940,6 +1128,7 @@ module.exports = { normalizeSyncHash, syncBranchForHash, parseBooleanInput, + parsePromotionEvidenceFromCommitMessage, requiredContextsFromRulesets, rulesetRefPatternMatches, selectSyncPrGatingChecks, @@ -952,5 +1141,10 @@ module.exports = { summarizeResults, buildMergeReport, buildDeliveryHandoff, + candidateRefreshDecision, + deliveryRefreshDecision, + candidatePromotionDecision, + classifyDeliveryContinuation, + continuationLaneForBranch, buildMarkdownSummary, }; diff --git a/.github/workflows/agents-pr-meta-v4.yml b/.github/workflows/agents-pr-meta-v4.yml index a4bd110c6..44810c56e 100644 --- a/.github/workflows/agents-pr-meta-v4.yml +++ b/.github/workflows/agents-pr-meta-v4.yml @@ -7,7 +7,11 @@ on: issue_comment: types: [created] pull_request: - types: [opened, synchronize, reopened, edited] + # Do not listen for `edited`: this workflow writes the PR body itself. An + # edited-body wake can observe a newly-created metadata-only check (for + # example the dependency-repair contract), write its new run URL back into + # the body, and create an unbounded self-trigger loop. + types: [opened, synchronize, reopened] workflow_run: workflows: [Gate] types: [completed] diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index fb02d5fe5..d2b6ef122 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -1,4 +1,5 @@ name: Maint 68 Sync Consumer Repos +run-name: Maint 68 ${{ inputs.phase || 'canary' }} ${{ inputs.delivery_scope || 'auto' }} # Push workflow template updates to registered consumer repositories # @@ -428,6 +429,7 @@ jobs: SCOPE_BASE_SHA: ${{ needs.prepare.outputs.scope_base_sha }} PLAN_SOURCE_COMMIT: ${{ needs.prepare.outputs.source_commit }} SYNC_PHASE: ${{ needs.prepare.outputs.phase }} + CANARY_EVIDENCE_JSON: ${{ inputs.canary_evidence_json || '' }} DURABLE_ISSUE_URL: https://github.com/stranske/Workflows/issues/1836 steps: - name: Compute result artifact name @@ -1198,6 +1200,15 @@ jobs: rm -f "${commit_message_file:-}" "${signed_result_file:-}" } trap cleanup_signed_commit EXIT + promotion_evidence_line="" + if [ "$SYNC_PHASE" = "promote" ]; then + if ! jq -e . >/dev/null 2>&1 <<<"$CANARY_EVIDENCE_JSON"; then + echo "::error::Promotion evidence is missing or invalid" + exit 1 + fi + promotion_evidence_base64=$(printf '%s' "$CANARY_EVIDENCE_JSON" | base64 -w 0) + promotion_evidence_line="Canary evidence JSON (base64): $promotion_evidence_base64" + fi cat >"$commit_message_file" <- + ${{ + always() && + needs.prepare.result == 'success' && + needs.sync.result == 'success' && + needs.prepare.outputs.has_plan_items == 'true' && + (inputs.dry_run || false) != true && + needs.prepare.outputs.phase != 'preview' + }} + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Checkout API retry helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/actions/setup-api-client + .github/scripts + sparse-checkout-cone-mode: false + + - name: Setup API client + uses: ./.github/actions/setup-api-client + with: + github_token: ${{ github.token }} + owner_pr_pat: ${{ secrets.OWNER_PR_PAT }} + service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} + + - name: Dispatch Maint 71 for the generated lane + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + SYNC_PHASE: ${{ needs.prepare.outputs.phase }} + with: + github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + script: | + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' + ); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'maint-68-continue-generated-delivery', + capabilities: ['actions:write'], + }); + const phase = process.env.SYNC_PHASE; + const activeSyncHash = phase === 'canary' ? 'candidate' : 'delivery'; + const nonAdminRepos = String(process.env.REGISTERED_CONSUMER_REPOS || '') + .split(/\s+/) + .map((repo) => repo.trim()) + .filter((repo) => repo && repo !== 'stranske/Collab-Admin'); + const inputs = { + active_sync_hash: activeSyncHash, + auto_merge: 'true', + dry_run: 'false', + cleanup_branches: 'true', + }; + if (activeSyncHash === 'delivery') inputs.repos = nonAdminRepos.join(','); + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-71-merge-sync-prs.yml', + ref: context.payload.repository?.default_branch || 'main', + inputs, + })); + core.notice(`Dispatched Maint 71 ${activeSyncHash} reconciliation`); + # ============================================================================ # FAILURE NOTIFICATION: Create issue when sync fails # ============================================================================ diff --git a/.github/workflows/maint-71-merge-sync-prs.yml b/.github/workflows/maint-71-merge-sync-prs.yml index 92a7828b9..9578c401f 100644 --- a/.github/workflows/maint-71-merge-sync-prs.yml +++ b/.github/workflows/maint-71-merge-sync-prs.yml @@ -16,6 +16,11 @@ # - Uses merge commit (not squash) to preserve sync history name: Merge Sync PRs +run-name: >- + Merge Sync PRs + [${{ inputs.active_sync_hash || + github.event.client_payload.active_sync_hash || + 'unscoped' }}] on: schedule: @@ -45,7 +50,8 @@ on: type: boolean default: false active_sync_hash: - description: "Active sync selector (candidate or delivery for stable lanes; legacy hash accepted)" + description: >- + Active sync selector (candidate or delivery for stable lanes; legacy hash accepted) required: false type: string default: "" @@ -59,6 +65,11 @@ on: required: false type: boolean default: true + review_resolution_json: + description: "Exact-head source-fix proofs for specific review threads" + required: false + type: string + default: "" workflow_call: inputs: repos: @@ -77,7 +88,8 @@ on: type: boolean default: false active_sync_hash: - description: "Active sync selector (candidate or delivery for stable lanes; legacy hash accepted)" + description: >- + Active sync selector (candidate or delivery for stable lanes; legacy hash accepted) required: false type: string default: "" @@ -91,6 +103,11 @@ on: required: false type: boolean default: true + review_resolution_json: + description: "Exact-head source-fix proofs for specific review threads" + required: false + type: string + default: "" permissions: checks: read @@ -99,23 +116,35 @@ permissions: statuses: read concurrency: - group: merge-sync-prs-${{ github.repository }}-${{ github.ref }} - cancel-in-progress: true + group: >- + merge-sync-prs-${{ github.repository }}-${{ + inputs.active_sync_hash || + github.event.client_payload.active_sync_hash || + 'unscoped' + }} + cancel-in-progress: false jobs: merge_sync_prs: runs-on: ubuntu-latest + permissions: + actions: write + checks: read + contents: write + pull-requests: write + statuses: read steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup API client uses: ./.github/actions/setup-api-client with: - secrets: ${{ toJSON(secrets) }} github_token: ${{ github.token }} - - + owner_pr_pat: ${{ secrets.OWNER_PR_PAT }} + service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} - name: Extract consumer repos from sync workflow id: repos @@ -146,9 +175,39 @@ jobs: fs.appendFileSync(process.env.GITHUB_OUTPUT, `candidate=${selector === 'candidate'}\n`); NODE + - name: Apply proof-bound candidate review resolutions + id: candidate_review_resolution + if: >- + ${{ + steps.candidate_mode.outputs.candidate == 'true' && + inputs.review_resolution_json != '' + }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + REPOS_INPUT: all + AUTO_MERGE_INPUT: "false" + DRY_RUN_INPUT: "true" + ACTIVE_SYNC_HASH_INPUT: ${{ steps.candidate_mode.outputs.selector }} + CLEANUP_BRANCHES_INPUT: "false" + EVIDENCE_ONLY_INPUT: "false" + RESOLUTION_ONLY_INPUT: "true" + CONSUMER_SYNC_CANARIES_PATH: config/consumer_sync_canaries.json + CONSUMER_SYNC_REVIEW_POLICY_PATH: config/consumer_sync_review_policy.json + TRUSTED_SYNC_ACTORS: stranske,stranske-automation-bot,github-actions[bot] + TRUSTED_REVIEW_RESOLUTION_ACTORS: stranske,stranske-automation-bot + REVIEW_RESOLUTION_JSON: ${{ inputs.review_resolution_json }} + EXCLUDED_REPOS_INPUT: stranske/Collab-Admin + SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-review-resolution-report.json + REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} + with: + github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + script: | + const { run } = require("./.github/scripts/maint71_merge_sync_prs.js"); + await run({ github, context, core }); + - name: Collect and validate canary evidence before merge id: candidate_evidence - if: ${{ steps.candidate_mode.outputs.candidate == 'true' }} + if: ${{ always() && steps.candidate_mode.outputs.candidate == 'true' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: REPOS_INPUT: ${{ inputs.repos || github.event.client_payload.repos || 'all' }} @@ -160,6 +219,9 @@ jobs: CONSUMER_SYNC_CANARIES_PATH: config/consumer_sync_canaries.json CONSUMER_SYNC_REVIEW_POLICY_PATH: config/consumer_sync_review_policy.json TRUSTED_SYNC_ACTORS: stranske,stranske-automation-bot,github-actions[bot] + TRUSTED_REVIEW_RESOLUTION_ACTORS: stranske,stranske-automation-bot + REVIEW_RESOLUTION_JSON: ${{ inputs.review_resolution_json || '' }} + EXCLUDED_REPOS_INPUT: stranske/Collab-Admin SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-premerge-report.json REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} with: @@ -179,7 +241,42 @@ jobs: artifacts/sync-canary-evidence.json if-no-files-found: error + - name: Validate complete pre-merge canary evidence + id: candidate_evidence_validation + if: >- + ${{ + always() && + steps.candidate_mode.outputs.candidate == 'true' && + steps.candidate_evidence.outcome == 'success' && + steps.candidate_artifact.outcome == 'success' + }} + run: | + node <<'NODE' + const fs = require('fs'); + const { + validateCanaryEvidence, + } = require('./.github/scripts/sync_pr_merge_contract.js'); + const evidence = JSON.parse( + fs.readFileSync('artifacts/sync-canary-evidence.json', 'utf8'), + ); + const canaryConfig = JSON.parse( + fs.readFileSync('config/consumer_sync_canaries.json', 'utf8'), + ); + const expectedCanaries = (canaryConfig.canaries || []) + .map((entry) => String(entry.repo || '').trim()) + .filter(Boolean); + const rows = Array.isArray(evidence) ? evidence : evidence.results; + const validation = validateCanaryEvidence(rows, expectedCanaries); + if (!validation.ok) { + throw new Error( + `Pre-merge canary evidence is incomplete: ${validation.errors.join(', ')}`, + ); + } + console.log(`Validated complete pre-merge evidence for ${validation.plan_id}`); + NODE + - name: Check and merge sync PRs + id: merge if: >- ${{ always() && @@ -198,12 +295,15 @@ jobs: AUTO_MERGE_INPUT: ${{ inputs.auto_merge }} DRY_RUN_INPUT: ${{ inputs.dry_run }} ACTIVE_SYNC_HASH_INPUT: ${{ steps.candidate_mode.outputs.selector }} - CANDIDATE_EVIDENCE_RESULT: ${{ steps.candidate_evidence.outcome }} + CANDIDATE_EVIDENCE_RESULT: ${{ steps.candidate_evidence_validation.outcome }} CANDIDATE_ARTIFACT_RESULT: ${{ steps.candidate_artifact.outcome }} CLEANUP_BRANCHES_INPUT: ${{ inputs.cleanup_branches }} CONSUMER_SYNC_CANARIES_PATH: config/consumer_sync_canaries.json CONSUMER_SYNC_REVIEW_POLICY_PATH: config/consumer_sync_review_policy.json TRUSTED_SYNC_ACTORS: stranske,stranske-automation-bot,github-actions[bot] + TRUSTED_REVIEW_RESOLUTION_ACTORS: stranske,stranske-automation-bot + REVIEW_RESOLUTION_JSON: ${{ inputs.review_resolution_json || '' }} + EXCLUDED_REPOS_INPUT: stranske/Collab-Admin SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-merge-report.json REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} with: @@ -221,3 +321,229 @@ jobs: artifacts/sync-pr-merge-report.json artifacts/sync-canary-evidence.json if-no-files-found: warn + + - name: Refresh stale candidate bases + id: refresh_candidate + if: >- + ${{ + always() && + steps.candidate_mode.outputs.candidate == 'true' && + steps.merge.outcome == 'success' + }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + script: | + const fs = require('fs'); + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' + ); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'maint-71-refresh-stale-candidate-bases', + capabilities: ['actions:write'], + }); + const { + candidateRefreshDecision, + } = require('./.github/scripts/sync_pr_merge_contract.js'); + const report = JSON.parse( + fs.readFileSync('artifacts/sync-pr-merge-report.json', 'utf8'), + ); + const decision = candidateRefreshDecision({ report }); + if (!decision.eligible) { + core.notice(`Candidate refresh not required: ${decision.errors.join(', ')}`); + return; + } + const { data: runs } = await withRetry((client) => + client.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + per_page: 20, + }), + ); + const activeSync = (runs.workflow_runs || []).find((run) => + ['queued', 'in_progress', 'waiting', 'pending'].includes(run.status) + && ( + String(run.display_title || '').includes('Maint 68 canary') + || String(run.display_title || '').includes('Maint 68 promote') + ), + ); + if (activeSync) { + core.notice( + `Maint 68 ${activeSync.id} is already active; candidate refresh remains queued`, + ); + return; + } + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + ref: context.payload.repository?.default_branch || 'main', + inputs: { + phase: 'canary', + delivery_scope: 'full', + dry_run: 'false', + force: 'false', + }, + })); + core.notice( + `Dispatched no-filter canary refresh for ${decision.repositories.join(', ')}`, + ); + + - name: Refresh stale delivery bases + id: refresh_delivery + if: >- + ${{ + always() && + steps.candidate_mode.outputs.selector == 'delivery' && + steps.merge.outcome == 'success' + }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + script: | + const fs = require('fs'); + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' + ); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'maint-71-refresh-stale-delivery-bases', + capabilities: ['actions:write'], + }); + const { + deliveryRefreshDecision, + } = require('./.github/scripts/sync_pr_merge_contract.js'); + const report = JSON.parse( + fs.readFileSync('artifacts/sync-pr-merge-report.json', 'utf8'), + ); + const canaryConfig = JSON.parse( + fs.readFileSync('config/consumer_sync_canaries.json', 'utf8'), + ); + const expectedCanaries = (canaryConfig.canaries || []) + .map((entry) => String(entry.repo || '').trim()) + .filter(Boolean); + const decision = deliveryRefreshDecision({ report, expectedCanaries }); + if (!decision.eligible) { + core.notice(`Delivery refresh not required: ${decision.errors.join(', ')}`); + return; + } + const { data: runs } = await withRetry((client) => + client.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + per_page: 20, + }), + ); + const activeSync = (runs.workflow_runs || []).find((run) => + ['queued', 'in_progress', 'waiting', 'pending'].includes(run.status) + && ( + String(run.display_title || '').includes('Maint 68 canary') + || String(run.display_title || '').includes('Maint 68 promote') + ), + ); + if (activeSync) { + core.notice( + `Maint 68 ${activeSync.id} is already active; delivery refresh remains queued`, + ); + return; + } + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + ref: context.payload.repository?.default_branch || 'main', + inputs: { + phase: 'promote', + canary_evidence_json: JSON.stringify(decision.evidence), + delivery_scope: 'auto', + dry_run: 'false', + force: 'false', + }, + })); + core.notice( + `Replayed exact-plan promotion ${decision.plan_id} for ` + + `${decision.repositories.join(', ')}`, + ); + + - name: Promote complete exact-plan canary evidence + id: promote + if: >- + ${{ + always() && + steps.candidate_mode.outputs.candidate == 'true' && + steps.candidate_evidence_validation.outcome == 'success' && + steps.candidate_artifact.outcome == 'success' && + steps.merge.outcome == 'success' + }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + script: | + const fs = require('fs'); + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' + ); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'maint-71-promote-canary-evidence', + capabilities: ['actions:write'], + }); + const { + candidatePromotionDecision, + } = require('./.github/scripts/sync_pr_merge_contract.js'); + const report = JSON.parse( + fs.readFileSync('artifacts/sync-pr-merge-report.json', 'utf8'), + ); + const evidence = JSON.parse( + fs.readFileSync('artifacts/sync-canary-evidence.json', 'utf8'), + ); + const canaryConfig = JSON.parse( + fs.readFileSync('config/consumer_sync_canaries.json', 'utf8'), + ); + const expectedCanaries = (canaryConfig.canaries || []) + .map((entry) => String(entry.repo || '').trim()) + .filter(Boolean); + const decision = candidatePromotionDecision({ report, evidence, expectedCanaries }); + if (!decision.eligible) { + core.notice(`Promotion remains held: ${decision.errors.join(', ')}`); + return; + } + const { data: runs } = await withRetry((client) => + client.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + per_page: 20, + }), + ); + const activePromotion = (runs.workflow_runs || []).find((run) => + ['queued', 'in_progress', 'waiting', 'pending'].includes(run.status) + && String(run.display_title || '').includes('Maint 68 promote'), + ); + if (activePromotion) { + core.notice(`Exact-plan promotion already active in run ${activePromotion.id}`); + return; + } + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-68-sync-consumer-repos.yml', + ref: context.payload.repository?.default_branch || 'main', + inputs: { + phase: 'promote', + canary_evidence_json: JSON.stringify(evidence), + delivery_scope: 'auto', + dry_run: 'false', + force: 'false', + }, + })); + core.notice(`Dispatched exact-plan promotion for ${decision.plan_id}`); diff --git a/.github/workflows/maint-82-sync-dependency-campaign.yml b/.github/workflows/maint-82-sync-dependency-campaign.yml index 394cac707..3f1bcc2bb 100644 --- a/.github/workflows/maint-82-sync-dependency-campaign.yml +++ b/.github/workflows/maint-82-sync-dependency-campaign.yml @@ -6,9 +6,9 @@ name: Sync/Dependency Campaign on: schedule: - # Remote discovery runs on GitHub capacity. The local watcher only starts - # Codex when the campaign issue has a needs-local-codex queue item. - - cron: "17 */6 * * *" + # The campaign marker is also Maint 71's durable continuation queue. This + # bounded pass wakes due transient lanes even when no new GitHub event fires. + - cron: "*/10 * * * *" # A focused pass after the usual weekly Dependabot update window. - cron: "30 10 * * 1" workflow_dispatch: @@ -44,11 +44,25 @@ jobs: campaign: name: Refresh campaign queue runs-on: ubuntu-latest + permissions: + actions: write + contents: read + issues: write + pull-requests: read env: CAMPAIGN_TOKEN: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} steps: - name: Checkout uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup API client + uses: ./.github/actions/setup-api-client + with: + github_token: ${{ github.token }} + owner_pr_pat: ${{ secrets.OWNER_PR_PAT }} + service_bot_pat: ${{ secrets.SERVICE_BOT_PAT }} - name: Load registered consumer repos id: registered @@ -104,6 +118,7 @@ jobs: script: | const { formatCampaignRunSummaryMarkdown, + planMaint71Continuations, runCampaign, } = require('./.github/scripts/sync_dependency_campaign.js'); const fs = require('fs'); @@ -132,6 +147,10 @@ jobs: currentSyncHash, deliveryHandoffRecords, }); + const continuations = planMaint71Continuations(result.state.delivery_handoffs, { + now: new Date().toISOString(), + }); + core.setOutput('maint71_continuations', JSON.stringify(continuations)); const stats = result.state.stats || {}; fs.writeFileSync( @@ -168,6 +187,80 @@ jobs: ]) .write(); + - name: Dispatch due Maint 71 continuations + if: ${{ steps.inputs.outputs.dry_run != 'true' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + CONTINUATIONS_JSON: ${{ steps.campaign.outputs.maint71_continuations || '[]' }} + REGISTERED_REPOS: ${{ steps.registered.outputs.repos }} + with: + github-token: ${{ env.CAMPAIGN_TOKEN }} + script: | + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' + ); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + env: process.env, + task: 'maint-82-dispatch-generated-continuations', + capabilities: ['actions:write'], + }); + const continuations = JSON.parse(process.env.CONTINUATIONS_JSON || '[]'); + const nonAdminRepos = String(process.env.REGISTERED_REPOS || '') + .split(',') + .map((repo) => repo.trim()) + .filter((repo) => repo && repo !== 'stranske/Collab-Admin'); + const { data: runs } = await withRetry((client) => + client.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-71-merge-sync-prs.yml', + per_page: 40, + }), + ); + const activeTitles = new Set( + (runs.workflow_runs || []) + .filter((run) => + ['queued', 'in_progress', 'waiting', 'pending'].includes(run.status), + ) + .map((run) => String(run.display_title || '')), + ); + for (const continuation of continuations) { + const selector = continuation.lane; + const title = `Merge Sync PRs [${selector}]`; + if ( + selector === 'candidate' + && activeTitles.has('Merge Sync PRs [delivery]') + ) { + core.notice('Maint 71 candidate lane held while delivery is active'); + continue; + } + if (activeTitles.has(title)) { + core.notice(`Maint 71 ${selector} lane is already active`); + continue; + } + const inputs = { + auto_merge: 'true', + dry_run: 'false', + cleanup_branches: 'true', + }; + inputs.active_sync_hash = selector; + if (selector !== 'candidate') inputs.repos = nonAdminRepos.join(','); + await withRetry((client) => client.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'maint-71-merge-sync-prs.yml', + ref: context.payload.repository?.default_branch || 'main', + inputs, + })); + activeTitles.add(title); + core.notice( + `Dispatched due Maint 71 ${selector} continuation for ` + + `${continuation.repository}#${continuation.pr} (${continuation.reason})`, + ); + } + - name: Upload campaign state if: ${{ always() }} uses: actions/upload-artifact@v7 diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index c3094b716..f7627debc 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -127,7 +127,11 @@ tree or signature does not match the staged delivery. A reviewer capacity outage cannot require responses from every configured bot or keep the PR open indefinitely. Maint 71 requires one substantive response when available; a successful status whose own description says the review was skipped or not performed is recorded -as unavailable, not as reviewer quorum. +as unavailable, not as reviewer quorum. Generated-branch Gate completions wake +Maint 71 immediately; the durable campaign queue supplies the timed fallback for +review windows and pending checks. Complete exact-plan candidate evidence starts +promotion automatically, while active review findings remain held until resolved +or covered by an authenticated exact-head Workflows source-fix proof. ### Method 3: Hybrid Approach @@ -798,7 +802,7 @@ curl -sL https://raw.githubusercontent.com/stranske/Workflows/main/templates/con | `ci.yml` | Python CI (lint, format, tests, typecheck) | push, PR | | `agents-issue-intake.yml` | Assigns Codex/Copilot to issues | issue labeled `agent:codex` | | `agents-80-pr-event-hub.yml` | Handles PR event routing, keepalive metadata, bot comments, and verification follow-ups | PR events and comments | -| `agents-81-gate-followups.yml` | Coordinates Gate follow-ups, keepalive continuation, and autofix recovery | Gate completion and follow-up events | +| `agents-81-gate-followups.yml` | Coordinates Gate follow-ups, keepalive/autofix recovery, and generated-delivery wakeups | Gate completion and follow-up events | | `agents-verifier.yml` | Runs label-driven post-merge verification | manual dispatch, `verify:*` labels | | `autofix.yml` | Auto-fixes lint/format issues | PR sync, `autofix` label | | `pr-00-gate.yml` | Required PR gate and summary status | PR | @@ -918,7 +922,9 @@ Without this workflow, Codex PRs will stall after the first round. #### `agents-81-gate-followups.yml` This is the current Gate follow-up hub. It coordinates keepalive continuation, -autofix recovery, and post-Gate actions after `pr-00-gate.yml` completes. +autofix recovery, and post-Gate actions after `pr-00-gate.yml` completes. A Gate +completion on a stable consumer-sync or dev-tool-sync branch also dispatches the +matching central Maint 71 lane; it never merges the generated PR locally. #### `autofix.yml` diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 3f70d017d..b451b7d49 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -59,12 +59,12 @@ _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, and promotion reconstructs that immutable source-delta scope 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. 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 sync PRs. Scheduled reconciliation uses the full manifest; bounded source repairs may instead select only manifest entries changed across an exact base/head range, and promotion reconstructs that immutable source-delta scope 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 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-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. - **`maint-71-auto-fix-integration.yml`** — Auto-triggered integration fixer that watches “Integration CI failed” issues/comments, re-runs the formatting routine, and pushes via PAT when available (otherwise posting a skipped note). -- **`maint-71-merge-sync-prs.yml`** — Scans each registered consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence is persisted and validated before the irreversible candidate merge, with bounded recovery for trusted candidates already merged by an older controller. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets. +- **`maint-71-merge-sync-prs.yml`** — Scans each registered non-admin consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. A specific thread may be resolved only from an authenticated exact-head proof that names the merged Workflows source fix and whose source commit is contained in the delivery. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence is persisted and validated before the irreversible candidate merge; once every candidate is merged or safely recovered, Maint 71 dispatches `phase=promote` with that exact evidence. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets. - **`maint-72-fix-pr-body-conflicts.yml`** — Periodically removes stray `pr_body.md` files from consumer repos and ensures `.gitignore` blocks them, reusing the registered-repo helper + PAT discovery so cleanups only run when push access is available. - **`maint-74-ledger-base-sync.yml`** — Keeps `.agents` ledger base entries aligned with the repo’s default branch by running `scripts/ledger_migrate_base.py` and opening a helper PR (no extra App token mint needed). - **`maint-auto-update-pypi-versions.yml`** — Monday 03:00 UTC canonical source lane that batches routine PyPI pin updates into one mutable source PR; an explicit security override may run outside that window. @@ -116,7 +116,7 @@ _Inline Gate helper_ - **`agents-issue-optimizer.yml`** — Powers the analyzer/apply/format stages for legacy agent issues. It fires on the `agents:*` optimizer labels or via manual dispatch, re-reads live issue eligibility before format work, skips held, exempt, closed, bot-authored, and `agents:auto-pilot` work, releases the `agents:format` lease when that recheck makes formatting ineligible, validates formatted path evidence against the current checkout, enforces recursion guards via the GitHub CLI, and runs the LangChain optimizer before posting suggestions or applying changes. It now uses only the shared API client (no manual App-token mint) for GH CLI/auth flows. - **`agents-80-pr-event-hub.yml`** — Consumer-template consolidated PR event hub that fans out keepalive metadata, bot-comment handling, and verification follow-ups after a single PR context fetch. - **`agents-81-gate-followups.yml`** — Consumer-template consolidated Gate follow-up hub that coordinates keepalive, autofix, and post-CI recovery. -- **`agents-pr-meta-v4.yml`** — Workflows-repo PR metadata/keepalive front door: listens to issue comments, PR updates, and Gate completions to detect `@agent` activations, enforce gate/run-cap rules, dispatch the orchestrator, and write dispatch summaries. It leaves release-please PR bodies untouched so release-please can parse merged release PRs and publish tags/releases. This remains a Workflows-local service workflow; the current consumer default is the `agents-80-pr-event-hub.yml` / `agents-81-gate-followups.yml` pair distributed from `templates/consumer-repo/`. +- **`agents-pr-meta-v4.yml`** — Workflows-repo PR metadata/keepalive front door: listens to issue comments, structural PR updates (open, synchronize, and reopen), and Gate completions to detect `@agent` activations, enforce gate/run-cap rules, dispatch the orchestrator, and write dispatch summaries. It deliberately ignores PR-body edit events because it writes that body itself; observing its own edits can create an unbounded metadata-check loop. It leaves release-please PR bodies untouched so release-please can parse merged release PRs and publish tags/releases. This remains a Workflows-local service workflow; the current consumer default is the `agents-80-pr-event-hub.yml` / `agents-81-gate-followups.yml` pair distributed from `templates/consumer-repo/`. - **`agents-verifier.yml`** — Label-driven verification runner. When a merged PR gets `verify:*` (or when dispatched manually), it routes the request through the reusable verifier workflow to run checkbox/evaluate/compare modes, posts the structured summary, and opens follow-up issues on failures. It now mints a GitHub App token before checking out the caller repo/Workflows scripts so cross-repo verification works under the service account while still falling back to the installation token when App credentials are missing, and it explicitly waits for `pr-00-gate.yml`, `pr-11-ci-smoke.yml`, and `selftest-ci.yml` to finish so the verifier never outruns Workflows’ CI set. - **`agents-verify-to-issue-v2.yml`** — Converts verification feedback into an agent-ready follow-up issue whenever `verify:create-issue` is applied to a merged PR. It gathers the verification comments, original issue context, and PR metadata, runs LangChain templates, and opens a structured issue using the appropriate PAT/App token so ownership stays consistent. - **`agents-verify-to-new-pr.yml`** — Handles the `verify:create-new-pr` label end-to-end: collects verification comments, reconstructs the original issue context, creates a follow-up issue (carrying over the `agent:*` label plus a `runner:` override), and now dispatches `agents-auto-pilot.yml` (optimize step) inline so automation continues without a separate bridge workflow. diff --git a/docs/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index ef4860267..20846e0b6 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -148,7 +148,7 @@ Consumer default note: `agents-pr-meta-v4.yml` is a Workflows-repo service workf * [`agents-keepalive-branch-sync.yml`](../../.github/workflows/agents-keepalive-branch-sync.yml) issues short-lived sync branches, merges the reconciliation PR automatically, and tears down the branch once the update lands so keepalive can clear branch drift without human intervention. * [`agents-keepalive-dispatch-handler.yml`](../../.github/workflows/agents-keepalive-dispatch-handler.yml) listens for orchestrator `repository_dispatch` payloads and replays them through the reusable agents topology so keepalive actions stay aligned with branch-sync repairs. * [`agents-71-codex-belt-dispatcher.yml`](../../.github/workflows/agents-71-codex-belt-dispatcher.yml), [`agents-72-codex-belt-worker-dispatch.yml`](../../.github/workflows/agents-72-codex-belt-worker-dispatch.yml), and [`agents-72-codex-belt-worker.yml`](../../.github/workflows/agents-72-codex-belt-worker.yml) handle dispatching and execution. -* [`agents-pr-meta-v4.yml`](../../.github/workflows/agents-pr-meta-v4.yml) is the Workflows-repo PR meta manager, using external scripts to stay under GitHub workflow parser limits. It skips release-please release PRs because release-please owns those PR bodies and must parse them after merge. Consumer repos should use the current consumer-template PR event hub and Gate followups workflow pair unless they are intentionally maintaining a legacy compatibility file. +* [`agents-pr-meta-v4.yml`](../../.github/workflows/agents-pr-meta-v4.yml) is the Workflows-repo PR meta manager, using external scripts to stay under GitHub workflow parser limits. It responds to open, synchronize, reopen, comment, and Gate-completion events, but not PR-body edits: because this workflow writes the body, observing those edits can turn metadata-only check URLs into a self-trigger loop. It skips release-please release PRs because release-please owns those PR bodies and must parse them after merge. Consumer repos should use the current consumer-template PR event hub and Gate followups workflow pair unless they are intentionally maintaining a legacy compatibility file. * [`agents-bot-comment-handler.yml`](../../.github/workflows/agents-bot-comment-handler.yml) dispatches the reusable bot comment handler after Gate success, manual dispatch, or the `autofix:bot-comments` label to address bot review comments. * [`reusable-16-agents.yml`](../../.github/workflows/reusable-16-agents.yml) includes the keepalive sweep, which the orchestrator toggles via the `keepalive_enabled` flag and repository-level `keepalive:paused` label. * [`agents-63-issue-intake.yml`](../../.github/workflows/agents-63-issue-intake.yml) is the canonical front door. It now listens for `agent:codex` labels directly and routes both label triggers and ChatGPT sync requests through the shared normalization pipeline. @@ -201,12 +201,12 @@ Scheduled health jobs keep the automation ecosystem aligned: * [`health-76-codex-cli-freshness.yml`](../../.github/workflows/health-76-codex-cli-freshness.yml) emits a weekly machine-readable freshness contract for the verifier `@openai/codex` CLI pin and uploads the deliberate update path as an artifact (scheduled weekly, manual dispatch). * [`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. 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. -* [`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 is carried through Maint 71 canary evidence into promotion. 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. 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 `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 is carried through Maint 71 canary evidence into promotion. 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 or promotion write wave dispatches the matching Maint 71 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-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). * [`maint-71-auto-fix-integration.yml`](../../.github/workflows/maint-71-auto-fix-integration.yml) automatically applies formatting fixes to Integration-Tests when triggered by issue comments or workflow failures. -* [`maint-71-merge-sync-prs.yml`](../../.github/workflows/maint-71-merge-sync-prs.yml) is the sole sync-PR merge/close reconciler. Stable deliveries use an exact-head staging/reviewing/sealed lifecycle and a capacity-aware policy: one reviewer response suffices after seven minutes; all-capacity-unavailable and zero-response cases degrade after bounded waits, but active review threads never do. Sealing triggers a fresh required Gate, and the shared staging hold blocks every other merger. The final exact-head query also requires a valid GitHub-generated signature; unsigned sync heads are never merged. Candidate mode uploads complete plan-bound evidence before merge and can recover evidence from the latest trusted merged candidate attempt. Required contexts come from legacy branch protection or active repository/inherited organization rulesets (scheduled/manual/callable). +* [`maint-71-merge-sync-prs.yml`](../../.github/workflows/maint-71-merge-sync-prs.yml) is the sole sync-PR merge/close reconciler. Stable deliveries use an exact-head staging/reviewing/sealed lifecycle and a capacity-aware policy: one reviewer response suffices after seven minutes; all-capacity-unavailable and zero-response cases degrade after bounded waits, but active review threads never do. A proof-bound resolution input can clear only the named active thread on the named exact head after verifying that its Workflows source fix is contained in the delivery. Sealing triggers a fresh required Gate, and the shared staging hold blocks every other merger. The final exact-head query also requires a valid GitHub-generated signature; unsigned sync heads are never merged. Candidate mode uploads complete plan-bound evidence before merge and automatically dispatches exact-plan promotion only after every canary is terminal. Required contexts come from legacy branch protection or active repository/inherited organization rulesets (scheduled/manual/callable). * [`maint-72-fix-pr-body-conflicts.yml`](../../.github/workflows/maint-72-fix-pr-body-conflicts.yml) removes pr_body.md from main branch and adds to .gitignore across consumer repos - prevents merge conflicts from PR description files (manual dispatch, weekly schedule). * [`maint-74-ledger-base-sync.yml`](../../.github/workflows/maint-74-ledger-base-sync.yml) aligns `.agents` ledger base entries to the repository default branch on a weekly schedule or manual dispatch. * [`maint-77-model-registry-freshness.yml`](../../.github/workflows/maint-77-model-registry-freshness.yml) checks the canonical LLM registry for overdue or unproved decisions, invalid lifecycle/evidence references, and profile/slot drift. Scheduled and manual runs also perform credential-gated provider catalog discovery; catalog additions become review candidates and never auto-promote (scheduled weekly, manual dispatch, PR gate for registry/slot/policy/checker changes). diff --git a/docs/ci/WORKFLOW_SYSTEM.md b/docs/ci/WORKFLOW_SYSTEM.md index 236a0a71b..0c55acce4 100644 --- a/docs/ci/WORKFLOW_SYSTEM.md +++ b/docs/ci/WORKFLOW_SYSTEM.md @@ -607,8 +607,11 @@ Keep this table handy when you are triaging automation: it confirms which workfl - **Agents PR meta manager** – `.github/workflows/agents-pr-meta-v4.yml` is the canonical PR meta manager using external scripts to stay under GitHub workflow parser limits. Manages PR metadata and the Automated Status Summary - that tracks issue acceptance criteria completion. (Legacy v1/v2/v3 versions - archived to `archives/github-actions/2025-12-02-pr-meta-legacy/`.) + that tracks issue acceptance criteria completion. It intentionally ignores + PR-body edit events because it writes that surface itself; structural PR, + comment, and Gate-completion events provide the bounded wakeups. (Legacy + v1/v2/v3 versions archived to + `archives/github-actions/2025-12-02-pr-meta-legacy/`.) - **Keepalive loop (Gate workflow_run).** The `agents-keepalive-loop.yml` workflow evaluates PR guardrails after Gate completes, runs Codex CLI when eligible, and repeats on subsequent Gate completions until tasks are done. The @@ -738,12 +741,12 @@ Keep this table handy when you are triaging automation: it confirms which workfl | **Backplane Contract Integrity** (`health-78-backplane-contract.yml`, maintenance bucket) | `pull_request`, `push` (contract set: schemas, registry, validator, fixtures) | Workflows-internal gate over the run-contract/v1 contract set: 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 (validator self-smoke). | ⚪ Required on contract PRs | [Backplane contract integrity runs](https://github.com/stranske/Workflows/actions/workflows/health-78-backplane-contract.yml) | | **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) | | **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; 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 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 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) | | **Auto-Fix Integration Test Failures** (`maint-71-auto-fix-integration.yml`, maintenance bucket) | `issues` (labeled), `workflow_run` (failed) | Automatically applies Black and Ruff formatting fixes to Python files in the Workflows-Integration-Tests repository when triggered by issue labels or workflow failures. | 🟢 Automated | [Auto-fix runs](https://github.com/stranske/Workflows/actions/workflows/maint-71-auto-fix-integration.yml) | -| **Merge Sync PRs** (`maint-71-merge-sync-prs.yml`, maintenance bucket) | `schedule`, `repository_dispatch`, `workflow_dispatch`, `workflow_call` | Sole merge/close reconciler for generated sync delivery. Stable PRs advance through capacity-aware bounded review settlement, exact-head sealing, and a fresh required Gate; no policy requires all configured reviewers and active review threads remain hard blockers. Candidate mode validates and uploads plan-bound evidence before merge and supports bounded recovery from trusted already-merged candidates. | ⚪ Scheduled/manual/callable | [Merge sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-71-merge-sync-prs.yml) | +| **Merge Sync PRs** (`maint-71-merge-sync-prs.yml`, maintenance bucket) | `schedule`, `repository_dispatch`, `workflow_dispatch`, `workflow_call` | Sole merge/close reconciler for generated sync delivery. Stable PRs advance through capacity-aware bounded review settlement, exact-head sealing, and a fresh required Gate; active review threads remain hard blockers unless an authenticated proof names that exact head, thread, and a Workflows source fix contained in the delivery. Candidate mode validates and uploads plan-bound evidence before merge, then dispatches exact-plan promotion only when every canary is terminal. | ⚪ Scheduled/manual/callable | [Merge sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-71-merge-sync-prs.yml) | | **Maint 72 Fix PR Body Conflicts** (`maint-72-fix-pr-body-conflicts.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (weekly) | Removes `pr_body.md` from main and adds to `.gitignore` in consumer repos to prevent merge conflicts. | ⚪ Manual/scheduled | [PR body fix runs](https://github.com/stranske/Workflows/actions/workflows/maint-72-fix-pr-body-conflicts.yml) | | **Maint 74 Ledger Base Sync** (`maint-74-ledger-base-sync.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 06:00 UTC) | Align `.agents` ledger base entries to the repository default branch. | ⚪ Manual/scheduled | [Ledger base sync runs](https://github.com/stranske/Workflows/actions/workflows/maint-74-ledger-base-sync.yml) | | **Maint 77 Model Registry Freshness** (`maint-77-model-registry-freshness.yml`, maintenance bucket) | `schedule` (Mondays 05:20 UTC), `workflow_dispatch`, `pull_request` (registry/slot/policy/checker paths) | Validates explicit model decisions, evidence, lifecycle, and profile slots offline. Scheduled/manual runs add credential-gated provider-catalog drift and refresh one review issue; catalog changes never auto-select a model. | ⚪ Scheduled/manual + PR gate | [Model registry freshness runs](https://github.com/stranske/Workflows/actions/workflows/maint-77-model-registry-freshness.yml) | @@ -752,7 +755,7 @@ Keep this table handy when you are triaging automation: it confirms which workfl | **Maint 86 Model Promotion Prepare** (`maint-86-model-promotion-prepare.yml`, maintenance bucket) | `workflow_dispatch` | Evaluates a benchmark and, for a same-family non-inferior cost≤ candidate (or a gate-breach rollback), opens a registry-change PR. Not auto-merged — a human merges to approve. Dispatch-only until a trustworthy approval benchmark exists. | ⚪ Manual/prepared | [Model promotion prepare runs](https://github.com/stranske/Workflows/actions/workflows/maint-86-model-promotion-prepare.yml) | | **LangSmith Metrics Dashboard** (`maint-80-langsmith-metrics-dashboard.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 09:00 UTC) | Generates weekly LangSmith trace coverage dashboard by downloading metrics from autopilot artifacts, computing coverage, and creating issue reports. | ⚪ Manual/scheduled | [LangSmith metrics runs](https://github.com/stranske/Workflows/actions/workflows/maint-80-langsmith-metrics-dashboard.yml) | | **LangSmith Fleet Conformance** (`maint-81-langsmith-fleet-conformance.yml`, maintenance bucket) | `workflow_dispatch`, `schedule` (Mondays 09:30 UTC) | Validates LangSmith fleet artifact coverage against `config/langsmith_fleet_registry.json`, emits markdown/JSON reports, and can optionally enforce non-valid rows. | ⚪ Manual/scheduled | [LangSmith fleet conformance runs](https://github.com/stranske/Workflows/actions/workflows/maint-81-langsmith-fleet-conformance.yml) | -| **Sync/Dependency Campaign** (`maint-82-sync-dependency-campaign.yml`, maintenance bucket) | `schedule`, `workflow_dispatch`, `repository_dispatch` | Refreshes a GitHub-visible campaign issue for sync-generated and dependency-bot PRs with active bot review threads so local Codex only claims queued work when remote discovery finds it. | ⚪ Scheduled/manual | [Sync/Dependency campaign runs](https://github.com/stranske/Workflows/actions/workflows/maint-82-sync-dependency-campaign.yml) | +| **Sync/Dependency Campaign** (`maint-82-sync-dependency-campaign.yml`, maintenance bucket) | `schedule` (10-minute continuation sweep), `workflow_dispatch`, `repository_dispatch` | Refreshes the GitHub-visible campaign issue and persists Maint 71 handoffs as a durable queue. Due transient review/check holds dispatch at most one candidate, delivery, and dev-tool lane; actionable review or source failures remain assigned rather than retried as timers. | ⚪ Scheduled/manual | [Sync/Dependency campaign runs](https://github.com/stranske/Workflows/actions/workflows/maint-82-sync-dependency-campaign.yml) | | **Maint 83 Bootstrap Consumer** (`maint-83-bootstrap-consumer.yml`, maintenance bucket) | `workflow_dispatch` | Applies the manual GitHub-settings bootstrap toggles a freshly-registered consumer needs (SETUP_CHECKLIST §3.1/§3.3/§3.3.1: `default_workflow_permissions=write`, `USE_CONSOLIDATED_WORKFLOWS` + `ALLOWED_KEEPALIVE_LOGINS` variables, `stranske-automation-bot` push-collaborator invite) via `scripts/bootstrap_consumer_settings.py`; dry-run by default. | ⚪ Manual | [Bootstrap consumer runs](https://github.com/stranske/Workflows/actions/workflows/maint-83-bootstrap-consumer.yml) | | **Maint 84 Prune Agent Stubs** (`maint-84-prune-agent-stubs.yml`, maintenance bucket) | `schedule` (Mondays 08:00 UTC), `workflow_dispatch` | Garbage-collects `agents/-.md` bootstrap stubs whose issue `#N` is closed via `scripts/prune_agent_stubs.py`, preventing unbounded accumulation; dry-run by default on manual dispatch. | ⚪ Scheduled/manual | [Prune agent stubs runs](https://github.com/stranske/Workflows/actions/workflows/maint-84-prune-agent-stubs.yml) | | **Maint 85 Keepalive Durability Export** (`maint-85-keepalive-durability-export.yml`, maintenance bucket) | `schedule` (Mondays 09:45 UTC), `workflow_dispatch` | Classifies merged keepalive PRs after a grace period and emits Workflows-owned `langsmith-fleet/v1` durability records for Orchestrator outcome ingest. | ⚪ Scheduled/manual | [Keepalive durability export runs](https://github.com/stranske/Workflows/actions/workflows/maint-85-keepalive-durability-export.yml) | diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 0275905c3..19daf6830 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -343,6 +343,21 @@ threads. A successful promotion targets all registered non-canary repositories once every configured canary has current, green, review-clear evidence for the same plan. +The normal chain is automatic: Maint 68 dispatches the candidate selector after +writing canary PRs; Maint 71 dispatches `phase=promote` only after the persisted +evidence is complete and every candidate is merged or safely recovered; and a +successful promotion dispatches the delivery selector. Generated-branch Gate +completions provide event-driven wakeups through the synced Gate-followups hub. +Maint 82 retains every transient Maint 71 handoff with a due time and supplies a +ten-minute fallback for candidate-evidence holds, delivery-review startup, +pending checks, changed heads, review windows, reviewer settlement, sealed Gate +checks, and stable candidate base refreshes, so an absent event cannot strand +the lifecycle. It does not retry actionable CI failures, unresolved review +findings, or a dry-run-only sealed-head mismatch as if they were timer states. +Promoted delivery commits carry their exact canary evidence in the verified +commit message so Maint 71 can replay the same promotion if a consumer base +advances during review; editable PR body fields never authorize that replay. + Maint 71 persists and validates the `sync-canary-evidence-premerge` artifact before its merge step is allowed to run. A GitHub pre-job approval hold, a cancelled evidence step, or an artifact-upload failure therefore leaves the @@ -434,6 +449,15 @@ Workflow-call, manual, and repository-dispatch candidate selectors normalize to the same gate. The executor requires same-job evidence/upload authorization, so scheduled or malformed paths cannot merge a candidate implicitly. +Active non-outdated review threads remain merge blockers. When a shared source +repair proves a finding obsolete on the current generated head, an authenticated +operator may pass `review_resolution_json` to Maint 71. Each +`workflows-sync-review-resolution/v1` proof names one thread, PR, exact head, +Workflows source-fix SHA, evidence URL, and reason. Maint 71 verifies that the +fix is contained in the delivery's recorded source commit and re-reads the +thread before resolving it. A source fix without this exact proof, a later +candidate plan, or a passing Gate never resolves the current PR's review debt. + A non-empty workflow-sync selector applies only to the `sync/workflows-*` lane. An open sibling `deps/sync-dev-versions-*` delivery is therefore ignored for the selector's expected-branch check instead of producing a false diff --git a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md index 6076e4e67..38901b7ed 100644 --- a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md +++ b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md @@ -60,6 +60,59 @@ fingerprint materially changes. Timestamps alone do not constitute new work. Local watchers consume the normalized handoff record and do not independently decide merge or close disposition. +The same marker is the timed continuation queue. Maint 71 classifies every +handoff as `transient`, `actionable`, or `terminal`, names its candidate, +delivery, or dev-tool lane, and records an immutable `resume_after` for +transient states. Maint 82 checks that queue every ten minutes and dispatches at +most one due run per lane, suppressing a new candidate while a promoted delivery +is active. Consumer `agents-81-gate-followups.yml` sends the event-driven wakeup +when a generated branch's Gate finishes; the timed queue covers quiet-period +expiration and lost/delayed events. Pending checks and review windows therefore +advance automatically, while failed checks and review findings keep their named +owner instead of being disguised as timer retries. + +Every transient classification has a non-zero retry delay and becomes eligible +only after that due time has passed. Dev-tool wakes use the explicit `dev-tool` +selector, which reconciles the newest dev-tool PR per registered non-admin repo +without allowing a newer workflow-sync PR to hide it. + +A stable candidate that falls behind its consumer base is also transient. +Maint 71 returns it to staging and dispatches one deduplicated, no-filter Maint +68 `phase=canary` refresh; it never sends non-canaries through that phase and it +will not start the refresh while a canary or promotion run is active. The timer +queue retains the candidate continuation if that dispatch is lost. + +Promotion binds the exact canary evidence into every Workflows-App-signed +delivery commit. If a stable delivery falls behind during review, Maint 71 +restages it, extracts and validates that signed evidence against the delivery +plan, and replays Maint 68 `phase=promote` with `delivery_scope=auto`. Mutable +consumer PR text is not an evidence source. Missing or unverifiable signed +evidence is actionable and fails closed instead of becoming a retry loop. + +After a candidate-selector run has complete same-plan evidence and every +configured candidate was merged or recovered, Maint 71 passes that exact JSON +to Maint 68 `phase=promote`. Maint 68 in turn dispatches the delivery selector +after writing non-canary PRs. Neither chain permits an explicit non-canary repo +through `phase=canary`, and `stranske/Collab-Admin` is excluded from reconciler +targets. + +An active review thread remains a hard merge block. The bounded exception is an +explicit `workflows-sync-review-resolution/v1` proof supplied to Maint 71. It +must name one active thread, PR, exact head, substantive reason, Workflows PR or +commit evidence URL, and merged Workflows source-fix SHA. Maint 71 verifies the +authenticated dispatcher, unchanged head, active thread, and that the fix is an +ancestor of the delivery's recorded source commit before resolving that thread. +General source ancestry, a newer wave, or passing CI alone never clears review +debt. + +Proof application is a dedicated resolution-only prepass: it may resolve only +the named verified thread and cannot merge, close, restage, or seal a PR. Maint +71 then performs its read-only evidence pass and explicitly validates that the +persisted premerge artifact contains every configured canary on one green, +review-clear plan. Upload success without complete evidence cannot authorize a +candidate merge; normal lifecycle transitions may still advance for the next +pass. + ## Remote delivery handoff schema (`workflows-generated-delivery-handoff/v1`) Maint 71 emits normalized result records (artifact + best-effort @@ -82,7 +135,8 @@ Required fields: | `review_state` | Review summary (`clear`, `blocked`, …) | Optional fields retained when present: `branch`, `lane` (`sync` / -`dev-tool-sync`), `observed_at`. +`dev-tool-sync`), `observed_at`, and `continuation` (`class`, `lane`, `reason`, +`resume_after`). Exception fingerprints used for local Codex handoff include repository, PR number, head SHA, and active review-thread identity. `updated_at` is metadata diff --git a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml index c09a3426f..2811e3b8b 100644 --- a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml +++ b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml @@ -1732,6 +1732,60 @@ jobs: path: autofix-metrics.ndjson retention-days: 30 + generated-delivery-wakeup: + name: Wake generated delivery reconciler + if: >- + ${{ + github.event_name == 'workflow_run' && + (github.event.workflow_run.head_branch == 'sync/workflows-candidate' || + github.event.workflow_run.head_branch == 'sync/workflows-delivery' || + startsWith(github.event.workflow_run.head_branch, 'deps/sync-dev-versions-')) + }} + runs-on: ubuntu-latest + environment: agent-standard + permissions: + contents: read + steps: + - name: Dispatch exact generated lane to Workflows + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ env.WRITE_TOKEN }} + script: | + const branch = String(context.payload.workflow_run?.head_branch || ''); + const activeSyncHash = branch === 'sync/workflows-candidate' + ? 'candidate' + : branch === 'sync/workflows-delivery' + ? 'delivery' + : 'dev-tool'; + const clientPayload = { + source_repository: `${context.repo.owner}/${context.repo.repo}`, + source_run_id: String(context.payload.workflow_run?.id || ''), + }; + clientPayload.active_sync_hash = activeSyncHash; + const withRetry = async (operation) => { + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if (![429, 500, 502, 503, 504].includes(Number(error?.status))) throw error; + await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); + } + } + core.setFailed( + `Generated-delivery wakeup failed: ${lastError?.message || lastError}`, + ); + throw lastError; + }; + await withRetry(() => github.rest.repos.createDispatchEvent({ + owner: 'stranske', + repo: 'Workflows', + event_type: 'merge-sync-prs', + client_payload: clientPayload, + })); + core.notice(`Woke Maint 71 lane ${activeSyncHash} from ${branch}`); + guarded-merge: name: Merge automerge-labelled agent PRs # #2270 (FO-5): in-repo guarded merger for consumer repos. Runs on the same diff --git a/tests/workflows/fixtures/keepalive_post_work/update_branch.json b/tests/workflows/fixtures/keepalive_post_work/update_branch.json index 367ec8734..9ad47234b 100644 --- a/tests/workflows/fixtures/keepalive_post_work/update_branch.json +++ b/tests/workflows/fixtures/keepalive_post_work/update_branch.json @@ -12,7 +12,7 @@ "AGENT_STATE": "done", "TTL_SHORT_MS": "0", "POLL_SHORT_MS": "0", - "TTL_LONG_MS": "100", + "TTL_LONG_MS": "5000", "POLL_LONG_MS": "0" }, "updateBranch": { diff --git a/tests/workflows/test_sync_delivery_liveness.py b/tests/workflows/test_sync_delivery_liveness.py new file mode 100644 index 000000000..ef5d5b0d0 --- /dev/null +++ b/tests/workflows/test_sync_delivery_liveness.py @@ -0,0 +1,50 @@ +from pathlib import Path + + +def test_maint71_has_proof_bound_review_resolution_and_exact_evidence_promotion(): + workflow = Path(".github/workflows/maint-71-merge-sync-prs.yml").read_text() + executor = Path(".github/scripts/maint71_merge_sync_prs.js").read_text() + + assert "review_resolution_json:" in workflow + assert "github.event.client_payload.review_resolution_json" not in workflow + assert "Apply proof-bound candidate review resolutions" in workflow + assert 'RESOLUTION_ONLY_INPUT: "true"' in workflow + assert "Validate complete pre-merge canary evidence" in workflow + assert ( + "CANDIDATE_EVIDENCE_RESULT: ${{ steps.candidate_evidence_validation.outcome }}" in workflow + ) + assert "dryRun && !resolutionOnly" in executor + assert "workflows-sync-review-resolution/v1" in executor + assert "resolveReviewThread" in executor + assert "source_fix_not_in_delivery_source" in executor + assert "candidatePromotionDecision" in workflow + assert "candidateRefreshDecision" in workflow + assert "deliveryRefreshDecision" in workflow + assert "Refresh stale candidate bases" in workflow + assert "Refresh stale delivery bases" in workflow + assert "phase: 'canary'" in workflow + assert "delivery_scope: 'full'" in workflow + assert "canary_evidence_json: JSON.stringify(evidence)" in workflow + assert "cancel-in-progress: false" in workflow + assert "EXCLUDED_REPOS_INPUT: stranske/Collab-Admin" in workflow + + +def test_sync_lifecycle_chains_and_has_event_plus_timer_fallbacks(): + maint68 = Path(".github/workflows/maint-68-sync-consumer-repos.yml").read_text() + maint82 = Path(".github/workflows/maint-82-sync-dependency-campaign.yml").read_text() + followups = Path( + "templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml" + ).read_text() + + assert "Start generated delivery reconciliation" in maint68 + assert "Canary evidence JSON (base64)" in maint68 + assert "activeSyncHash = phase === 'canary' ? 'candidate' : 'delivery'" in maint68 + assert 'cron: "*/10 * * * *"' in maint82 + assert "planMaint71Continuations" in maint82 + assert "Dispatch due Maint 71 continuations" in maint82 + assert "const selector = continuation.lane" in maint82 + assert "activeTitles.has('Merge Sync PRs [delivery]')" in maint82 + assert "Wake generated delivery reconciler" in followups + assert "github.event.workflow_run.head_branch == 'sync/workflows-candidate'" in followups + assert "event_type: 'merge-sync-prs'" in followups + assert ": 'dev-tool';" in followups diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index 0fd95b218..08906ae44 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -871,6 +871,13 @@ def test_agents_pr_meta_keepalive_configuration(): "created" ], "Keepalive detection must trigger on comment creation only" + pull_request = triggers.get("pull_request", {}) + assert pull_request.get("types") == [ + "opened", + "synchronize", + "reopened", + ], "PR body edits must not recursively wake the body-writing metadata workflow" + jobs = workflow.get("jobs", {}) # v4 structure differs from v2 - check for the relevant jobs assert (