ReviewOS

also looking at this

craft-native/craft

fix(release): publish one complete platform manifest

#293
Merged glennmichael123 wants to merge codex/complete-release-manifest into main
4 files +145 -2
.github/workflows/ci.ymlmodified+3-0
Changes to .github/workflows/ci.yml
@@ -283,6 +283,9 @@ jobs:
283283 - name: Test release identity validation
284284 run: bun test scripts/release-version.test.ts
285285
286 - name: Test complete release manifest verification
287 run: bun test scripts/release-manifest.test.ts
288
286289 integration:
287290 runs-on: ubuntu-latest
288291 needs: [zig-core, typescript-sdk, create-craft, ts-maps]
.github/workflows/release.ymlmodified+18-2
Changes to .github/workflows/release.yml
@@ -346,9 +346,11 @@ jobs:
346346 if: ${{ !cancelled() && needs.validate-release.result == 'success' }}
347347 runs-on: ubuntu-latest
348348 permissions:
349 contents: read
349 contents: write
350350
351351 steps:
352 - uses: actions/checkout@v6
353 - uses: oven-sh/setup-bun@v2
352354 - name: Every platform pantry installs must have shipped
353355 env:
354356 GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
@@ -358,7 +360,7 @@ jobs:
358360
359361 # Kept in step with the `required` list in pantry's recipe:
360362 # packages/ts-pantry/src/recipes/craft-native.org.ts
361 REQUIRED="craft-darwin-arm64.zip craft-darwin-x64.zip craft-linux-x64.zip"
363 REQUIRED="craft-darwin-arm64.zip craft-darwin-x64.zip craft-linux-x64.zip craft-windows-x64.zip"
362364
363365 echo "Assets on $TAG:"
364366 ASSETS=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')
@@ -377,6 +379,20 @@ jobs:
377379
378380 echo "All required assets present."
379381
382 - name: Publish and verify the complete release manifest
383 env:
384 GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
385 TAG: ${{ github.ref_name }}
386 run: |
387 set -euo pipefail
388 ARCHIVE_DIR=$(mktemp -d)
389 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'craft-*.zip' --dir "$ARCHIVE_DIR"
390 bun scripts/release-manifest.ts create "$ARCHIVE_DIR" "$ARCHIVE_DIR/release-manifest.json"
391 gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" "$ARCHIVE_DIR/release-manifest.json" --clobber
392 mkdir "$ARCHIVE_DIR/verified"
393 gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern release-manifest.json --dir "$ARCHIVE_DIR/verified"
394 bun scripts/release-manifest.ts verify "$ARCHIVE_DIR" "$ARCHIVE_DIR/verified/release-manifest.json"
395
380396 # Tell the registry this version exists, with the token this workflow
381397 # already holds to publish to it.
382398 #
scripts/release-manifest.test.tsadded+63-0
Changes to scripts/release-manifest.test.ts
@@ -0,0 +1,63 @@
1import { expect, test } from 'bun:test'
2import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { createReleaseManifest, verifyReleaseManifest } from './release-manifest'
6
7const identity = { repository: 'craft-native/craft', tag: 'v0.0.93', commit: 'a'.repeat(40) }
8const names = ['craft-darwin-arm64.zip', 'craft-darwin-x64.zip', 'craft-linux-x64.zip', 'craft-windows-x64.zip']
9
10function withArchives(check: (dir: string) => void): void {
11 const dir = mkdtempSync(join(tmpdir(), 'craft-manifest-'))
12 try {
13 for (const name of names) writeFileSync(join(dir, name), `bytes for ${name}`)
14 check(dir)
15 }
16 finally {
17 rmSync(dir, { recursive: true, force: true })
18 }
19}
20
21test('includes every platform and additional future archives with exact byte hashes', () => {
22 withArchives((dir) => {
23 writeFileSync(join(dir, 'craft-linux-arm64.zip'), 'abc')
24 const manifest = createReleaseManifest(identity, dir)
25 expect(manifest.assets).toHaveLength(5)
26 expect(manifest.assets.find(asset => asset.name === 'craft-linux-arm64.zip')).toEqual({ name: 'craft-linux-arm64.zip', size: 3, sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' })
27 expect(() => verifyReleaseManifest(manifest, manifest)).not.toThrow()
28 })
29})
30
31test('rejects the macOS-only manifest shape that shipped in v0.0.92', () => {
32 withArchives((dir) => {
33 const expected = createReleaseManifest(identity, dir)
34 expect(() => verifyReleaseManifest({ ...expected, assets: expected.assets.slice(0, 2) }, expected)).toThrow('asset set mismatch')
35 })
36})
37
38test('requires Windows and rejects empty archives', () => {
39 withArchives((dir) => {
40 rmSync(join(dir, 'craft-windows-x64.zip'))
41 expect(() => createReleaseManifest(identity, dir)).toThrow('craft-windows-x64.zip')
42 writeFileSync(join(dir, 'craft-windows-x64.zip'), '')
43 expect(() => createReleaseManifest(identity, dir)).toThrow('empty')
44 })
45})
46
47test('rejects changed bytes, duplicate assets, unexpected names and stale identity', () => {
48 withArchives((dir) => {
49 const expected = createReleaseManifest(identity, dir)
50 for (const key of ['schemaVersion', 'repository', 'tag', 'commit'])
51 expect(() => verifyReleaseManifest({ ...expected, [key]: 'wrong' }, expected)).toThrow('mismatch')
52 const duplicate = structuredClone(expected)
53 duplicate.assets[1] = duplicate.assets[0]
54 expect(() => verifyReleaseManifest(duplicate, expected)).toThrow('duplicate')
55 for (const changed of [{ name: 'craft-unknown.zip' }, { size: 1 }, { sha256: '0'.repeat(64) }]) {
56 const manifest = structuredClone(expected)
57 Object.assign(manifest.assets[0], changed)
58 expect(() => verifyReleaseManifest(manifest, expected)).toThrow()
59 }
60 writeFileSync(join(dir, names[0]), 'tampered')
61 expect(() => verifyReleaseManifest(expected, createReleaseManifest(identity, dir))).toThrow('SHA-256 mismatch')
62 })
63})
scripts/release-manifest.tsadded+61-0
Changes to scripts/release-manifest.ts
@@ -0,0 +1,61 @@
1import { createHash } from 'node:crypto'
2import { lstatSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
3import { join } from 'node:path'
4
5const requiredAssets = ['craft-darwin-arm64.zip', 'craft-darwin-x64.zip', 'craft-linux-x64.zip', 'craft-windows-x64.zip']
6
7type ReleaseIdentity = { repository: string, tag: string, commit: string }
8type ReleaseAsset = { name: string, size: number, sha256: string }
9type ReleaseManifest = ReleaseIdentity & { schemaVersion: 1, generatedAt: string, assets: ReleaseAsset[] }
10
11export function createReleaseManifest(identity: ReleaseIdentity, directory: string): ReleaseManifest {
12 if (!/^[\w.-]+\/[\w.-]+$/.test(identity.repository) || !/^v\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/.test(identity.tag) || !/^[a-f\d]{40}$/i.test(identity.commit))
13 throw new Error('Release manifest requires repository, version tag, and full commit identity')
14 const names = readdirSync(directory).filter(name => /^craft-.*\.zip$/.test(name)).sort()
15 const missing = requiredAssets.filter(name => !names.includes(name))
16 if (missing.length)
17 throw new Error(`Missing release archives: ${missing.join(', ')}`)
18 const assets = names.map((name) => {
19 const path = join(directory, name)
20 if (!lstatSync(path).isFile())
21 throw new Error(`Release archive must be a regular file: ${name}`)
22 const bytes = readFileSync(path)
23 if (bytes.length === 0)
24 throw new Error(`Release archive is empty: ${name}`)
25 return { name, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }
26 })
27 return { schemaVersion: 1, ...identity, generatedAt: new Date().toISOString(), assets }
28}
29
30export function verifyReleaseManifest(actual: unknown, expected: ReleaseManifest): void {
31 if (!actual || typeof actual !== 'object')
32 throw new Error('Release manifest must be an object')
33 const manifest = actual as Partial<ReleaseManifest>
34 for (const key of ['schemaVersion', 'repository', 'tag', 'commit'] as const) {
35 if (manifest[key] !== expected[key])
36 throw new Error(`Release manifest ${key} mismatch`)
37 }
38 if (!Array.isArray(manifest.assets) || manifest.assets.length !== expected.assets.length)
39 throw new Error('Release manifest asset set mismatch')
40 const seen = new Set<string>()
41 for (const asset of manifest.assets) {
42 const wanted = expected.assets.find(entry => entry.name === asset?.name)
43 if (!wanted || seen.has(asset.name))
44 throw new Error(`Unexpected or duplicate release asset: ${asset?.name}`)
45 seen.add(asset.name)
46 if (asset.size !== wanted.size || asset.sha256 !== wanted.sha256)
47 throw new Error(`Release manifest size or SHA-256 mismatch: ${asset.name}`)
48 }
49}
50
51if (import.meta.main) {
52 const [mode, directory, path] = Bun.argv.slice(2)
53 if (!directory || !path || !['create', 'verify'].includes(mode))
54 throw new Error('Usage: bun scripts/release-manifest.ts <create|verify> <archive-directory> <manifest-path>')
55 const manifest = createReleaseManifest({ repository: process.env.GITHUB_REPOSITORY ?? '', tag: process.env.GITHUB_REF_NAME ?? '', commit: process.env.GITHUB_SHA ?? '' }, directory)
56 if (mode === 'create')
57 writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
58 else
59 verifyReleaseManifest(JSON.parse(readFileSync(path, 'utf8')), manifest)
60 console.log(`${mode === 'create' ? 'Created' : 'Verified'} manifest for ${manifest.assets.length} final release archives`)
61}