Skip to content

Commit 184e140

Browse files
authored
Merge pull request #137 from beNative/codex/fix-auto-update-checksum-mismatch-y83hy9
Add release regression tests for update metadata
2 parents 70b3499 + 12651ee commit 184e140

File tree

3 files changed

+196
-19
lines changed

3 files changed

+196
-19
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"publish": "npm run build && electron-builder --publish always",
1212
"prepare:icons": "node scripts/prepare-icons.mjs",
1313
"test": "node scripts/validate-gui-test-plan.mjs",
14+
"test:release": "node --test scripts/__tests__/release-workflow.test.mjs",
1415
"test:auto-update": "node scripts/test-auto-update.mjs",
1516
"postinstall": "electron-builder install-app-deps",
1617
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./dist/tailwind.css --minify",
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { execFile } from 'node:child_process';
4+
import crypto from 'node:crypto';
5+
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
import { promisify } from 'node:util';
8+
import { promises as fs } from 'node:fs';
9+
import YAML from 'yaml';
10+
11+
const execFileAsync = promisify(execFile);
12+
13+
const REPO_ROOT = path.dirname(fileURLToPath(import.meta.url));
14+
15+
function repoPath(...segments) {
16+
return path.resolve(REPO_ROOT, '..', '..', ...segments);
17+
}
18+
19+
async function createTemporaryWorkspace(t) {
20+
const prefix = path.join(repoPath(), `.tmp-release-${process.pid}-`);
21+
const dir = await fs.mkdtemp(prefix);
22+
t.after(async () => {
23+
await fs.rm(dir, { recursive: true, force: true });
24+
});
25+
return dir;
26+
}
27+
28+
async function writeFixtureInstaller(directory, version) {
29+
const releaseDir = path.join(directory, 'release-artifacts', 'docforge-windows-x64', 'release');
30+
await fs.mkdir(releaseDir, { recursive: true });
31+
const originalName = `DocForge Setup ${version}.exe`;
32+
const installerPath = path.join(releaseDir, originalName);
33+
const binary = crypto.randomBytes(1024);
34+
await fs.writeFile(installerPath, binary);
35+
36+
const latestMetadata = {
37+
version,
38+
files: [
39+
{
40+
url: originalName,
41+
sha512: 'placeholder',
42+
size: 0,
43+
},
44+
],
45+
path: originalName,
46+
sha512: 'placeholder',
47+
releaseDate: new Date().toISOString(),
48+
};
49+
50+
const latestPath = path.join(releaseDir, 'latest.yml');
51+
await fs.writeFile(latestPath, YAML.stringify(latestMetadata), 'utf8');
52+
53+
return { releaseDir, latestPath };
54+
}
55+
56+
async function runGenerateReleaseNotes({
57+
workspace,
58+
version,
59+
tag,
60+
changelogPath,
61+
outputPath,
62+
filesOutputPath,
63+
}) {
64+
const env = {
65+
...process.env,
66+
GITHUB_REPOSITORY: 'beNative/docforge',
67+
};
68+
69+
await execFileAsync(
70+
'node',
71+
[
72+
repoPath('scripts', 'generate-release-notes.mjs'),
73+
'--tag',
74+
tag,
75+
'--version',
76+
version,
77+
'--artifact-root',
78+
path.join(workspace, 'release-artifacts'),
79+
'--changelog',
80+
changelogPath,
81+
'--output',
82+
outputPath,
83+
'--files-output',
84+
filesOutputPath,
85+
],
86+
{
87+
cwd: repoPath(),
88+
env,
89+
},
90+
);
91+
}
92+
93+
async function runLocalVerification(directory) {
94+
await execFileAsync(
95+
'node',
96+
[repoPath('scripts', 'test-auto-update.mjs'), '--local', directory],
97+
{
98+
cwd: repoPath(),
99+
},
100+
);
101+
}
102+
103+
function readManifestEntries(manifestSource) {
104+
return manifestSource
105+
.split(/\r?\n/)
106+
.map((line) => line.trim())
107+
.filter(Boolean);
108+
}
109+
110+
function computeSha512Base64(buffer) {
111+
const hash = crypto.createHash('sha512');
112+
hash.update(buffer);
113+
return hash.digest('base64');
114+
}
115+
116+
test('release tooling rewrites metadata and keeps latest.yml published', async (t) => {
117+
const workspace = await createTemporaryWorkspace(t);
118+
const { releaseDir, latestPath } = await writeFixtureInstaller(workspace, '0.0.1');
119+
120+
const changelogPath = path.join(workspace, 'CHANGELOG.md');
121+
await fs.writeFile(
122+
changelogPath,
123+
['## v0.0.1', '', '- Test release entry for automated validation.'].join('\n'),
124+
'utf8',
125+
);
126+
127+
const notesPath = path.join(workspace, 'release-notes.md');
128+
const manifestPath = path.join(workspace, 'release-files.txt');
129+
130+
await runGenerateReleaseNotes({
131+
workspace,
132+
version: '0.0.1',
133+
tag: 'v0.0.1',
134+
changelogPath,
135+
outputPath: notesPath,
136+
filesOutputPath: manifestPath,
137+
});
138+
139+
const renamedInstaller = path.join(releaseDir, 'DocForge-Setup-0.0.1.exe');
140+
await assert.doesNotReject(() => fs.access(renamedInstaller));
141+
142+
const manifest = await fs.readFile(manifestPath, 'utf8');
143+
const entries = readManifestEntries(manifest);
144+
const relativeInstaller = path.relative(repoPath(), renamedInstaller);
145+
const relativeLatest = path.relative(repoPath(), latestPath);
146+
147+
assert(entries.includes(relativeInstaller), 'Installer should be present in manifest after renaming');
148+
assert(entries.includes(relativeLatest), 'latest.yml must be uploaded as part of the release');
149+
150+
const installerBuffer = await fs.readFile(renamedInstaller);
151+
const expectedSha = computeSha512Base64(installerBuffer);
152+
153+
const metadata = YAML.parse(await fs.readFile(latestPath, 'utf8'));
154+
assert.equal(metadata.path, 'DocForge-Setup-0.0.1.exe');
155+
assert.equal(metadata.sha512, expectedSha);
156+
if (Object.prototype.hasOwnProperty.call(metadata, 'size')) {
157+
assert.equal(metadata.size, installerBuffer.length);
158+
}
159+
assert(Array.isArray(metadata.files) && metadata.files.length === 1);
160+
assert.equal(metadata.files[0].url, 'DocForge-Setup-0.0.1.exe');
161+
assert.equal(metadata.files[0].sha512, expectedSha);
162+
assert.equal(metadata.files[0].size, installerBuffer.length);
163+
164+
await runLocalVerification(releaseDir);
165+
});
166+
167+
test('local auto-update verification fails when metadata assets are missing', async (t) => {
168+
const workspace = await createTemporaryWorkspace(t);
169+
const { releaseDir, latestPath } = await writeFixtureInstaller(workspace, '0.0.2');
170+
171+
await fs.rm(latestPath);
172+
173+
await assert.rejects(
174+
() => runLocalVerification(releaseDir),
175+
/No metadata files were found/,
176+
);
177+
});
178+

