diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 2edab2dc1..8518e9cc3 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -119,6 +119,18 @@ runs: exit 1 fi done + if [ "$SOURCE_ROOT" = . ] && ! jq -e ' + .releaseManifestFormatVersion == 2 and + (.publicationEvidence.activationVersion | type == "string") and + (.publicationEvidence.deploymentId | type == "string") and + .publicationEvidence.deploymentSourceGitSha == .sourceGitSha and + (.publicationEvidence.keyId | type == "string") and + (.publicationEvidence.pointerSha256 | type == "string") and + (.publicationEvidence.verifiedAt | type == "string") + ' "$1" > /dev/null; then + echo "::error::Production release manifest $1 requires exact observer publication evidence" + exit 1 + fi } if [ "$APP" = desktop ]; then @@ -139,6 +151,14 @@ runs: exit 1 fi done + if [ "$SOURCE_ROOT" = . ]; then + for field in deploymentId deploymentSourceGitSha keyId verifiedAt; do + if [ "$(pin "$work/manifest-ios.json" ".publicationEvidence.$field")" != "$(pin "$work/manifest-android.json" ".publicationEvidence.$field")" ]; then + echo "::error::iOS and Android release manifests disagree on publicationEvidence.$field; both targets must use one observed deployment" + exit 1 + fi + done + fi fi publisher_sha="$(pin "$primary" .publisherGitSha)" diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index d79b9582c..72a861e66 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -9,7 +9,7 @@ const CHECKLIST_KEYS = [ 'permissionsReviewed', 'storeMetadataReviewed', ]; -const RELEASE_MANIFEST_KEYS = [ +const RELEASE_MANIFEST_BASE_KEYS = [ 'brandId', 'channel', 'configRevisionId', @@ -22,6 +22,15 @@ const RELEASE_MANIFEST_KEYS = [ 'sourceGitSha', 'telemetryEndpoint', ]; +const PUBLICATION_EVIDENCE_KEYS = [ + 'activationVersion', + 'deploymentId', + 'deploymentSourceGitSha', + 'keyId', + 'pointerSha256', + 'verifiedAt', +]; +const RELEASE_MANIFEST_VERSIONS = new Set([1, 2]); const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; const RE_GIT_SHA = /^[0-9a-f]{40}$/; @@ -34,6 +43,9 @@ const RE_R2_PREFIX = /^[a-z0-9][a-z0-9/-]*$/; const RE_TRAILING_SLASH = /\/$/; const RE_TEAM_ID = /^[A-Z0-9]{10}$/; const RE_ASC_APP_ID = /^\d+$/; +const RE_MONOTONIC_VERSION = /^(?:0|[1-9]\d{0,19})$/; +const RE_KEY_ID = /^[\dA-Z][\w.-]{0,127}$/i; +const RE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; function fail(path, message) { throw new TypeError(`${path}: ${message}`); @@ -74,10 +86,21 @@ function httpsUrl(value, path) { return text; } -function releaseManifest(value, path, platform, brandId, channel) { +function releaseManifest(value, path, platform, brandId, channel, requirePublicationEvidence) { const manifest = record(value, path); - exact(manifest, RELEASE_MANIFEST_KEYS, path); - if (manifest.releaseManifestFormatVersion !== 1) fail(path, 'format version must be 1'); + if (!RELEASE_MANIFEST_VERSIONS.has(manifest.releaseManifestFormatVersion)) { + fail(path, 'format version must be 1 or 2'); + } + if (requirePublicationEvidence && manifest.releaseManifestFormatVersion !== 2) { + fail(path, 'production releases require observed publication evidence'); + } + exact( + manifest, + manifest.releaseManifestFormatVersion === 2 + ? [...RELEASE_MANIFEST_BASE_KEYS, 'publicationEvidence'] + : RELEASE_MANIFEST_BASE_KEYS, + path, + ); for (const field of ['brandId', 'channel', 'configRevisionId', 'platform']) { string(manifest[field], `${path}.${field}`); } @@ -89,6 +112,34 @@ function releaseManifest(value, path, platform, brandId, channel) { } string(manifest.configRevisionId, `${path}.configRevisionId`, RE_REVISION); httpsUrl(manifest.telemetryEndpoint, `${path}.telemetryEndpoint`); + if (manifest.releaseManifestFormatVersion === 2) { + const evidence = record(manifest.publicationEvidence, `${path}.publicationEvidence`); + exact(evidence, PUBLICATION_EVIDENCE_KEYS, `${path}.publicationEvidence`); + const activationVersion = string( + evidence.activationVersion, + `${path}.publicationEvidence.activationVersion`, + RE_MONOTONIC_VERSION, + ); + if (activationVersion === '0') { + fail(`${path}.publicationEvidence.activationVersion`, 'must be greater than zero'); + } + const deploymentId = string(evidence.deploymentId, `${path}.publicationEvidence.deploymentId`); + if (deploymentId.length > 256) fail(`${path}.publicationEvidence.deploymentId`, 'is too long'); + string( + evidence.deploymentSourceGitSha, + `${path}.publicationEvidence.deploymentSourceGitSha`, + RE_GIT_SHA, + ); + if (evidence.deploymentSourceGitSha !== manifest.sourceGitSha) { + fail(`${path}.publicationEvidence.deploymentSourceGitSha`, 'must equal sourceGitSha'); + } + string(evidence.keyId, `${path}.publicationEvidence.keyId`, RE_KEY_ID); + string(evidence.pointerSha256, `${path}.publicationEvidence.pointerSha256`, RE_SHA256); + string(evidence.verifiedAt, `${path}.publicationEvidence.verifiedAt`, RE_TIMESTAMP); + if (Number.isNaN(Date.parse(evidence.verifiedAt))) { + fail(`${path}.publicationEvidence.verifiedAt`, 'must be a valid timestamp'); + } + } if ( manifest.brandId !== brandId || manifest.channel !== channel || @@ -202,6 +253,7 @@ function parseBrandBuildMatrix(value, options = {}) { if (brand.sourceRoot !== '.' && brand.sourceRoot !== 'examples/acme-zenith') { fail(`${path}.sourceRoot`, 'must be . or examples/acme-zenith'); } + const requirePublicationEvidence = options.build || brand.sourceRoot === '.'; const manifests = record(brand.releaseManifests, `${path}.releaseManifests`); exact(manifests, PLATFORMS, `${path}.releaseManifests`); const declarations = record(brand.compliance, `${path}.compliance`); @@ -213,6 +265,7 @@ function parseBrandBuildMatrix(value, options = {}) { platform, brandId, brand.channel, + requirePublicationEvidence, ); declarations[platform] = compliance(declarations[platform], `${path}.compliance.${platform}`); } @@ -227,6 +280,19 @@ function parseBrandBuildMatrix(value, options = {}) { fail(`${path}.releaseManifests`, `all platforms must share ${field}`); } } + if (requirePublicationEvidence) { + for (const field of ['deploymentId', 'deploymentSourceGitSha', 'keyId', 'verifiedAt']) { + if ( + PLATFORMS.some( + (platform) => + manifests[platform].publicationEvidence[field] !== + manifests.desktop.publicationEvidence[field], + ) + ) { + fail(`${path}.releaseManifests`, `all platforms must share publicationEvidence.${field}`); + } + } + } const distribution = record(brand.distribution, `${path}.distribution`); exact(distribution, ['desktop', 'mobile'], `${path}.distribution`); distribution.desktop = desktopDistribution( diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index c5a0bdaba..394810807 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -20,6 +20,9 @@ const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/; const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release/; const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; const RE_INVALID_SOURCE_ROOT = /sourceRoot: must be/; +const RE_SHARED_DEPLOYMENT = /all platforms must share publicationEvidence\.deploymentId/; +const RE_PRODUCTION_EVIDENCE = /production releases require observed publication evidence/; +const RE_INACTIVE_PUBLICATION = /must be greater than zero/; const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/; const ACTIONS_EXPRESSION = String.fromCodePoint(36); @@ -48,9 +51,17 @@ function manifest(brandId, platform) { configRevisionId: 'fixture-v1', expectedSnapshotSha256: sha('a'), platform, + publicationEvidence: { + activationVersion: '1', + deploymentId: 'deployment-fixture', + deploymentSourceGitSha: gitSha('e'), + keyId: 'normal-fixture-1', + pointerSha256: sha('f'), + verifiedAt: '2026-08-15T03:01:00Z', + }, publicKeyringsSha256: sha('b'), publisherGitSha: gitSha('c'), - releaseManifestFormatVersion: 1, + releaseManifestFormatVersion: 2, revisionSha256: sha('d'), sourceGitSha: gitSha('e'), telemetryEndpoint: `https://${brandId}.example.invalid/telemetry`, @@ -119,7 +130,7 @@ describe('parseBrandBuildMatrix', () => { pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), ).toBe(true); expect(pilot.brands.every((entry) => entry.sourceRoot === 'examples/acme-zenith')).toBe(true); - expect(() => buildMatrixPlan(pilot, { build: true })).toThrow(RE_MISSING_DELIVERY); + expect(() => buildMatrixPlan(pilot, { build: true })).toThrow(RE_PRODUCTION_EVIDENCE); const fixture = await readFile( new URL('../../apps/desktop/e2e/fixtures/pilot-e2e-v1.json', import.meta.url), ); @@ -258,13 +269,38 @@ describe('parseBrandBuildMatrix', () => { const divergent = matrix(brand()); divergent.brands[0].releaseManifests.ios.sourceGitSha = gitSha('f'); + divergent.brands[0].releaseManifests.ios.publicationEvidence.deploymentSourceGitSha = + gitSha('f'); expect(() => parseBrandBuildMatrix(divergent)).toThrow(RE_DIVERGENT_SOURCE); + const mixedDeployment = matrix(brand()); + mixedDeployment.brands[0].releaseManifests.ios.publicationEvidence.deploymentId = + 'another-deployment'; + expect(() => parseBrandBuildMatrix(mixedDeployment)).toThrow(RE_SHARED_DEPLOYMENT); + + const inactive = matrix(brand()); + inactive.brands[0].releaseManifests.desktop.publicationEvidence.activationVersion = '0'; + expect(() => parseBrandBuildMatrix(inactive)).toThrow(RE_INACTIVE_PUBLICATION); + const redirected = matrix(brand()); redirected.brands[0].sourceRoot = 'brands/acme'; expect(() => parseBrandBuildMatrix(redirected)).toThrow(RE_INVALID_SOURCE_ROOT); }); + it('requires observer evidence for production while preserving nonproduction v1 fixtures', () => { + const input = brand(); + for (const platform of ['desktop', 'ios', 'android']) { + input.releaseManifests[platform].releaseManifestFormatVersion = 1; + delete input.releaseManifests[platform].publicationEvidence; + } + expect(() => parseBrandBuildMatrix(matrix(input))).toThrow(RE_PRODUCTION_EVIDENCE); + input.sourceRoot = 'examples/acme-zenith'; + expect(() => parseBrandBuildMatrix(matrix(input))).not.toThrow(); + expect(() => parseBrandBuildMatrix(matrix(input), { build: true })).toThrow( + RE_PRODUCTION_EVIDENCE, + ); + }); + it('emits the digest of the exact matrix-file bytes', async () => { const root = await mkdtemp(join(tmpdir(), 'brand-matrix-')); const matrixPath = join(root, 'matrix.json'); @@ -415,6 +451,10 @@ describe('release brand matrix workflow', () => { expect(action).toContain('checkout identity did not match expected repository'); expect(action).toContain('publisher/parser/schema contract'); expect(action).toContain('Pinned config source must contain source root'); + expect(action).toContain( + 'Production release manifest $1 requires exact observer publication evidence', + ); + expect(action).toContain('both targets must use one observed deployment'); expect(action).toContain('unset PUBLISHER_TOKEN SOURCE_TOKEN'); expect(action.indexOf('unset PUBLISHER_TOKEN SOURCE_TOKEN')).toBeLessThan( action.indexOf('pnpm --dir "$publisher" install --frozen-lockfile'), diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ed1ebfe23..7e7386f42 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -99,9 +99,11 @@ the selected publisher or source repository. The App must be installed on both p repositories. Missing secrets or installation access fail before rendering; no long-lived config-read token is used. -Production rendering reads the root of `CONFIG_SOURCE_REPO` and fails closed while production data -is absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used -by the nonproduction pilot; configuration data cannot supply a path. +Production rendering reads the root of `CONFIG_SOURCE_REPO` and requires release-manifest v2 +evidence from the read-only Pages observer for the exact deployed source commit. A green Pages +build is not publication evidence. Workflow code may select only that root or the reviewed +`examples/acme-zenith` root used by the nonproduction pilot; configuration data cannot supply a +path. Inputs live in the GitHub **`release` environment** and a missing value fails the build with an actionable error: @@ -114,7 +116,7 @@ Inputs live in the GitHub **`release` environment** and a missing value fails th The two repository values must differ. Current values are `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config`, respectively. - `CONFIG_RELEASE_REVISION` / `CONFIG_RELEASE_KEYRINGS` (vars) — exact revision-metadata and public-keyrings JSON bytes; the manifest pins their SHA-256s, so drifted content fails closed. Public keys only — private keys never enter this repo or its CI. -- `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v1 JSON per target (produced by the publisher's release flow), pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, and the expected published snapshot digest. +- `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v2 JSON per production target, pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, expected published snapshot digest, and exact observer evidence (`activationVersion`, Pages deployment/source identity, signing `keyId`, pointer digest, and verification time). Version 1 remains accepted only for the reviewed nonproduction fixture. Enforcement: `LINKCODE_REQUIRE_CONFIG_BUNDLE=1` (set for signed desktop builds) makes the Vite main build fail without `apps/desktop/generated/config-build-bundle.json` and makes `verify-artifacts.mts` require the staged asar copy, which is always byte-compared against the generated render. Mobile gates twice: `pnpm -F @linkcode/mobile config:verify-release` before `eas build`, and the `eas-build-pre-install` hook inside the EAS project archive rejects the committed `{ bundle: null }` sentinel on production profiles (the root `.easignore` — which replaces `.gitignore` for EAS archiving — deliberately lets the generated modules into the archive). @@ -142,9 +144,12 @@ the canonical schema in the pinned publisher checkout before parsing. Acme and Z The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has exactly `brandId`, `channel`, `sourceRoot`, `releaseManifests`, `compliance`, and `distribution`. `sourceRoot` is either `.` for reviewed production data or `examples/acme-zenith` for the pinned -nonproduction fixture; no other path is accepted. +nonproduction fixture; no other path is accepted. Any matrix run with `build=true` requires +release-manifest v2 observer evidence even when it uses the fixture root, so changing the path cannot +bypass the production publication gate. -- `releaseManifests.desktop|ios|android` are complete release-render manifest v1 objects. The three +- `releaseManifests.desktop|ios|android` are complete release-render manifest v2 objects for a + production source root; the reviewed nonproduction example may retain v1. The three targets must share publisher/source commits, config revision, revision digest, and public-keyring digest; target brand/platform/channel mismatches are rejected. - `compliance.desktop|ios|android` has a lexicographically sorted `disclosedFeatures` array and a diff --git a/packages/foundation/common/src/node/__tests__/config-build-render.test.ts b/packages/foundation/common/src/node/__tests__/config-build-render.test.ts index 7814a0ce5..c83488ff8 100644 --- a/packages/foundation/common/src/node/__tests__/config-build-render.test.ts +++ b/packages/foundation/common/src/node/__tests__/config-build-render.test.ts @@ -242,6 +242,42 @@ describe('release manifest binding', () => { expect(render?.args).toContain(request.releaseManifestPath); }); + it('accepts exact observer evidence and rejects a mismatched deployment source', async () => { + const request = await makeBoundRequest(); + const manifest = JSON.parse(await readFile(request.releaseManifestPath!, 'utf8')) as Record< + string, + unknown + >; + const publicationEvidence = { + activationVersion: '1', + deploymentId: 'deployment-fixture', + deploymentSourceGitSha: SOURCE_SHA, + keyId: 'normal-fixture-1', + pointerSha256: 'e'.repeat(64), + verifiedAt: '2026-08-15T03:01:00Z', + }; + await writeFile( + request.releaseManifestPath!, + JSON.stringify({ ...manifest, publicationEvidence, releaseManifestFormatVersion: 2 }), + ); + const { run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(fixture)), + }); + await expect(renderConfigBundleWithPublisher(request, run)).resolves.toBeDefined(); + + await writeFile( + request.releaseManifestPath!, + JSON.stringify({ + ...manifest, + publicationEvidence: { ...publicationEvidence, deploymentSourceGitSha: 'f'.repeat(40) }, + releaseManifestFormatVersion: 2, + }), + ); + await expect(renderConfigBundleWithPublisher(request, run)).rejects.toThrow( + 'invalid publication evidence', + ); + }); + it('fails when the rendered snapshot digest is not the pinned published digest', async () => { const request = await makeBoundRequest(); const manifest = JSON.parse(await readFile(request.releaseManifestPath!, 'utf8')) as Record< diff --git a/packages/foundation/common/src/node/config-build-render.ts b/packages/foundation/common/src/node/config-build-render.ts index 7afd34374..63595a31c 100644 --- a/packages/foundation/common/src/node/config-build-render.ts +++ b/packages/foundation/common/src/node/config-build-render.ts @@ -75,7 +75,10 @@ export function configBuildRenderArgs(request: ConfigBuildRenderRequest): readon } const RE_HEX_SHA256 = /^[0-9a-f]{64}$/; -const RELEASE_MANIFEST_KEYS = [ +const RE_MONOTONIC_VERSION = /^(?:0|[1-9]\d{0,19})$/; +const RE_KEY_ID = /^[\dA-Z][\w.-]{0,127}$/i; +const RE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const RELEASE_MANIFEST_BASE_KEYS = [ 'brandId', 'channel', 'configRevisionId', @@ -88,10 +91,21 @@ const RELEASE_MANIFEST_KEYS = [ 'sourceGitSha', 'telemetryEndpoint', ] as const; -const RELEASE_MANIFEST_KEY_SET: ReadonlySet = new Set(RELEASE_MANIFEST_KEYS); +const RELEASE_MANIFEST_V1_KEY_SET: ReadonlySet = new Set(RELEASE_MANIFEST_BASE_KEYS); +const RELEASE_MANIFEST_V2_KEY_SET: ReadonlySet = new Set([ + ...RELEASE_MANIFEST_BASE_KEYS, + 'publicationEvidence', +]); +const PUBLICATION_EVIDENCE_KEYS = new Set([ + 'activationVersion', + 'deploymentId', + 'deploymentSourceGitSha', + 'keyId', + 'pointerSha256', + 'verifiedAt', +]); -/** Frozen release-render manifest v1 (publisher CONTRACT.md "Release render manifest v1"). */ -export interface ConfigBuildReleaseManifest { +interface ConfigBuildReleaseManifestBase { readonly brandId: string; readonly channel: string; readonly configRevisionId: string; @@ -99,33 +113,55 @@ export interface ConfigBuildReleaseManifest { readonly platform: string; readonly publicKeyringsSha256: string; readonly publisherGitSha: string; - readonly releaseManifestFormatVersion: 1; readonly revisionSha256: string; readonly sourceGitSha: string; readonly telemetryEndpoint: string; } +export interface ConfigBuildPublicationEvidence { + readonly activationVersion: string; + readonly deploymentId: string; + readonly deploymentSourceGitSha: string; + readonly keyId: string; + readonly pointerSha256: string; + readonly verifiedAt: string; +} + +export type ConfigBuildReleaseManifest = + | (ConfigBuildReleaseManifestBase & { readonly releaseManifestFormatVersion: 1 }) + | (ConfigBuildReleaseManifestBase & { + readonly publicationEvidence: ConfigBuildPublicationEvidence; + readonly releaseManifestFormatVersion: 2; + }); + function parseReleaseManifest(value: unknown, path: string): ConfigBuildReleaseManifest { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new TypeError(`Release manifest at ${path} must be a JSON object`); } const manifest = value as Record; + const version = manifest.releaseManifestFormatVersion; + if (version !== 1 && version !== 2) { + throw new TypeError(`Release manifest at ${path} has an unsupported format version`); + } + const keys = version === 1 ? RELEASE_MANIFEST_V1_KEY_SET : RELEASE_MANIFEST_V2_KEY_SET; for (const key of Object.keys(manifest)) { - if (!RELEASE_MANIFEST_KEY_SET.has(key)) { + if (!keys.has(key)) { throw new TypeError(`Release manifest at ${path} contains unsupported field ${key}`); } } - if (manifest.releaseManifestFormatVersion !== 1) { - throw new TypeError(`Release manifest at ${path} has an unsupported format version`); + for (const key of keys) { + if (!(key in manifest)) { + throw new TypeError(`Release manifest at ${path} is missing field ${key}`); + } } - const field = (key: (typeof RELEASE_MANIFEST_KEYS)[number]): string => { + const field = (key: (typeof RELEASE_MANIFEST_BASE_KEYS)[number]): string => { const fieldValue = manifest[key]; if (typeof fieldValue !== 'string') { throw new TypeError(`Release manifest at ${path} is missing field ${key}`); } return fieldValue; }; - return { + const base: ConfigBuildReleaseManifestBase = { brandId: field('brandId'), channel: field('channel'), configRevisionId: field('configRevisionId'), @@ -133,11 +169,61 @@ function parseReleaseManifest(value: unknown, path: string): ConfigBuildReleaseM platform: field('platform'), publicKeyringsSha256: field('publicKeyringsSha256'), publisherGitSha: field('publisherGitSha'), - releaseManifestFormatVersion: 1, revisionSha256: field('revisionSha256'), sourceGitSha: field('sourceGitSha'), telemetryEndpoint: field('telemetryEndpoint'), }; + if (version === 1) return { ...base, releaseManifestFormatVersion: 1 }; + const publicationEvidence = parsePublicationEvidence( + manifest.publicationEvidence, + path, + base.sourceGitSha, + ); + return { ...base, publicationEvidence, releaseManifestFormatVersion: 2 }; +} + +function parsePublicationEvidence( + value: unknown, + path: string, + sourceGitSha: string, +): ConfigBuildPublicationEvidence { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`Release manifest at ${path} has invalid publication evidence`); + } + const evidence = value as Record; + const actual = Object.keys(evidence).sort(); + const expected = [...PUBLICATION_EVIDENCE_KEYS].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`Release manifest at ${path} has invalid publication evidence fields`); + } + const field = (key: string): string => { + const result = evidence[key]; + if (typeof result !== 'string' || result.length === 0) { + throw new TypeError(`Release manifest at ${path} has invalid publication evidence ${key}`); + } + return result; + }; + const publication = { + activationVersion: field('activationVersion'), + deploymentId: field('deploymentId'), + deploymentSourceGitSha: field('deploymentSourceGitSha'), + keyId: field('keyId'), + pointerSha256: field('pointerSha256'), + verifiedAt: field('verifiedAt'), + }; + if ( + !RE_MONOTONIC_VERSION.test(publication.activationVersion) || + publication.activationVersion === '0' || + publication.deploymentId.length > 256 || + publication.deploymentSourceGitSha !== sourceGitSha || + !RE_KEY_ID.test(publication.keyId) || + !RE_HEX_SHA256.test(publication.pointerSha256) || + !RE_TIMESTAMP.test(publication.verifiedAt) || + Number.isNaN(Date.parse(publication.verifiedAt)) + ) { + throw new TypeError(`Release manifest at ${path} has invalid publication evidence`); + } + return publication; } /** Re-verifies the release-manifest binding against the loaded bundle and the exact input file