feat(companion): add build-and-verify bundle automation pipeline
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user