feat(companion): add build-and-verify bundle automation pipeline

This commit is contained in:
William Valentin
2026-02-26 19:35:02 -08:00
parent ad2f7b7d04
commit 3839c04a7d
12 changed files with 293 additions and 5 deletions
+5
View File
@@ -13,6 +13,7 @@ export { createCompanionBootstrapManifest } from './bootstrapManifest.js';
export { writeCompanionReleaseBundle } from './releaseBundle.js';
export { writeCompanionShellTemplate } from './shellTemplate.js';
export { verifyCompanionReleaseBundle } from './releaseVerify.js';
export { buildAndVerifyCompanionReleaseBundle } from './releasePipeline.js';
export type {
CompanionRuntimeClientOptions,
@@ -93,3 +94,7 @@ export type {
VerifyCompanionReleaseBundleResult,
VerifiedReleaseFile,
} from './releaseVerify.js';
export type {
BuildAndVerifyCompanionReleaseBundleInput,
BuildAndVerifyCompanionReleaseBundleResult,
} from './releasePipeline.js';
+48
View File
@@ -0,0 +1,48 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { generateKeyPairSync } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { buildAndVerifyCompanionReleaseBundle } from './releasePipeline.js';
describe('buildAndVerifyCompanionReleaseBundle', () => {
it('builds and verifies a signed companion release bundle', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-release-pipeline-'));
const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
const result = await buildAndVerifyCompanionReleaseBundle({
outputDir: join(tempDir, 'bundle'),
gatewayUrl: 'ws://127.0.0.1:18800',
gatewayToken: 'token-123',
nodeId: 'pipeline-node',
role: 'companion',
platform: 'ios',
capabilities: ['ui.canvas', 'node.push.register'],
heartbeatSeconds: 45,
handoffTimeoutMs: 5000,
autoReconnect: true,
signingKeyPem: privatePem,
signingKeyId: 'pipeline-k1',
status: {
appVersion: '1.0.0',
statusText: 'ready',
powerSource: 'battery',
},
push: {
provider: 'apns',
token: '0123456789abcdef0123456789abcdef',
environment: 'production',
},
generatedAt: new Date('2026-02-27T00:00:00.000Z'),
});
expect(result.bundle.signaturePath).toBeDefined();
expect(result.verification.signaturePresent).toBe(true);
expect(result.verification.signatureVerified).toBe(true);
expect(result.verification.signatureKeyId).toBe('pipeline-k1');
expect(result.manifest.node.nodeId).toBe('pipeline-node');
await rm(tempDir, { recursive: true, force: true });
});
});
+80
View File
@@ -0,0 +1,80 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import type {
CreateCompanionBootstrapManifestInput,
CompanionBootstrapManifest,
} from './bootstrapManifest.js';
import { createCompanionBootstrapManifest } from './bootstrapManifest.js';
import {
writeCompanionReleaseBundle,
type WriteCompanionReleaseBundleResult,
} from './releaseBundle.js';
import {
verifyCompanionReleaseBundle,
type VerifyCompanionReleaseBundleResult,
} from './releaseVerify.js';
export interface BuildAndVerifyCompanionReleaseBundleInput
extends CreateCompanionBootstrapManifestInput {
outputDir: string;
signingKeyPem?: string;
signingKeyId?: string;
verifyPublicKeyPem?: string;
}
export interface BuildAndVerifyCompanionReleaseBundleResult {
manifest: CompanionBootstrapManifest;
bundle: WriteCompanionReleaseBundleResult;
verification: VerifyCompanionReleaseBundleResult;
}
function resolveVerifyPublicKey(input: BuildAndVerifyCompanionReleaseBundleInput): string | undefined {
if (input.verifyPublicKeyPem && input.verifyPublicKeyPem.trim().length > 0) {
return input.verifyPublicKeyPem;
}
if (input.signingKeyPem && input.signingKeyPem.trim().length > 0) {
const privateKey = createPrivateKey(input.signingKeyPem);
return createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
}
return undefined;
}
export async function buildAndVerifyCompanionReleaseBundle(
input: BuildAndVerifyCompanionReleaseBundleInput,
): Promise<BuildAndVerifyCompanionReleaseBundleResult> {
const manifest = createCompanionBootstrapManifest({
gatewayUrl: input.gatewayUrl,
gatewayToken: input.gatewayToken,
nodeId: input.nodeId,
role: input.role,
platform: input.platform,
capabilities: input.capabilities,
heartbeatSeconds: input.heartbeatSeconds,
handoffTimeoutMs: input.handoffTimeoutMs,
autoReconnect: input.autoReconnect,
status: input.status,
location: input.location,
push: input.push,
generatedAt: input.generatedAt,
});
const bundle = await writeCompanionReleaseBundle({
outputDir: input.outputDir,
manifest,
signingKeyPem: input.signingKeyPem,
signingKeyId: input.signingKeyId,
});
const verifyPublicKeyPem = resolveVerifyPublicKey(input);
const verification = await verifyCompanionReleaseBundle({
bundleDir: input.outputDir,
publicKeyPem: verifyPublicKeyPem,
expectedKeyId: input.signingKeyId,
requireSignature: Boolean(input.signingKeyPem),
});
return {
manifest,
bundle,
verification,
};
}