diff --git a/.changeset/fierce-lynx-howl.md b/.changeset/fierce-lynx-howl.md new file mode 100644 index 000000000..3f4aabe5a --- /dev/null +++ b/.changeset/fierce-lynx-howl.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 674 +--- +Accept published installer migration checksums so Windows users upgrading from 1.2.0 to 1.3.0 do not hit checksum drift failures. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index d78e08a2a..124d7bfe0 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -125,6 +125,11 @@ The checksum is calculated from the migration definition. If an applied migration's checksum changes, the installer must warn and refuse to silently re-run it. Fix-forward migrations should use a new migration id. +Migration records may pin a stable `checksum` and list `legacyChecksums` for +checksums already written by published packages. This is only for compatibility +with released install state; new behavior should still ship as a new migration +id rather than mutating an already-applied migration in place. + ## Migration Record Each migration exports a plain record plus pure planning logic. diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 299491b2b..771aa81be 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -64,6 +64,7 @@ const RULES = [ 'tests/install-regressions.test.cjs', 'tests/install-runtime-artifacts.test.cjs', 'tests/install-path-detection.test.cjs', + 'tests/installer-migration-checksum-compat.test.cjs', 'tests/release-tarball-smoke.install.test.cjs', 'tests/runtime-artifact-layout.test.cjs', ], @@ -254,7 +255,7 @@ function classify(files) { let fullMatrix = false; for (const file of files) { - if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || + if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/', 'src/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || file.startsWith('.github/workflows/') || diff --git a/src/installer-migration-authoring.cts b/src/installer-migration-authoring.cts index a859363d7..a09a4fcc5 100644 --- a/src/installer-migration-authoring.cts +++ b/src/installer-migration-authoring.cts @@ -91,6 +91,7 @@ export function validateInstallerMigrationRecord(record: unknown, source?: strin throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`); } validateStringArray(rec, 'runtimes', displaySource); + validateStringArray(rec, 'legacyChecksums', displaySource); requireStringArray(rec, 'scopes', displaySource); if (typeof rec['plan'] !== 'function') { throw new Error(`migration record must include a plan function: ${displaySource}`); diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 591fd5f8a..4133c07ed 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -208,12 +208,22 @@ function migrationChecksum(migration: MigrationRecord): string { return `sha256:${sha256Text(JSON.stringify(serializable))}`; } +function migrationLegacyChecksums(migration: MigrationRecord): string[] { + return Array.isArray(migration.legacyChecksums) + ? migration.legacyChecksums.filter((checksum): checksum is string => typeof checksum === 'string' && checksum.length > 0) + : []; +} + +function migrationAcceptedChecksums(migration: MigrationRecord): Set { + return new Set([migrationChecksum(migration), ...migrationLegacyChecksums(migration)]); +} + function assertAppliedMigrationChecksums(applied: Map>, migrations: MigrationRecord[]): void { 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) { + const accepted = migrationAcceptedChecksums(migration); + if (!accepted.has(entry.checksum as string)) { throw new Error( `applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id` ); @@ -923,6 +933,7 @@ export = { acquireInstallMigrationLock, applyInstallerMigrationPlan, classifyArtifact, + computeInstallerMigrationChecksum: migrationChecksum, discoverInstallerMigrations, planInstallerMigrations, readInstallManifest, diff --git a/src/installer-migrations/000-first-time-baseline.cts b/src/installer-migrations/000-first-time-baseline.cts index c81f30e6a..0f31541c8 100644 --- a/src/installer-migrations/000-first-time-baseline.cts +++ b/src/installer-migrations/000-first-time-baseline.cts @@ -171,6 +171,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: PlanContext) => BaselineAction[]; @@ -181,6 +183,10 @@ const migration: InstallerMigration = { title: 'Record first-time installer migration baseline', description: 'Classify existing install surfaces before destructive installer migrations run.', introducedIn: '1.50.0', + checksum: 'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a', + legacyChecksums: [ + 'sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67', + ], scopes: ['global', 'local'], destructive: false, plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => { diff --git a/src/installer-migrations/001-legacy-orphan-files.cts b/src/installer-migrations/001-legacy-orphan-files.cts index bd3dad449..0f7e7e677 100644 --- a/src/installer-migrations/001-legacy-orphan-files.cts +++ b/src/installer-migrations/001-legacy-orphan-files.cts @@ -31,6 +31,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: MigrationPlanContext) => MigrationAction[]; @@ -46,6 +48,10 @@ const migration: InstallerMigration = { title: 'Remove manifest-managed legacy orphan hook files', description: 'Remove legacy orphan hook files that are still manifest-managed.', introducedIn: '1.50.0', + checksum: 'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c', + legacyChecksums: [ + 'sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3', + ], scopes: ['global', 'local'], destructive: true, // Retired generated hook files are removed only with manifest-managed diff --git a/src/installer-migrations/002-codex-legacy-hooks-json.cts b/src/installer-migrations/002-codex-legacy-hooks-json.cts index 4e3a8a214..8032bed09 100644 --- a/src/installer-migrations/002-codex-legacy-hooks-json.cts +++ b/src/installer-migrations/002-codex-legacy-hooks-json.cts @@ -48,6 +48,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; runtimes: string[]; scopes: string[]; destructive: boolean; @@ -110,6 +112,10 @@ const migration: InstallerMigration = { title: 'Remove legacy Codex hooks.json GSD hook registrations', description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.', introducedIn: '1.50.0', + checksum: 'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058', + legacyChecksums: [ + 'sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da', + ], runtimes: ['codex'], scopes: ['global', 'local'], destructive: true, diff --git a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts index 13729c737..3db761dd9 100644 --- a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts +++ b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts @@ -48,6 +48,7 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; scopes: string[]; destructive: boolean; plan(ctx: MigrationPlanContext): MigrationAction[]; @@ -80,6 +81,7 @@ const migration: InstallerMigration = { 'After the config dir rename from get-shit-done/ to gsd-core/ (#604), remove prior-manifest-managed files ' + // gsd-allow-legacy-name 'from the stale legacy directory during install (framework rollback restores them if install fails). User-added files are preserved.', introducedIn: '1.2.0', + checksum: 'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95', scopes: ['global', 'local'], destructive: true, plan(ctx: MigrationPlanContext): MigrationAction[] { diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 2ce8d4f1d..be00683ce 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -56,9 +56,18 @@ describe('ci-test-scope.cjs', () => { assert.strictEqual(result.code_changed, true); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/install.test.cjs')); + assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs')); }); + test('ADR-457 installer source changes wake scoped CI and checksum compatibility guard', () => { + const result = scopeFor(['src/installer-migrations/000-first-time-baseline.cts']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.full_matrix, true); + assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); + assert.ok(result.windows_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); + }); + test('missing required CLI values fail with usage', () => { const r = spawnSync(process.execPath, [SCRIPT, '--files'], { cwd: ROOT, diff --git a/tests/fixtures/installer-migrations/published-checksums.json b/tests/fixtures/installer-migrations/published-checksums.json new file mode 100644 index 000000000..d7784748e --- /dev/null +++ b/tests/fixtures/installer-migrations/published-checksums.json @@ -0,0 +1,49 @@ +{ + "_comment": "Published installer migration checksums that must remain accepted by future releases. If an existing migration implementation changes, preserve compatibility here via legacyChecksums or create a new fix-forward migration id.", + "migrations": { + "2026-05-11-first-time-baseline-scan": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67" + }, + { + "version": "1.3.0", + "checksum": "sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a" + } + ] + }, + "2026-05-11-legacy-orphan-files": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3" + }, + { + "version": "1.3.0", + "checksum": "sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c" + } + ] + }, + "2026-05-11-codex-legacy-hooks-json": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da" + }, + { + "version": "1.3.0", + "checksum": "sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058" + } + ] + }, + "2026-06-02-rename-get-shit-done-to-gsd-core": { + "published": [ + { + "version": "1.3.0", + "checksum": "sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95" + } + ] + } + } +} diff --git a/tests/installer-migration-checksum-compat.test.cjs b/tests/installer-migration-checksum-compat.test.cjs new file mode 100644 index 000000000..790bf208d --- /dev/null +++ b/tests/installer-migration-checksum-compat.test.cjs @@ -0,0 +1,94 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + computeInstallerMigrationChecksum, + discoverInstallerMigrations, + planInstallerMigrations, +} = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const FIXTURE = require('./fixtures/installer-migrations/published-checksums.json'); +const MIGRATIONS_DIR = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'); + +function createConfigDir() { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migration-checksum-compat-')); + fs.writeFileSync( + path.join(configDir, 'gsd-file-manifest.json'), + JSON.stringify({ version: 'compat-fixture', timestamp: '2026-06-04T00:00:00.000Z', mode: 'full', files: {} }, null, 2), + 'utf8' + ); + return configDir; +} + +function writeAppliedState(configDir, migration, checksum, version) { + fs.writeFileSync( + path.join(configDir, 'gsd-install-state.json'), + JSON.stringify({ + schemaVersion: 1, + appliedMigrations: [ + { + id: migration.id, + checksum, + appliedAt: '2026-06-04T00:00:00.000Z', + packageVersion: version, + journal: 'gsd-migration-journal/published-compat-fixture.json', + }, + ], + }, null, 2), + 'utf8' + ); +} + +function planContextFor(migration) { + return { + runtime: Array.isArray(migration.runtimes) && migration.runtimes.length > 0 ? migration.runtimes[0] : 'claude', + scope: Array.isArray(migration.scopes) && migration.scopes.length > 0 ? migration.scopes[0] : 'global', + }; +} + +test('shipped installer migration checksums remain accepted for upgrade compatibility', () => { + const migrations = discoverInstallerMigrations({ migrationsDir: MIGRATIONS_DIR }); + const byId = new Map(migrations.map((migration) => [migration.id, migration])); + const fixtureIds = new Set(Object.keys(FIXTURE.migrations)); + + for (const migration of migrations) { + assert.ok( + fixtureIds.has(migration.id), + `current migration must be pinned in published-checksums.json: ${migration.id}` + ); + } + + for (const [migrationId, fixture] of Object.entries(FIXTURE.migrations)) { + const migration = byId.get(migrationId); + assert.ok(migration, `published migration fixture no longer exists: ${migrationId}`); + + const currentChecksum = computeInstallerMigrationChecksum(migration); + assert.ok( + fixture.published.some((entry) => entry.checksum === currentChecksum), + `${migrationId} current checksum ${currentChecksum} is not pinned in published-checksums.json` + ); + + for (const { version, checksum } of fixture.published) { + const configDir = createConfigDir(); + try { + writeAppliedState(configDir, migration, checksum, version); + assert.doesNotThrow( + () => planInstallerMigrations({ + configDir, + migrations: [migration], + ...planContextFor(migration), + }), + `${migrationId} must accept checksum ${checksum} from ${version}` + ); + } finally { + cleanup(configDir); + } + } + } +});