diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a40678..9a384da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,12 +40,15 @@ jobs: - uses: actions/checkout@v7 with: submodules: recursive + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: 22 cache: npm - name: Verify release version run: node scripts/verify-release-version.js + - name: Verify Companion release identity + run: node scripts/verify-companion-release.js - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} diff --git a/RELEASE.md b/RELEASE.md index f4b8954..a99b8b9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -65,6 +65,11 @@ pass. A `workflow_dispatch` on a `v*` tag also publishes when its release-certification inputs; clean-machine QA remains a release-owner gate before pushing the tag. +For paired releases, publish and verify the Companion release first. The +desktop release workflow requires `Extensions/Browser` to be at a clean commit +whose exact tag matches both the Companion `package.json` and `manifest.json` +versions before building desktop packages. + ## Automated release builds Push a version tag to build and verify native artifacts: diff --git a/scripts/verify-companion-release.js b/scripts/verify-companion-release.js new file mode 100644 index 0000000..4b607f5 --- /dev/null +++ b/scripts/verify-companion-release.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const defaultRepositoryRoot = path.resolve(scriptDirectory, '..'); +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function exactVersionTag(extensionRoot, expectedTag) { + try { + const tags = execFileSync( + 'git', + ['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ) + .split(/\r?\n/) + .map(tag => tag.trim()) + .find(tag => tag === expectedTag); + return tags || null; + } catch { + return null; + } +} + +export function verifyCompanionRelease({ + repositoryRoot = defaultRepositoryRoot, + resolveExactTag = exactVersionTag, +} = {}) { + const extensionRoot = path.join(repositoryRoot, 'Extensions', 'Browser'); + const packagePath = path.join(extensionRoot, 'package.json'); + const manifestPath = path.join(extensionRoot, 'manifest.json'); + + if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) { + throw new Error('Companion package.json and manifest.json must both exist.'); + } + + const packageVersion = readJson(packagePath).version; + const manifestVersion = readJson(manifestPath).version; + if (!packageVersion || packageVersion !== manifestVersion) { + throw new Error( + `Companion versions do not agree: package.json=${packageVersion || 'missing'}, ` + + `manifest.json=${manifestVersion || 'missing'}.` + ); + } + if (typeof packageVersion !== 'string' || !SEMVER.test(packageVersion)) { + throw new Error(`Companion version ${String(packageVersion)} is not a valid semantic version.`); + } + + const expectedTag = `v${packageVersion}`; + const tag = resolveExactTag(extensionRoot, expectedTag); + if (!tag) { + throw new Error( + `Companion HEAD is not exactly tagged ${expectedTag}; publish the Companion release first.` + ); + } + if (tag !== expectedTag) { + throw new Error(`Companion HEAD tag ${tag} does not match ${expectedTag}.`); + } + + return { tag, version: packageVersion }; +} + +function main() { + try { + const { tag, version } = verifyCompanionRelease(); + console.log(`Companion release ${version} matches exact tag ${tag}.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/verify-companion-release.node-test.js b/scripts/verify-companion-release.node-test.js new file mode 100644 index 0000000..2799301 --- /dev/null +++ b/scripts/verify-companion-release.node-test.js @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { verifyCompanionRelease } from './verify-companion-release.js'; + +function createFixture(packageVersion, manifestVersion) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-')); + const extensionRoot = path.join(root, 'Extensions', 'Browser'); + fs.mkdirSync(extensionRoot, { recursive: true }); + fs.writeFileSync( + path.join(extensionRoot, 'package.json'), + `${JSON.stringify({ version: packageVersion }, null, 2)}\n` + ); + fs.writeFileSync( + path.join(extensionRoot, 'manifest.json'), + `${JSON.stringify({ version: manifestVersion }, null, 2)}\n` + ); + return root; +} + +test('accepts matching Companion metadata and exact release tag', () => { + const root = createFixture('2.0.7', '2.0.7'); + try { + let resolvedExpectedTag; + assert.deepEqual( + verifyCompanionRelease({ + repositoryRoot: root, + resolveExactTag: (_extensionRoot, expectedTag) => { + resolvedExpectedTag = expectedTag; + return 'v2.0.7'; + }, + }), + { tag: 'v2.0.7', version: '2.0.7' } + ); + assert.equal(resolvedExpectedTag, 'v2.0.7'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects mismatched Companion package and manifest versions', () => { + const root = createFixture('2.0.7', '2.0.6'); + try { + assert.throws( + () => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.7' }), + /versions do not agree/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects a malformed Companion semantic version before tag lookup', () => { + const root = createFixture('--contains=HEAD', '--contains=HEAD'); + let tagLookupCalled = false; + try { + assert.throws( + () => verifyCompanionRelease({ + repositoryRoot: root, + resolveExactTag: () => { + tagLookupCalled = true; + return 'v--contains=HEAD'; + }, + }), + /not a valid semantic version/ + ); + assert.equal(tagLookupCalled, false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects an untagged Companion commit', () => { + const root = createFixture('2.0.7', '2.0.7'); + try { + assert.throws( + () => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => null }), + /not exactly tagged v2.0.7/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects a Companion tag for another version', () => { + const root = createFixture('2.0.7', '2.0.7'); + try { + assert.throws( + () => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.6' }), + /does not match v2.0.7/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});