Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/actions/render-release-config/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
AprilNEA marked this conversation as resolved.
(.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
Expand All @@ -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)"
Expand Down
74 changes: 70 additions & 4 deletions .github/scripts/brand-matrix.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const CHECKLIST_KEYS = [
'permissionsReviewed',
'storeMetadataReviewed',
];
const RELEASE_MANIFEST_KEYS = [
const RELEASE_MANIFEST_BASE_KEYS = [
'brandId',
'channel',
'configRevisionId',
Expand All @@ -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}$/;
Expand All @@ -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}`);
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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 ||
Expand Down Expand Up @@ -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`);
Expand All @@ -213,6 +265,7 @@ function parseBrandBuildMatrix(value, options = {}) {
platform,
brandId,
brand.channel,
requirePublicationEvidence,
);
declarations[platform] = compliance(declarations[platform], `${path}.compliance.${platform}`);
}
Expand All @@ -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(
Expand Down
44 changes: 42 additions & 2 deletions .github/scripts/brand-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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'),
Expand Down
17 changes: 11 additions & 6 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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).

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
Loading
Loading