diff --git a/.github/workflows/smart-ci-shadow.yml b/.github/workflows/smart-ci-shadow.yml index de51d57b7..e890a2660 100644 --- a/.github/workflows/smart-ci-shadow.yml +++ b/.github/workflows/smart-ci-shadow.yml @@ -119,6 +119,8 @@ jobs: --base-ref "$BASE_REF" \ --merge-out artifacts/merge-sha.txt \ --tree-out artifacts/merge-tree-sha.txt \ + --merge-base-out artifacts/merge-base-sha.txt \ + --merge-base-tip-out artifacts/merge-base-tip-sha.txt \ --note-out artifacts/merge-ref-note.txt - name: Plan @@ -131,10 +133,13 @@ jobs: if [ -s artifacts/merge-ref-note.txt ]; then plan_args+=(--note "$(cat artifacts/merge-ref-note.txt)") fi - if [ -s artifacts/merge-sha.txt ] && [ -s artifacts/merge-tree-sha.txt ]; then + if [ -s artifacts/merge-sha.txt ] && [ -s artifacts/merge-tree-sha.txt ] \ + && [ -s artifacts/merge-base-sha.txt ] && [ -s artifacts/merge-base-tip-sha.txt ]; then plan_args+=( --merge-sha "$(cat artifacts/merge-sha.txt)" --merge-tree-sha "$(cat artifacts/merge-tree-sha.txt)" + --merge-base-sha "$(cat artifacts/merge-base-sha.txt)" + --merge-base-tip-sha "$(cat artifacts/merge-base-tip-sha.txt)" ) fi node scripts/ci/smart-ci/plan.mjs \ diff --git a/ci/schemas/ci-plan.v1.schema.json b/ci/schemas/ci-plan.v1.schema.json index f622f73eb..4c31242a4 100644 --- a/ci/schemas/ci-plan.v1.schema.json +++ b/ci/schemas/ci-plan.v1.schema.json @@ -28,6 +28,8 @@ "headSha": { "type": ["string", "null"] }, "mergeSha": { "type": ["string", "null"] }, "mergeTreeSha": { "type": ["string", "null"], "description": "tree of refs/pull/N/merge at plan time — the binding the landed verifier matches against HEAD^{tree} (CI-03)" }, + "mergeBaseSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40}$", "description": "observed first parent of the same accepted refs/pull/N/merge observation as mergeSha and mergeTreeSha; null for non-PR and error plans" }, + "mergeBaseTipSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40}$", "description": "authenticated live base-ref tip when the accepted first parent moved after dispatch; null for an exact control-base match, non-PR plan, or error plan" }, "actor": { "type": "object", "additionalProperties": false, @@ -113,5 +115,9 @@ "required": ["name", "message"], "properties": { "name": { "type": "string" }, "message": { "type": "string" } } } + }, + "dependentRequired": { + "mergeBaseSha": ["mergeBaseTipSha"], + "mergeBaseTipSha": ["mergeBaseSha"] } } diff --git a/ci/schemas/ci-run.v1.schema.json b/ci/schemas/ci-run.v1.schema.json index 063aa99e5..2625eb1cf 100644 --- a/ci/schemas/ci-run.v1.schema.json +++ b/ci/schemas/ci-run.v1.schema.json @@ -19,7 +19,7 @@ "additionalProperties": false, "required": ["code", "detail"], "properties": { - "code": { "enum": ["plan-missing", "plan-invalid", "planner-error", "plan-job-failed", "plan-job-cancelled", "head-sha-mismatch", "base-sha-mismatch", "policy-digest-mismatch", "trust-mismatch", "labels-mismatch", "mode-mismatch", "evidence-unavailable", "selected-evidence-missing", "selected-not-success", "evidence-sha-missing", "evidence-wrong-sha", "skipped-without-reason"] }, + "code": { "enum": ["plan-missing", "plan-invalid", "planner-error", "plan-job-failed", "plan-job-cancelled", "head-sha-mismatch", "base-sha-mismatch", "merge-base-binding-mismatch", "policy-digest-mismatch", "trust-mismatch", "labels-mismatch", "mode-mismatch", "evidence-unavailable", "selected-evidence-missing", "selected-not-success", "evidence-sha-missing", "evidence-wrong-sha", "skipped-without-reason"] }, "detail": { "type": "string" } } } @@ -32,6 +32,8 @@ "headSha": { "type": ["string", "null"] }, "mergeSha": { "type": ["string", "null"] }, "mergeTreeSha": { "type": ["string", "null"] }, + "mergeBaseSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40}$", "description": "observed merge-ref first parent copied from the plan; null when the plan is missing, legacy, non-PR, or errored" }, + "mergeBaseTipSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40}$", "description": "authenticated moved-base tip copied from the plan; null for an exact control-base match or unavailable binding" }, "risk": { "type": ["string", "null"] }, "trust": { "type": ["string", "null"] }, "escalated": { "type": ["boolean", "null"] }, @@ -54,5 +56,9 @@ } }, "generatedAtUtc": { "type": "string", "format": "date-time" } + }, + "dependentRequired": { + "mergeBaseSha": ["mergeBaseTipSha"], + "mergeBaseTipSha": ["mergeBaseSha"] } } diff --git a/docs/ci/SMART_CI.md b/docs/ci/SMART_CI.md index 8e412501f..74675fd74 100644 --- a/docs/ci/SMART_CI.md +++ b/docs/ci/SMART_CI.md @@ -112,9 +112,12 @@ event head (`CONTROL_HEAD`) to match the merge ref's second parent **exactly** untrusted side and is never negotiable — while the first parent may be either the dispatch-time control base (`CONTROL_BASE`) or the base branch's live tip on origin — the same branch whose commit already supplies the control-plane tooling — which the resolver reads itself with -`--base-ref` before accepting. Nothing untrusted enters the binding. An accepted move is recorded as a `merge-ref-moved` planner note in the receipt. +`--base-ref` before accepting. Nothing untrusted enters the binding. The plan records the accepted +observation structurally: `mergeBaseSha` is the merge commit's observed first parent, and +`mergeBaseTipSha` is the authenticated live tip only for an accepted move (otherwise `null`). The +optional `merge-ref-moved` note is explanatory text and is never parsed as evidence. Any other first parent, an unreadable base branch tip, or any head mismatch still fails closed with no -merge/tree outputs, which the planner turns into a `planner-error` plan. Before this rule +merge/tree/base outputs, which the planner turns into a `planner-error` plan. Before this rule (CI-03 #2327) an ordinary base push produced a false red on a healthy PR; because an error plan pins its trust class to `T3` by construction, the gate's re-derivation also emitted a misleading `trust-mismatch` failure alongside it — that comparison is now reported as a note instead, and @@ -134,7 +137,8 @@ decided on that observation evidence, not in advance (SC-4, no pre-ruling 2026-0 ## 8. Receipts and reports Every gate run writes `ci-run.json` (schema `ci/schemas/ci-run.v1.schema.json`): SHAs and merge -tree, policy digest, risk/trust, selected/skipped with reasons, per-job runner class / hosted flag / +tree, observed merge first parent and optional authenticated moved-base tip, policy digest, +risk/trust, selected/skipped with reasons, per-job runner class / hosted flag / queue / setup / test / total seconds / allowance-minute estimate / tests run-failed-skipped / rerun / cache hit / artifact bytes; summary critical path, aggregate runner seconds, hosted minutes and cost estimate, self-hosted wall seconds, flake and duplicate-qualification flags. Names, ids, timestamps @@ -143,11 +147,19 @@ P50/P95 critical path, minutes per merged PR, hosted cost, queue delay, selectio per lane, flake rate, slow-test regressions, duplicate exact-SHA runs, cache utility and storage. Provisional budgets: R0/R1 ≤5 min, R2 ≤10, R3 ≤20, main verifier ≤5 — a regression names the lane. +The version-1 executable reader accepts a legacy plan only when both `mergeBaseSha` and +`mergeBaseTipSha` are absent; it then uses `baseSha` as the historical parent fallback and explicitly +makes no claim that the new first-parent receipt was captured. A partial pair, malformed SHA, or +binding mismatch is invalid. New successful PR plans always carry both fields; non-PR and error plans +carry both as `null`. Because the old documentation schemas use `additionalProperties: false`, an old +schema cannot validate a new receipt containing these fields; consumers must use the updated schema. + The shadow recall report is read-only and uses GitHub REST plus `gh run download`; it does not need GraphQL project quota. It counts unique merged PRs, while retaining every measurable head and rerun attempt so an earlier failure cannot disappear behind a later green run. A PR is usable only when all of its collected evidence is exact and its final head has a successful required run. The plan's fetched -merge commit must have the recorded base and head parents and merge tree, and the landed merge commit +merge commit must have the recorded observed first parent and head parent plus merge tree; the observed +first parent must be `baseSha` or the matching recorded `mergeBaseTipSha`. The landed merge commit must match the final base, head and tree. Missing, duplicate, expired, stale or mismatched evidence fails closed. A lane family is ready only after at least 20 usable merged PRs, an actual failure in that family, 100% recall and no missed failure anywhere in the sample. Exit codes are `0` ready, `1` diff --git a/docs/product/LAUNCH_KIT.md b/docs/product/LAUNCH_KIT.md index 8f28acc0a..017caf199 100644 --- a/docs/product/LAUNCH_KIT.md +++ b/docs/product/LAUNCH_KIT.md @@ -24,7 +24,7 @@ sentence that cannot inherit one of these shipped sources. | A workspace is local SQLite data the operator controls; back up its accompanying local configuration/keys too. | [Shipped local-first direction](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L354-L354), [README local-first ownership](../../README.md), and [upgrade guide](../../UPGRADING.md) | Operator | 2026-09-02 | | Captured text can become source-linked proposals; the review/apply loop is a separate, explicit user decision. | [Live-verified proposal loop](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L105-L105) | Product maintainer | 2026-09-02 | | Untouched v0.3 builds have no automatic usage ping, crash reporter, update check, analytics script, or background destination. Configured LLMs, connectors, webhooks, login, Sentry, and OTLP are separate, user/operator-enabled egress. | [Shipped v0.3 telemetry statement](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L55-L55) and [telemetry policy](../TELEMETRY.md) | Release maintainer | 2026-09-02 | -| Agent-originated board changes are review-first: proposal, review, approval, then an explicit Apply confirmation. Single-proposal Apply remains a separate action. The API can execute a selected batch of already-Approved proposals, up to 500, and returns an independent outcome for each item without whole-batch rollback; a request with no executable item may collapse to 404/403. The current Paper batch control deliberately offers only the reviewer's own, live, non-deferred, exact-Low, create-card-only approved proposals; other approved proposals retain individual Apply. | [Shipped batch endpoint contract](../../backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs), [Paper eligibility boundary](../../frontend/taskdeck-web/src/composables/useBatchExecuteProposals.ts), [per-item receipt shape](../../backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs), [D-4(a) ruling](../STATUS.md#L848), and [Windows quick start](../releases/WINDOWS_QUICK_START.md) | Product maintainer | 2026-09-07 | +| Agent-originated board changes are review-first: proposal, review, approval, then an explicit Apply confirmation. Single-proposal Apply remains a separate action. The API can execute a selected batch of already-Approved proposals, up to 500, and returns an independent outcome for each item without whole-batch rollback. The current Paper batch control covers live, non-deferred Approved proposals: shared-board proposals are eligible regardless of author, while boardless proposals require ownership by the signed-in reviewer. The server rechecks each item's access, status, policy, and approved-revision pin; a request with no executable item may collapse to 404/403. Batch approval remains narrower. | [Shipped batch endpoint contract](../../backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs), [batch authorization checks](../../backend/src/Taskdeck.Application/Services/BatchProposalExecutionService.cs), [Paper eligibility boundary](../../frontend/taskdeck-web/src/composables/useBatchExecuteProposals.ts), [per-item receipt shape](../../backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs), [D-4(a) ruling](../STATUS.md#L848), and [Windows quick start](../releases/WINDOWS_QUICK_START.md) | Product maintainer | 2026-09-07 | | Encrypted backup/restore and connector verification exist for the supported Docker deployment. The recovery objectives are objectives, not measured guarantees. | [Shipped recovery receipt](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L39-L39), [PR #2360](https://github.com/Chris0Jeky/Taskdeck/pull/2360), [PR #2361](https://github.com/Chris0Jeky/Taskdeck/pull/2361), and [disaster-recovery runbook](../ops/DISASTER_RECOVERY_RUNBOOK.md) | Recovery operator | 2026-09-02 | | Windows ZIP checksums are published; the current ZIP is unsigned. | [Shipped ZIP/checksum receipt](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L31-L35), [published-artifact journey](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L121-L121), and [Windows quick start](../releases/WINDOWS_QUICK_START.md) | Release maintainer | 2026-09-02 | | The core is GPL-3.0-only; earlier MIT releases retain the grants already made. | [Shipped licensing record](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L281-L281), [licensing follow-up](https://github.com/Chris0Jeky/Taskdeck/blob/dcd258af262a0b7179b58ac3fb36f744f92255da/docs/STATUS.md#L366-L366), [licensing policy](../../LICENSING.md), [GPL text](../../LICENSE), and [ADR-0050](../decisions/ADR-0050-gplv3-copyleft-core.md) | Maintainer/legal owner | 2026-09-02 | @@ -122,10 +122,13 @@ them in Review, approve them, and then Apply is a separate confirmation. A proposal is not a board mutation. Single-proposal Apply remains explicit. The API can execute a selected batch of already-Approved proposals, up to 500 in one request, with an independent `Applied`, `Skipped`, or `Failed` outcome for -each item and no whole-batch rollback. In the current Paper UI, batch Apply is -deliberately limited to the reviewer's own live, non-deferred, exact-Low, -create-card-only approved proposals; other approved proposals use individual -Apply. A request with no executable item may collapse to 404/403. +each item and no whole-batch rollback. In the current Paper UI, batch Apply +covers live, non-deferred Approved proposals. Shared-board proposals are eligible +regardless of author; boardless proposals require ownership by the signed-in +reviewer. The server rechecks each item's access, status, policy, and +approved-revision pin. Batch Apply still requires explicit confirmation, accepts +up to 500 selected proposals, and a request with no executable item may collapse +to 404/403. Batch approval remains narrower. The Windows artifact is currently unsigned, so SmartScreen may say “Windows protected your PC.” Only continue after downloading from the official release @@ -178,11 +181,13 @@ or speaker diarization, artefact extraction is not wired to a request path, and MFA TOTP seeds remain unencrypted at rest in its single-node SQLite data. There is no hosted instance. Single-proposal Apply remains explicit. The API can execute a selected batch of already-Approved proposals, up to 500, and reports -`Applied`, `Skipped`, or `Failed` independently for each item; the current -Paper UI deliberately limits batch Apply to the reviewer's own live, -non-deferred, exact-Low, create-card-only approved proposals. Other approved -proposals use individual Apply; a request with no executable item may collapse -to 404/403. +`Applied`, `Skipped`, or `Failed` independently for each item; the current Paper +UI covers live, non-deferred Approved proposals. Shared-board proposals are +eligible regardless of author; boardless proposals require ownership by the +signed-in reviewer. The server rechecks each item's access, status, policy, and +approved-revision pin. Batch Apply still requires explicit confirmation, accepts +up to 500 selected proposals, and a request with no executable item may collapse +to 404/403. Batch approval remains narrower. **First comment:** @@ -192,10 +197,13 @@ extraction is not wired to a request path, and MFA TOTP seeds remain unencrypted at rest in the single-node SQLite data. Single-proposal Apply remains explicit. The API can execute a selected batch of already-Approved proposals, up to 500, and returns `Applied`, `Skipped`, or `Failed` for each item without rolling back -successful neighbours. The current Paper UI deliberately limits batch Apply to -the reviewer's own live, non-deferred, exact-Low, create-card-only approved -proposals; other approved proposals use individual Apply. A request with no -executable item may collapse to 404/403. +successful neighbours. The current Paper UI covers live, non-deferred Approved +proposals. Shared-board proposals are eligible regardless of author; boardless +proposals require ownership by the signed-in reviewer. The server rechecks each +item's access, status, policy, and approved-revision pin. Batch Apply still +requires explicit confirmation, accepts up to 500 selected proposals, and a +request with no executable item may collapse to 404/403. Batch approval remains +narrower. Use the [approved public release page](https://github.com/Chris0Jeky/taskdeck-release/releases) and [public security policy](https://github.com/Chris0Jeky/taskdeck-release/blob/main/SECURITY.md); there is no hosted instance and the public support route is available only after @@ -245,10 +253,12 @@ MFA TOTP seeds remain unencrypted at rest in the single-node SQLite data. There is no hosted instance. Single-proposal Apply remains explicit; a separate batch Apply can execute a selected set of already-Approved proposals, up to 500, and reports `Applied`, `Skipped`, or `Failed` independently for each item. -The current Paper UI deliberately limits batch Apply to the reviewer's own live, -non-deferred, exact-Low, create-card-only approved proposals. Other approved -proposals use individual Apply; a request with no executable item may collapse -to 404/403. +The current Paper UI covers live, non-deferred Approved proposals. Shared-board +proposals are eligible regardless of author; boardless proposals require +ownership by the signed-in reviewer. The server rechecks each item's access, +status, policy, and approved-revision pin. Batch Apply still requires explicit +confirmation, accepts up to 500 selected proposals, and a request with no +executable item may collapse to 404/403. Batch approval remains narrower. ### awesome-selfhosted — do not submit yet @@ -274,10 +284,13 @@ rest. The release does not ingest audio or diarize speakers, and artefact extraction is not wired to a request path. Single-proposal Apply remains explicit. The API can execute a selected batch of already-Approved proposals, up to 500, with an independent `Applied`, `Skipped`, or `Failed` outcome for -each item. The current Paper UI deliberately limits batch Apply to the -reviewer's own live, non-deferred, exact-Low, create-card-only approved -proposals. Other approved proposals use individual Apply; a request with no -executable item may collapse to 404/403. Use the +each item. The current Paper UI covers live, non-deferred Approved proposals. +Shared-board proposals are eligible regardless of author; boardless proposals +require ownership by the signed-in reviewer. The server rechecks each item's +access, status, policy, and approved-revision pin. Batch Apply still requires +explicit confirmation, accepts up to 500 selected proposals, and a request with +no executable item may collapse to 404/403. Batch approval remains narrower. +Use the [approved public source and release mirror](https://github.com/Chris0Jeky/taskdeck-release) and its [public security policy](https://github.com/Chris0Jeky/taskdeck-release/blob/main/SECURITY.md) after the publication gate has passed. @@ -322,10 +335,13 @@ policy summary, not legal advice. - Apply remains an explicit action for one proposal. The API's separate batch Apply path accepts a selected set of already-Approved proposals, up to 500 per request, and returns `Applied`, `Skipped`, or `Failed` independently for - each item; there is no whole-batch rollback. The current Paper UI deliberately - limits batch Apply to the reviewer's own live, non-deferred, exact-Low, - create-card-only approved proposals. Other approved proposals use individual - Apply. +each item; there is no whole-batch rollback. The current Paper UI covers live, +non-deferred Approved proposals. Shared-board proposals are eligible regardless +of author; boardless proposals require ownership by the signed-in reviewer. +Batch Apply still requires explicit confirmation, accepts up to 500 selected +proposals, and reports independent outcomes without whole-batch rollback. The +server rechecks each item's access, status, policy, and approved-revision pin; +batch approval remains narrower. - There is no hosted instance. Do not turn the v0.4 direction into a current availability claim. @@ -364,9 +380,11 @@ policy summary, not legal advice. > it. Known limits include no audio ingestion/diarization, unwired artefact > extraction, unencrypted TOTP seeds at rest, and explicit single/batch Apply. > The API batch is bounded at 500 selected already-Approved proposals and -> reports an independent result for each item. The current Paper UI limits it -> to the reviewer's own live, non-deferred, exact-Low, create-card-only approved -> proposals. Please +> reports an independent result for each item. The current Paper UI covers live, +> non-deferred Approved proposals. Shared-board proposals are eligible regardless +> of author; boardless proposals require ownership by the signed-in reviewer. +> Batch Apply still requires explicit confirmation, and the server rechecks each +> item's access, status, policy, and approved-revision pin. Please > report reproducible non-security bugs with redacted steps; never post > secrets, keys, or private workspace data. Suspected vulnerabilities must not > be posted as a public issue, discussion, or PR; use the private diff --git a/scripts/ci/smart-ci/control-trust.test.mjs b/scripts/ci/smart-ci/control-trust.test.mjs index fe7e2d975..1408fc88e 100644 --- a/scripts/ci/smart-ci/control-trust.test.mjs +++ b/scripts/ci/smart-ci/control-trust.test.mjs @@ -6,7 +6,7 @@ import { test } from 'node:test'; const WORKFLOW_DIRECTORY = new URL('../../../.github/workflows/', import.meta.url); const SUPPORTED_WORKFLOW = 'smart-ci-shadow.yml'; const EXPECTED_JOB_IDS = ['plan', 'required-gate']; -const EXPECTED_WORKFLOW_FINGERPRINT = '3d1c24768d218402f250f7c6fef7c02f113984811113ef449cdf8bb6dff1d182'; +const EXPECTED_WORKFLOW_FINGERPRINT = '7735fc735753bd0ad30820d231ba308fd8a386351a4bb9d172366dad03f465bb'; // These fingerprints cover executable step configuration. Step names and comment-only lines are // deliberately omitted. A changed action, input, environment binding, condition, or run body must @@ -17,8 +17,8 @@ const EXPECTED_STEP_FINGERPRINTS = { 'a6342a9bd46d724db04e7559ae7b51b73153a22b474d52167f86da0934f1a2c3', '6ee8ac0fb69ff35cc55766271d8547c83c1133f77cb21e546b51074bd026d12a', '3e6e7741bbee62150621607b3a3a686c291f896e4268c9adef72a914a57bdcdc', - 'f4e7fb7d1587380193bb4b87dc9a48802923412c47485d7c76e7f7a4de5008d9', - '0b1a3547a5895f0f13c4ebf219755c818193cc8fa88d7cbebb6857ba72757508', + 'ab7b91b6f5aae2854a98ec0f10a97982e38792721776852dd0019d3c0e13791b', + 'c800841e0bb44197cb09f1df284704ea1ca855a054df99db529340c4e0f2ad27', '5be7249fea7aef1261b2b749b3bbc3e2ad69333891b95bf7135b53f02c36eff2', ], 'required-gate': [ diff --git a/scripts/ci/smart-ci/evaluate-gate.mjs b/scripts/ci/smart-ci/evaluate-gate.mjs index 534a7ed7d..e109fb8eb 100644 --- a/scripts/ci/smart-ci/evaluate-gate.mjs +++ b/scripts/ci/smart-ci/evaluate-gate.mjs @@ -103,6 +103,8 @@ function main() { headSha: plan ? plan.headSha : null, mergeSha: plan ? plan.mergeSha : null, mergeTreeSha: plan ? plan.mergeTreeSha : null, + mergeBaseSha: plan ? plan.mergeBaseSha ?? null : null, + mergeBaseTipSha: plan ? plan.mergeBaseTipSha ?? null : null, risk: plan ? plan.risk : null, trust: plan ? plan.trust : null, escalated: plan ? plan.escalated : null, diff --git a/scripts/ci/smart-ci/lib/plan.mjs b/scripts/ci/smart-ci/lib/plan.mjs index 52e9fc1c3..1ddb76503 100644 --- a/scripts/ci/smart-ci/lib/plan.mjs +++ b/scripts/ci/smart-ci/lib/plan.mjs @@ -46,6 +46,10 @@ function isStringArray(value) { return Array.isArray(value) && value.every(isNonEmptyString); } +function isFullSha(value) { + return /^[0-9a-f]{40}$/i.test(String(value ?? '')); +} + /** Structural validation of a policy document. Returns a list of error strings (empty = valid). */ export function validatePolicy(policy) { const errors = []; @@ -217,7 +221,7 @@ function uniqueSorted(values) { /** * Build the deterministic plan. * @param {object} input see README: eventName, repository, pullRequestNumber, ref, isDraft, baseSha, - * headSha, mergeSha, mergeTreeSha, actorLogin, actorType, authorAssociation, isFork, labels, + * headSha, mergeSha, mergeTreeSha, mergeBaseSha, mergeBaseTipSha, actorLogin, actorType, authorAssociation, isFork, labels, * changedFiles, changedFilesAvailable, executionMode * @param {object} policy parsed policy document * @param {string} digest policyDigest() of the policy file bytes @@ -319,6 +323,8 @@ export function buildPlan(input, policy, digest) { headSha: input.headSha ?? null, mergeSha: input.mergeSha ?? null, mergeTreeSha: input.mergeTreeSha ?? null, + mergeBaseSha: input.mergeBaseSha ?? null, + mergeBaseTipSha: input.mergeBaseTipSha ?? null, actor: { login: input.actorLogin ?? null, type: input.actorType ?? null, @@ -359,6 +365,8 @@ export function errorPlan(input, policy, digest, error) { headSha: input.headSha ?? null, mergeSha: input.mergeSha ?? null, mergeTreeSha: input.mergeTreeSha ?? null, + mergeBaseSha: null, + mergeBaseTipSha: null, actor: { login: input.actorLogin ?? null, type: input.actorType ?? null, association: input.authorAssociation ?? null, isFork: input.isFork === true, sender: input.senderLogin ?? null, headActors: Array.isArray(input.headActors) ? uniqueSorted(input.headActors.map(String).filter(Boolean)) : [] }, labels: uniqueSorted((input.labels ?? []).map(String)), trust: 'T3', @@ -396,6 +404,27 @@ export function validatePlan(plan, policy = null) { if (!/^sha256:[0-9a-f]{64}$/.test(String(plan.policyDigest ?? ''))) errors.push('policyDigest must be sha256:'); if (!['shadow', 'enforce'].includes(plan.mode)) errors.push('mode must be shadow or enforce'); if (!plan.event || !Object.hasOwn(plan.event, 'action') || (plan.event.action !== null && typeof plan.event.action !== 'string')) errors.push('event.action must be a string or null'); + const hasMergeBaseSha = Object.hasOwn(plan, 'mergeBaseSha'); + const hasMergeBaseTipSha = Object.hasOwn(plan, 'mergeBaseTipSha'); + if (hasMergeBaseSha !== hasMergeBaseTipSha) { + errors.push('mergeBaseSha and mergeBaseTipSha must both be present or both be absent'); + } else if (hasMergeBaseSha) { + const successfulPullRequestPlan = Number.isInteger(plan.event && plan.event.pullRequest) && !plan.plannerError; + if (successfulPullRequestPlan) { + if (!isFullSha(plan.mergeBaseSha)) errors.push('mergeBaseSha must be a full 40-character Git SHA'); + if (!(plan.mergeBaseTipSha === null || isFullSha(plan.mergeBaseTipSha))) errors.push('mergeBaseTipSha must be null or a full 40-character Git SHA'); + if (isFullSha(plan.mergeBaseSha)) { + if (plan.mergeBaseTipSha === null && plan.mergeBaseSha !== plan.baseSha) { + errors.push('mergeBaseSha must equal plan.baseSha when mergeBaseTipSha is null'); + } else if (isFullSha(plan.mergeBaseTipSha)) { + if (plan.mergeBaseSha !== plan.mergeBaseTipSha) errors.push('mergeBaseSha must equal mergeBaseTipSha for an accepted moved base'); + if (plan.mergeBaseTipSha === plan.baseSha) errors.push('mergeBaseTipSha must be null when the merge ref used plan.baseSha'); + } + } + } else if (plan.mergeBaseSha !== null || plan.mergeBaseTipSha !== null) { + errors.push('non-PR and error plans must record null merge-base metadata'); + } + } if (!TRUST_CLASSES.includes(plan.trust)) errors.push('trust must be T0..T4'); if (!RISK_ORDER.includes(plan.risk)) errors.push('risk must be R0..R4'); if (typeof plan.escalated !== 'boolean') errors.push('escalated must be boolean'); @@ -436,7 +465,7 @@ export function validatePlan(plan, policy = null) { return errors; } -const PLANNER_FAILURE_CODES = new Set(['plan-missing', 'plan-invalid', 'planner-error', 'plan-job-failed', 'policy-digest-mismatch', 'head-sha-mismatch', 'base-sha-mismatch', 'trust-mismatch', 'labels-mismatch', 'mode-mismatch']); +const PLANNER_FAILURE_CODES = new Set(['plan-missing', 'plan-invalid', 'planner-error', 'plan-job-failed', 'policy-digest-mismatch', 'head-sha-mismatch', 'base-sha-mismatch', 'merge-base-binding-mismatch', 'trust-mismatch', 'labels-mismatch', 'mode-mismatch']); /** * Evaluate the gate. @@ -468,6 +497,20 @@ export function evaluateGate(plan, context) { } if (context.expectedHeadSha && plan.headSha !== context.expectedHeadSha) failures.push({ code: 'head-sha-mismatch', detail: `plan ${plan.headSha} vs event ${context.expectedHeadSha}` }); if (context.expectedBaseSha && plan.baseSha !== context.expectedBaseSha) failures.push({ code: 'base-sha-mismatch', detail: `plan ${plan.baseSha} vs event ${context.expectedBaseSha}` }); + const hasMergeBaseSha = Object.hasOwn(plan, 'mergeBaseSha'); + const hasMergeBaseTipSha = Object.hasOwn(plan, 'mergeBaseTipSha'); + if (!hasMergeBaseSha && !hasMergeBaseTipSha) { + notes.push('legacy plan has no observed merge-base receipt; plan.baseSha remains the compatibility fallback without the new first-parent proof'); + } else if (hasMergeBaseSha && hasMergeBaseTipSha && Number.isInteger(plan.event && plan.event.pullRequest) && !plan.plannerError) { + const observedMatchesControl = plan.mergeBaseSha === plan.baseSha && plan.mergeBaseTipSha === null; + const observedMatchesLiveTip = isFullSha(plan.mergeBaseTipSha) + && plan.mergeBaseSha === plan.mergeBaseTipSha + && plan.mergeBaseTipSha !== plan.baseSha; + if (!observedMatchesControl && !observedMatchesLiveTip) failures.push({ + code: 'merge-base-binding-mismatch', + detail: `observed first parent ${plan.mergeBaseSha} is not plan base ${plan.baseSha} or its recorded distinct live tip ${plan.mergeBaseTipSha}`, + }); + } if (context.expectedPolicyDigest && plan.policyDigest !== context.expectedPolicyDigest) failures.push({ code: 'policy-digest-mismatch', detail: `plan ${plan.policyDigest} vs policy ${context.expectedPolicyDigest}` }); if (context.eventInput && context.policy) { const expectedTrust = classifyTrust(context.eventInput, plan.controlPathsChanged ?? [], context.policy); diff --git a/scripts/ci/smart-ci/merge-base-roundtrip.test.mjs b/scripts/ci/smart-ci/merge-base-roundtrip.test.mjs new file mode 100644 index 000000000..96f538fbc --- /dev/null +++ b/scripts/ci/smart-ci/merge-base-roundtrip.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { policyDigest } from './lib/plan.mjs'; +import { normaliseObservation } from './recall-report.mjs'; + +const repository = 'Chris0Jeky/Taskdeck'; +const policyPath = fileURLToPath(new URL('../../../ci/policy.v1.json', import.meta.url)); +const policyText = readFileSync(policyPath, 'utf8'); +const policy = JSON.parse(policyText); +const resolverPath = fileURLToPath(new URL('./resolve-merge-ref.mjs', import.meta.url)); +const plannerPath = fileURLToPath(new URL('./plan.mjs', import.meta.url)); +const gatePath = fileURLToPath(new URL('./evaluate-gate.mjs', import.meta.url)); + +test('resolver, planner, gate receipt, and recall preserve one exact merge-base observation', async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'taskdeck-merge-base-roundtrip-')); + const origin = join(fixtureRoot, 'origin.git'); + const source = join(fixtureRoot, 'source'); + const checkout = join(fixtureRoot, 'checkout'); + const artifacts = join(fixtureRoot, 'artifacts'); + const gitEnvironment = { + ...process.env, + GIT_AUTHOR_NAME: 'Taskdeck Smart CI Test', + GIT_AUTHOR_EMAIL: 'smart-ci-test@example.invalid', + GIT_COMMITTER_NAME: 'Taskdeck Smart CI Test', + GIT_COMMITTER_EMAIL: 'smart-ci-test@example.invalid', + }; + const git = (cwd, ...args) => execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: gitEnvironment, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + + try { + git(fixtureRoot, 'init', '--bare', origin); + git(fixtureRoot, 'init', source); + git(source, 'config', 'user.name', gitEnvironment.GIT_AUTHOR_NAME); + git(source, 'config', 'user.email', gitEnvironment.GIT_AUTHOR_EMAIL); + writeFileSync(join(source, 'fixture.txt'), 'base\n'); + git(source, 'add', 'fixture.txt'); + git(source, 'commit', '-m', 'Create base'); + git(source, 'branch', '-M', 'main'); + const baseSha = git(source, 'rev-parse', 'HEAD'); + + git(source, 'switch', '-c', 'feature'); + writeFileSync(join(source, 'fixture.txt'), 'head\n'); + git(source, 'commit', '-am', 'Create head'); + const headSha = git(source, 'rev-parse', 'HEAD'); + + git(source, 'switch', 'main'); + git(source, 'merge', '--no-ff', '--no-edit', 'feature'); + const mergeSha = git(source, 'rev-parse', 'HEAD'); + const mergeTreeSha = git(source, 'rev-parse', 'HEAD^{tree}'); + const originUrl = pathToFileURL(origin).href; + git(source, 'remote', 'add', 'origin', originUrl); + git(source, 'push', 'origin', + `${baseSha}:refs/heads/main`, + `${headSha}:refs/heads/feature`, + `${mergeSha}:refs/pull/1/merge`); + git(fixtureRoot, 'clone', '--no-checkout', '--depth=1', '--branch', 'main', originUrl, checkout); + + const mergePath = join(artifacts, 'merge-sha.txt'); + const treePath = join(artifacts, 'merge-tree-sha.txt'); + const mergeBasePath = join(artifacts, 'merge-base-sha.txt'); + const mergeBaseTipPath = join(artifacts, 'merge-base-tip-sha.txt'); + execFileSync(process.execPath, [ + resolverPath, + '--pr', '1', + '--base', baseSha, + '--head', headSha, + '--base-ref', 'main', + '--merge-out', mergePath, + '--tree-out', treePath, + '--merge-base-out', mergeBasePath, + '--merge-base-tip-out', mergeBaseTipPath, + ], { + cwd: checkout, + env: { ...process.env, GH_TOKEN: 'synthetic-fixture-token' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + assert.equal(readFileSync(mergeBasePath, 'utf8').trim(), baseSha); + assert.equal(readFileSync(mergeBaseTipPath, 'utf8').trim(), 'null'); + + const eventPath = join(fixtureRoot, 'event.json'); + const changedFilesPath = join(fixtureRoot, 'changed-files.tsv'); + const planPath = join(artifacts, 'ci-plan.json'); + const receiptPath = join(artifacts, 'ci-run.json'); + const event = { + action: 'synchronize', + repository: { full_name: repository, owner: { login: 'Chris0Jeky' } }, + sender: { login: 'Chris0Jeky', type: 'User' }, + pull_request: { + number: 1, + draft: false, + changed_files: 1, + base: { sha: baseSha, ref: 'main', repo: { full_name: repository } }, + head: { sha: headSha, ref: 'feature', repo: { full_name: repository } }, + user: { login: 'Chris0Jeky', type: 'User' }, + author_association: 'OWNER', + labels: [], + }, + }; + writeFileSync(eventPath, `${JSON.stringify(event)}\n`); + writeFileSync(changedFilesPath, 'modified\tdocs/example.md\t\n'); + execFileSync(process.execPath, [ + plannerPath, + '--policy', policyPath, + '--event', eventPath, + '--event-name', 'pull_request_target', + '--base-sha', baseSha, + '--head-actors', 'Chris0Jeky', + '--changed-files', changedFilesPath, + '--changed-files-expected', '1', + '--merge-sha', readFileSync(mergePath, 'utf8').trim(), + '--merge-tree-sha', readFileSync(treePath, 'utf8').trim(), + '--merge-base-sha', readFileSync(mergeBasePath, 'utf8').trim(), + '--merge-base-tip-sha', readFileSync(mergeBaseTipPath, 'utf8').trim(), + '--out', planPath, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + execFileSync(process.execPath, [ + gatePath, + '--plan', planPath, + '--policy', policyPath, + '--event', eventPath, + '--event-name', 'pull_request_target', + '--head-actors', 'Chris0Jeky', + '--expected-head', headSha, + '--expected-base', baseSha, + '--plan-job-result', 'success', + '--receipt', receiptPath, + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const plan = JSON.parse(readFileSync(planPath, 'utf8')); + const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); + assert.deepEqual( + [plan.mergeBaseSha, plan.mergeBaseTipSha, receipt.mergeBaseSha, receipt.mergeBaseTipSha], + [baseSha, null, baseSha, null], + ); + + const mergedAt = '2026-09-01T12:00:00.000Z'; + const raw = { + repository, + prNumber: 1, + mergedAt, + headSha, + finalHeadSha: headSha, + headBranch: 'feature', + headRepository: repository, + baseSha, + baseBranch: 'main', + baseRepository: repository, + mergeCommitSha: mergeSha, + mergeCommit: { sha: mergeSha, parents: [baseSha, headSha], treeSha: mergeTreeSha }, + headPullRequests: [1], + artifact: { id: 10, name: `smart-ci-plan-1-${headSha}`, expired: false, workflowRunId: 20, headSha, headBranch: 'feature', createdAt: '2026-09-01T09:02:00.000Z', updatedAt: '2026-09-01T09:03:00.000Z' }, + shadowRun: { id: 20, path: '.github/workflows/smart-ci-shadow.yml', event: 'pull_request_target', status: 'completed', conclusion: 'success', headSha, headBranch: 'feature', headRepository: repository, createdAt: '2026-09-01T09:00:00.000Z', updatedAt: '2026-09-01T09:05:00.000Z', pullRequests: [1] }, + plan, + planMergeCommit: { sha: mergeSha, parents: [baseSha, headSha], treeSha: mergeTreeSha }, + requiredRun: { id: 30, path: '.github/workflows/ci-required.yml', event: 'pull_request', status: 'completed', conclusion: 'success', headSha, headBranch: 'feature', headRepository: repository, triggerCreatedAt: '2026-09-01T09:01:00.000Z', createdAt: '2026-09-01T10:00:00.000Z', updatedAt: '2026-09-01T11:00:00.000Z', runAttempt: 1, pullRequests: [1] }, + jobs: [{ name: 'Docs Governance / Docs Governance', status: 'completed', conclusion: 'success', runId: 30, runAttempt: 1, headSha }], + }; + const recalled = normaliseObservation(raw, policy, { + repository, + since: '2026-09-01T00:00:00.000Z', + until: '2026-09-01T23:59:59.000Z', + policyDigest: policyDigest(policyText), + }); + assert.equal(recalled.usable, true, recalled.errors.join(', ')); + assert.equal(recalled.mergeBaseSha, baseSha); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}); diff --git a/scripts/ci/smart-ci/plan.mjs b/scripts/ci/smart-ci/plan.mjs index f66214a3a..77b50156f 100644 --- a/scripts/ci/smart-ci/plan.mjs +++ b/scripts/ci/smart-ci/plan.mjs @@ -4,7 +4,8 @@ // node scripts/ci/smart-ci/plan.mjs --policy ci/policy.v1.json \ // --event "$GITHUB_EVENT_PATH" --changed-files changed.txt \ // [--event-name pull_request_target] [--execution-mode hosted] \ -// [--merge-sha --merge-tree-sha ] --out artifacts/ci-plan.json [--summary "$GITHUB_STEP_SUMMARY"] +// [--merge-sha --merge-tree-sha --merge-base-sha +// --merge-base-tip-sha ] --out artifacts/ci-plan.json [--summary "$GITHUB_STEP_SUMMARY"] // // Reads ONLY metadata: the event payload (SHAs, actor login/type/association, labels, // draft/fork flags) and a changed-file list. It never reads file contents and never @@ -16,7 +17,7 @@ import { dirname } from 'node:path'; import { buildPlan, errorPlan, policyDigest, renderPlanSummary } from './lib/plan.mjs'; function parseArgs(argv) { - const args = { policy: 'ci/policy.v1.json', event: null, eventName: process.env.GITHUB_EVENT_NAME ?? null, changedFiles: null, changedFilesExpected: null, headActors: null, headActorsKnown: false, notes: [], executionMode: null, mergeSha: null, mergeTreeSha: null, out: 'artifacts/ci-plan.json', summary: null, overrides: {} }; + const args = { policy: 'ci/policy.v1.json', event: null, eventName: process.env.GITHUB_EVENT_NAME ?? null, changedFiles: null, changedFilesExpected: null, headActors: null, headActorsKnown: false, notes: [], executionMode: null, mergeSha: null, mergeTreeSha: null, mergeBaseSha: undefined, mergeBaseTipSha: undefined, out: 'artifacts/ci-plan.json', summary: null, overrides: {} }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const next = () => argv[++index]; @@ -28,6 +29,12 @@ function parseArgs(argv) { case '--execution-mode': args.executionMode = next(); break; case '--merge-sha': args.mergeSha = next(); break; case '--merge-tree-sha': args.mergeTreeSha = next(); break; + case '--merge-base-sha': args.mergeBaseSha = next(); break; + case '--merge-base-tip-sha': { + const value = next(); + args.mergeBaseTipSha = value === 'null' ? null : value; + break; + } case '--changed-files-expected': args.changedFilesExpected = Number(next()); break; case '--head-actors': args.headActors = next().split(',').map((value) => value.trim()).filter(Boolean); args.headActorsKnown = true; break; case '--note': args.notes.push(next()); break; @@ -43,7 +50,7 @@ function parseArgs(argv) { case '--fork': args.overrides.isFork = true; break; case '--labels': args.overrides.labels = next().split(',').map((label) => label.trim()).filter(Boolean); break; case '--help': - console.log('usage: plan.mjs --policy (--event | --base-sha S --head-sha S [--actor L] [--association A] [--fork] [--labels a,b] [--pr N]) --changed-files [--event-name N] [--execution-mode M] [--merge-sha S --merge-tree-sha S] --out [--summary ]'); + console.log('usage: plan.mjs --policy (--event | --base-sha S --head-sha S [--actor L] [--association A] [--fork] [--labels a,b] [--pr N]) --changed-files [--event-name N] [--execution-mode M] [--merge-sha S --merge-tree-sha S --merge-base-sha S --merge-base-tip-sha S|null] --out [--summary ]'); process.exit(0); break; default: throw new Error(`Unknown argument: ${arg}`); @@ -58,6 +65,18 @@ export function requirePullRequestMergeBinding(event, input) { || !/^[0-9a-f]{40}$/i.test(String(input && input.mergeTreeSha ? input.mergeTreeSha : ''))) { throw new Error('pull-request planning requires merge SHA and tree SHA from the same fetched merge ref'); } + const validSha = (value) => /^[0-9a-f]{40}$/i.test(String(value ?? '')); + if (!validSha(input.mergeBaseSha) + || !(input.mergeBaseTipSha === null || validSha(input.mergeBaseTipSha))) { + throw new Error('pull-request planning requires the observed first parent and live-tip metadata from the same fetched merge ref'); + } + const observedBase = input.mergeBaseSha.toLowerCase(); + const controlBase = String(input.baseSha ?? '').toLowerCase(); + const liveTip = input.mergeBaseTipSha === null ? null : input.mergeBaseTipSha.toLowerCase(); + if ((liveTip === null && observedBase !== controlBase) + || (liveTip !== null && (observedBase !== liveTip || liveTip === controlBase))) { + throw new Error('pull-request merge-base metadata does not bind the observed first parent to the control base or a distinct authenticated live tip'); + } } /** Turn a GitHub event payload into the content-free planner input. */ @@ -74,6 +93,8 @@ export function inputFromEvent(event, eventName, options = {}) { headSha: null, mergeSha: options.mergeSha ?? null, mergeTreeSha: options.mergeTreeSha ?? null, + mergeBaseSha: null, + mergeBaseTipSha: null, actorLogin: null, actorType: null, authorAssociation: null, @@ -99,6 +120,8 @@ export function inputFromEvent(event, eventName, options = {}) { input.baseSha = pr.base ? pr.base.sha ?? null : null; input.headSha = pr.head ? pr.head.sha ?? null : null; input.mergeSha = Object.hasOwn(options, 'mergeSha') ? options.mergeSha : pr.merge_commit_sha ?? null; + input.mergeBaseSha = options.mergeBaseSha; + input.mergeBaseTipSha = options.mergeBaseTipSha; input.actorLogin = pr.user ? pr.user.login ?? null : null; input.actorType = pr.user ? pr.user.type ?? null : null; input.authorAssociation = pr.author_association ?? null; @@ -162,7 +185,7 @@ function main() { changedFilesAvailable = true; } const notes = [...args.notes]; - input = inputFromEvent(event, args.eventName ?? (event ? null : 'local'), { changedFiles: [...changedFiles], changedFileRows, changedFilesAvailable, changedFilesExpected: args.changedFilesExpected, headActors: args.headActors, headActorsKnown: args.headActorsKnown, notes, executionMode: args.executionMode, mergeSha: args.mergeSha, mergeTreeSha: args.mergeTreeSha }); + input = inputFromEvent(event, args.eventName ?? (event ? null : 'local'), { changedFiles: [...changedFiles], changedFileRows, changedFilesAvailable, changedFilesExpected: args.changedFilesExpected, headActors: args.headActors, headActorsKnown: args.headActorsKnown, notes, executionMode: args.executionMode, mergeSha: args.mergeSha, mergeTreeSha: args.mergeTreeSha, mergeBaseSha: args.mergeBaseSha, mergeBaseTipSha: args.mergeBaseTipSha }); if (!event) { // Local what-if: an explicit actor is the operator; default to a trusted owner preview. input.actorLogin = 'local'; diff --git a/scripts/ci/smart-ci/plan.test.mjs b/scripts/ci/smart-ci/plan.test.mjs index b51d5c928..087490fae 100644 --- a/scripts/ci/smart-ci/plan.test.mjs +++ b/scripts/ci/smart-ci/plan.test.mjs @@ -25,6 +25,7 @@ import { observeMergeRef } from './resolve-merge-ref.mjs'; const policyText = readFileSync(new URL('../../../ci/policy.v1.json', import.meta.url), 'utf8'); const policy = JSON.parse(policyText); +const planReceiptSchema = JSON.parse(readFileSync(new URL('../../../ci/schemas/ci-plan.v1.schema.json', import.meta.url), 'utf8')); const runReceiptSchema = JSON.parse(readFileSync(new URL('../../../ci/schemas/ci-run.v1.schema.json', import.meta.url), 'utf8')); const digest = policyDigest(policyText); const BASE = 'a'.repeat(40); @@ -39,6 +40,8 @@ function ownerInput(changedFiles, overrides = {}) { isDraft: false, baseSha: BASE, headSha: HEAD, + mergeBaseSha: BASE, + mergeBaseTipSha: null, mergeSha: null, mergeTreeSha: null, actorLogin: 'Chris0Jeky', @@ -215,6 +218,78 @@ test('errorPlan selects every lane hosted and records the error', () => { assert.equal(plan.selected.length, Object.keys(policy.lanes).length); assert.ok(plan.selected.every((entry) => entry.hosted === true)); assert.equal(plan.executionMode.effective, 'hosted'); + assert.equal(plan.mergeBaseSha, null); + assert.equal(plan.mergeBaseTipSha, null); + assert.deepEqual(validatePlan(plan), []); +}); + +test('merge-base receipt metadata binds the observed first parent without rewriting the control base', () => { + const movedBase = 'c'.repeat(40); + const exact = buildPlan(ownerInput(['docs/x.md']), policy, digest); + assert.deepEqual(validatePlan(exact), []); + assert.equal(evaluateGate(exact, { mode: 'shadow', expectedBaseSha: BASE }).ok, true); + + const moved = buildPlan(ownerInput(['docs/x.md'], { + mergeBaseSha: movedBase, + mergeBaseTipSha: movedBase, + }), policy, digest); + const movedVerdict = evaluateGate(moved, { mode: 'shadow', expectedBaseSha: BASE }); + assert.equal(movedVerdict.ok, true); + assert.equal(moved.baseSha, BASE); + assert.equal(moved.mergeBaseSha, movedBase); + assert.equal(moved.mergeBaseTipSha, movedBase); + + const wrongControlBase = evaluateGate(moved, { mode: 'shadow', expectedBaseSha: movedBase }); + assert.ok(wrongControlBase.failures.some((failure) => failure.code === 'base-sha-mismatch')); +}); + +test('partial, malformed, and mismatched merge-base metadata fail closed while truly absent legacy fields remain readable', () => { + const exact = buildPlan(ownerInput(['docs/x.md']), policy, digest); + const partial = structuredClone(exact); + delete partial.mergeBaseTipSha; + assert.ok(validatePlan(partial).some((error) => error.includes('must both be present'))); + + const malformed = { ...exact, mergeBaseSha: 'not-a-sha' }; + assert.ok(validatePlan(malformed).some((error) => error.includes('mergeBaseSha'))); + + const mismatched = { ...exact, mergeBaseSha: 'c'.repeat(40), mergeBaseTipSha: 'd'.repeat(40) }; + const mismatchVerdict = evaluateGate(mismatched, { mode: 'shadow', expectedBaseSha: BASE }); + assert.equal(mismatchVerdict.ok, false); + assert.ok(mismatchVerdict.failures.some((failure) => failure.code === 'merge-base-binding-mismatch')); + + const redundantTip = { ...exact, mergeBaseTipSha: BASE }; + assert.ok(validatePlan(redundantTip).some((error) => error.includes('null when the merge ref used plan.baseSha'))); + + const legacy = structuredClone(exact); + delete legacy.mergeBaseSha; + delete legacy.mergeBaseTipSha; + assert.deepEqual(validatePlan(legacy), []); + const legacyVerdict = evaluateGate(legacy, { mode: 'shadow', expectedBaseSha: BASE }); + assert.equal(legacyVerdict.ok, true); + assert.ok(legacyVerdict.notes.some((note) => note.includes('legacy plan'))); +}); + +test('version-1 documentation schemas expose the paired merge-base receipt fields', () => { + for (const schema of [planReceiptSchema, runReceiptSchema]) { + assert.ok(schema.properties.mergeBaseSha); + assert.ok(schema.properties.mergeBaseTipSha); + assert.deepEqual(schema.dependentRequired.mergeBaseSha, ['mergeBaseTipSha']); + assert.deepEqual(schema.dependentRequired.mergeBaseTipSha, ['mergeBaseSha']); + } + assert.ok(runReceiptSchema.properties.failures.items.properties.code.enum.includes('merge-base-binding-mismatch')); +}); + +test('non-PR plans record explicit null merge-base metadata', () => { + const input = inputFromEvent({ + repository: { full_name: 'Chris0Jeky/Taskdeck', owner: { login: 'Chris0Jeky' } }, + before: BASE, + after: HEAD, + ref: 'refs/heads/main', + sender: { login: 'Chris0Jeky', type: 'User' }, + }, 'push', { changedFiles: ['docs/x.md'], changedFilesAvailable: true }); + const plan = buildPlan(input, policy, digest); + assert.equal(plan.mergeBaseSha, null); + assert.equal(plan.mergeBaseTipSha, null); assert.deepEqual(validatePlan(plan), []); }); @@ -511,6 +586,8 @@ test('inputFromEvent reads pull_request payloads without content and detects for changedFilesAvailable: true, mergeSha: fetchedMergeSha, mergeTreeSha: fetchedMergeTreeSha, + mergeBaseSha: BASE, + mergeBaseTipSha: null, }); assert.equal(input.isFork, true); assert.equal(input.eventAction, 'synchronize'); @@ -521,11 +598,17 @@ test('inputFromEvent reads pull_request payloads without content and detects for assert.deepEqual(input.labels, ['ci:full']); assert.equal(input.mergeSha, fetchedMergeSha); assert.equal(input.mergeTreeSha, fetchedMergeTreeSha); + assert.equal(input.mergeBaseSha, BASE); + assert.equal(input.mergeBaseTipSha, null); requirePullRequestMergeBinding(event, input); assert.throws( () => requirePullRequestMergeBinding(event, { ...input, mergeSha: null }), /merge SHA and tree SHA from the same fetched merge ref/, ); + assert.throws( + () => requirePullRequestMergeBinding(event, { ...input, mergeBaseTipSha: undefined }), + /observed first parent and live-tip metadata/, + ); const plan = buildPlan(input, policy, digest); assert.equal(plan.trust, 'T3'); assert.equal(plan.event.action, 'synchronize'); @@ -544,6 +627,7 @@ test('a merge-binding error receipt keeps trusted CLI identity overrides', () => const eventPath = join(fixtureRoot, 'event.json'); const changedFilesPath = join(fixtureRoot, 'changed-files.tsv'); const planPath = join(fixtureRoot, 'ci-plan.json'); + const receiptPath = join(fixtureRoot, 'ci-run.json'); const staleEventBase = 'c'.repeat(40); try { @@ -582,10 +666,27 @@ test('a merge-binding error receipt keeps trusted CLI identity overrides', () => assert.notEqual(plan.baseSha, staleEventBase); assert.ok(!JSON.stringify(plan).includes(staleEventBase)); assert.equal(plan.headSha, HEAD); + assert.equal(plan.mergeBaseSha, null); + assert.equal(plan.mergeBaseTipSha, null); assert.equal(plan.trust, 'T3'); assert.equal(plan.risk, 'R4'); assert.equal(plan.executionMode.effective, 'hosted'); assert.deepEqual(plan.escalationReasons, ['planner-error']); + assert.throws(() => execFileSync(process.execPath, [ + fileURLToPath(new URL('./evaluate-gate.mjs', import.meta.url)), + '--plan', planPath, + '--policy', fileURLToPath(new URL('../../../ci/policy.v1.json', import.meta.url)), + '--event', eventPath, + '--event-name', 'pull_request_target', + '--expected-head', HEAD, + '--expected-base', BASE, + '--plan-job-result', 'success', + '--receipt', receiptPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); + assert.equal(receipt.mergeBaseSha, null); + assert.equal(receipt.mergeBaseTipSha, null); + assert.equal(receipt.ok, false); } finally { rmSync(fixtureRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } @@ -600,11 +701,15 @@ test('the shadow workflow binds resolver, planner, and gate to fixed control ide assert.match(workflow, /--head "\$CONTROL_HEAD"/); assert.match(workflow, /--merge-out artifacts\/merge-sha\.txt/); assert.match(workflow, /--tree-out artifacts\/merge-tree-sha\.txt/); + assert.match(workflow, /--merge-base-out artifacts\/merge-base-sha\.txt/); + assert.match(workflow, /--merge-base-tip-out artifacts\/merge-base-tip-sha\.txt/); assert.match(workflow, /--base-sha "\$CONTROL_BASE"/); assert.match(workflow, /EXPECTED_BASE: \$\{\{ env\.CONTROL_BASE \}\}/); assert.match(workflow, /EXPECTED_HEAD: \$\{\{ env\.CONTROL_HEAD \}\}/); assert.match(workflow, /--merge-sha "\$\(cat artifacts\/merge-sha\.txt\)"/); assert.match(workflow, /--merge-tree-sha "\$\(cat artifacts\/merge-tree-sha\.txt\)"/); + assert.match(workflow, /--merge-base-sha "\$\(cat artifacts\/merge-base-sha\.txt\)"/); + assert.match(workflow, /--merge-base-tip-sha "\$\(cat artifacts\/merge-base-tip-sha\.txt\)"/); assert.doesNotMatch(workflow, /auth_header=/); }); @@ -662,7 +767,7 @@ test('the shadow workflow fetch depth exposes both parents of a synthetic merge assert.equal(depthMatch[1], '2'); assert.deepEqual( await observeMergeRef({ pullRequestNumber: 1, token: 'synthetic-fixture-token', cwd: checkout }), - { mergeSha, baseSha, headSha, treeSha: mergeTreeSha }, + { mergeSha, mergeBaseSha: baseSha, headSha, treeSha: mergeTreeSha }, ); } finally { rmSync(fixtureRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); diff --git a/scripts/ci/smart-ci/recall-report.mjs b/scripts/ci/smart-ci/recall-report.mjs index 3dda3a9cc..732ceb0d2 100644 --- a/scripts/ci/smart-ci/recall-report.mjs +++ b/scripts/ci/smart-ci/recall-report.mjs @@ -547,6 +547,12 @@ export function normaliseObservation(raw, policy, options = {}) { } const plan = raw.plan; + const legacyMergeBaseReceipt = isObject(plan) + && !Object.hasOwn(plan, 'mergeBaseSha') + && !Object.hasOwn(plan, 'mergeBaseTipSha'); + const observedPlanBaseSha = isObject(plan) + ? (legacyMergeBaseReceipt ? plan.baseSha : plan.mergeBaseSha) + : null; const planErrors = validatePlan(plan, policy); if (planErrors.length > 0) addError(`plan-invalid:${planErrors.join('; ')}`); if (isObject(plan)) { @@ -566,9 +572,9 @@ export function normaliseObservation(raw, policy, options = {}) { if (!REQUIRED_PULL_REQUEST_ACTIONS.has(plan.event.action)) addError('plan-event-action-not-required'); if (plan.event.ref !== raw.baseBranch) addError('plan-base-branch-mismatch'); } - validateCommit(raw.planMergeCommit, plan.mergeSha, [plan.baseSha, headSha], plan.mergeTreeSha, 'plan-merge-commit'); + validateCommit(raw.planMergeCommit, plan.mergeSha, [observedPlanBaseSha, headSha], plan.mergeTreeSha, 'plan-merge-commit'); if (headSha === finalHeadSha) { - if (plan.baseSha !== baseSha) addError('final-plan-base-sha-mismatch'); + if (observedPlanBaseSha !== baseSha) addError('final-plan-base-sha-mismatch'); if (isObject(raw.mergeCommit) && plan.mergeTreeSha !== raw.mergeCommit.treeSha) addError('final-plan-merge-tree-mismatch'); } } @@ -649,6 +655,7 @@ export function normaliseObservation(raw, policy, options = {}) { baseBranch: typeof raw.baseBranch === 'string' ? raw.baseBranch : null, baseRepository: typeof raw.baseRepository === 'string' ? raw.baseRepository : null, baseSha: validSha(baseSha) ? String(baseSha).toLowerCase() : null, + mergeBaseSha: validSha(observedPlanBaseSha) ? String(observedPlanBaseSha).toLowerCase() : null, mergeCommitSha: validSha(mergeCommitSha) ? String(mergeCommitSha).toLowerCase() : null, mergeTreeSha: isObject(raw.mergeCommit) && validSha(raw.mergeCommit.treeSha) ? String(raw.mergeCommit.treeSha).toLowerCase() : null, shadowRunId: isObject(raw.shadowRun) && Number.isInteger(raw.shadowRun.id) ? raw.shadowRun.id : null, diff --git a/scripts/ci/smart-ci/recall-report.test.mjs b/scripts/ci/smart-ci/recall-report.test.mjs index ce0dfe705..e9e1cf5f4 100644 --- a/scripts/ci/smart-ci/recall-report.test.mjs +++ b/scripts/ci/smart-ci/recall-report.test.mjs @@ -37,6 +37,8 @@ function planFor(prNumber, headSha, options = {}) { isDraft: false, baseSha: options.baseSha, headSha, + mergeBaseSha: options.mergeBaseSha, + mergeBaseTipSha: options.mergeBaseTipSha ?? null, mergeSha: options.mergeSha, mergeTreeSha: options.mergeTreeSha, actorLogin: 'Chris0Jeky', @@ -66,6 +68,8 @@ function observation(prNumber, options = {}) { const headBranch = `issue-${prNumber}/fixture`; const baseSha = options.baseSha ?? shaFor(100000 + prNumber); const planBaseSha = options.planBaseSha ?? baseSha; + const mergeBaseSha = options.mergeBaseSha ?? planBaseSha; + const mergeBaseTipSha = options.mergeBaseTipSha ?? null; const planMergeSha = options.planMergeSha ?? shaFor(200000 + prNumber); const planMergeTreeSha = options.planMergeTreeSha ?? shaFor(300000 + prNumber); const mergeCommitSha = options.mergeCommitSha ?? shaFor(400000 + prNumber); @@ -114,10 +118,16 @@ function observation(prNumber, options = {}) { updatedAt: '2026-08-31T09:05:00.000Z', pullRequests: [prNumber], }, - plan: planFor(prNumber, headSha, { baseSha: planBaseSha, mergeSha: planMergeSha, mergeTreeSha: planMergeTreeSha }), + plan: planFor(prNumber, headSha, { + baseSha: planBaseSha, + mergeBaseSha, + mergeBaseTipSha, + mergeSha: planMergeSha, + mergeTreeSha: planMergeTreeSha, + }), planMergeCommit: { sha: planMergeSha, - parents: [planBaseSha, headSha], + parents: [mergeBaseSha, headSha], treeSha: planMergeTreeSha, }, requiredRun: { @@ -464,6 +474,7 @@ test('plan, landed merge, PR and temporal bindings fail closed on mismatch', () const finalBase = observation(393, { mutate: (raw) => { raw.plan.baseSha = 'f'.repeat(40); + raw.plan.mergeBaseSha = 'f'.repeat(40); raw.planMergeCommit.parents[0] = 'f'.repeat(40); } }); assert(normaliseObservation(finalBase, policy, window).errors.includes('final-plan-base-sha-mismatch')); @@ -475,6 +486,73 @@ test('plan, landed merge, PR and temporal bindings fail closed on mismatch', () assert(normaliseObservation(outsideWindow, policy, window).errors.includes('merged-at-outside-window')); }); +test('a valid older-head failure keeps recall evidence after the base moves for the final head', () => { + const prNumber = 389; + const finalHeadSha = shaFor(900389); + const earlierBase = shaFor(800389); + const finalBase = shaFor(800390); + const earlier = observation(prNumber, { + headSha: shaFor(900388), + finalHeadSha, + baseSha: finalBase, + planBaseSha: earlierBase, + mergeBaseSha: earlierBase, + requiredRunId: 700389, + runAttempt: 1, + failedCheckName: 'Docs Governance / Docs Governance', + }); + const final = observation(prNumber, { + headSha: finalHeadSha, + finalHeadSha, + baseSha: finalBase, + planBaseSha: finalBase, + mergeBaseSha: finalBase, + requiredRunId: 700390, + runAttempt: 1, + }); + assert.equal(normaliseObservation(earlier, policy, window).usable, true); + assert.equal(normaliseObservation(final, policy, window).usable, true); + const report = buildRecallReport([earlier, final], policy, window); + assert.equal(report.observationCount, 1); + assert.equal(report.revisionObservationCount, 2); + assert.equal(report.unusableObservationCount, 0); + assert.equal(report.failedLaneCount, 1); + assert.equal(report.missedFailureCount, 0); + assert.equal(report.pullRequests[0].usable, true); + assert.equal(report.pullRequests[0].revisionCount, 2); +}); + +test('recall validates an accepted moved-base plan against its observed first parent', () => { + const controlBase = shaFor(500390); + const liveBase = shaFor(500391); + const moved = observation(398, { + baseSha: liveBase, + planBaseSha: controlBase, + mergeBaseSha: liveBase, + mergeBaseTipSha: liveBase, + }); + const result = normaliseObservation(moved, policy, window); + assert.equal(result.usable, true); + assert.equal(result.baseSha, liveBase); + assert.equal(result.mergeBaseSha, liveBase); + + moved.planMergeCommit.parents[0] = controlBase; + assert(normaliseObservation(moved, policy, window).errors.includes('plan-merge-commit-parents-mismatch')); +}); + +test('recall uses legacy plan.baseSha only when both merge-base fields are absent', () => { + const legacy = observation(399); + delete legacy.plan.mergeBaseSha; + delete legacy.plan.mergeBaseTipSha; + assert.equal(normaliseObservation(legacy, policy, window).usable, true); + + const partial = observation(400); + delete partial.plan.mergeBaseTipSha; + const result = normaliseObservation(partial, policy, window); + assert.equal(result.usable, false); + assert(result.errors.some((error) => error.startsWith('plan-invalid:') && error.includes('must both be present'))); +}); + test('present PR association metadata must match, while GitHub empty arrays remain explicit', () => { const mismatched = observation(396, { mutate: (raw) => { raw.headPullRequests = [999]; diff --git a/scripts/ci/smart-ci/resolve-merge-ref.mjs b/scripts/ci/smart-ci/resolve-merge-ref.mjs index 43dd2461c..6d391e3ca 100644 --- a/scripts/ci/smart-ci/resolve-merge-ref.mjs +++ b/scripts/ci/smart-ci/resolve-merge-ref.mjs @@ -94,8 +94,8 @@ export async function observeMergeRef({ throw new Error('the fetched merge ref did not yield one complete four-SHA observation'); } - const [mergeSha, baseSha, headSha, treeSha] = values.map((value) => value.toLowerCase()); - return { mergeSha, baseSha, headSha, treeSha }; + const [mergeSha, mergeBaseSha, headSha, treeSha] = values.map((value) => value.toLowerCase()); + return { mergeSha, mergeBaseSha, headSha, treeSha }; } /** @@ -137,20 +137,23 @@ function removeOutputs(paths) { for (const path of paths) rmSync(path, { force: true }); } -function publishOutputs(observation, mergeOutput, treeOutput) { - const mergeTemporary = `${mergeOutput}.tmp-${process.pid}`; - const treeTemporary = `${treeOutput}.tmp-${process.pid}`; - const temporaryPaths = [mergeTemporary, treeTemporary]; - const outputPaths = [mergeOutput, treeOutput]; +function publishOutputs(observation, outputs) { + const entries = [ + [outputs.merge, observation.mergeSha], + [outputs.tree, observation.treeSha], + [outputs.mergeBase, observation.mergeBaseSha], + [outputs.mergeBaseTip, observation.mergeBaseTipSha ?? 'null'], + ]; + const outputPaths = entries.map(([path]) => path); + const temporaryPaths = outputPaths.map((path) => `${path}.tmp-${process.pid}`); try { - mkdirSync(dirname(mergeOutput), { recursive: true }); - mkdirSync(dirname(treeOutput), { recursive: true }); + for (const path of outputPaths) mkdirSync(dirname(path), { recursive: true }); removeOutputs(temporaryPaths); - writeFileSync(mergeTemporary, `${observation.mergeSha}\n`, { encoding: 'utf8', flag: 'wx' }); - writeFileSync(treeTemporary, `${observation.treeSha}\n`, { encoding: 'utf8', flag: 'wx' }); - renameSync(mergeTemporary, mergeOutput); - renameSync(treeTemporary, treeOutput); + entries.forEach(([, value], index) => { + writeFileSync(temporaryPaths[index], `${value}\n`, { encoding: 'utf8', flag: 'wx' }); + }); + entries.forEach(([path], index) => renameSync(temporaryPaths[index], path)); } catch (error) { removeOutputs([...temporaryPaths, ...outputPaths]); throw error; @@ -161,14 +164,14 @@ function mismatchReason(observation, expectedBase, expectedHead) { if (!observation || typeof observation !== 'object') return 'merge ref unavailable'; const values = [ observation.mergeSha, - observation.baseSha, + observation.mergeBaseSha, observation.headSha, observation.treeSha, ]; if (values.some((value) => !SHA_PATTERN.test(String(value ?? '')))) return 'invalid observation'; - if (observation.baseSha.toLowerCase() !== expectedBase + if (observation.mergeBaseSha.toLowerCase() !== expectedBase && observation.headSha.toLowerCase() !== expectedHead) return 'base and head mismatch'; - if (observation.baseSha.toLowerCase() !== expectedBase) return 'base mismatch'; + if (observation.mergeBaseSha.toLowerCase() !== expectedBase) return 'base mismatch'; if (observation.headSha.toLowerCase() !== expectedHead) return 'head mismatch'; return null; } @@ -190,6 +193,8 @@ export async function resolveMergeRef({ expectedHead, mergeOutput, treeOutput, + mergeBaseOutput, + mergeBaseTipOutput, observe, resolveBaseTip = null, sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), @@ -199,10 +204,13 @@ export async function resolveMergeRef({ const normalizedHead = requireSha(expectedHead, 'expected head'); const mergeOutputPath = requireOutputPath(mergeOutput, 'merge output path'); const treeOutputPath = requireOutputPath(treeOutput, 'tree output path'); - if (mergeOutputPath === treeOutputPath) throw new Error('merge and tree output paths must differ'); + const mergeBaseOutputPath = requireOutputPath(mergeBaseOutput, 'merge base output path'); + const mergeBaseTipOutputPath = requireOutputPath(mergeBaseTipOutput, 'merge base tip output path'); + const outputPaths = [mergeOutputPath, treeOutputPath, mergeBaseOutputPath, mergeBaseTipOutputPath]; + if (new Set(outputPaths).size !== outputPaths.length) throw new Error('merge identity output paths must differ'); if (typeof observe !== 'function') throw new Error('merge-ref observer is required'); - removeOutputs([mergeOutputPath, treeOutputPath]); + removeOutputs(outputPaths); let finalReason = 'merge ref unavailable'; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { @@ -220,7 +228,7 @@ export async function resolveMergeRef({ // only if — it is the live tip of the base ref on origin. try { const tip = await resolveBaseTip(); - if (SHA_PATTERN.test(String(tip ?? '')) && String(tip).toLowerCase() === observation.baseSha.toLowerCase()) { + if (SHA_PATTERN.test(String(tip ?? '')) && String(tip).toLowerCase() === observation.mergeBaseSha.toLowerCase()) { movedBase = String(tip).toLowerCase(); finalReason = null; } else { @@ -232,13 +240,19 @@ export async function resolveMergeRef({ } if (finalReason === null) { - publishOutputs(observation, mergeOutputPath, treeOutputPath); + const resolved = { ...observation, mergeRefMoved: movedBase !== null, mergeBaseTipSha: movedBase }; + publishOutputs(resolved, { + merge: mergeOutputPath, + tree: treeOutputPath, + mergeBase: mergeBaseOutputPath, + mergeBaseTip: mergeBaseTipOutputPath, + }); if (movedBase) { log(`merge-ref-moved — the base advanced from ${normalizedBase} to the live base branch tip ${movedBase} after dispatch; the event head matched exactly on attempt ${attempt}/${MAX_ATTEMPTS}`); } else { log(`merge ref matched the control base and event head on attempt ${attempt}/${MAX_ATTEMPTS}`); } - return { ...observation, mergeRefMoved: movedBase !== null, baseTipSha: movedBase }; + return resolved; } if (attempt < MAX_ATTEMPTS) { @@ -247,7 +261,7 @@ export async function resolveMergeRef({ } } - removeOutputs([mergeOutputPath, treeOutputPath]); + removeOutputs(outputPaths); throw new Error(`merge ref resolution failed closed after ${MAX_ATTEMPTS} attempts: ${finalReason}`); } @@ -259,7 +273,7 @@ export function writeMergeRefNote(noteOutput, expectedBase, resolved) { mkdirSync(dirname(noteOutput), { recursive: true }); writeFileSync( noteOutput, - `merge-ref-moved: the base advanced from ${String(expectedBase).toLowerCase()} to ${resolved.baseTipSha} after dispatch; the merge ref was regenerated against the base branch live tip on origin and the event head matched exactly\n`, + `merge-ref-moved: the base advanced from ${String(expectedBase).toLowerCase()} to ${resolved.mergeBaseTipSha} after dispatch; the merge ref was regenerated against the base branch live tip on origin and the event head matched exactly\n`, { encoding: 'utf8' }, ); } @@ -272,6 +286,8 @@ function parseArgs(argv) { baseRef: null, mergeOutput: null, treeOutput: null, + mergeBaseOutput: null, + mergeBaseTipOutput: null, noteOutput: null, }; for (let index = 0; index < argv.length; index += 1) { @@ -284,6 +300,8 @@ function parseArgs(argv) { case '--base-ref': args.baseRef = next(); break; case '--merge-out': args.mergeOutput = next(); break; case '--tree-out': args.treeOutput = next(); break; + case '--merge-base-out': args.mergeBaseOutput = next(); break; + case '--merge-base-tip-out': args.mergeBaseTipOutput = next(); break; case '--note-out': args.noteOutput = next(); break; default: throw new Error(`unknown argument: ${argument}`); } @@ -299,6 +317,8 @@ async function main() { expectedHead: args.expectedHead, mergeOutput: args.mergeOutput, treeOutput: args.treeOutput, + mergeBaseOutput: args.mergeBaseOutput, + mergeBaseTipOutput: args.mergeBaseTipOutput, observe: () => observeMergeRef({ pullRequestNumber: args.pullRequestNumber, token: process.env.GH_TOKEN, diff --git a/scripts/ci/smart-ci/resolve-merge-ref.test.mjs b/scripts/ci/smart-ci/resolve-merge-ref.test.mjs index 3bba51ec4..8758c8622 100644 --- a/scripts/ci/smart-ci/resolve-merge-ref.test.mjs +++ b/scripts/ci/smart-ci/resolve-merge-ref.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -22,7 +22,7 @@ const NEWLINE = String.fromCharCode(10); function observation(overrides = {}) { return { mergeSha: MERGE_SHA, - baseSha: CONTROL_BASE, + mergeBaseSha: CONTROL_BASE, headSha: EVENT_HEAD, treeSha: TREE_SHA, ...overrides, @@ -35,23 +35,29 @@ function outputFixture() { root, mergeOutput: join(root, 'merge-sha.txt'), treeOutput: join(root, 'merge-tree-sha.txt'), + mergeBaseOutput: join(root, 'merge-base-sha.txt'), + mergeBaseTipOutput: join(root, 'merge-base-tip-sha.txt'), }; } -function assertPublished(fixture) { +function assertPublished(fixture, { mergeBaseSha = CONTROL_BASE, mergeBaseTipSha = null } = {}) { assert.equal(readFileSync(fixture.mergeOutput, 'utf8'), `${MERGE_SHA}\n`); assert.equal(readFileSync(fixture.treeOutput, 'utf8'), `${TREE_SHA}\n`); + assert.equal(readFileSync(fixture.mergeBaseOutput, 'utf8'), `${mergeBaseSha}\n`); + assert.equal(readFileSync(fixture.mergeBaseTipOutput, 'utf8'), `${mergeBaseTipSha ?? 'null'}\n`); } function assertNotPublished(fixture) { assert.equal(existsSync(fixture.mergeOutput), false); assert.equal(existsSync(fixture.treeOutput), false); + assert.equal(existsSync(fixture.mergeBaseOutput), false); + assert.equal(existsSync(fixture.mergeBaseTipOutput), false); } test('a stale base observation retries and then publishes one valid identity', async () => { const fixture = outputFixture(); const observations = [ - observation({ baseSha: 'e'.repeat(40) }), + observation({ mergeBaseSha: 'e'.repeat(40) }), observation(), ]; const sleeps = []; @@ -62,11 +68,13 @@ test('a stale base observation retries and then publishes one valid identity', a expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, observe: async () => observations.shift(), sleep: async (milliseconds) => sleeps.push(milliseconds), }); - assert.deepEqual(resolved, observation({ mergeRefMoved: false, baseTipSha: null })); + assert.deepEqual(resolved, observation({ mergeRefMoved: false, mergeBaseTipSha: null })); assert.equal(observations.length, 0); assert.equal(sleeps.length, 1); assertPublished(fixture); @@ -85,6 +93,8 @@ test('an unavailable observation retries and then publishes one valid identity', expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, observe: async () => { attempts += 1; if (attempts === 1) throw new Error('merge ref unavailable'); @@ -93,7 +103,7 @@ test('an unavailable observation retries and then publishes one valid identity', sleep: async () => {}, }); - assert.deepEqual(resolved, observation({ mergeRefMoved: false, baseTipSha: null })); + assert.deepEqual(resolved, observation({ mergeRefMoved: false, mergeBaseTipSha: null })); assert.equal(attempts, 2); assertPublished(fixture); } finally { @@ -112,9 +122,11 @@ test('a persistently wrong base stops after three attempts without outputs', asy expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, observe: async () => { attempts += 1; - return observation({ baseSha: 'e'.repeat(40) }); + return observation({ mergeBaseSha: 'e'.repeat(40) }); }, sleep: async () => {}, }), @@ -139,6 +151,8 @@ test('a persistently wrong head stops without publishing outputs', async () => { expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, observe: async () => { attempts += 1; return observation({ headSha: 'f'.repeat(40) }); @@ -194,16 +208,22 @@ test('a merge ref regenerated against the live base branch tip resolves as merge expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, - observe: async () => observation({ baseSha: advancedBase }), + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => observation({ mergeBaseSha: advancedBase }), resolveBaseTip: async () => advancedBase, sleep: async () => {}, log: (message) => logs.push(message), }); assert.equal(resolved.mergeRefMoved, true); - assert.equal(resolved.baseTipSha, advancedBase); + assert.equal(resolved.mergeBaseSha, advancedBase); + assert.equal(resolved.mergeBaseTipSha, advancedBase); assert.equal(resolved.headSha, EVENT_HEAD); - assertPublished(fixture); + assert.equal(readFileSync(fixture.mergeOutput, 'utf8'), `${MERGE_SHA}\n`); + assert.equal(readFileSync(fixture.treeOutput, 'utf8'), `${TREE_SHA}\n`); + assert.equal(readFileSync(fixture.mergeBaseOutput, 'utf8'), `${ADVANCED_BASE}\n`); + assert.equal(readFileSync(fixture.mergeBaseTipOutput, 'utf8'), `${ADVANCED_BASE}\n`); assert.equal(logs.length, 1); assert.match(logs[0], /^merge-ref-moved —/); } finally { @@ -221,7 +241,9 @@ test('a first parent that is not the live base branch tip stays fail-closed', as expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, - observe: async () => { attempts.push('observe'); return observation({ baseSha: 'e'.repeat(40) }); }, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => { attempts.push('observe'); return observation({ mergeBaseSha: 'e'.repeat(40) }); }, resolveBaseTip: async () => 'f'.repeat(40), sleep: async () => {}, }), /not the live base branch tip/); @@ -243,7 +265,9 @@ test('a moved base never excuses a head mismatch', async () => { expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, - observe: async () => observation({ baseSha: 'f'.repeat(40), headSha: '9'.repeat(40) }), + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => observation({ mergeBaseSha: 'f'.repeat(40), headSha: '9'.repeat(40) }), resolveBaseTip: async () => { baseTipReads += 1; return 'f'.repeat(40); }, sleep: async () => {}, }), /base and head mismatch/); @@ -264,7 +288,9 @@ test('an unreadable base branch tip stays fail-closed', async () => { expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, - observe: async () => observation({ baseSha: 'f'.repeat(40) }), + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => observation({ mergeBaseSha: 'f'.repeat(40) }), resolveBaseTip: async () => { throw new Error('network down'); }, sleep: async () => {}, }), /the base branch tip could not be read/); @@ -284,12 +310,12 @@ test('observeBaseTip fetches exactly the named base ref and keeps the token out return `${ADVANCED_BASE}${NEWLINE}`; }; - const tip = await observeBaseTip({ baseRef: 'main', token, executeGit }); + const tip = await observeBaseTip({ baseRef: 'release/v0.3', token, executeGit }); assert.equal(tip, ADVANCED_BASE); assert.equal(calls.length, 2); assert.match(calls[0].args[0], /^--config-env=http\.extraHeader=/); - assert.deepEqual(calls[0].args.slice(1), ['fetch', '--no-tags', '--depth=1', 'origin', 'refs/heads/main']); + assert.deepEqual(calls[0].args.slice(1), ['fetch', '--no-tags', '--depth=1', 'origin', 'refs/heads/release/v0.3']); assert.deepEqual(calls[1].args, ['rev-parse', 'FETCH_HEAD^{commit}']); assert.equal(JSON.stringify(calls.map((call) => call.args)).includes(token), false); // The token travels only in the fetch environment, never the argument list. @@ -318,7 +344,9 @@ test('the CLI note wiring records an accepted moved base with an LF-terminated l expectedHead: EVENT_HEAD, mergeOutput: fixture.mergeOutput, treeOutput: fixture.treeOutput, - observe: async () => observation({ baseSha: ADVANCED_BASE }), + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => observation({ mergeBaseSha: ADVANCED_BASE }), resolveBaseTip: async () => ADVANCED_BASE, sleep: async () => {}, }); @@ -332,7 +360,30 @@ test('the CLI note wiring records an accepted moved base with an LF-terminated l assert.ok(note.includes(ADVANCED_BASE)); assert.equal(note.endsWith(NEWLINE), true); assert.equal(note.includes('\r'), false); - assertPublished(fixture); + assertPublished(fixture, { mergeBaseSha: ADVANCED_BASE, mergeBaseTipSha: ADVANCED_BASE }); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('failure removes stale outputs for every merge identity field', async () => { + const fixture = outputFixture(); + try { + for (const path of [fixture.mergeOutput, fixture.treeOutput, fixture.mergeBaseOutput, fixture.mergeBaseTipOutput]) { + writeFileSync(path, 'stale\n'); + } + await assert.rejects(resolveMergeRef({ + expectedBase: CONTROL_BASE, + expectedHead: EVENT_HEAD, + mergeOutput: fixture.mergeOutput, + treeOutput: fixture.treeOutput, + mergeBaseOutput: fixture.mergeBaseOutput, + mergeBaseTipOutput: fixture.mergeBaseTipOutput, + observe: async () => observation({ mergeBaseSha: ADVANCED_BASE }), + resolveBaseTip: async () => 'e'.repeat(40), + sleep: async () => {}, + }), /not the live base branch tip/); + assertNotPublished(fixture); } finally { rmSync(fixture.root, { recursive: true, force: true }); }