fix(release): enforce tagged companion identity

This commit is contained in:
NimBold
2026-07-28 19:33:05 +03:30
parent 5633896d14
commit 7e8f1c7d7b
4 changed files with 186 additions and 0 deletions
+3
View File
@@ -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 }}
+5
View File
@@ -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:
+81
View File
@@ -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();
}
@@ -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 });
}
});