fix(release): stream large migration checksums from git
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m50s

This commit is contained in:
2026-09-23 15:37:19 +02:00
parent f6996c1d7b
commit 74c0cf40b3
4 changed files with 125 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
@@ -91,11 +91,33 @@ export function componentVersions(sha, cwd = process.cwd()) {
}; };
} }
export function migrationManifest(sha, cwd = process.cwd()) { function gitBlobSha256(reference, cwd) {
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => { return new Promise((resolve, reject) => {
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd); const child = spawn('git', ['cat-file', 'blob', reference], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({ const hash = createHash('sha256');
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'), let stderr = '';
})) : []; const fail = message => reject(new Error(`Prüfsumme für ${reference} fehlgeschlagen: ${message}`));
// Reference-data migrations can exceed 90 MB. Hash raw chunks without
// buffering the entire blob or altering whitespace/encoding.
child.stdout.on('data', chunk => hash.update(chunk));
child.stderr.setEncoding('utf8');
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(0, 8192); });
child.once('error', error => fail(error.message));
for (const stream of [child.stdout, child.stderr]) {
stream.once('error', error => { child.kill(); fail(error.message); });
}
child.once('close', (code, signal) => {
if (code !== 0) fail(`git cat-file (Exit ${code ?? signal}): ${stderr.trim()}`);
else resolve(hash.digest('hex'));
});
}); });
} }
export async function migrationManifest(sha, cwd = process.cwd()) {
const files = git(['ls-tree', '-r', '-z', '--name-only', sha, '--',
'backend/db/migrations', 'push-server/packages/db/drizzle'], cwd)
.split('\0').filter(path => path.endsWith('.sql'));
const manifest = [];
for (const path of files) manifest.push({ path, sha256: await gitBlobSha256(`${sha}:${path}`, cwd) });
return manifest;
}

View File

@@ -14,11 +14,11 @@ export function assertManifest(manifest, plan, config) {
} }
} }
export function makeManifest(plan, images, config) { export async function makeManifest(plan, images, config) {
return { return {
schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha, schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha,
previousTag: plan.previousTag, repository: config.repositoryUrl, previousTag: plan.previousTag, repository: config.repositoryUrl,
images, components: componentVersions(plan.sha), migrations: migrationManifest(plan.sha), images, components: componentVersions(plan.sha), migrations: await migrationManifest(plan.sha),
migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.', migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.',
}; };
} }
@@ -61,7 +61,7 @@ export async function publishRelease({ plan, config, api, build, promote, files
} }
if (!manifest) { if (!manifest) {
const images = await build(plan.version); const images = await build(plan.version);
manifest = manifestFactory(plan, images, config); manifest = await manifestFactory(plan, images, config);
} }
assertManifest(manifest, plan, config); assertManifest(manifest, plan, config);
if (!release) { if (!release) {

View File

@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { git, migrationManifest } from '../lib.mjs';
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-migrations-test-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
const run = args => git(args, cwd, { stdio: ['pipe', 'pipe', 'pipe'] });
run(['init', '-b', 'main']);
run(['config', 'user.name', 'Release Test']);
run(['config', 'user.email', 'release@example.test']);
// Keep CRLF test bytes in Git regardless of the developer's autocrlf setting.
writeFileSync(join(cwd, '.gitattributes'), '*.sql -text\n');
const write = (path, data) => {
mkdirSync(dirname(join(cwd, path)), { recursive: true });
writeFileSync(join(cwd, path), data);
};
const commit = () => {
run(['add', '.']);
run(['commit', '--allow-empty', '-m', 'test: add migrations']);
return run(['rev-parse', 'HEAD']);
};
return { cwd, run, write, commit };
}
test('large migration hashes use the complete committed bytes beyond process buffer limits', async t => {
const f = fixture(t);
const path = 'backend/db/migrations/0058_reference_data.sql';
f.write(path, '');
const expected = createHash('sha256');
const chunk = Buffer.alloc(1024 * 1024, Buffer.from(" INSERT INTO reference VALUES ('Grüße');\r\n"));
// Exceed both Node's 1 MiB default and the text Git helper's 32 MiB buffer.
for (let i = 0; i < 34; i++) {
appendFileSync(join(f.cwd, path), chunk);
expected.update(chunk);
}
const sha = f.commit();
f.write(path, 'local changes must not affect the release checksum');
assert.deepEqual(await migrationManifest(sha, f.cwd), [{ path, sha256: expected.digest('hex') }]);
});
test('both migration folders are hashed byte-for-byte and non-SQL files are excluded', async t => {
const f = fixture(t);
const files = [
['backend/db/migrations/0000_init.sql', Buffer.from(' \r\nSELECT 1;\r\n\t')],
['push-server/packages/db/drizzle/0000_init.sql', Buffer.from('SELECT \'äöü\';\n')],
];
for (const [path, content] of files) f.write(path, content);
f.write('backend/db/migrations/meta/snapshot.json', '{}');
const sha = f.commit();
assert.deepEqual(await migrationManifest(sha, f.cwd), files.map(([path, content]) => ({
path, sha256: createHash('sha256').update(content).digest('hex'),
})));
});
test('a commit without migration folders produces an empty manifest', async t => {
const f = fixture(t);
assert.deepEqual(await migrationManifest(f.commit(), f.cwd), []);
});
test('an unreadable Git blob rejects the manifest instead of recording a partial checksum', async t => {
const f = fixture(t);
const path = 'backend/db/migrations/0000_missing.sql';
f.write(path, 'SELECT 1;');
const sha = f.commit();
const blob = f.run(['rev-parse', `${sha}:${path}`]);
rmSync(join(f.cwd, '.git', 'objects', blob.slice(0, 2), blob.slice(2)));
await assert.rejects(async () => migrationManifest(sha, f.cwd), /0000_missing\.sql/);
});

View File

@@ -58,6 +58,26 @@ test('failed build never creates a draft or changes release channels', async ()
assert.deepEqual(f.events, []); assert.deepEqual(f.events, []);
}); });
test('asynchronous manifest generation finishes before creating the release draft', async () => {
const f = fixture();
f.dependencies.manifestFactory = async () => {
await new Promise(resolve => setImmediate(resolve));
f.events.push('manifest');
return f.manifest;
};
await publishRelease(f.dependencies);
assert.deepEqual(f.events.slice(0, 3), ['build', 'manifest', 'draft']);
assert.equal(f.getRelease().draft, false);
});
test('failed migration hashing never creates a draft or promotes images', async () => {
const f = fixture();
f.dependencies.manifestFactory = async () => { throw new Error('migration hashing failed'); };
await assert.rejects(publishRelease(f.dependencies), /migration hashing failed/);
assert.deepEqual(f.events, ['build']);
assert.equal(f.getRelease(), null);
});
test('retry a draft uses saved digests and original notes without rebuilding', async () => { test('retry a draft uses saved digests and original notes without rebuilding', async () => {
const f = fixture(); const f = fixture();
f.dependencies.promote = async () => { throw new Error('registry interrupted'); }; f.dependencies.promote = async () => { throw new Error('registry interrupted'); };