From 5108226b1b73343d9d313f253bfb0c95fbd97acc Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 1 Sep 2026 10:08:46 +0200 Subject: [PATCH 1/4] Keep the stored release commit when HEAD is behind it A sync from a rolled-back checkout used to write its older HEAD onto an existing release, rewinding the pipeline's scan baseline. The next sync then re-scanned the range up to the current HEAD and re-attached issues that had already shipped. Before syncing, compare HEAD to the newest stored release anchor from recentReleasesByAccessKey. When HEAD is strictly behind it, omit commitSha from the sync input and log that the release commit is left unchanged. The scan range itself is unaffected; --base-ref keeps controlling it without dragging the anchor backwards as a side effect. Requires server support for a nullable commitSha on ReleaseSyncInputBase. Co-Authored-By: Claude Fable 5 --- src/index.ts | 19 +++++++++++++++- src/scan-base.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ src/scan-base.ts | 28 ++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c903b7a..c45ab29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,14 @@ import { getCommitContextsBetweenShas, getCurrentGitInfo, getRemoteUrl, + isAncestor, resolveCommitRef, verifyAncestorReachable, } from "./git"; import { assertBaseRefIsAncestor, evaluateScanRangeSize, + findAnchorAheadOfHead, getBroadScanWarning, ScanBase, selectAutomaticScanBase, @@ -292,6 +294,19 @@ async function syncCommand(): Promise<{ } const recentReleases = await getRecentReleases(); + + // Forward-only anchor: a rolled-back HEAD must not rewind a release's stored commit, or the + // next sync would re-scan (and re-attach) work that already shipped. + const anchorAheadOfHead = findAnchorAheadOfHead(recentReleases, currentCommit.commit, { + isAncestor, + verifyAncestorReachable, + }); + if (anchorAheadOfHead) { + info( + `HEAD ${currentCommit.commit.slice(0, 7)} is behind the stored baseline ${anchorAheadOfHead.slice(0, 7)}; leaving the release commit unchanged`, + ); + } + const scanBase = getScanBase(recentReleases, currentCommit.commit, releaseVersion); let latestSha = scanBase.sha; let inspectingOnlyCurrentCommit = false; @@ -416,6 +431,7 @@ async function syncCommand(): Promise<{ links, documents, releaseNotes, + { omitCommitSha: Boolean(anchorAheadOfHead) }, ); info( `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, @@ -618,6 +634,7 @@ async function syncRelease( releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], releaseNotesValue: ReleaseNotes | undefined, + options: { omitCommitSha: boolean }, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -650,7 +667,7 @@ async function syncRelease( input: { name: releaseName, version: releaseVersion, - commitSha: currentSha, + commitSha: options.omitCommitSha ? undefined : currentSha, issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, links: releaseLinks.length > 0 ? releaseLinks : undefined, diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index fc61740..9534dfd 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -9,6 +9,7 @@ import { assertBaseRefIsAncestor, BROAD_SCAN_COMMIT_THRESHOLD, evaluateScanRangeSize, + findAnchorAheadOfHead, getBroadScanWarning, SCAN_COMMIT_HARD_LIMIT, type ScanBase, @@ -428,3 +429,53 @@ describe("evaluateScanRangeSize", () => { expect(evaluateScanRangeSize(null, releaseBase)).toEqual({ degradeToCurrentCommit: false }); }); }); + +describe("findAnchorAheadOfHead", () => { + const anchor = "a".repeat(40); + const head = "b".repeat(40); + + function release(commitSha?: string): Release { + return { id: "release-id", name: "release", createdAt: "2026-01-01T00:00:00.000Z", commitSha }; + } + + function deps(overrides: { isAncestor?: boolean; verifyAncestorReachable?: boolean }) { + return { + isAncestor: vi.fn().mockReturnValue(overrides.isAncestor ?? false), + verifyAncestorReachable: vi.fn().mockReturnValue(overrides.verifyAncestorReachable ?? false), + }; + } + + it("returns the anchor when HEAD is strictly behind it", () => { + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d)).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + expect(d.verifyAncestorReachable).toHaveBeenCalledWith(head, anchor); + }); + + it("returns undefined when the anchor is an ancestor of HEAD, without the reachability walk", () => { + const d = deps({ isAncestor: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d)).toBeUndefined(); + expect(d.verifyAncestorReachable).not.toHaveBeenCalled(); + }); + + it("returns undefined when HEAD equals the anchor", () => { + const d = deps({}); + expect(findAnchorAheadOfHead([release(head)], head, d)).toBeUndefined(); + expect(d.isAncestor).not.toHaveBeenCalled(); + }); + + it("returns undefined when ancestry cannot be established in either direction", () => { + expect(findAnchorAheadOfHead([release(anchor)], head, deps({}))).toBeUndefined(); + }); + + it("returns undefined when no candidate carries a commit SHA", () => { + expect(findAnchorAheadOfHead([release(undefined), release(undefined)], head, deps({}))).toBeUndefined(); + }); + + it("compares against the first candidate with a commit SHA", () => { + const older = "c".repeat(40); + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(undefined), release(anchor), release(older)], head, d)).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + }); +}); diff --git a/src/scan-base.ts b/src/scan-base.ts index 8bff0ad..9495e34 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -79,6 +79,34 @@ export function selectAutomaticScanBase( }; } +export type AnchorComparisonDeps = FindBaseShaDeps & { + isAncestor: (sha: string, headSha: string) => boolean; +}; + +/** + * Returns the newest stored release anchor when the current HEAD is strictly behind it — the + * rollback case, where syncing HEAD as the release commit would rewind the pipeline's scan + * baseline and make the next sync re-scan (and re-attach) work that already shipped. Returns + * undefined when HEAD is at or ahead of the anchor, when no candidate carries a commit SHA, or + * when ancestry cannot be established (unrelated history, unreachable anchor). + */ +export function findAnchorAheadOfHead( + candidates: Release[], + headSha: string, + deps: AnchorComparisonDeps, +): string | undefined { + const anchorSha = candidates.find((candidate) => candidate.commitSha)?.commitSha; + if (!anchorSha || anchorSha === headSha) { + return undefined; + } + // Cheap forward check first: an anchor that is an ancestor of HEAD means HEAD is at or ahead of + // it — the common case, answered without deepening a shallow clone. + if (deps.isAncestor(anchorSha, headSha)) { + return undefined; + } + return deps.verifyAncestorReachable(headSha, anchorSha) ? anchorSha : undefined; +} + export function assertBaseRefIsAncestor( baseRef: string, resolvedSha: string, From fd5e9c349607c220a146a0b30c7afe5a9895bc0a Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 1 Sep 2026 11:16:45 +0200 Subject: [PATCH 2/4] Send preserveStoredCommitSha instead of omitting the commit Omitting commitSha broke continuous pipelines, which identify releases by it. Always send the checked-out commit and set preserveStoredCommitSha when HEAD is behind the stored anchor; the server applies it to scheduled pipelines only. The field is only sent when set, so normal syncs remain compatible with older servers. Co-Authored-By: Claude Fable 5 --- src/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index c45ab29..5af9932 100644 --- a/src/index.ts +++ b/src/index.ts @@ -431,7 +431,7 @@ async function syncCommand(): Promise<{ links, documents, releaseNotes, - { omitCommitSha: Boolean(anchorAheadOfHead) }, + { preserveStoredCommitSha: Boolean(anchorAheadOfHead) }, ); info( `Synced to release ${release.name} (${formatVersion(release)}): ${scanned}${formatLinkSummary(links)}${formatDocumentsSummary(documents)}${formatReleaseNotesSummary(releaseNotes)}`, @@ -634,7 +634,7 @@ async function syncRelease( releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], releaseNotesValue: ReleaseNotes | undefined, - options: { omitCommitSha: boolean }, + options: { preserveStoredCommitSha: boolean }, ): Promise { const currentSha = await getCurrentGitInfo().commit; if (!currentSha) { @@ -667,7 +667,9 @@ async function syncRelease( input: { name: releaseName, version: releaseVersion, - commitSha: options.omitCommitSha ? undefined : currentSha, + commitSha: currentSha, + // Only sent when set, so normal syncs stay compatible with servers that predate the field. + preserveStoredCommitSha: options.preserveStoredCommitSha || undefined, issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, links: releaseLinks.length > 0 ? releaseLinks : undefined, From 748e0fdf7481dd9758ebf32db39aae33f31e7012 Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 1 Sep 2026 11:20:13 +0200 Subject: [PATCH 3/4] Always send preserveStoredCommitSha The field was dropped from the payload when false to tolerate servers without it. The server change deploys before this releases, so the compatibility gate is unnecessary. Co-Authored-By: Claude Fable 5 --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5af9932..958998b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -668,8 +668,7 @@ async function syncRelease( name: releaseName, version: releaseVersion, commitSha: currentSha, - // Only sent when set, so normal syncs stay compatible with servers that predate the field. - preserveStoredCommitSha: options.preserveStoredCommitSha || undefined, + preserveStoredCommitSha: options.preserveStoredCommitSha, issueReferences, revertedIssueReferences: revertedIssueReferences.length > 0 ? revertedIssueReferences : undefined, links: releaseLinks.length > 0 ? releaseLinks : undefined, From 3bf4bcfcddb6710ef33034ef9d540e2ce36ba3c4 Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 1 Sep 2026 11:38:48 +0200 Subject: [PATCH 4/4] Prefer the syncing version's release in the rollback check The candidate list is ordered by activity, not ancestry, so a release from a divergent train could rank first. Ancestry to it cannot be established, the check failed open, and the sync could still replace the syncing version's stored commit with the rollback HEAD. When a version is supplied and a candidate carries it, that release's commit alone decides preservation, mirroring the scan-base rule that prefers the syncing version's release. Co-Authored-By: Claude Fable 5 --- src/index.ts | 10 ++++++---- src/scan-base.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/scan-base.ts | 29 +++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 958998b..be06b98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -297,10 +297,12 @@ async function syncCommand(): Promise<{ // Forward-only anchor: a rolled-back HEAD must not rewind a release's stored commit, or the // next sync would re-scan (and re-attach) work that already shipped. - const anchorAheadOfHead = findAnchorAheadOfHead(recentReleases, currentCommit.commit, { - isAncestor, - verifyAncestorReachable, - }); + const anchorAheadOfHead = findAnchorAheadOfHead( + recentReleases, + currentCommit.commit, + { isAncestor, verifyAncestorReachable }, + releaseVersion, + ); if (anchorAheadOfHead) { info( `HEAD ${currentCommit.commit.slice(0, 7)} is behind the stored baseline ${anchorAheadOfHead.slice(0, 7)}; leaving the release commit unchanged`, diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index 9534dfd..cacab2c 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -478,4 +478,40 @@ describe("findAnchorAheadOfHead", () => { expect(findAnchorAheadOfHead([release(undefined), release(anchor), release(older)], head, d)).toBe(anchor); expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); }); + + it("prioritizes the syncing version's stored commit over a more recent release from another train", () => { + // Divergent hotfix release ranks first by activity; ancestry to it cannot be established, + // but HEAD is strictly behind the syncing version's own stored commit. + const hotfix = "d".repeat(40); + const d = { + isAncestor: vi.fn().mockReturnValue(false), + verifyAncestorReachable: vi.fn((sha: string, headSha: string) => headSha === anchor), + }; + const candidates = [ + { ...release(hotfix), version: "hotfix-1" }, + { ...release(anchor), version: "v1.2.3" }, + ]; + expect(findAnchorAheadOfHead(candidates, head, d, "v1.2.3")).toBe(anchor); + expect(d.isAncestor).toHaveBeenCalledTimes(1); + expect(d.isAncestor).toHaveBeenCalledWith(anchor, head); + }); + + it("lets the syncing version's release decide alone when HEAD is at or ahead of it", () => { + const newer = "d".repeat(40); + const d = { + isAncestor: vi.fn((sha: string) => sha === anchor), + verifyAncestorReachable: vi.fn().mockReturnValue(true), + }; + const candidates = [ + { ...release(newer), version: "other" }, + { ...release(anchor), version: "v1.2.3" }, + ]; + expect(findAnchorAheadOfHead(candidates, head, d, "v1.2.3")).toBeUndefined(); + expect(d.verifyAncestorReachable).not.toHaveBeenCalled(); + }); + + it("falls back to the newest anchor when no candidate carries the syncing version", () => { + const d = deps({ isAncestor: false, verifyAncestorReachable: true }); + expect(findAnchorAheadOfHead([release(anchor)], head, d, "v9.9.9")).toBe(anchor); + }); }); diff --git a/src/scan-base.ts b/src/scan-base.ts index 9495e34..b2e4fa5 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -84,19 +84,36 @@ export type AnchorComparisonDeps = FindBaseShaDeps & { }; /** - * Returns the newest stored release anchor when the current HEAD is strictly behind it — the - * rollback case, where syncing HEAD as the release commit would rewind the pipeline's scan - * baseline and make the next sync re-scan (and re-attach) work that already shipped. Returns - * undefined when HEAD is at or ahead of the anchor, when no candidate carries a commit SHA, or - * when ancestry cannot be established (unrelated history, unreachable anchor). + * Returns the stored release anchor when the current HEAD is strictly behind it — the rollback + * case, where syncing HEAD as the release commit would rewind the pipeline's scan baseline and + * make the next sync re-scan (and re-attach) work that already shipped. When a syncing version is + * supplied and a candidate carries it, that release's commit alone decides, since it is the one + * the sync would overwrite; the candidate list is ordered by activity, so a release from another + * train can sit above it. Otherwise the newest stored anchor is used. Returns undefined when HEAD + * is at or ahead of the anchor, when no candidate carries a commit SHA, or when ancestry cannot + * be established (unrelated history, unreachable anchor). */ export function findAnchorAheadOfHead( candidates: Release[], headSha: string, deps: AnchorComparisonDeps, + syncingVersion?: string, ): string | undefined { + if (syncingVersion !== undefined) { + const versionedSha = candidates.find( + (candidate) => candidate.version === syncingVersion && candidate.commitSha, + )?.commitSha; + if (versionedSha) { + return anchorIfAheadOfHead(versionedSha, headSha, deps); + } + } + const anchorSha = candidates.find((candidate) => candidate.commitSha)?.commitSha; - if (!anchorSha || anchorSha === headSha) { + return anchorSha ? anchorIfAheadOfHead(anchorSha, headSha, deps) : undefined; +} + +function anchorIfAheadOfHead(anchorSha: string, headSha: string, deps: AnchorComparisonDeps): string | undefined { + if (anchorSha === headSha) { return undefined; } // Cheap forward check first: an anchor that is an ancestor of HEAD means HEAD is at or ahead of