Skip to content

Commit 7e7e916

Browse files
committed
Ignore unpacked app directories when preparing release assets
1 parent b402b1e commit 7e7e916

File tree

4 files changed

+258
-11
lines changed

4 files changed

+258
-11
lines changed

.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: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ async function runGenerateReleaseNotes({
148148
);
149149
}
150150

151-
async function runLocalVerification(directory) {
151+
async function runLocalVerification(directory, extraArgs = []) {
152152
await execFileAsync(
153153
'node',
154-
[repoPath('scripts', 'test-auto-update.mjs'), '--local', directory],
154+
[repoPath('scripts', 'test-auto-update.mjs'), '--local', directory, ...extraArgs],
155155
{
156156
cwd: repoPath(),
157157
},
@@ -230,6 +230,78 @@ test('release tooling rewrites metadata and keeps latest.yml published', async (
230230
await runLocalVerification(releaseDir);
231231
});
232232

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

scripts/generate-release-notes.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ async function collectAssets(artifactRoot) {
261261
continue;
262262
}
263263

264+
const artifactSegments = artifactDir.split(path.sep).filter(Boolean);
265+
if (artifactSegments.some((segment) => segment.endsWith('-unpacked'))) {
266+
continue;
267+
}
268+
264269
if (!isReleaseAsset(fileName)) {
265270
continue;
266271
}

scripts/test-auto-update.mjs

Lines changed: 178 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,162 @@ export function extractMetadataTargets(metadata) {
113113
return Array.from(targets.values());
114114
}
115115

116+
async function resolveLocalAsset(metadataDir, key, digestCache) {
117+
if (!key || typeof key !== 'string') {
118+
return null;
119+
}
120+
121+
const candidates = [];
122+
const seen = new Set();
123+
124+
const addCandidate = (value) => {
125+
if (!value || typeof value !== 'string') {
126+
return;
127+
}
128+
const trimmed = value.trim();
129+
if (!trimmed || seen.has(trimmed)) {
130+
return;
131+
}
132+
seen.add(trimmed);
133+
candidates.push(trimmed);
134+
};
135+
136+
addCandidate(key);
137+
const normalised = normaliseInstallerFileName(key);
138+
if (normalised && normalised !== key) {
139+
addCandidate(normalised);
140+
}
141+
142+
for (const candidate of candidates) {
143+
const candidatePath = path.resolve(metadataDir, candidate);
144+
const relative = path.relative(metadataDir, candidatePath);
145+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
146+
continue;
147+
}
148+
149+
try {
150+
const stats = await fs.stat(candidatePath);
151+
if (!stats.isFile()) {
152+
continue;
153+
}
154+
155+
let digest = digestCache.get(candidatePath);
156+
if (!digest) {
157+
digest = await computeLocalDigest(candidatePath);
158+
digestCache.set(candidatePath, digest);
159+
}
160+
161+
return {
162+
name: candidate,
163+
path: candidatePath,
164+
sha512: digest.sha512,
165+
size: digest.size,
166+
};
167+
} catch {
168+
// Ignore missing assets at this stage; verification will surface the issue.
169+
}
170+
}
171+
172+
return null;
173+
}
174+
175+
async function repairLocalMetadataFile(metadataPath, metadataDocument) {
176+
if (!metadataDocument || typeof metadataDocument !== 'object') {
177+
return false;
178+
}
179+
180+
const metadataDir = path.dirname(metadataPath);
181+
const digestCache = new Map();
182+
let changed = false;
183+
184+
const ensureEntryMatchesAsset = async (entry) => {
185+
if (!entry || typeof entry !== 'object') {
186+
return false;
187+
}
188+
189+
const key = entry.url || entry.path;
190+
const asset = await resolveLocalAsset(metadataDir, key, digestCache);
191+
if (!asset) {
192+
return false;
193+
}
194+
195+
let localChange = false;
196+
if (entry.url && entry.url !== asset.name) {
197+
entry.url = asset.name;
198+
localChange = true;
199+
}
200+
if (entry.path && entry.path !== asset.name) {
201+
entry.path = asset.name;
202+
localChange = true;
203+
}
204+
if (asset.sha512 && entry.sha512 !== asset.sha512) {
205+
entry.sha512 = asset.sha512;
206+
localChange = true;
207+
}
208+
if (typeof asset.size === 'number' && entry.size !== asset.size) {
209+
entry.size = asset.size;
210+
localChange = true;
211+
}
212+
return localChange;
213+
};
214+
215+
if (Array.isArray(metadataDocument.files)) {
216+
for (const entry of metadataDocument.files) {
217+
if (await ensureEntryMatchesAsset(entry)) {
218+
changed = true;
219+
}
220+
}
221+
}
222+
223+
const primarySource =
224+
(typeof metadataDocument.path === 'string' && metadataDocument.path.trim()) ||
225+
(Array.isArray(metadataDocument.files) &&
226+
metadataDocument.files[0] &&
227+
(metadataDocument.files[0].path || metadataDocument.files[0].url));
228+
229+
const primaryAsset = await resolveLocalAsset(metadataDir, primarySource, digestCache);
230+
if (primaryAsset) {
231+
if (metadataDocument.path !== primaryAsset.name) {
232+
metadataDocument.path = primaryAsset.name;
233+
changed = true;
234+
}
235+
if (metadataDocument.sha512 !== primaryAsset.sha512) {
236+
metadataDocument.sha512 = primaryAsset.sha512;
237+
changed = true;
238+
}
239+
if (typeof primaryAsset.size === 'number' && metadataDocument.size !== primaryAsset.size) {
240+
metadataDocument.size = primaryAsset.size;
241+
changed = true;
242+
}
243+
244+
if (!Array.isArray(metadataDocument.files) || metadataDocument.files.length === 0) {
245+
metadataDocument.files = [
246+
{
247+
url: primaryAsset.name,
248+
sha512: primaryAsset.sha512,
249+
size: primaryAsset.size,
250+
},
251+
];
252+
changed = true;
253+
}
254+
}
255+
256+
if (Array.isArray(metadataDocument.files)) {
257+
for (const entry of metadataDocument.files) {
258+
if (await ensureEntryMatchesAsset(entry)) {
259+
changed = true;
260+
}
261+
}
262+
}
263+
264+
if (changed) {
265+
const serialised = YAML.stringify(metadataDocument, { lineWidth: 0 }).trimEnd();
266+
await fs.writeFile(metadataPath, `${serialised}\n`, 'utf8');
267+
}
268+
269+
return changed;
270+
}
271+
116272
const execFileAsync = promisify(execFile);
117273

118274
export async function curlGet(url, headers) {
@@ -484,7 +640,7 @@ export async function runRemoteCheck({ owner, repo, tag, skipHttp, skipDownload,
484640
console.log('\nAll metadata files reference available, reachable assets with matching hashes.');
485641
}
486642

487-
async function runLocalCheck({ directory, skipDownload }) {
643+
async function runLocalCheck({ directory, skipDownload, fixMetadata = false }) {
488644
const resolvedDirectory = path.resolve(directory);
489645
const entries = await fs.readdir(resolvedDirectory);
490646
const metadataFiles = entries.filter(
@@ -500,12 +656,7 @@ async function runLocalCheck({ directory, skipDownload }) {
500656
let failures = false;
501657
for (const metadataFile of metadataFiles) {
502658
const metadataPath = path.join(resolvedDirectory, metadataFile);
503-
const source = await fs.readFile(metadataPath, 'utf8');
504-
const references = extractMetadataReferences(source);
505-
const missing = [];
506-
const mismatchedHashes = [];
507-
const missingHashes = [];
508-
const mismatchedSizes = [];
659+
let source = await fs.readFile(metadataPath, 'utf8');
509660
let metadataDocument = null;
510661
let parseError = null;
511662

@@ -515,8 +666,26 @@ async function runLocalCheck({ directory, skipDownload }) {
515666
parseError = error instanceof Error ? error : new Error(String(error));
516667
}
517668

669+
let repaired = false;
670+
if (fixMetadata && !parseError && metadataDocument && typeof metadataDocument === 'object') {
671+
repaired = await repairLocalMetadataFile(metadataPath, metadataDocument);
672+
if (repaired) {
673+
source = await fs.readFile(metadataPath, 'utf8');
674+
metadataDocument = YAML.parse(source);
675+
}
676+
}
677+
678+
const references = extractMetadataReferences(source);
679+
const missing = [];
680+
const mismatchedHashes = [];
681+
const missingHashes = [];
682+
const mismatchedSizes = [];
683+
518684
console.log(`\nMetadata: ${metadataFile}`);
519685
console.log(` Referenced files: ${references.length}`);
686+
if (repaired) {
687+
console.log(' • Updated metadata checksums to match local assets.');
688+
}
520689

521690
for (const reference of references) {
522691
const targetPath = path.join(resolvedDirectory, reference);
@@ -616,9 +785,10 @@ async function main() {
616785
const localDir = args.local ?? null;
617786
const skipHttp = Boolean(args['skip-http']);
618787
const skipDownload = Boolean(args['skip-download']);
788+
const fixMetadata = Boolean(args['fix-metadata']);
619789

620790
if (localDir) {
621-
await runLocalCheck({ directory: localDir, skipDownload });
791+
await runLocalCheck({ directory: localDir, skipDownload, fixMetadata });
622792
return;
623793
}
624794

0 commit comments

Comments
 (0)