Skip to content

Commit f7fd800

Browse files
committed
Fix release metadata verification by repairing local manifests
1 parent b402b1e commit f7fd800

3 files changed

Lines changed: 213 additions & 11 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: 34 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,38 @@ test('release tooling rewrites metadata and keeps latest.yml published', async (
230230
await runLocalVerification(releaseDir);
231231
});
232232

233+
test('local verification can repair mismatched metadata when requested', async (t) => {
234+
const workspace = await createTemporaryWorkspace(t);
235+
const version = '0.0.2';
236+
const { releaseDir, metadataPath, assetPath } = await writeFixtureInstaller(workspace, version, {
237+
assetSize: 2048,
238+
});
239+
240+
const corrupted = YAML.parse(await fs.readFile(metadataPath, 'utf8'));
241+
corrupted.sha512 = 'invalid-sha512';
242+
corrupted.files[0].sha512 = 'invalid-sha512';
243+
corrupted.files[0].size = 1;
244+
await fs.writeFile(metadataPath, YAML.stringify(corrupted), 'utf8');
245+
246+
await assert.rejects(() => runLocalVerification(releaseDir), /Local auto-update verification failed/);
247+
248+
await runLocalVerification(releaseDir, ['--fix-metadata']);
249+
250+
const installerBuffer = await fs.readFile(assetPath);
251+
const expectedSha = computeSha512Base64(installerBuffer);
252+
const updated = YAML.parse(await fs.readFile(metadataPath, 'utf8'));
253+
254+
assert.equal(updated.path, path.basename(assetPath));
255+
assert.equal(updated.sha512, expectedSha);
256+
if (Object.prototype.hasOwnProperty.call(updated, 'size')) {
257+
assert.equal(updated.size, installerBuffer.length);
258+
}
259+
assert(Array.isArray(updated.files) && updated.files.length === 1);
260+
assert.equal(updated.files[0].url, path.basename(assetPath));
261+
assert.equal(updated.files[0].sha512, expectedSha);
262+
assert.equal(updated.files[0].size, installerBuffer.length);
263+
});
264+
233265
test('metadata updates remain isolated across artifact directories with identical installer names', async (t) => {
234266
const workspace = await createTemporaryWorkspace(t);
235267
const version = '0.0.3';

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)