scripts/generate-release-notes.mjs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -168,22 +168,7 @@ async function normaliseReleaseAsset(filePath) {
168168
return { filePath: targetPath, fileName: normalisedName, originalFileName: fileName };
169169
}
170170

171-
async function computeSha512(filePath) {
172-
return new Promise((resolve, reject) => {
173-
const hash = crypto.createHash('sha512');
174-
const stream = createReadStream(filePath);
175-
stream.on('data', (chunk) => hash.update(chunk));
176-
stream.on('error', reject);
177-
stream.on('end', () => resolve(hash.digest('base64')));
178-
});
179-
}
180-
181-
async function getFileSize(filePath) {
182-
const stats = await fs.stat(filePath);
183-
return stats.size;
184-
}
185-
186-
async function computeSha512(filePath) {
171+
async function computeFileSha512(filePath) {
187172
return new Promise((resolve, reject) => {
188173
const hash = crypto.createHash('sha512');
189174
const stream = createReadStream(filePath);
@@ -229,7 +214,7 @@ async function collectAssets(artifactRoot) {
229214
}
230215

231216
const [sha512, size] = await Promise.all([
232-
computeSha512(filePath),
217+
computeFileSha512(filePath),
233218
getFileSize(filePath),
234219
]);
235220

@@ -268,7 +253,14 @@ async function updateMetadataFiles(metadataFiles, releaseAssets) {
268253
return;
269254
}
270255

271-
const assetByName = new Map(releaseAssets.map((asset) => [asset.fileName, asset]));
256+
const assetByName = new Map();
257+
for (const asset of releaseAssets) {
258+
assetByName.set(asset.fileName, asset);
259+
const originalName = asset.originalFileName;
260+
if (originalName && originalName !== asset.fileName && !assetByName.has(originalName)) {
261+
assetByName.set(originalName, asset);
262+
}
263+
}
272264

273265
const ensureEntryMatchesAsset = (entry) => {
274266
if (!entry) {
@@ -278,7 +270,13 @@ async function updateMetadataFiles(metadataFiles, releaseAssets) {
278270
if (!key) {
279271
return false;
280272
}
281-
const asset = assetByName.get(key);
273+
let asset = assetByName.get(key);
274+
if (!asset) {
275+
const normalisedKey = normaliseInstallerFileName(key);
276+
if (normalisedKey !== key && assetByName.has(normalisedKey)) {
277+
asset = assetByName.get(normalisedKey);
278+
}
279+
}
282280
if (!asset) {
283281
return false;
284282
}

0 commit comments

Comments
 (0)