also looking at this
fix(release): publish one complete platform manifest
#293
4 files
+145
-2
| @@ -283,6 +283,9 @@ jobs: | ||
| 283 | 283 | - name: Test release identity validation |
| 284 | 284 | run: bun test scripts/release-version.test.ts |
| 285 | 285 | |
| 286 | - name: Test complete release manifest verification | |
| 287 | run: bun test scripts/release-manifest.test.ts | |
| 288 | ||
| 286 | 289 | integration: |
| 287 | 290 | runs-on: ubuntu-latest |
| 288 | 291 | needs: [zig-core, typescript-sdk, create-craft, ts-maps] |
| @@ -346,9 +346,11 @@ jobs: | ||
| 346 | 346 | if: ${{ !cancelled() && needs.validate-release.result == 'success' }} |
| 347 | 347 | runs-on: ubuntu-latest |
| 348 | 348 | permissions: |
| 349 | contents: read | |
| 349 | contents: write | |
| 350 | 350 | |
| 351 | 351 | steps: |
| 352 | - uses: actions/checkout@v6 | |
| 353 | - uses: oven-sh/setup-bun@v2 | |
| 352 | 354 | - name: Every platform pantry installs must have shipped |
| 353 | 355 | env: |
| 354 | 356 | GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }} |
| @@ -358,7 +360,7 @@ jobs: | ||
| 358 | 360 | |
| 359 | 361 | # Kept in step with the `required` list in pantry's recipe: |
| 360 | 362 | # 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" | |
| 362 | 364 | |
| 363 | 365 | echo "Assets on $TAG:" |
| 364 | 366 | ASSETS=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name') |
| @@ -377,6 +379,20 @@ jobs: | ||
| 377 | 379 | |
| 378 | 380 | echo "All required assets present." |
| 379 | 381 | |
| 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 | ||
| 380 | 396 | # Tell the registry this version exists, with the token this workflow |
| 381 | 397 | # already holds to publish to it. |
| 382 | 398 | # |
| @@ -0,0 +1,63 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { createReleaseManifest, verifyReleaseManifest } from './release-manifest' | |
| 6 | ||
| 7 | const identity = { repository: 'craft-native/craft', tag: 'v0.0.93', commit: 'a'.repeat(40) } | |
| 8 | const names = ['craft-darwin-arm64.zip', 'craft-darwin-x64.zip', 'craft-linux-x64.zip', 'craft-windows-x64.zip'] | |
| 9 | ||
| 10 | function 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 | ||
| 21 | test('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 | ||
| 31 | test('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 | ||
| 38 | test('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 | ||
| 47 | test('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 | }) | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { lstatSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | ||
| 5 | const requiredAssets = ['craft-darwin-arm64.zip', 'craft-darwin-x64.zip', 'craft-linux-x64.zip', 'craft-windows-x64.zip'] | |
| 6 | ||
| 7 | type ReleaseIdentity = { repository: string, tag: string, commit: string } | |
| 8 | type ReleaseAsset = { name: string, size: number, sha256: string } | |
| 9 | type ReleaseManifest = ReleaseIdentity & { schemaVersion: 1, generatedAt: string, assets: ReleaseAsset[] } | |
| 10 | ||
| 11 | export 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 | ||
| 30 | export 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 | ||
| 51 | if (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 | } | |