From a3eda8e4b83c5bfb1b6e69c946b0ad6a87c22e47 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Sat, 1 Aug 2026 20:41:11 -0500 Subject: [PATCH 1/2] fix(sync): close #2881 verifier CONCERNS gaps Add shared-source-failure classification, exception lifecycle observability, three-run/deliberate-break fingerprint proofs, and document the remote handoff schema so the Maint 71/82 contract matches #2881 acceptance. --- .../sync_dependency_campaign.test.js | 130 ++++++++++++++++++ ...test.js => sync_pr_merge_contract.test.js} | 29 ++++ .github/scripts/sync_dependency_campaign.js | 30 ++++ .github/scripts/sync_pr_merge_contract.js | 20 ++- .github/workflows/maint-71-merge-sync-prs.yml | 2 +- docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md | 48 ++++++- 6 files changed, 251 insertions(+), 8 deletions(-) rename .github/scripts/__tests__/{sync-pr-merge-contract.test.js => sync_pr_merge_contract.test.js} (93%) diff --git a/.github/scripts/__tests__/sync_dependency_campaign.test.js b/.github/scripts/__tests__/sync_dependency_campaign.test.js index fbd3e3a60..6d377698c 100644 --- a/.github/scripts/__tests__/sync_dependency_campaign.test.js +++ b/.github/scripts/__tests__/sync_dependency_campaign.test.js @@ -82,6 +82,136 @@ test('mergeCampaignState counts observed handoffs from the current payload only' assert.equal(state.delivery_handoffs.length, 2); }); +test('three consecutive identical exceptions create one claim generation and one agent handoff', () => { + const item = buildQueueItem({ + repoFullName: 'stranske/Ready', + pr: { + number: 42, + title: 'sync workflows', + html_url: 'https://github.com/stranske/Ready/pull/42', + head: { sha: 'abc123', ref: 'sync/workflows-deadbeef' }, + base: { ref: 'main' }, + }, + threads: [{ id: 'thread-1', isResolved: false, isOutdated: false, comments_count: 1 }], + classification: 'sync', + now: '2026-08-02T00:00:00Z', + defaultOwner: 'stranske', + }); + + let state = {}; + const observedAts = [ + '2026-08-02T00:00:00Z', + '2026-08-02T01:00:00Z', + '2026-08-02T02:00:00Z', + ]; + for (const observedAt of observedAts) { + state = mergeCampaignState(state, [{ ...item }], observedAt); + } + + const matching = state.items.filter((entry) => entry.id === item.id); + assert.equal(matching.length, 1); + assert.equal(matching[0].attempts, 0); + assert.equal(matching[0].status, 'needs-local-codex'); + assert.equal(state.stats.items_claimable_local_codex, 1); + assert.equal(state.stats.exception_lifecycle.new, 0); + assert.equal(state.stats.exception_lifecycle.unchanged, 1); + assert.equal(state.stats.exception_lifecycle.resolved, 0); + assert.equal(state.stats.exception_lifecycle.re_opened, 0); + assert.match( + formatCampaignBody(state), + /Exception lifecycle \(new\/unchanged\/resolved\/re-opened\): 0\/1\/0\/0/, + ); +}); + +test('deliberate-break: timestamp-only identity creates duplicate handoffs; real fingerprint does not', () => { + const base = buildQueueItem({ + repoFullName: 'stranske/Ready', + pr: { + number: 77, + title: 'sync workflows', + html_url: 'https://github.com/stranske/Ready/pull/77', + head: { sha: 'fff111', ref: 'sync/workflows-cafe' }, + base: { ref: 'main' }, + }, + threads: [{ id: 'thread-a', isResolved: false, isOutdated: false, comments_count: 2 }], + classification: 'sync', + now: '2026-08-02T00:00:00Z', + defaultOwner: 'stranske', + }); + + // Broken identity wrongly folds updated_at into the fingerprint/id. + const brokenDiscoveries = [ + { ...base, id: `${base.id}:2026-08-02T00:00:00Z` }, + { ...base, id: `${base.id}:2026-08-02T01:00:00Z` }, + { ...base, id: `${base.id}:2026-08-02T02:00:00Z` }, + ]; + let broken = {}; + for (let i = 0; i < brokenDiscoveries.length; i += 1) { + broken = mergeCampaignState(broken, [brokenDiscoveries[i]], `2026-08-02T0${i}:00:00Z`); + } + assert.equal( + broken.items.filter((entry) => entry.id.startsWith(`${base.id}:`)).length, + 3, + 'timestamp-in-id break must surface as duplicate handoffs', + ); + assert.equal(broken.stats.exception_lifecycle.new, 1); + + // Correct fingerprint ignores wall-clock updated_at. + let good = {}; + for (const observedAt of ['2026-08-02T00:00:00Z', '2026-08-02T01:00:00Z', '2026-08-02T02:00:00Z']) { + good = mergeCampaignState(good, [{ ...base }], observedAt); + } + assert.equal(good.items.filter((entry) => entry.id === base.id).length, 1); + assert.equal(good.stats.items_claimable_local_codex, 1); + assert.equal(good.stats.exception_lifecycle.unchanged, 1); +}); + +test('exception lifecycle counts new, resolved, and re-opened transitions', () => { + const first = buildQueueItem({ + repoFullName: 'stranske/Ready', + pr: { + number: 9, + title: 'sync', + html_url: 'https://github.com/stranske/Ready/pull/9', + head: { sha: 'aaa', ref: 'sync/workflows-aaa' }, + base: { ref: 'main' }, + }, + threads: [{ id: 't1', isResolved: false, isOutdated: false, comments_count: 1 }], + classification: 'sync', + now: '2026-08-02T00:00:00Z', + defaultOwner: 'stranske', + }); + const second = buildQueueItem({ + repoFullName: 'stranske/Ready', + pr: { + number: 10, + title: 'sync', + html_url: 'https://github.com/stranske/Ready/pull/10', + head: { sha: 'bbb', ref: 'sync/workflows-bbb' }, + base: { ref: 'main' }, + }, + threads: [{ id: 't2', isResolved: false, isOutdated: false, comments_count: 1 }], + classification: 'sync', + now: '2026-08-02T00:00:00Z', + defaultOwner: 'stranske', + }); + + const opened = mergeCampaignState({}, [first, second], '2026-08-02T00:00:00Z'); + assert.deepEqual(opened.stats.exception_lifecycle, { + new: 2, unchanged: 0, resolved: 0, re_opened: 0, + }); + + const resolvedOne = mergeCampaignState(opened, [first], '2026-08-02T01:00:00Z'); + assert.deepEqual(resolvedOne.stats.exception_lifecycle, { + new: 0, unchanged: 1, resolved: 1, re_opened: 0, + }); + + const reopened = mergeCampaignState(resolvedOne, [first, second], '2026-08-02T02:00:00Z'); + assert.deepEqual(reopened.stats.exception_lifecycle, { + new: 0, unchanged: 1, resolved: 0, re_opened: 1, + }); +}); + test('formats and parses campaign marker', () => { const state = { schema: 'sync-dependabot-campaign/v1', diff --git a/.github/scripts/__tests__/sync-pr-merge-contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js similarity index 93% rename from .github/scripts/__tests__/sync-pr-merge-contract.test.js rename to .github/scripts/__tests__/sync_pr_merge_contract.test.js index 246adc7b4..b7c1cd8fe 100644 --- a/.github/scripts/__tests__/sync-pr-merge-contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -85,7 +85,36 @@ test('generated delivery classification gives sync and dev-tool lanes identical assert.equal(classifyGeneratedPr({ pr: candidate, activeReviewThreadCount: 1, now: '2026-08-01T00:00:00Z' }).disposition, 'review-blocked'); assert.equal(classifyGeneratedPr({ pr: candidate, activeReviewThreadCount: -1, now: '2026-08-01T00:00:00Z' }).next_command, 'retry-review-thread-query'); assert.equal(classifyGeneratedPr({ pr: candidate, checkState: { status: 'checks_failed' }, now: '2026-08-01T00:00:00Z' }).disposition, 'repo-local-failure'); + assert.equal( + classifyGeneratedPr({ + pr: candidate, + checkState: { status: 'checks_failed', failure_scope: 'shared-source' }, + now: '2026-08-01T00:00:00Z', + }).disposition, + 'shared-source-failure', + ); } + + const sharedGate = classifySyncPrChecks({ + checkRuns: [checkRun({ name: 'Gate / gate', conclusion: 'failure' })], + requiredContexts: ['Gate / gate'], + }); + assert.equal(sharedGate.status, 'checks_failed'); + assert.equal(sharedGate.failure_scope, 'shared-source'); + assert.equal( + classifyGeneratedPr({ pr: sync, checkState: sharedGate, now: '2026-08-01T00:00:00Z' }).disposition, + 'shared-source-failure', + ); + + const localFail = classifySyncPrChecks({ + checkRuns: [checkRun({ name: 'unit-tests', conclusion: 'failure' })], + requiredContexts: ['unit-tests'], + }); + assert.equal(localFail.failure_scope, 'repo-local'); + assert.equal( + classifyGeneratedPr({ pr: sync, checkState: localFail, now: '2026-08-01T00:00:00Z' }).disposition, + 'repo-local-failure', + ); }); test('selectActiveSyncPr honors target hash instead of newest PR', () => { diff --git a/.github/scripts/sync_dependency_campaign.js b/.github/scripts/sync_dependency_campaign.js index 576b48408..6ef553c46 100644 --- a/.github/scripts/sync_dependency_campaign.js +++ b/.github/scripts/sync_dependency_campaign.js @@ -412,11 +412,18 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, ); const failedRepos = new Set(parseCsv(options.failedRepos)); const nextItems = []; + const exceptionLifecycle = { + new: 0, + unchanged: 0, + resolved: 0, + re_opened: 0, + }; for (const discovered of discoveredItems) { const previous = previousById.get(discovered.id); const sourceFixedCandidate = sourceFixedCandidateFor(discovered, finishedBySourceReviewKey); if (!previous) { + exceptionLifecycle.new += 1; const item = { ...discovered, status: discovered.status || 'needs-local-codex', @@ -432,6 +439,11 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, }); continue; } + if (ACTIVE_STATUSES.has(previous.status)) { + exceptionLifecycle.unchanged += 1; + } else { + exceptionLifecycle.re_opened += 1; + } let status = previous.status || 'needs-local-codex'; let lease = previous.lease || null; @@ -480,6 +492,7 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, updated_at: now, }); } else if (ACTIVE_STATUSES.has(previous.status)) { + exceptionLifecycle.resolved += 1; nextItems.push({ ...previous, status: 'stale', @@ -513,6 +526,8 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, ...buildStats(items, discoveredItems, options), // Count only this run's payload observations, not the retained durable total. delivery_handoffs_observed: observedIncomingHandoffs.length, + // Per-run exception lifecycle (new / unchanged / resolved / re-opened). + exception_lifecycle: exceptionLifecycle, }, source_review_history: sourceReviewHistory, delivery_handoffs: deliveryHandoffs, @@ -1014,6 +1029,11 @@ function formatCampaignBody(state) { `- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`, `- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`, `- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`, + `- Exception lifecycle (new/unchanged/resolved/re-opened): ` + + `${Number(stats.exception_lifecycle?.new || 0)}/` + + `${Number(stats.exception_lifecycle?.unchanged || 0)}/` + + `${Number(stats.exception_lifecycle?.resolved || 0)}/` + + `${Number(stats.exception_lifecycle?.re_opened || 0)}`, `- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`, `- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`, `- Claimed local Codex items: ${claims.count || 0}`, @@ -1185,6 +1205,11 @@ function formatCompactCampaignBody(state) { `- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`, `- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`, `- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`, + `- Exception lifecycle (new/unchanged/resolved/re-opened): ` + + `${Number(stats.exception_lifecycle?.new || 0)}/` + + `${Number(stats.exception_lifecycle?.unchanged || 0)}/` + + `${Number(stats.exception_lifecycle?.resolved || 0)}/` + + `${Number(stats.exception_lifecycle?.re_opened || 0)}`, `- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`, `- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`, `- Claimed local Codex items: ${claims.count || 0}`, @@ -1555,6 +1580,11 @@ function formatCampaignRunSummaryMarkdown(state = {}, issue = null) { `- Claimable local Codex items: ${stats.items_claimable_local_codex || 0}`, `- Source-fixed candidates: ${stats.items_source_fixed_candidates || 0}`, `- Superseded sync candidates: ${stats.items_superseded_sync_candidates || 0}`, + `- Exception lifecycle (new/unchanged/resolved/re-opened): ` + + `${Number(stats.exception_lifecycle?.new || 0)}/` + + `${Number(stats.exception_lifecycle?.unchanged || 0)}/` + + `${Number(stats.exception_lifecycle?.resolved || 0)}/` + + `${Number(stats.exception_lifecycle?.re_opened || 0)}`, `- Source sync states: ${formatSourceSyncStatusCounts(stats.source_sync_status_counts)}`, `- Finished local results without published source changes: ${stats.items_unpublished_source_results || 0}`, `- Items claimed: ${stats.items_claimed || 0}`, diff --git a/.github/scripts/sync_pr_merge_contract.js b/.github/scripts/sync_pr_merge_contract.js index e64310208..1fa53aa95 100644 --- a/.github/scripts/sync_pr_merge_contract.js +++ b/.github/scripts/sync_pr_merge_contract.js @@ -84,6 +84,14 @@ function classifyGeneratedPr({ pr = {}, checkState = {}, activeReviewThreadCount return { disposition: 'awaiting-checks', blocker_owner: 'ci', next_command: 'await-required-checks' }; } if (checkState.status === 'checks_failed') { + const failureScope = String(checkState.failure_scope || '').trim().toLowerCase(); + if (failureScope === 'shared-source' || checkState.shared_source === true) { + return { + disposition: 'shared-source-failure', + blocker_owner: 'source', + next_command: 'repair-shared-source-and-redeliver', + }; + } return { disposition: 'repo-local-failure', blocker_owner: 'repo', next_command: 'repair-required-checks' }; } return { disposition: 'current', blocker_owner: 'maint-71', next_command: 'merge-current-delivery' }; @@ -178,6 +186,15 @@ function selectSyncPrGatingChecks({ ); } +// Workflows-owned / template-propagated gates. Consumer app test names stay repo-local. +const SHARED_SOURCE_CHECK_RE = + /\b(gate(?:\s*\/\s*gate)?|health(?:\s*\d+)?|workflow|template|consumer\s*sync|sync\s*templates?)\b/i; + +function isSharedSourceFailedCheck(check = {}) { + const name = String(check?.name || check?.context || '').trim(); + return Boolean(name) && SHARED_SOURCE_CHECK_RE.test(name); +} + function classifySyncPrChecks({ checkRuns = [], requiredContexts = [], @@ -198,7 +215,8 @@ function classifySyncPrChecks({ const pending = gatingChecks.filter((check) => check?.status !== 'completed'); if (failed.length > 0) { - return { status: 'checks_failed', failed, pending: [] }; + const failure_scope = failed.some(isSharedSourceFailedCheck) ? 'shared-source' : 'repo-local'; + return { status: 'checks_failed', failure_scope, failed, pending: [] }; } if (pending.length > 0) { return { status: 'checks_pending', failed: [], pending }; diff --git a/.github/workflows/maint-71-merge-sync-prs.yml b/.github/workflows/maint-71-merge-sync-prs.yml index ddd47154d..d3c08d0ac 100644 --- a/.github/workflows/maint-71-merge-sync-prs.yml +++ b/.github/workflows/maint-71-merge-sync-prs.yml @@ -11,7 +11,7 @@ # - Repository dispatch (event_type: merge-sync-prs) for Codespace triggers # # Safety: -# - Only merges PRs with sync/workflows-* branch names +# - Merges trusted generated delivery PRs (sync/workflows-* and deps/sync-dev-versions-*) # - Requires all checks to pass # - Uses merge commit (not squash) to preserve sync history diff --git a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md index 7b0a6fb83..a48a5c222 100644 --- a/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md +++ b/docs/ops/SYNC_DEPENDENCY_CAMPAIGN.md @@ -12,13 +12,49 @@ both `sync/workflows-*` consumer-sync branches and - current attempts with passing required checks and no active review threads may be merged; -- pending checks, active reviews, and repository-local failures retain a - precise owner and next command; +- pending checks, active reviews, repository-local failures, and shared-source + failures retain a precise owner and next command; - expired or superseded attempts are closed rather than revived; and - legacy attempts without a record require an explicit, one-time provenance decision before any merge. -Maint 82 owns the durable campaign state and only requests local agent work -when an actionable exception 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. +Maint 82 (`maint-82-sync-dependency-campaign.yml`) owns the durable campaign +state and only requests local agent work when an actionable exception +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. + +## Remote delivery handoff schema (`workflows-generated-delivery-handoff/v1`) + +Maint 71 emits normalized result records (artifact + best-effort +`repository_dispatch` payload field `delivery_handoff_records`). Maint 82 +persists them in the campaign marker as `delivery_handoffs`. + +Required fields: + +| Field | Meaning | +| --- | --- | +| `schema` | Always `workflows-generated-delivery-handoff/v1` | +| `repository` | `owner/repo` for the generated PR | +| `pr` | Generated PR number | +| `head_sha` | Head commit SHA observed at reconciliation | +| `delivery_generation` | Lease/delivery generation from the PR record | +| `disposition` | One of: `current`, `awaiting-checks`, `review-blocked`, `repo-local-failure`, `shared-source-failure`, `superseded`, `expired`, `owner-decision` (terminal rewrite may set `merged` / `closed`) | +| `blocker_owner` | Exact owner for the next action (`maint-71`, `ci`, `closer`, `repo`, `source`, …) | +| `next_command` | Exact resume command/token for opener/closer | +| `check_state` | Check summary (`ready`, `checks_pending`, `checks_failed`, …) | +| `review_state` | Review summary (`clear`, `blocked`, …) | + +Optional fields retained when present: `branch`, `lane` (`sync` / +`dev-tool-sync`), `observed_at`. + +Exception fingerprints used for local Codex handoff include repository, PR +number, head SHA, and active review-thread identity. `updated_at` is metadata +only and must not create a new fingerprint or claim generation. + +## Local-consumer migration (operator follow-up) + +Repository automation publishes the remote handoff schema above. Updating any +local watcher / opener / closer consumer to read `delivery_handoffs` (instead of +re-deriving merge/close policy) is an **operator follow-up**, not additional +repository implementation work for this contract. From 28ba8a05cd8d453e2a3167835add140fd2aa7141 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 1 Aug 2026 21:26:05 -0500 Subject: [PATCH 2/2] fix(sync): classify failures by causal ownership --- .../sync_dependency_campaign.test.js | 1 + .../__tests__/sync_pr_merge_contract.test.js | 30 +++++++++++++++---- .github/scripts/sync_dependency_campaign.js | 8 +++-- .github/scripts/sync_pr_merge_contract.js | 9 ++++-- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/scripts/__tests__/sync_dependency_campaign.test.js b/.github/scripts/__tests__/sync_dependency_campaign.test.js index 6d377698c..e6647eb46 100644 --- a/.github/scripts/__tests__/sync_dependency_campaign.test.js +++ b/.github/scripts/__tests__/sync_dependency_campaign.test.js @@ -210,6 +210,7 @@ test('exception lifecycle counts new, resolved, and re-opened transitions', () = assert.deepEqual(reopened.stats.exception_lifecycle, { new: 0, unchanged: 1, resolved: 0, re_opened: 1, }); + assert.equal(reopened.items.find((item) => item.id === second.id).status, 'needs-local-codex'); }); test('formats and parses campaign marker', () => { diff --git a/.github/scripts/__tests__/sync_pr_merge_contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js index b7c1cd8fe..3982f207f 100644 --- a/.github/scripts/__tests__/sync_pr_merge_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -95,17 +95,35 @@ test('generated delivery classification gives sync and dev-tool lanes identical ); } - const sharedGate = classifySyncPrChecks({ - checkRuns: [checkRun({ name: 'Gate / gate', conclusion: 'failure' })], - requiredContexts: ['Gate / gate'], + const sharedHealth = classifySyncPrChecks({ + checkRuns: [checkRun({ name: 'Health 40 Sweep', conclusion: 'failure' })], + requiredContexts: ['Health 40 Sweep'], }); - assert.equal(sharedGate.status, 'checks_failed'); - assert.equal(sharedGate.failure_scope, 'shared-source'); + assert.equal(sharedHealth.status, 'checks_failed'); + assert.equal(sharedHealth.failure_scope, 'shared-source'); assert.equal( - classifyGeneratedPr({ pr: sync, checkState: sharedGate, now: '2026-08-01T00:00:00Z' }).disposition, + classifyGeneratedPr({ pr: sync, checkState: sharedHealth, now: '2026-08-01T00:00:00Z' }).disposition, 'shared-source-failure', ); + const aggregateGate = classifySyncPrChecks({ + checkRuns: [checkRun({ name: 'Gate / gate', conclusion: 'failure' })], + requiredContexts: ['Gate / gate'], + }); + assert.equal(aggregateGate.failure_scope, 'repo-local'); + + const localWorkflowCheck = classifySyncPrChecks({ + checkRuns: [checkRun({ name: 'workflow integration tests', conclusion: 'failure' })], + requiredContexts: ['workflow integration tests'], + }); + assert.equal(localWorkflowCheck.failure_scope, 'repo-local'); + + const explicitlyShared = classifySyncPrChecks({ + checkRuns: [{ ...checkRun({ name: 'Gate / gate', conclusion: 'failure' }), shared_source: true }], + requiredContexts: ['Gate / gate'], + }); + assert.equal(explicitlyShared.failure_scope, 'shared-source'); + const localFail = classifySyncPrChecks({ checkRuns: [checkRun({ name: 'unit-tests', conclusion: 'failure' })], requiredContexts: ['unit-tests'], diff --git a/.github/scripts/sync_dependency_campaign.js b/.github/scripts/sync_dependency_campaign.js index 6ef553c46..c0da5cf4d 100644 --- a/.github/scripts/sync_dependency_campaign.js +++ b/.github/scripts/sync_dependency_campaign.js @@ -445,7 +445,11 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, exceptionLifecycle.re_opened += 1; } - let status = previous.status || 'needs-local-codex'; + // A rediscovered inactive exception is actionable again. Retaining `stale` + // would report it as re-opened while silently keeping it out of the claim queue. + let status = ACTIVE_STATUSES.has(previous.status) + ? previous.status + : 'needs-local-codex'; let lease = previous.lease || null; const attempts = Number(previous.attempts || 0); @@ -473,7 +477,7 @@ function mergeCampaignState(previousState = {}, discoveredItems = [], nowValue, attempts, lease: status === 'local-codex-claimed' ? lease : null, result: previous.result || null, - source_fixed_candidate: sourceFixedCandidate || previous.source_fixed_candidate || null, + source_fixed_candidate: sourceFixedCandidate || null, }; item.status = localCodexClaimableStatus(item, item.status); nextItems.push({ diff --git a/.github/scripts/sync_pr_merge_contract.js b/.github/scripts/sync_pr_merge_contract.js index 1fa53aa95..0149466d8 100644 --- a/.github/scripts/sync_pr_merge_contract.js +++ b/.github/scripts/sync_pr_merge_contract.js @@ -187,12 +187,17 @@ function selectSyncPrGatingChecks({ } // Workflows-owned / template-propagated gates. Consumer app test names stay repo-local. +// Keep these identities deliberately narrow: an aggregate Gate only reports that a +// consumer required context failed; it does not identify the failed Gate leg. const SHARED_SOURCE_CHECK_RE = - /\b(gate(?:\s*\/\s*gate)?|health(?:\s*\d+)?|workflow|template|consumer\s*sync|sync\s*templates?)\b/i; + /^(?:health\s+\d+(?:\s|$)|consumer\s+sync(?:\s|$)|sync\s+templates?(?:\s|$))/i; function isSharedSourceFailedCheck(check = {}) { const name = String(check?.name || check?.context || '').trim(); - return Boolean(name) && SHARED_SOURCE_CHECK_RE.test(name); + const explicitScope = String(check?.failure_scope || check?.source || '').trim().toLowerCase(); + return check?.shared_source === true || + explicitScope === 'shared-source' || + (Boolean(name) && SHARED_SOURCE_CHECK_RE.test(name)); } function classifySyncPrChecks({