fix(#670): self-healing recovery for installer-migration checksum drift

Backport of #675 to the 1.3.1 hotfix. An already-applied installer migration
whose recorded checksum drifted (e.g. a shipped body was edited in #615) is now
detected and reconciled on the next install instead of hard-aborting the
upgrade ("applied migration checksum changed"). Adds a CI baseline lock that
fails on any shipped-migration body drift.

Fixes #670

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-04 13:49:49 -04:00
parent ca7d9d050f
commit 50eda858cc
3 changed files with 276 additions and 33 deletions

View File

@@ -208,17 +208,51 @@ function migrationChecksum(migration: MigrationRecord): string {
return `sha256:${sha256Text(JSON.stringify(serializable))}`;
}
function assertAppliedMigrationChecksums(applied: Map<string, Record<string, unknown>>, migrations: MigrationRecord[]): void {
// Rewrite the stored checksum of any already-applied entry whose id drifted, so the
// drift is reconciled durably and not re-detected on every subsequent run (issue #670).
// Returns the number of entries actually changed (so callers know whether a write is needed).
function reconcileDriftedChecksums(
appliedEntries: Array<Record<string, unknown>>,
checksumDrift: Array<{ id: string; currentChecksum: string }> | undefined
): number {
if (!Array.isArray(checksumDrift) || checksumDrift.length === 0) return 0;
const reconcile = new Map(checksumDrift.map((d) => [d.id, d.currentChecksum]));
let changed = 0;
for (let i = 0; i < appliedEntries.length; i++) {
const existing = appliedEntries[i];
if (existing && typeof existing.id === 'string' && reconcile.has(existing.id)) {
const next = reconcile.get(existing.id) as string;
if (existing.checksum !== next) {
appliedEntries[i] = { ...existing, checksum: next };
changed += 1;
}
}
}
return changed;
}
function collectAppliedChecksumDrift(
applied: Map<string, Record<string, unknown>>,
migrations: MigrationRecord[]
): Array<{ id: string; storedChecksum: string; currentChecksum: string }> {
const drift: Array<{ id: string; storedChecksum: string; currentChecksum: string }> = [];
for (const migration of migrations) {
const entry = applied.get(migration.id as string);
if (!entry || !entry.checksum) continue;
const checksum = migrationChecksum(migration);
if (entry.checksum !== checksum) {
throw new Error(
`applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id`
);
const currentChecksum = migrationChecksum(migration);
if (entry.checksum !== currentChecksum) {
// An already-applied migration is never re-run (it is filtered out of `pending`),
// so a checksum drift here is functionally inert. A prior release may have edited a
// shipped migration body (see issue #670). Surface it for reconciliation instead of
// hard-aborting the user's upgrade.
drift.push({
id: migration.id as string,
storedChecksum: entry.checksum as string,
currentChecksum,
});
}
}
return drift;
}
function migrationMatchesContext(migration: MigrationRecord, { runtime, scope }: { runtime: string | null; scope: string | null }): boolean {
@@ -451,6 +485,7 @@ interface MigrationPlan {
pendingMigrations: MigrationRecord[];
actions: PlannedAction[];
blocked: PlannedAction[];
checksumDrift: Array<{ id: string; storedChecksum: string; currentChecksum: string }>;
}
function planInstallerMigrations({
@@ -480,7 +515,7 @@ function planInstallerMigrations({
migrationMatchesContext(migration, { runtime, scope })
);
const applied = appliedMigrationEntries(state);
assertAppliedMigrationChecksums(applied, scopedMigrations);
const checksumDrift = collectAppliedChecksumDrift(applied, scopedMigrations);
const pending = scopedMigrations.filter((migration) => !applied.has(migration.id as string));
const actions: PlannedAction[] = [];
const blocked: PlannedAction[] = [];
@@ -568,6 +603,7 @@ function planInstallerMigrations({
pendingMigrations: pending,
actions,
blocked,
checksumDrift,
};
}
@@ -747,6 +783,7 @@ function applyInstallerMigrationPlan({
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const nextApplied = [...state.appliedMigrations];
reconcileDriftedChecksums(nextApplied, plan.checksumDrift);
const actionsByMigrationId = new Map<string, PlannedAction>();
for (const action of plan.actions) {
if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) {
@@ -809,29 +846,36 @@ function markPendingMigrationsApplied({
plan: MigrationPlan;
now?: () => string;
}): string[] {
if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) {
return [];
}
if (!plan) return [];
const hasPending = Array.isArray(plan.pendingMigrationIds) && plan.pendingMigrationIds.length > 0;
const hasDrift = Array.isArray(plan.checksumDrift) && plan.checksumDrift.length > 0;
if (!hasPending && !hasDrift) return [];
const appliedAt = now();
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);
const checksumsByMigrationId = new Map<string, string>();
for (const migration of plan.pendingMigrations || []) {
checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration));
}
const nextApplied = [...state.appliedMigrations];
const reconciledCount = reconcileDriftedChecksums(nextApplied, plan.checksumDrift);
const newlyApplied: string[] = [];
for (const id of plan.pendingMigrationIds) {
if (applied.has(id)) continue;
nextApplied.push({
id,
appliedAt,
journal: null,
checksum: checksumsByMigrationId.get(id) || null,
});
newlyApplied.push(id);
if (hasPending) {
const checksumsByMigrationId = new Map<string, string>();
for (const migration of plan.pendingMigrations || []) {
checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration));
}
for (const id of plan.pendingMigrationIds) {
if (applied.has(id)) continue;
nextApplied.push({
id,
appliedAt,
journal: null,
checksum: checksumsByMigrationId.get(id) || null,
});
newlyApplied.push(id);
}
}
if (newlyApplied.length > 0) {
if (newlyApplied.length > 0 || reconciledCount > 0) {
writeInstallState(configDir, {
schemaVersion: 1,
appliedMigrations: nextApplied,
@@ -924,6 +968,7 @@ export = {
applyInstallerMigrationPlan,
classifyArtifact,
discoverInstallerMigrations,
migrationChecksum,
planInstallerMigrations,
readInstallManifest,
readInstallState,