diff --git a/.github/scripts/__tests__/sync-run-contract.test.js b/.github/scripts/__tests__/sync-run-contract.test.js index 854c818e2..c97c5fa36 100644 --- a/.github/scripts/__tests__/sync-run-contract.test.js +++ b/.github/scripts/__tests__/sync-run-contract.test.js @@ -4,11 +4,94 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { + buildNoChangeCanaryEvidence, buildMarkdownSummary, buildSyncRunReport, summarizeResults, } = require('../sync_run_contract'); +test('buildNoChangeCanaryEvidence binds no-diff canaries to the exact plan and head', () => { + const planId = `sha256:${'a'.repeat(64)}`; + const sourceCommit = 'b'.repeat(40); + const consumerHeadSha = 'c'.repeat(40); + const result = buildNoChangeCanaryEvidence({ + expectedCanaries: ['stranske/Ready', 'stranske/Travel'], + planId, + planScope: 'full', + sourceCommit, + results: [ + { + repo: 'stranske/Ready', + status: 'no_changes', + plan_id: planId, + plan_scope: 'full', + scope_base_sha: '', + source_commit: sourceCommit, + consumer_head_sha: consumerHeadSha, + }, + { repo: 'stranske/Travel', status: 'created_pr', plan_id: planId }, + ], + }); + + assert.equal(result.ok, true); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.evidence.results, [{ + repo: 'stranske/Ready', + plan_id: planId, + plan_scope: 'full', + scope_base_sha: '', + source_commit: sourceCommit, + head_sha: consumerHeadSha, + evidence_source: 'no-change-canary', + required_check_state: 'success', + active_review_thread_count: 0, + }]); +}); + +test('buildNoChangeCanaryEvidence rejects stale plan and missing head claims', () => { + const result = buildNoChangeCanaryEvidence({ + expectedCanaries: ['stranske/Ready'], + planId: `sha256:${'a'.repeat(64)}`, + planScope: 'source-delta', + scopeBaseSha: '1'.repeat(40), + sourceCommit: '2'.repeat(40), + results: [{ + repo: 'stranske/Ready', + status: 'no_changes', + plan_id: `sha256:${'f'.repeat(64)}`, + plan_scope: 'source-delta', + scope_base_sha: '1'.repeat(40), + source_commit: '2'.repeat(40), + consumer_head_sha: '', + }], + }); + + assert.equal(result.ok, false); + assert.ok(result.errors.includes('no_change_canary_plan_mismatch:stranske/Ready')); + assert.ok(result.errors.includes('no_change_canary_head_invalid:stranske/Ready')); +}); + +test('buildNoChangeCanaryEvidence rejects duplicate and immutable scope mismatches', () => { + const planId = `sha256:${'a'.repeat(64)}`; + const sourceCommit = 'b'.repeat(40); + const result = buildNoChangeCanaryEvidence({ + expectedCanaries: ['stranske/Ready'], + planId, + planScope: 'source-delta', + scopeBaseSha: 'c'.repeat(40), + sourceCommit, + results: [ + { repo: 'stranske/Ready', status: 'no_changes', plan_id: planId, plan_scope: 'full', scope_base_sha: 'd'.repeat(40), source_commit: sourceCommit, consumer_head_sha: 'e'.repeat(40) }, + { repo: 'stranske/Ready', status: 'no_changes', plan_id: planId, plan_scope: 'source-delta', scope_base_sha: 'c'.repeat(40), source_commit: sourceCommit, consumer_head_sha: 'e'.repeat(40) }, + ], + }); + + assert.equal(result.ok, false); + assert.ok(result.errors.includes('no_change_canary_scope_mismatch:stranske/Ready')); + assert.ok(result.errors.includes('no_change_canary_scope_base_mismatch:stranske/Ready')); + assert.ok(result.errors.includes('duplicate_no_change_canary:stranske/Ready')); +}); + test('summarizeResults counts known statuses and buckets unknown as error', () => { assert.deepEqual( summarizeResults([ diff --git a/.github/scripts/__tests__/sync_pr_merge_contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js index b929f53dd..89317a427 100644 --- a/.github/scripts/__tests__/sync_pr_merge_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -42,6 +42,7 @@ const { summarizeResults, syncBranchForHash, validateCanaryEvidence, + validateExpectedCandidateIdentity, validateSourceDeltaEvidenceBinding, } = require('../sync_pr_merge_contract'); const { assertRuntimeAcMergeAllowed } = require('../runtime_ac_merge_guard'); @@ -353,6 +354,176 @@ test('maint71 fails closed before cross-repository API calls without OWNER_PR_PA } }); +test('maint71 accepts no-change canary evidence only while the exact base head is current', async () => { + const originalCwd = process.cwd(); + const envKeys = [ + 'REGISTERED_REPOS_INPUT', + 'CLEANUP_BRANCHES_INPUT', + 'DRY_RUN_INPUT', + 'AUTO_MERGE_INPUT', + 'EVIDENCE_ONLY_INPUT', + 'ACTIVE_SYNC_HASH_INPUT', + 'EXPECTED_PLAN_ID_INPUT', + 'EXPECTED_PLAN_SCOPE_INPUT', + 'EXPECTED_SCOPE_BASE_SHA_INPUT', + 'EXPECTED_SOURCE_COMMIT_INPUT', + 'CANARY_BASELINE_EVIDENCE_JSON', + 'OWNER_PR_PAT', + 'CONSUMER_SYNC_CANARIES_PATH', + 'TRUSTED_SYNC_ACTORS', + 'SYNC_PR_MERGE_REPORT_JSON', + ]; + const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maint71-no-change-')); + const reportPath = path.join(tempDir, 'artifacts', 'merge-report.json'); + const canaryConfigPath = path.join(tempDir, 'consumer-sync-canaries.json'); + const repoName = 'stranske/Travel-Plan-Permission'; + const planId = `sha256:${'a'.repeat(64)}`; + const sourceCommit = 'b'.repeat(40); + const headSha = 'c'.repeat(40); + fs.writeFileSync( + canaryConfigPath, + JSON.stringify({ canaries: [{ repo: repoName }] }), + ); + const baseline = { + schema: 'workflows.consumer-sync-canary-evidence/v1', + version: 1, + results: [{ + repo: repoName, + plan_id: planId, + plan_scope: 'full', + scope_base_sha: '', + source_commit: sourceCommit, + head_sha: headSha, + evidence_source: 'no-change-canary', + required_check_state: 'success', + active_review_thread_count: 0, + }], + }; + let checkConclusion = 'success'; + let requiredContexts = ['Gate / gate']; + const github = { + paginate: async (_method, params) => ( + params.ref === headSha + ? [{ name: 'Gate / gate', status: 'completed', conclusion: checkConclusion }] + : [] + ), + rest: { + pulls: { list: () => {} }, + checks: { listForRef: () => {} }, + repos: { + get: async () => ({ data: { default_branch: 'main' } }), + getBranchProtection: async () => ({ + data: { required_status_checks: { contexts: requiredContexts, checks: [] } }, + }), + getRepoRulesets: async () => ({ data: [] }), + getCombinedStatusForRef: async () => ({ data: { statuses: [] } }), + createDispatchEvent: async () => ({}), + }, + git: { + getRef: async () => ({ data: { object: { sha: headSha } } }), + }, + }, + }; + const failures = []; + const core = { + notice: () => {}, + setFailed: (message) => failures.push(message), + warning: () => {}, + summary: { addRaw: () => ({ write: async () => {} }) }, + }; + + try { + process.chdir(tempDir); + process.env.REGISTERED_REPOS_INPUT = repoName; + process.env.CLEANUP_BRANCHES_INPUT = 'false'; + process.env.DRY_RUN_INPUT = 'true'; + process.env.AUTO_MERGE_INPUT = 'false'; + process.env.EVIDENCE_ONLY_INPUT = 'true'; + process.env.ACTIVE_SYNC_HASH_INPUT = 'candidate'; + process.env.EXPECTED_PLAN_ID_INPUT = planId; + process.env.EXPECTED_PLAN_SCOPE_INPUT = 'full'; + process.env.EXPECTED_SCOPE_BASE_SHA_INPUT = ''; + process.env.EXPECTED_SOURCE_COMMIT_INPUT = sourceCommit; + process.env.CANARY_BASELINE_EVIDENCE_JSON = JSON.stringify(baseline); + process.env.OWNER_PR_PAT = 'test-owner-token'; + process.env.CONSUMER_SYNC_CANARIES_PATH = canaryConfigPath; + process.env.TRUSTED_SYNC_ACTORS = 'stranske'; + process.env.SYNC_PR_MERGE_REPORT_JSON = reportPath; + + await run({ + github, + core, + context: { + repo: { owner: 'stranske', repo: 'Workflows' }, + payload: {}, + runId: 3, + runNumber: 3, + workflow: 'Maint 71', + ref: 'refs/heads/main', + sha: sourceCommit, + }, + }); + + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const evidence = JSON.parse( + fs.readFileSync(path.join(tempDir, 'artifacts', 'sync-canary-evidence.json'), 'utf8'), + ); + assert.equal(report.summary.evidence_recovered, 1); + assert.equal(report.results[0].branch, 'sync/workflows-candidate'); + assert.equal(evidence.results[0].head_sha, headSha); + assert.equal(evidence.results[0].evidence_source, 'no-change-canary'); + assert.deepEqual(failures, []); + + checkConclusion = 'failure'; + await run({ + github, + core, + context: { + repo: { owner: 'stranske', repo: 'Workflows' }, + payload: {}, + runId: 4, + runNumber: 4, + workflow: 'Maint 71', + ref: 'refs/heads/main', + sha: sourceCommit, + }, + }); + const redReport = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + assert.equal(redReport.summary.checks_failed, 1); + assert.match(failures.at(-1), /Canary evidence is incomplete or unsafe/); + + checkConclusion = 'success'; + requiredContexts = []; + await run({ + github, + core, + context: { + repo: { owner: 'stranske', repo: 'Workflows' }, + payload: {}, + runId: 5, + runNumber: 5, + workflow: 'Maint 71', + ref: 'refs/heads/main', + sha: sourceCommit, + }, + }); + const unconfiguredReport = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + assert.equal(unconfiguredReport.summary.checks_failed, 1); + assert.equal( + unconfiguredReport.results[0].reason, + 'no_change_canary_required_checks_unconfigured', + ); + } finally { + process.chdir(originalCwd); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('maint71 recovers exact-head evidence from an already-merged candidate PR', async () => { const originalCwd = process.cwd(); const envKeys = [ @@ -362,6 +533,10 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR', 'AUTO_MERGE_INPUT', 'EVIDENCE_ONLY_INPUT', 'ACTIVE_SYNC_HASH_INPUT', + 'EXPECTED_PLAN_ID_INPUT', + 'EXPECTED_PLAN_SCOPE_INPUT', + 'EXPECTED_SCOPE_BASE_SHA_INPUT', + 'EXPECTED_SOURCE_COMMIT_INPUT', 'OWNER_PR_PAT', 'CONSUMER_SYNC_CANARIES_PATH', 'TRUSTED_SYNC_ACTORS', @@ -476,6 +651,10 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR', process.env.DRY_RUN_INPUT = 'true'; process.env.AUTO_MERGE_INPUT = 'false'; process.env.ACTIVE_SYNC_HASH_INPUT = 'candidate'; + process.env.EXPECTED_PLAN_ID_INPUT = 'plan-abc'; + process.env.EXPECTED_PLAN_SCOPE_INPUT = 'full'; + process.env.EXPECTED_SCOPE_BASE_SHA_INPUT = ''; + process.env.EXPECTED_SOURCE_COMMIT_INPUT = 'source-abc'; process.env.OWNER_PR_PAT = 'test-owner-token'; process.env.EVIDENCE_ONLY_INPUT = 'true'; process.env.CONSUMER_SYNC_CANARIES_PATH = canaryConfigPath; @@ -525,20 +704,87 @@ test('normalizeSyncHash accepts raw hashes and branch names', () => { }); test('selectLatestMergedCandidatePr recovers only the newest trusted merged candidate', () => { - const candidate = (number, mergedAt, actor = 'stranske') => ({ + const candidate = ( + number, + mergedAt, + actor = 'stranske', + planId = 'plan-current', + sourceCommit = 'source-current', + ) => ({ ...pr(number, 'sync/workflows-candidate', '2026-08-11T01:00:00Z'), merged_at: mergedAt, head: { ref: 'sync/workflows-candidate', sha: `head-${number}` }, user: { login: actor }, + body: ``, }); const selected = selectLatestMergedCandidatePr([ candidate(1, '2026-08-11T02:00:00Z'), candidate(2, '2026-08-11T03:00:00Z'), candidate(3, '2026-08-11T04:00:00Z', 'untrusted'), + candidate(5, '2026-08-11T05:00:00Z', 'stranske', 'plan-stale', 'source-stale'), { ...candidate(4, null), merged_at: null }, - ], ['stranske']); + ], ['stranske'], { + planId: 'plan-current', + sourceCommit: 'source-current', + }); assert.equal(selected.number, 2); + assert.equal(selectLatestMergedCandidatePr([ + candidate(5, '2026-08-11T05:00:00Z', 'stranske', 'plan-stale', 'source-stale'), + ], ['stranske'], { + planId: 'plan-current', + sourceCommit: 'source-current', + }), null); +}); + +test('validateExpectedCandidateIdentity binds open candidates to every immutable input', () => { + const expected = { + expectedPlanId: 'plan-current', + expectedPlanScope: 'source-delta', + expectedScopeBaseSha: 'a'.repeat(40), + expectedSourceCommit: 'b'.repeat(40), + repository: 'stranske/Ready', + }; + const metadata = { + consumer_repo: 'stranske/Ready', + plan_id: 'plan-current', + plan_scope: 'source-delta', + scope_base_sha: 'a'.repeat(40), + source_sha: 'b'.repeat(40), + source_commit: 'b'.repeat(40), + }; + const deliveryRecord = { + repository: 'stranske/Ready', + plan_id: 'plan-current', + source_commit: 'b'.repeat(40), + }; + assert.deepEqual(validateExpectedCandidateIdentity({ + metadata, + deliveryRecord, + ...expected, + }), { ok: true, errors: [] }); + + const stale = validateExpectedCandidateIdentity({ + metadata: { ...metadata, source_commit: 'c'.repeat(40) }, + deliveryRecord: { ...deliveryRecord, plan_id: 'plan-stale' }, + ...expected, + }); + assert.equal(stale.ok, false); + assert.deepEqual(stale.errors, [ + 'delivery_plan_id_mismatch', + 'metadata_source_commit_mismatch', + ]); }); test('validateCanaryEvidence fails closed on missing, mixed, red, or reviewed canaries', () => { diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index 7c7ded941..4fff0eec8 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -286,6 +286,7 @@ async function run({ github, context, core }) { selectSyncPrGatingChecks, syncBranchForHash, validateCanaryEvidence, + validateExpectedCandidateIdentity, validateSourceDeltaEvidenceBinding, } = require('./sync_pr_merge_contract.js'); const { @@ -431,6 +432,55 @@ async function run({ github, context, core }) { return { contexts: requiredContexts, source: 'rulesets' }; } + async function classifyRequiredChecksForRef({ owner, repo, branch, ref }) { + const { data: combinedStatus } = await withRetry((client) => + client.rest.repos.getCombinedStatusForRef({ owner, repo, ref }), + ); + const paginatedCheckRuns = await withRetry((client) => + client.paginate(client.rest.checks.listForRef, { + owner, + repo, + ref, + per_page: 100, + }), + ); + const statusAsChecks = (combinedStatus.statuses || []).map(legacyStatusAsCheck); + const checkNames = new Set( + paginatedCheckRuns.map((check) => String(check?.name || '').trim()).filter(Boolean), + ); + const allChecks = [ + ...paginatedCheckRuns, + ...statusAsChecks.filter((status) => !checkNames.has(String(status.name || '').trim())), + ]; + const requiredCheckPolicy = await getRequiredContexts({ owner, repo, branch }); + const requiredContexts = requiredCheckPolicy.contexts; + let classification = requiredContexts.size > 0 + ? classifySyncPrChecks({ checkRuns: allChecks, requiredContexts }) + : { status: 'ready', failed: [], pending: [] }; + if (requiredContexts.size > 0 && classification.status === 'ready') { + const seenNames = new Set( + allChecks.map((check) => String(check?.name || '').trim()).filter(Boolean), + ); + const missingRequired = [...requiredContexts].filter((ctx) => !seenNames.has(ctx)); + if (missingRequired.length > 0) { + classification = { + status: 'checks_pending', + failed: [], + pending: missingRequired.map((name) => ({ name, status: 'queued' })), + }; + } + } + return { + allChecks, + checkGateMode: requiredCheckPolicy.source, + classification, + gatingChecks: requiredContexts.size > 0 + ? selectSyncPrGatingChecks({ checkRuns: allChecks, requiredContexts }) + : [], + requiredContexts, + }; + } + async function resolveProvenReviewDebt({ owner, repo, pr, deliveryRecord }) { const matching = reviewResolutionProofs.filter((proof) => proof?.repository === `${owner}/${repo}` @@ -595,6 +645,19 @@ async function run({ github, context, core }) { (context.payload.client_payload && context.payload.client_payload.sync_hash) || '', ); + const expectedPlanId = String(process.env.EXPECTED_PLAN_ID_INPUT || '').trim(); + const expectedPlanScope = String(process.env.EXPECTED_PLAN_SCOPE_INPUT || '').trim() || 'full'; + const expectedScopeBaseSha = String( + process.env.EXPECTED_SCOPE_BASE_SHA_INPUT || '', + ).trim().toLowerCase(); + const expectedSourceCommit = String( + process.env.EXPECTED_SOURCE_COMMIT_INPUT || '', + ).trim().toLowerCase(); + if (requestedSyncHash === 'candidate' && (!expectedPlanId || !expectedSourceCommit)) { + throw new Error( + 'Candidate reconciliation requires an exact expected plan and source commit', + ); + } const trustedSyncActors = String(process.env.TRUSTED_SYNC_ACTORS || '') .split(',') .map((actor) => actor.trim()) @@ -636,6 +699,66 @@ async function run({ github, context, core }) { .filter(Boolean); } + const baselineEvidenceByRepo = new Map(); + const rawBaselineEvidence = String( + process.env.CANARY_BASELINE_EVIDENCE_JSON || '', + ).trim(); + if (rawBaselineEvidence) { + if (requestedSyncHash !== 'candidate') { + throw new Error('No-change canary evidence is only valid for the candidate lane'); + } + if (!expectedPlanId || !expectedSourceCommit) { + throw new Error( + 'No-change canary evidence requires an exact expected plan and source commit', + ); + } + let baselineDocument; + try { + baselineDocument = JSON.parse(rawBaselineEvidence); + } catch (error) { + throw new Error(`No-change canary evidence is not valid JSON: ${error.message}`); + } + if ( + baselineDocument?.schema !== 'workflows.consumer-sync-canary-evidence/v1' + || baselineDocument?.version !== 1 + || !Array.isArray(baselineDocument?.results) + ) { + throw new Error('No-change canary evidence has an unsupported schema'); + } + const expectedCanarySet = new Set(expectedCanaryRepos); + for (const row of baselineDocument.results) { + const repoName = String(row?.repo || '').trim(); + const rowPlanId = String(row?.plan_id || '').trim(); + const rowPlanScope = String(row?.plan_scope || '').trim() || 'full'; + const rowScopeBaseSha = String(row?.scope_base_sha || '').trim().toLowerCase(); + const rowSourceCommit = String(row?.source_commit || '').trim().toLowerCase(); + const rowHeadSha = String(row?.head_sha || '').trim().toLowerCase(); + if (!expectedCanarySet.has(repoName)) { + throw new Error(`Unexpected no-change canary evidence: ${repoName || ''}`); + } + if (baselineEvidenceByRepo.has(repoName)) { + throw new Error(`Duplicate no-change canary evidence: ${repoName}`); + } + if ( + row?.evidence_source !== 'no-change-canary' + || rowPlanId !== expectedPlanId + || rowPlanScope !== expectedPlanScope + || rowScopeBaseSha !== expectedScopeBaseSha + || rowSourceCommit !== expectedSourceCommit + || !/^[0-9a-f]{40}$/.test(rowHeadSha) + || row?.required_check_state !== 'success' + || Number(row?.active_review_thread_count) !== 0 + ) { + throw new Error(`Unsafe no-change canary evidence: ${repoName}`); + } + baselineEvidenceByRepo.set(repoName, { + ...row, + repo: repoName, + head_sha: rowHeadSha, + }); + } + } + // Candidate reconciliation is a registry-owned operation. Processing the // complete consumer registry here lets unrelated non-canary delivery PRs // create target_missing failures and can make promotion evidence unusable. @@ -660,6 +783,9 @@ async function run({ github, context, core }) { if (requestedSyncHash) { console.log(`Target sync hash: ${requestedSyncHash}`); } + if (expectedPlanId) { + console.log(`Expected plan identity: ${expectedPlanId} @ ${expectedSourceCommit}`); + } const results = []; const canaryEvidence = []; @@ -1052,8 +1178,86 @@ async function run({ github, context, core }) { const hasOpenCandidate = syncPRs.some( (pr) => pr?.head?.ref === syncBranchForHash('candidate'), ); + const baselineEvidence = baselineEvidenceByRepo.get(`${owner}/${repo}`) || null; + if (!hasOpenCandidate && requestedSyncHash === 'candidate' && baselineEvidence) { + const { data: repository } = await withRetry((client) => client.rest.repos.get({ + owner, + repo, + })); + const { data: defaultRef } = await withRetry((client) => client.rest.git.getRef({ + owner, + repo, + ref: `heads/${repository.default_branch}`, + })); + const liveHeadSha = String(defaultRef?.object?.sha || '').trim().toLowerCase(); + if (liveHeadSha !== baselineEvidence.head_sha) { + console.log( + `No-change canary baseline moved: ${baselineEvidence.head_sha} -> ` + + `${liveHeadSha || ''}`, + ); + results.push({ + owner, + repo, + status: 'target_missing', + expected_head_sha: baselineEvidence.head_sha, + observed_head_sha: liveHeadSha, + reason: 'no_change_canary_head_changed', + }); + continue; + } + const baselineChecks = await classifyRequiredChecksForRef({ + owner, + repo, + branch: repository.default_branch, + ref: liveHeadSha, + }); + if (baselineChecks.requiredContexts.size === 0) { + console.log('No-change canary has no authoritative required check contexts'); + results.push({ + owner, + repo, + status: 'checks_failed', + expected_head_sha: baselineEvidence.head_sha, + observed_head_sha: liveHeadSha, + reason: 'no_change_canary_required_checks_unconfigured', + }); + continue; + } + if (baselineChecks.classification.status !== 'ready') { + console.log( + `No-change canary required checks are not green: ` + + baselineChecks.classification.status, + ); + results.push({ + owner, + repo, + status: baselineChecks.classification.status, + expected_head_sha: baselineEvidence.head_sha, + observed_head_sha: liveHeadSha, + reason: 'no_change_canary_required_checks_not_ready', + failed_checks: baselineChecks.classification.failed.map((check) => check.name), + pending_checks: baselineChecks.classification.pending.map((check) => check.name), + }); + continue; + } + canaryEvidence.push(baselineEvidence); + results.push({ + owner, + repo, + status: 'evidence_recovered', + delivery_disposition: 'no-change-canary-confirmed', + branch: syncBranchForHash('candidate'), + observed_head_sha: liveHeadSha, + active_review_thread_count: 0, + }); + console.log(`✓ Confirmed exact-head no-change evidence at ${liveHeadSha}`); + continue; + } if (!hasOpenCandidate && requestedSyncHash === 'candidate') { - const mergedCandidate = selectLatestMergedCandidatePr(closedPRs, trustedSyncActors); + const mergedCandidate = selectLatestMergedCandidatePr(closedPRs, trustedSyncActors, { + planId: expectedPlanId, + sourceCommit: expectedSourceCommit, + }); if (mergedCandidate) { candidatePRs = [mergedCandidate]; recoveredMergedCandidate = true; @@ -1073,6 +1277,7 @@ async function run({ github, context, core }) { let selection = selectMergeEligibleSyncPr(candidatePRs, { syncHash: requestedSyncHash, now: new Date().toISOString(), + planId: expectedPlanId, repository: `${owner}/${repo}`, }); if (selection.missingExpected) { @@ -1104,6 +1309,7 @@ async function run({ github, context, core }) { selection = selectMergeEligibleSyncPr(candidatePRs, { syncHash: requestedSyncHash, now: new Date().toISOString(), + planId: expectedPlanId, repository: `${owner}/${repo}`, desiredTreeHash: selectedHeadCommit?.tree?.sha || '', }); @@ -1273,6 +1479,34 @@ async function run({ github, context, core }) { continue; } const metadata = syncMetadata(pr); + if (requestedSyncHash === 'candidate' && !recoveredMergedCandidate) { + const identity = validateExpectedCandidateIdentity({ + metadata, + deliveryRecord: selection.deliveryRecord, + expectedPlanId, + expectedPlanScope, + expectedScopeBaseSha, + expectedSourceCommit, + repository: `${owner}/${repo}`, + }); + if (!identity.ok) { + console.log(`Candidate immutable identity mismatch: ${identity.errors.join(', ')}`); + results.push({ + owner, + repo, + pr: pr.number, + branch: pr.head.ref, + head_sha: pr.head.sha, + status: 'delivery_contract_blocked', + delivery_disposition: 'source-binding-blocked', + blocker_owner: 'maint-68', + next_command: 'refresh-candidate-from-exact-plan', + delivery_reason: 'candidate_immutable_identity_mismatch', + identity_errors: identity.errors, + }); + continue; + } + } console.log(`\nProcessing active PR #${pr.number}: ${pr.title}`); console.log(`Branch: ${pr.head.ref}`); console.log(`Created: ${pr.created_at}`); @@ -1300,56 +1534,19 @@ async function run({ github, context, core }) { } // Combined legacy statuses + every check-run page (paginate returns a flat array). - const { data: combinedStatus } = await withRetry((client) => - client.rest.repos.getCombinedStatusForRef({ - owner, - repo, - ref: pr.head.sha, - }), - ); - const paginatedCheckRuns = await withRetry((client) => - client.paginate(client.rest.checks.listForRef, { - owner, - repo, - ref: pr.head.sha, - per_page: 100, - }), - ); - const statusAsChecks = (combinedStatus.statuses || []).map(legacyStatusAsCheck); - const checkNames = new Set( - paginatedCheckRuns.map((check) => String(check?.name || '').trim()).filter(Boolean), - ); - const allChecks = [ - ...paginatedCheckRuns, - ...statusAsChecks.filter((status) => !checkNames.has(String(status.name || '').trim())), - ]; - const requiredCheckPolicy = await getRequiredContexts({ + const checkEvidence = await classifyRequiredChecksForRef({ owner, repo, branch: pr.base.ref, + ref: pr.head.sha, }); - const requiredContexts = requiredCheckPolicy.contexts; - let classification = requiredContexts.size > 0 - ? classifySyncPrChecks({ checkRuns: allChecks, requiredContexts }) - : { status: 'ready', failed: [], pending: [] }; - // Fail closed: a required context absent from both checks and statuses is not "ready". - if (requiredContexts.size > 0 && classification.status === 'ready') { - const seenNames = new Set( - allChecks.map((check) => String(check?.name || '').trim()).filter(Boolean), - ); - const missingRequired = [...requiredContexts].filter((ctx) => !seenNames.has(ctx)); - if (missingRequired.length > 0) { - classification = { - status: 'checks_pending', - failed: [], - pending: missingRequired.map((name) => ({ name, status: 'queued' })), - }; - } - } - const gatingChecks = requiredContexts.size > 0 - ? selectSyncPrGatingChecks({ checkRuns: allChecks, requiredContexts }) - : []; - const checkGateMode = requiredCheckPolicy.source; + const { + allChecks, + checkGateMode, + classification, + gatingChecks, + requiredContexts, + } = checkEvidence; const failedChecks = classification.failed; const pendingChecks = classification.pending; let deliveryRecord = parseDeliveryRecord(pr.body || ''); diff --git a/.github/scripts/sync_pr_merge_contract.js b/.github/scripts/sync_pr_merge_contract.js index 22df86191..fee2ceef9 100644 --- a/.github/scripts/sync_pr_merge_contract.js +++ b/.github/scripts/sync_pr_merge_contract.js @@ -594,12 +594,32 @@ function selectMergeEligibleSyncPr( return { ...selection, deliveryRecord: record, eligibility }; } -function selectLatestMergedCandidatePr(prs, trustedActors = []) { +function selectLatestMergedCandidatePr( + prs, + trustedActors = [], + { planId = '', sourceCommit = '' } = {}, +) { + const expectedPlanId = String(planId || '').trim(); + const expectedSourceCommit = String(sourceCommit || '').trim().toLowerCase(); const mergedCandidates = (prs || []).filter( - (pr) => - pr?.head?.ref === `${SYNC_BRANCH_PREFIX}candidate` - && Boolean(pr?.merged_at || pr?.mergedAt) - && isTrustedGeneratedDeliveryPr(pr, trustedActors), + (pr) => { + if ( + pr?.head?.ref !== `${SYNC_BRANCH_PREFIX}candidate` + || !Boolean(pr?.merged_at || pr?.mergedAt) + || !isTrustedGeneratedDeliveryPr(pr, trustedActors) + ) { + return false; + } + const record = parseDeliveryRecord(pr.body || ''); + if (expectedPlanId && record?.plan_id !== expectedPlanId) return false; + if ( + expectedSourceCommit + && String(record?.source_commit || '').trim().toLowerCase() !== expectedSourceCommit + ) { + return false; + } + return true; + }, ); return mergedCandidates.sort((a, b) => { const aTime = new Date(a.merged_at || a.mergedAt || a.updated_at || 0).getTime(); @@ -608,6 +628,77 @@ function selectLatestMergedCandidatePr(prs, trustedActors = []) { })[0] || null; } +function validateExpectedCandidateIdentity({ + metadata = null, + deliveryRecord = null, + expectedPlanId = '', + expectedPlanScope = 'full', + expectedScopeBaseSha = '', + expectedSourceCommit = '', + repository = '', +} = {}) { + const expected = { + planId: String(expectedPlanId || '').trim(), + planScope: String(expectedPlanScope || '').trim() || 'full', + scopeBaseSha: String(expectedScopeBaseSha || '').trim().toLowerCase(), + sourceCommit: String(expectedSourceCommit || '').trim().toLowerCase(), + repository: String(repository || '').trim(), + }; + const errors = []; + if (!expected.planId) errors.push('missing_expected_plan_id'); + if (!expected.sourceCommit) errors.push('missing_expected_source_commit'); + if (!metadata) errors.push('missing_sync_metadata'); + if (!deliveryRecord) errors.push('missing_delivery_record'); + if (errors.length > 0) return { ok: false, errors }; + + if (String(metadata.plan_id || '').trim() !== expected.planId) { + errors.push('metadata_plan_id_mismatch'); + } + if (String(deliveryRecord.plan_id || '').trim() !== expected.planId) { + errors.push('delivery_plan_id_mismatch'); + } + if ((String(metadata.plan_scope || '').trim() || 'full') !== expected.planScope) { + errors.push('metadata_plan_scope_mismatch'); + } + if ( + String(metadata.scope_base_sha || '').trim().toLowerCase() + !== expected.scopeBaseSha + ) { + errors.push('metadata_scope_base_sha_mismatch'); + } + if ( + String(metadata.source_commit || '').trim().toLowerCase() + !== expected.sourceCommit + ) { + errors.push('metadata_source_commit_mismatch'); + } + if ( + String(metadata.source_sha || '').trim().toLowerCase() + !== expected.sourceCommit + ) { + errors.push('metadata_source_sha_mismatch'); + } + if ( + String(deliveryRecord.source_commit || '').trim().toLowerCase() + !== expected.sourceCommit + ) { + errors.push('delivery_source_commit_mismatch'); + } + if ( + expected.repository + && String(metadata.consumer_repo || '').trim() !== expected.repository + ) { + errors.push('metadata_repository_mismatch'); + } + if ( + expected.repository + && String(deliveryRecord.repository || '').trim() !== expected.repository + ) { + errors.push('delivery_repository_mismatch'); + } + return { ok: errors.length === 0, errors }; +} + function validateCanaryEvidence(evidence = [], expectedRepos = []) { const expected = new Set((expectedRepos || []).map((repo) => String(repo || '').trim()).filter(Boolean)); const rowsByRepo = new Map(); @@ -1136,6 +1227,7 @@ module.exports = { selectActiveSyncPr, selectMergeEligibleSyncPr, selectLatestMergedCandidatePr, + validateExpectedCandidateIdentity, validateCanaryEvidence, validateSourceDeltaEvidenceBinding, summarizeResults, diff --git a/.github/scripts/sync_run_contract.js b/.github/scripts/sync_run_contract.js index b668392c6..de18b1bda 100644 --- a/.github/scripts/sync_run_contract.js +++ b/.github/scripts/sync_run_contract.js @@ -1,6 +1,79 @@ 'use strict'; const REPORT_SCHEMA = 'workflows-consumer-sync-run/v1'; +const CANARY_EVIDENCE_SCHEMA = 'workflows.consumer-sync-canary-evidence/v1'; + +function buildNoChangeCanaryEvidence({ + results = [], + expectedCanaries = [], + planId = '', + planScope = '', + scopeBaseSha = '', + sourceCommit = '', +} = {}) { + const expected = new Set( + (expectedCanaries || []).map((repo) => String(repo || '').trim()).filter(Boolean), + ); + const rows = []; + const errors = []; + const seen = new Set(); + const normalizedPlanId = String(planId || '').trim(); + const normalizedPlanScope = String(planScope || '').trim() || 'full'; + const normalizedScopeBaseSha = String(scopeBaseSha || '').trim().toLowerCase(); + const normalizedSourceCommit = String(sourceCommit || '').trim().toLowerCase(); + const shaPattern = /^[0-9a-f]{40}$/; + + for (const result of results || []) { + const repo = String(result?.repo || '').trim(); + if (!expected.has(repo) || result?.status !== 'no_changes') continue; + if (seen.has(repo)) { + errors.push(`duplicate_no_change_canary:${repo}`); + continue; + } + seen.add(repo); + const resultPlanId = String(result?.plan_id || '').trim(); + const resultPlanScope = String(result?.plan_scope || '').trim() || 'full'; + const resultScopeBaseSha = String(result?.scope_base_sha || '').trim().toLowerCase(); + const resultSourceCommit = String(result?.source_commit || '').trim().toLowerCase(); + const consumerHeadSha = String(result?.consumer_head_sha || '').trim().toLowerCase(); + if (!normalizedPlanId || resultPlanId !== normalizedPlanId) { + errors.push(`no_change_canary_plan_mismatch:${repo}`); + } + if (resultPlanScope !== normalizedPlanScope) { + errors.push(`no_change_canary_scope_mismatch:${repo}`); + } + if (resultScopeBaseSha !== normalizedScopeBaseSha) { + errors.push(`no_change_canary_scope_base_mismatch:${repo}`); + } + if (!normalizedSourceCommit || resultSourceCommit !== normalizedSourceCommit) { + errors.push(`no_change_canary_source_mismatch:${repo}`); + } + if (!shaPattern.test(consumerHeadSha)) { + errors.push(`no_change_canary_head_invalid:${repo}`); + } + rows.push({ + repo, + plan_id: resultPlanId, + plan_scope: resultPlanScope, + scope_base_sha: resultScopeBaseSha, + source_commit: resultSourceCommit, + head_sha: consumerHeadSha, + evidence_source: 'no-change-canary', + required_check_state: 'success', + active_review_thread_count: 0, + }); + } + + return { + ok: errors.length === 0, + errors, + evidence: { + schema: CANARY_EVIDENCE_SCHEMA, + version: 1, + results: rows, + }, + }; +} function summarizeResults(results) { const counts = { @@ -80,6 +153,8 @@ function buildMarkdownSummary(report) { module.exports = { REPORT_SCHEMA, + CANARY_EVIDENCE_SCHEMA, + buildNoChangeCanaryEvidence, summarizeResults, buildSyncRunReport, buildMarkdownSummary, diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index d2b6ef122..b755d70eb 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -473,6 +473,17 @@ jobs: run: | gh repo clone "$TARGET_REPOSITORY" consumer -- --depth=1 + - name: Record exact consumer base + id: consumer_base + run: | + set -euo pipefail + consumer_head_sha=$(git -C consumer rev-parse HEAD) + [[ "$consumer_head_sha" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::Unable to resolve the consumer base commit" + exit 1 + } + echo "sha=$consumer_head_sha" >> "$GITHUB_OUTPUT" + - name: Download manifest uses: actions/download-artifact@v8 with: @@ -1414,6 +1425,10 @@ jobs: REPO: ${{ matrix.repo }} TEMPLATE_HASH: ${{ needs.prepare.outputs.template_hash }} PLAN_ID: ${{ needs.prepare.outputs.plan_id }} + PLAN_SCOPE: ${{ needs.prepare.outputs.plan_scope }} + SCOPE_BASE_SHA: ${{ needs.prepare.outputs.scope_base_sha }} + SOURCE_COMMIT: ${{ needs.prepare.outputs.source_commit }} + CONSUMER_HEAD_SHA: ${{ steps.consumer_base.outputs.sha || '' }} SYNC_PHASE: ${{ needs.prepare.outputs.phase }} SYNC_BRANCH: ${{ needs.prepare.outputs.sync_branch }} DRY_RUN: ${{ inputs.dry_run || 'false' }} @@ -1462,6 +1477,10 @@ jobs: "status": status, "template_hash": os.environ.get("TEMPLATE_HASH", ""), "plan_id": os.environ.get("PLAN_ID", ""), + "plan_scope": os.environ.get("PLAN_SCOPE", "") or "full", + "scope_base_sha": os.environ.get("SCOPE_BASE_SHA", ""), + "source_commit": os.environ.get("SOURCE_COMMIT", ""), + "consumer_head_sha": os.environ.get("CONSUMER_HEAD_SHA", ""), "sync_phase": os.environ.get("SYNC_PHASE", ""), "expected_branch": os.environ.get("SYNC_BRANCH", ""), "dry_run": dry_run, @@ -1639,8 +1658,64 @@ jobs: sparse-checkout: | .github/actions/setup-api-client .github/scripts + config/consumer_sync_canaries.json sparse-checkout-cone-mode: false + - name: Download per-repository sync results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: consumer-sync-result-* + path: artifacts/consumer-sync-results + merge-multiple: true + + - name: Build exact no-change canary evidence + id: baseline + env: + PLAN_ID: ${{ needs.prepare.outputs.plan_id }} + PLAN_SCOPE: ${{ needs.prepare.outputs.plan_scope }} + SCOPE_BASE_SHA: ${{ needs.prepare.outputs.scope_base_sha }} + SOURCE_COMMIT: ${{ needs.prepare.outputs.source_commit }} + run: | + node <<'NODE' + const fs = require('fs'); + const path = require('path'); + const { + buildNoChangeCanaryEvidence, + } = require('./.github/scripts/sync_run_contract.js'); + 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 resultsDir = 'artifacts/consumer-sync-results'; + const results = fs.existsSync(resultsDir) + ? fs.readdirSync(resultsDir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => JSON.parse( + fs.readFileSync(path.join(resultsDir, name), 'utf8'), + )) + : []; + const baseline = buildNoChangeCanaryEvidence({ + results, + expectedCanaries, + planId: process.env.PLAN_ID, + planScope: process.env.PLAN_SCOPE, + scopeBaseSha: process.env.SCOPE_BASE_SHA, + sourceCommit: process.env.SOURCE_COMMIT, + }); + if (!baseline.ok) { + throw new Error( + `Unsafe no-change canary evidence: ${baseline.errors.join(', ')}`, + ); + } + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `evidence_json=${JSON.stringify(baseline.evidence)}\n`, + ); + NODE + - name: Setup API client uses: ./.github/actions/setup-api-client with: @@ -1652,6 +1727,11 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: SYNC_PHASE: ${{ needs.prepare.outputs.phase }} + PLAN_ID: ${{ needs.prepare.outputs.plan_id }} + PLAN_SCOPE: ${{ needs.prepare.outputs.plan_scope }} + SCOPE_BASE_SHA: ${{ needs.prepare.outputs.scope_base_sha }} + SOURCE_COMMIT: ${{ needs.prepare.outputs.source_commit }} + CANARY_BASELINE_EVIDENCE_JSON: ${{ steps.baseline.outputs.evidence_json }} with: github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} script: | @@ -1676,7 +1756,17 @@ jobs: auto_merge: 'true', dry_run: 'false', cleanup_branches: 'true', + immutable_handoff_json: JSON.stringify({ + plan_id: process.env.PLAN_ID, + plan_scope: process.env.PLAN_SCOPE, + scope_base_sha: process.env.SCOPE_BASE_SHA, + source_commit: process.env.SOURCE_COMMIT, + }), }; + if (activeSyncHash === 'candidate') { + inputs.canary_baseline_evidence_json = + process.env.CANARY_BASELINE_EVIDENCE_JSON || ''; + } if (activeSyncHash === 'delivery') inputs.repos = nonAdminRepos.join(','); await withRetry((client) => client.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, diff --git a/.github/workflows/maint-71-merge-sync-prs.yml b/.github/workflows/maint-71-merge-sync-prs.yml index 475cae58c..5c13846ff 100644 --- a/.github/workflows/maint-71-merge-sync-prs.yml +++ b/.github/workflows/maint-71-merge-sync-prs.yml @@ -55,11 +55,6 @@ on: required: false type: string default: "" - sync_hash: - description: "Deprecated alias for active_sync_hash" - required: false - type: string - default: "" cleanup_branches: description: "Delete sync/workflows-* branches left behind by closed or merged sync PRs" required: false @@ -70,6 +65,17 @@ on: required: false type: string default: "" + immutable_handoff_json: + description: >- + JSON plan binding from Maint 68: plan_id, plan_scope, scope_base_sha, source_commit + required: false + type: string + default: "" + canary_baseline_evidence_json: + description: "Exact no-change canary evidence emitted by Maint 68" + required: false + type: string + default: "" workflow_call: inputs: repos: @@ -108,6 +114,31 @@ on: required: false type: string default: "" + expected_plan_id: + description: "Exact consumer-sync plan expected from the calling Maint 68 run" + required: false + type: string + default: "" + expected_plan_scope: + description: "Expected plan scope for candidate evidence" + required: false + type: string + default: "" + expected_scope_base_sha: + description: "Expected source-delta base SHA, or empty for full scope" + required: false + type: string + default: "" + expected_source_commit: + description: "Expected immutable Workflows source commit" + required: false + type: string + default: "" + canary_baseline_evidence_json: + description: "Exact no-change canary evidence emitted by Maint 68" + required: false + type: string + default: "" permissions: checks: read @@ -153,6 +184,50 @@ jobs: echo "list=${repos}" >> "$GITHUB_OUTPUT" echo "Extracted repos: ${repos}" + - name: Resolve immutable handoff inputs + id: handoff + env: + IMMUTABLE_HANDOFF_JSON: >- + ${{ inputs.immutable_handoff_json || + github.event.client_payload.immutable_handoff_json || '' }} + EXPECTED_PLAN_ID_RAW: >- + ${{ inputs.expected_plan_id || + github.event.client_payload.expected_plan_id || '' }} + EXPECTED_PLAN_SCOPE_RAW: >- + ${{ inputs.expected_plan_scope || + github.event.client_payload.expected_plan_scope || '' }} + EXPECTED_SCOPE_BASE_SHA_RAW: >- + ${{ inputs.expected_scope_base_sha || + github.event.client_payload.expected_scope_base_sha || '' }} + EXPECTED_SOURCE_COMMIT_RAW: >- + ${{ inputs.expected_source_commit || + github.event.client_payload.expected_source_commit || '' }} + run: | + node <<'NODE' + const fs = require('fs'); + const pick = (value) => String(value || '').trim(); + let planId = pick(process.env.EXPECTED_PLAN_ID_RAW); + let planScope = pick(process.env.EXPECTED_PLAN_SCOPE_RAW); + let scopeBaseSha = pick(process.env.EXPECTED_SCOPE_BASE_SHA_RAW); + let sourceCommit = pick(process.env.EXPECTED_SOURCE_COMMIT_RAW); + const rawJson = pick(process.env.IMMUTABLE_HANDOFF_JSON); + if (rawJson) { + try { + const parsed = JSON.parse(rawJson); + if (parsed.plan_id != null) planId = pick(parsed.plan_id); + if (parsed.plan_scope != null) planScope = pick(parsed.plan_scope); + if (parsed.scope_base_sha != null) scopeBaseSha = pick(parsed.scope_base_sha); + if (parsed.source_commit != null) sourceCommit = pick(parsed.source_commit); + } catch (error) { + throw new Error(`immutable_handoff_json is not valid JSON: ${error.message}`); + } + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `expected_plan_id=${planId}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `expected_plan_scope=${planScope}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `expected_scope_base_sha=${scopeBaseSha}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `expected_source_commit=${sourceCommit}\n`); + NODE + - name: Resolve candidate evidence mode id: candidate_mode env: @@ -188,6 +263,11 @@ jobs: AUTO_MERGE_INPUT: "false" DRY_RUN_INPUT: "true" ACTIVE_SYNC_HASH_INPUT: ${{ steps.candidate_mode.outputs.selector }} + EXPECTED_PLAN_ID_INPUT: ${{ steps.handoff.outputs.expected_plan_id }} + EXPECTED_PLAN_SCOPE_INPUT: ${{ steps.handoff.outputs.expected_plan_scope }} + EXPECTED_SCOPE_BASE_SHA_INPUT: ${{ steps.handoff.outputs.expected_scope_base_sha }} + EXPECTED_SOURCE_COMMIT_INPUT: ${{ steps.handoff.outputs.expected_source_commit }} + CANARY_BASELINE_EVIDENCE_JSON: ${{ inputs.canary_baseline_evidence_json || github.event.client_payload.canary_baseline_evidence_json || '' }} CLEANUP_BRANCHES_INPUT: "false" EVIDENCE_ONLY_INPUT: "false" RESOLUTION_ONLY_INPUT: "true" @@ -214,6 +294,11 @@ jobs: AUTO_MERGE_INPUT: "false" DRY_RUN_INPUT: "true" ACTIVE_SYNC_HASH_INPUT: ${{ steps.candidate_mode.outputs.selector }} + EXPECTED_PLAN_ID_INPUT: ${{ steps.handoff.outputs.expected_plan_id }} + EXPECTED_PLAN_SCOPE_INPUT: ${{ steps.handoff.outputs.expected_plan_scope }} + EXPECTED_SCOPE_BASE_SHA_INPUT: ${{ steps.handoff.outputs.expected_scope_base_sha }} + EXPECTED_SOURCE_COMMIT_INPUT: ${{ steps.handoff.outputs.expected_source_commit }} + CANARY_BASELINE_EVIDENCE_JSON: ${{ inputs.canary_baseline_evidence_json || github.event.client_payload.canary_baseline_evidence_json || '' }} CLEANUP_BRANCHES_INPUT: "false" EVIDENCE_ONLY_INPUT: "true" CONSUMER_SYNC_CANARIES_PATH: config/consumer_sync_canaries.json @@ -295,6 +380,11 @@ jobs: AUTO_MERGE_INPUT: ${{ inputs.auto_merge }} DRY_RUN_INPUT: ${{ inputs.dry_run }} ACTIVE_SYNC_HASH_INPUT: ${{ steps.candidate_mode.outputs.selector }} + EXPECTED_PLAN_ID_INPUT: ${{ steps.handoff.outputs.expected_plan_id }} + EXPECTED_PLAN_SCOPE_INPUT: ${{ steps.handoff.outputs.expected_plan_scope }} + EXPECTED_SCOPE_BASE_SHA_INPUT: ${{ steps.handoff.outputs.expected_scope_base_sha }} + EXPECTED_SOURCE_COMMIT_INPUT: ${{ steps.handoff.outputs.expected_source_commit }} + CANARY_BASELINE_EVIDENCE_JSON: ${{ inputs.canary_baseline_evidence_json || github.event.client_payload.canary_baseline_evidence_json || '' }} CANDIDATE_EVIDENCE_RESULT: ${{ steps.candidate_evidence_validation.outcome }} CANDIDATE_ARTIFACT_RESULT: ${{ steps.candidate_artifact.outcome }} CLEANUP_BRANCHES_INPUT: ${{ inputs.cleanup_branches }} diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 4acb3233d..2cd22e40e 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -353,6 +353,20 @@ 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 68 binds that handoff to the exact plan ID, plan scope, source range, and +source commit. A canary that already matches the plan contributes explicit +`no-change-canary` evidence containing its observed default-branch SHA; Maint 71 +accepts it only while that SHA is still the live default-branch head and its +active-ruleset required checks are green. Recovery +from a previously merged candidate is likewise restricted to the dispatching +plan and source commit. Thus a no-diff run cannot silently recycle an older +merged candidate and promote stale content. If no open candidate, current +no-change evidence, or same-plan merged candidate exists, the evidence pass +fails closed. The remediation is to rerun Maint 68 for the intended immutable +full plan or source range, never to reuse an older evidence artifact. These +rules are enforced by `sync_run_contract.js`, `maint71_merge_sync_prs.js`, and +`sync_pr_merge_contract.js`, with the workflow carrying the immutable fields +between those boundaries. 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 diff --git a/tests/workflows/test_sync_manifest_delivery.py b/tests/workflows/test_sync_manifest_delivery.py index 67c658a84..2a891d1bf 100644 --- a/tests/workflows/test_sync_manifest_delivery.py +++ b/tests/workflows/test_sync_manifest_delivery.py @@ -224,6 +224,7 @@ def test_sync_fanout_is_canary_gated_and_promotion_is_plan_bound() -> None: dispatch_inputs = workflow.get("on", workflow.get(True))["workflow_dispatch"]["inputs"] prepare = workflow["jobs"]["prepare"] sync = workflow["jobs"]["sync"] + continuation = workflow["jobs"]["continue-delivery"] source = SYNC_WORKFLOW_PATH.read_text(encoding="utf-8") assert dispatch_inputs["phase"]["default"] == "canary" @@ -258,6 +259,14 @@ def test_sync_fanout_is_canary_gated_and_promotion_is_plan_bound() -> None: assert "--draft" in source assert "sync:delivery-staging" in source assert "sync:delivery-ready" in source + continuation_names = [step.get("name") for step in continuation["steps"]] + assert "Build exact no-change canary evidence" in continuation_names + assert "Record exact consumer base" in [step.get("name") for step in sync["steps"]] + assert "immutable_handoff_json: JSON.stringify({" in source + assert "plan_id: process.env.PLAN_ID" in source + assert "source_commit: process.env.SOURCE_COMMIT" in source + assert "canary_baseline_evidence_json" in source + assert '"consumer_head_sha": os.environ.get("CONSUMER_HEAD_SHA", "")' in source assert "autofix: false" in source reusable_autofix = REUSABLE_AUTOFIX_PATH.read_text(encoding="utf-8") assert '[[ "$head_ref" == sync/workflows-* ]]' in reusable_autofix @@ -300,8 +309,12 @@ def test_maint_71_persists_validated_candidate_evidence_before_merge() -> None: names = [step.get("name") for step in steps] assert "active_sync_hash" in dispatch_inputs - assert dispatch_inputs["sync_hash"]["description"] == "Deprecated alias for active_sync_hash" - resolve_index = names.index("Resolve candidate evidence mode") + assert "immutable_handoff_json" in dispatch_inputs + assert "canary_baseline_evidence_json" in dispatch_inputs + assert "sync_hash" not in dispatch_inputs + resolve_index = names.index("Resolve immutable handoff inputs") + candidate_mode_index = names.index("Resolve candidate evidence mode") + assert resolve_index < candidate_mode_index collect_index = names.index("Collect and validate canary evidence before merge") persist_index = names.index("Persist pre-merge canary evidence") merge_index = names.index("Check and merge sync PRs") @@ -320,6 +333,9 @@ def test_maint_71_persists_validated_candidate_evidence_before_merge() -> None: assert "github.event.client_payload.sync_hash" in source assert "CANDIDATE_EVIDENCE_RESULT" in source assert "CANDIDATE_ARTIFACT_RESULT" in source + assert "EXPECTED_PLAN_ID_INPUT" in source + assert "EXPECTED_SOURCE_COMMIT_INPUT" in source + assert "CANARY_BASELINE_EVIDENCE_JSON" in source assert "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3" in source assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in source