Skip to content

Commit 43f1150

Browse files
committed
Harden release manifest naming for Windows installers
1 parent b402b1e commit 43f1150

4 files changed

Lines changed: 547 additions & 29 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ jobs:
284284
285285
for dir in "${metadata_dirs[@]}"; do
286286
echo "Verifying auto-update metadata in ${dir}" >&2
287-
node scripts/test-auto-update.mjs --local "$dir"
287+
node scripts/test-auto-update.mjs --local "$dir" --fix-metadata
288288
done
289289
290290
- name: Display release notes

scripts/__tests__/release-workflow.test.mjs

Lines changed: 140 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ async function writeFixtureInstaller(directory, version, options = {}) {
3434
includeMetadata = true,
3535
additionalMetadata = [],
3636
assetSize = 1024,
37+
includeBlockmap = false,
3738
} = options;
3839

3940
const releaseDir = path.join(directory, 'release-artifacts', artifactDir, 'release');
@@ -43,6 +44,12 @@ async function writeFixtureInstaller(directory, version, options = {}) {
4344
const binary = crypto.randomBytes(assetSize);
4445
await fs.writeFile(installerPath, binary);
4546

47+
let blockmapPath = null;
48+
if (includeBlockmap) {
49+
blockmapPath = `${installerPath}.blockmap`;
50+
await fs.writeFile(blockmapPath, crypto.randomBytes(Math.max(256, Math.floor(assetSize / 4))));
51+
}
52+
4653
let metadataPath = null;
4754
if (includeMetadata && metadataFileName) {
4855
const metadata = {
@@ -84,6 +91,7 @@ async function writeFixtureInstaller(directory, version, options = {}) {
8491
artifactDir,
8592
assetPath: installerPath,
8693
assetName,
94+
blockmapPath,
8795
};
8896
}
8997

@@ -148,10 +156,10 @@ async function runGenerateReleaseNotes({
148156
);
149157
}
150158

151-
async function runLocalVerification(directory) {
159+
async function runLocalVerification(directory, extraArgs = []) {
152160
await execFileAsync(
153161
'node',
154-
[repoPath('scripts', 'test-auto-update.mjs'), '--local', directory],
162+
[repoPath('scripts', 'test-auto-update.mjs'), '--local', directory, ...extraArgs],
155163
{
156164
cwd: repoPath(),
157165
},
@@ -230,11 +238,89 @@ test('release tooling rewrites metadata and keeps latest.yml published', async (
230238
await runLocalVerification(releaseDir);
231239
});
232240

241+
test('release tooling ignores unpacked directories when enforcing metadata', async (t) => {
242+
const workspace = await createTemporaryWorkspace(t);
243+
await writeFixtureInstaller(workspace, '0.0.2', {
244+
artifactDir: 'docforge-windows-x64',
245+
});
246+
247+
const unpackedDir = path.join(
248+
workspace,
249+
'release-artifacts',
250+
'docforge-windows-x64',
251+
'win-unpacked',
252+
);
253+
await fs.mkdir(unpackedDir, { recursive: true });
254+
const unpackedExecutable = path.join(unpackedDir, 'DocForge.exe');
255+
await fs.writeFile(unpackedExecutable, crypto.randomBytes(2048));
256+
257+
const changelogPath = path.join(workspace, 'CHANGELOG.md');
258+
await fs.writeFile(
259+
changelogPath,
260+
['## v0.0.2', '', '- Ignore unpacked executable fixtures.'].join('\n'),
261+
'utf8',
262+
);
263+
264+
const notesPath = path.join(workspace, 'release-notes.md');
265+
const manifestPath = path.join(workspace, 'release-files.txt');
266+
267+
await runGenerateReleaseNotes({
268+
workspace,
269+
version: '0.0.2',
270+
tag: 'v0.0.2',
271+
changelogPath,
272+
outputPath: notesPath,
273+
filesOutputPath: manifestPath,
274+
});
275+
276+
const manifest = await fs.readFile(manifestPath, 'utf8');
277+
const entries = readManifestEntries(manifest);
278+
assert(entries.every((line) => !line.includes('DocForge.exe')));
279+
});
280+
281+
test('local verification can repair mismatched metadata when requested', async (t) => {
282+
const workspace = await createTemporaryWorkspace(t);
283+
const version = '0.0.2';
284+
const { releaseDir, metadataPath, assetPath } = await writeFixtureInstaller(workspace, version, {
285+
assetSize: 2048,
286+
});
287+
288+
const corrupted = YAML.parse(await fs.readFile(metadataPath, 'utf8'));
289+
corrupted.sha512 = 'invalid-sha512';
290+
corrupted.files[0].sha512 = 'invalid-sha512';
291+
corrupted.files[0].size = 1;
292+
await fs.writeFile(metadataPath, YAML.stringify(corrupted), 'utf8');
293+
294+
await assert.rejects(() => runLocalVerification(releaseDir), /Local auto-update verification failed/);
295+
296+
await runLocalVerification(releaseDir, ['--fix-metadata']);
297+
298+
const installerBuffer = await fs.readFile(assetPath);
299+
const expectedSha = computeSha512Base64(installerBuffer);
300+
const updated = YAML.parse(await fs.readFile(metadataPath, 'utf8'));
301+
302+
assert.equal(updated.path, path.basename(assetPath));
303+
assert.equal(updated.sha512, expectedSha);
304+
if (Object.prototype.hasOwnProperty.call(updated, 'size')) {
305+
assert.equal(updated.size, installerBuffer.length);
306+
}
307+
assert(Array.isArray(updated.files) && updated.files.length === 1);
308+
assert.equal(updated.files[0].url, path.basename(assetPath));
309+
assert.equal(updated.files[0].sha512, expectedSha);
310+
assert.equal(updated.files[0].size, installerBuffer.length);
311+
});
312+
233313
test('metadata updates remain isolated across artifact directories with identical installer names', async (t) => {
234314
const workspace = await createTemporaryWorkspace(t);
235315
const version = '0.0.3';
236-
const x64 = await writeFixtureInstaller(workspace, version, { artifactDir: 'docforge-windows-x64' });
237-
const arm64 = await writeFixtureInstaller(workspace, version, { artifactDir: 'docforge-windows-arm64' });
316+
const x64 = await writeFixtureInstaller(workspace, version, {
317+
artifactDir: 'docforge-windows-x64',
318+
includeBlockmap: true,
319+
});
320+
const arm64 = await writeFixtureInstaller(workspace, version, {
321+
artifactDir: 'docforge-windows-arm64',
322+
includeBlockmap: true,
323+
});
238324

239325
const changelogPath = path.join(workspace, 'CHANGELOG.md');
240326
await fs.writeFile(
@@ -255,13 +341,16 @@ test('metadata updates remain isolated across artifact directories with identica
255341
filesOutputPath: manifestPath,
256342
});
257343

258-
const renamedInstallerName = `DocForge-Setup-${version}.exe`;
259-
const renamedX64 = path.join(x64.releaseDir, renamedInstallerName);
260-
const renamedArm64 = path.join(arm64.releaseDir, renamedInstallerName);
344+
const renamedX64Name = `DocForge-Setup-${version}.exe`;
345+
const renamedArm64Name = `DocForge-Setup-${version}-arm64.exe`;
346+
const renamedX64 = path.join(x64.releaseDir, renamedX64Name);
347+
const renamedArm64 = path.join(arm64.releaseDir, renamedArm64Name);
261348

262349
await Promise.all([
263350
assert.doesNotReject(() => fs.access(renamedX64)),
264351
assert.doesNotReject(() => fs.access(renamedArm64)),
352+
assert.doesNotReject(() => fs.access(path.join(x64.releaseDir, `${renamedX64Name}.blockmap`))),
353+
assert.doesNotReject(() => fs.access(path.join(arm64.releaseDir, `${renamedArm64Name}.blockmap`))),
265354
]);
266355

267356
const [x64Buffer, arm64Buffer] = await Promise.all([
@@ -286,20 +375,20 @@ test('metadata updates remain isolated across artifact directories with identica
286375
assert(entries.includes(expectedX64Entry), 'x64 installer should be listed in manifest');
287376
assert(entries.includes(expectedArm64Entry), 'arm64 installer should be listed in manifest');
288377

289-
const assertMetadataMatches = (metadata, expectedSha, bufferLength) => {
290-
assert.equal(metadata.path, renamedInstallerName);
378+
const assertMetadataMatches = (metadata, expectedName, expectedSha, bufferLength) => {
379+
assert.equal(metadata.path, expectedName);
291380
assert.equal(metadata.sha512, expectedSha);
292381
if (Object.prototype.hasOwnProperty.call(metadata, 'size')) {
293382
assert.equal(metadata.size, bufferLength);
294383
}
295384
assert(Array.isArray(metadata.files) && metadata.files.length === 1);
296-
assert.equal(metadata.files[0].url, renamedInstallerName);
385+
assert.equal(metadata.files[0].url, expectedName);
297386
assert.equal(metadata.files[0].sha512, expectedSha);
298387
assert.equal(metadata.files[0].size, bufferLength);
299388
};
300389

301-
assertMetadataMatches(x64Metadata, x64Sha, x64Buffer.length);
302-
assertMetadataMatches(arm64Metadata, arm64Sha, arm64Buffer.length);
390+
assertMetadataMatches(x64Metadata, renamedX64Name, x64Sha, x64Buffer.length);
391+
assertMetadataMatches(arm64Metadata, renamedArm64Name, arm64Sha, arm64Buffer.length);
303392

304393
await Promise.all([
305394
runLocalVerification(x64.releaseDir),
@@ -313,6 +402,7 @@ test('release workflow verifies metadata directories across platforms and publis
313402

314403
const ia32 = await writeFixtureInstaller(workspace, version, {
315404
artifactDir: 'docforge-windows-ia32',
405+
includeBlockmap: true,
316406
additionalMetadata: [
317407
{
318408
relativePath: path.join('win-ia32-unpacked', 'resources', 'app-update.yml'),
@@ -326,6 +416,7 @@ test('release workflow verifies metadata directories across platforms and publis
326416

327417
const x64 = await writeFixtureInstaller(workspace, version, {
328418
artifactDir: 'docforge-windows-x64',
419+
includeBlockmap: true,
329420
});
330421

331422
const linux = await writeFixtureInstaller(workspace, version, {
@@ -360,6 +451,43 @@ test('release workflow verifies metadata directories across platforms and publis
360451
});
361452

362453
const manifestEntries = new Set(readManifestEntries(await fs.readFile(manifestPath, 'utf8')));
454+
const publishedNames = new Map();
455+
for (const entry of manifestEntries) {
456+
const [relativePath, explicitName] = entry.split('#');
457+
const candidateName = explicitName || path.basename(relativePath);
458+
const previousEntry = publishedNames.get(candidateName);
459+
assert(
460+
!previousEntry,
461+
`Duplicate release asset name detected: ${candidateName} (entries: ${previousEntry}, ${entry})`,
462+
);
463+
publishedNames.set(candidateName, entry);
464+
}
465+
const appUpdateRelative = path.relative(
466+
repoPath(),
467+
path.join(ia32.releaseDir, 'win-ia32-unpacked', 'resources', 'app-update.yml'),
468+
);
469+
assert(
470+
!manifestEntries.has(appUpdateRelative),
471+
'app-update.yml should not be uploaded to the release',
472+
);
473+
const ia32InstallerName = `DocForge-Setup-${version}-ia32.exe`;
474+
const x64InstallerName = `DocForge-Setup-${version}.exe`;
475+
const expectedWindowsBinaries = [
476+
path.relative(repoPath(), path.join(ia32.releaseDir, ia32InstallerName)),
477+
path.relative(repoPath(), path.join(x64.releaseDir, x64InstallerName)),
478+
];
479+
for (const entry of expectedWindowsBinaries) {
480+
assert(manifestEntries.has(entry), `${entry} must be included for Windows release assets`);
481+
}
482+
483+
const expectedBlockmaps = [
484+
path.relative(repoPath(), path.join(ia32.releaseDir, `${ia32InstallerName}.blockmap`)),
485+
path.relative(repoPath(), path.join(x64.releaseDir, `${x64InstallerName}.blockmap`)),
486+
];
487+
for (const blockmap of expectedBlockmaps) {
488+
assert(manifestEntries.has(blockmap), `${blockmap} must be published after renaming installers`);
489+
}
490+
363491
const expectedMetadataUploads = [x64.metadataPath, linux.metadataPath, mac.metadataPath];
364492
for (const metadataPath of expectedMetadataUploads) {
365493
assert(metadataPath, 'metadataPath should be defined for uploaded manifests');

0 commit comments

Comments
 (0)