Merge pull request #674 from Solvely-Colin/fix/installer-migration-checksum-compat
fix: accept published installer migration checksums
This commit is contained in:
5
.changeset/fierce-lynx-howl.md
Normal file
5
.changeset/fierce-lynx-howl.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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/') ||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string> {
|
||||
return new Set([migrationChecksum(migration), ...migrationLegacyChecksums(migration)]);
|
||||
}
|
||||
|
||||
function assertAppliedMigrationChecksums(applied: Map<string, Record<string, unknown>>, 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,
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
tests/fixtures/installer-migrations/published-checksums.json
vendored
Normal file
49
tests/fixtures/installer-migrations/published-checksums.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
94
tests/installer-migration-checksum-compat.test.cjs
Normal file
94
tests/installer-migration-checksum-compat.test.cjs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user