Skip to content
Open
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
87 changes: 74 additions & 13 deletions .github/scripts/brand-matrix.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ function compliance(value, path) {
function desktopDistribution(value, path, brandId, channel) {
if (value === null) return null;
const distribution = record(value, path);
exact(distribution, ['credentialEnvironment', 'r2Bucket', 'r2Prefix', 'updateUrl'], path);
const keys = ['credentialEnvironment', 'r2Bucket', 'r2Prefix', 'updateUrl'];
if (Object.hasOwn(distribution, 'legacyDestination')) keys.push('legacyDestination');
exact(distribution, keys, path);
const updateUrl = httpsUrl(distribution.updateUrl, `${path}.updateUrl`);
const credentialEnvironment = string(
distribution.credentialEnvironment,
Expand All @@ -143,12 +145,59 @@ function desktopDistribution(value, path, brandId, channel) {
if (!new URL(updateUrl).pathname.replace(RE_TRAILING_SLASH, '').endsWith(expectedSuffix)) {
fail(path, 'updateUrl path must end with r2Prefix');
}
return {
const result = {
credentialEnvironment,
r2Bucket,
r2Prefix: r2Prefix.replace(RE_TRAILING_SLASH, ''),
updateUrl,
};
if (Object.hasOwn(distribution, 'legacyDestination')) {
const legacyPath = `${path}.legacyDestination`;
const legacy = record(distribution.legacyDestination, legacyPath);
exact(legacy, ['r2Bucket', 'r2Prefix', 'updateUrl'], legacyPath);
result.legacyDestination = {
r2Bucket: string(legacy.r2Bucket, `${legacyPath}.r2Bucket`, RE_BUCKET),
r2Prefix: string(legacy.r2Prefix, `${legacyPath}.r2Prefix`, RE_R2_PREFIX).replace(
RE_TRAILING_SLASH,
'',
),
updateUrl: httpsUrl(legacy.updateUrl, `${legacyPath}.updateUrl`),
};
const legacySuffix = `/${result.legacyDestination.r2Prefix}`;
const legacyPathname = new URL(result.legacyDestination.updateUrl).pathname.replace(
RE_TRAILING_SLASH,
'',
);
if (!legacyPathname.endsWith(legacySuffix)) {
fail(legacyPath, 'updateUrl path must end with r2Prefix');
}
}
return result;
}

function destinationEntries(distribution) {
if (!distribution) return [];
const entries = [distribution];
if (distribution.legacyDestination) entries.push(distribution.legacyDestination);
return entries;
}

function destinationsOverlap(left, right) {
const prefixesOverlap =
left.r2Bucket === right.r2Bucket &&
(left.r2Prefix === right.r2Prefix ||
left.r2Prefix.startsWith(`${right.r2Prefix}/`) ||
right.r2Prefix.startsWith(`${left.r2Prefix}/`));
const leftUrl = new URL(left.updateUrl);
const rightUrl = new URL(right.updateUrl);
const leftPath = leftUrl.pathname.replace(RE_TRAILING_SLASH, '');
const rightPath = rightUrl.pathname.replace(RE_TRAILING_SLASH, '');
const urlsOverlap =
leftUrl.origin === rightUrl.origin &&
(leftPath === rightPath ||
leftPath.startsWith(`${rightPath}/`) ||
rightPath.startsWith(`${leftPath}/`));
return prefixesOverlap || urlsOverlap;
}

function mobileDistribution(value, path) {
Expand Down Expand Up @@ -243,15 +292,17 @@ function parseBrandBuildMatrix(value, options = {}) {
);
}
if (distribution.desktop) {
const collision = destinations.some(
({ bucket, prefix }) =>
bucket === distribution.desktop.r2Bucket &&
(prefix === distribution.desktop.r2Prefix ||
prefix.startsWith(`${distribution.desktop.r2Prefix}/`) ||
distribution.desktop.r2Prefix.startsWith(`${prefix}/`)),
const entries = destinationEntries(distribution.desktop);
const collision = entries.some((entry, index) =>
[...destinations, ...entries.slice(0, index)].some((existing) =>
destinationsOverlap(existing, entry),
),
);
if (collision) {
fail(`${path}.distribution.desktop`, 'R2 prefixes in one bucket must not overlap');
fail(
`${path}.distribution.desktop`,
'Desktop destinations must not have overlapping R2 prefixes or update URL paths',
);
}
}
if (distribution.mobile && projects.has(distribution.mobile.easProjectId)) {
Expand All @@ -261,17 +312,23 @@ function parseBrandBuildMatrix(value, options = {}) {
fail(`${path}.distribution.mobile.ios.ascAppId`, 'must be unique');
}
if (distribution.desktop) {
destinations.push({
bucket: distribution.desktop.r2Bucket,
prefix: distribution.desktop.r2Prefix,
});
for (const destination of destinationEntries(distribution.desktop)) {
destinations.push(destination);
}
}
if (distribution.mobile) {
projects.add(distribution.mobile.easProjectId);
appStoreApps.add(distribution.mobile.ios.ascAppId);
}
return brand;
});
if (options.upload && brands.some((brand) => brand.distribution.desktop?.legacyDestination)) {
const desktopVersion = string(options.desktopVersion, 'options.desktopVersion');
const expectedRef = `refs/tags/v${desktopVersion}`;
if (options.releaseRef !== expectedRef) {
fail('options.releaseRef', `must equal ${expectedRef} for a legacy Desktop upload`);
}
}
return { brandBuildMatrixVersion: BUILD_MATRIX_VERSION, brands };
}

Expand Down Expand Up @@ -301,7 +358,9 @@ function runCli(argv = process.argv.slice(2), env = process.env) {
args: argv,
options: {
build: { type: 'string', default: 'false' },
'desktop-version': { type: 'string', default: '' },
'matrix-file': { type: 'string' },
'release-ref': { type: 'string', default: '' },
sign: { type: 'string', default: 'false' },
upload: { type: 'string', default: 'false' },
},
Expand All @@ -318,6 +377,8 @@ function runCli(argv = process.argv.slice(2), env = process.env) {
}
const plan = buildMatrixPlan(matrix, {
build: strictBoolean(values.build, '--build'),
desktopVersion: values['desktop-version'],
releaseRef: values['release-ref'],
sign: strictBoolean(values.sign, '--sign'),
upload: strictBoolean(values.upload, '--upload'),
});
Expand Down
174 changes: 173 additions & 1 deletion .github/scripts/brand-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ const RE_UPLOAD_WITHOUT_SIGN = /upload requires sign=true/;
const RE_MISSING_BRAND_SEGMENT = /must include the brand id/;
const RE_UNKNOWN_FIELD = /must contain exactly/;
const RE_DIVERGENT_SOURCE = /all platforms must share sourceGitSha/;
const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/;
const RE_SHARED_DESTINATION = /Desktop destinations must not have overlapping/;
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_LEGACY_DESKTOP_UPLOAD = /"s3:\/\/\$\{R2_BUCKET\}\/\$\{R2_PREFIX\}\/"/;
const RE_LEGACY_RELEASE_TAG = /must equal refs\/tags\/v1\.2\.3/;
const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/;
const ACTIONS_EXPRESSION = String.fromCodePoint(36);

Expand Down Expand Up @@ -84,6 +86,27 @@ function matrix(...brands) {
return { brandBuildMatrixVersion: 1, brands };
}

function desktopDestination(brandId) {
return {
credentialEnvironment: 'release',
r2Bucket: `release-${brandId}`,
r2Prefix: `desktop/${brandId}/canary`,
updateUrl: `https://${brandId}.example.invalid/desktop/${brandId}/canary`,
};
}

function releasableBrand(brandId = 'acme') {
const entry = brand(brandId);
entry.distribution.desktop = desktopDestination(brandId);
entry.distribution.mobile = {
android: { track: 'internal' },
easProjectId: '11111111-1111-4111-8111-111111111111',
ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' },
updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111',
};
return entry;
}

describe('parseBrandBuildMatrix', () => {
it('pins the CODE-561 credential-free pilot to two brands and all platforms', async () => {
const pilot = JSON.parse(
Expand Down Expand Up @@ -251,6 +274,129 @@ describe('parseBrandBuildMatrix', () => {
);
});

it('accepts an explicit generic legacy Desktop destination', () => {
const arbitrary = brand('northstar');
arbitrary.distribution.desktop = desktopDestination('northstar');
arbitrary.distribution.desktop.legacyDestination = {
r2Bucket: 'linkcode-releases',
r2Prefix: 'desktop',
updateUrl: 'https://releases.linkcode.ai/desktop',
};

const parsed = parseBrandBuildMatrix(matrix(arbitrary));
expect(parsed.brands[0].distribution.desktop.legacyDestination).toStrictEqual({
r2Bucket: 'linkcode-releases',
r2Prefix: 'desktop',
updateUrl: 'https://releases.linkcode.ai/desktop',
});
});

it('requires the package-version tag for a legacy Desktop upload', async () => {
const root = await mkdtemp(join(tmpdir(), 'brand-matrix-legacy-'));
const matrixPath = join(root, 'matrix.json');
const outputPath = join(root, 'github-output');
const release = releasableBrand();
release.distribution.desktop.legacyDestination = {
r2Bucket: 'legacy-releases',
r2Prefix: 'desktop',
updateUrl: 'https://legacy.example.invalid/desktop',
};
await writeFile(matrixPath, JSON.stringify(matrix(release)));
const args = [
'--matrix-file',
matrixPath,
'--build',
'true',
'--sign',
'true',
'--upload',
'true',
'--desktop-version',
'1.2.3',
];

expect(() =>
runCli([...args, '--release-ref', 'refs/heads/master'], {
GITHUB_OUTPUT: outputPath,
}),
).toThrow(RE_LEGACY_RELEASE_TAG);
expect(() =>
runCli([...args, '--release-ref', 'refs/tags/v1.2.3'], {
GITHUB_OUTPUT: outputPath,
}),
).not.toThrow();
});

it('rejects malformed or implicit legacy Desktop destinations', () => {
const missingOptIn = brand();
missingOptIn.distribution.desktop = desktopDestination('acme');
missingOptIn.distribution.desktop.legacyR2Prefix = 'desktop';
expect(() => parseBrandBuildMatrix(matrix(missingOptIn))).toThrow(RE_UNKNOWN_FIELD);

for (const legacyDestination of [
null,
{ r2Bucket: 'valid-bucket', r2Prefix: 'desktop' },
{
r2Bucket: 'Valid_Bucket',
r2Prefix: 'desktop',
updateUrl: 'https://downloads.example.invalid/desktop',
},
{
r2Bucket: 'valid-bucket',
r2Prefix: '../desktop',
updateUrl: 'https://downloads.example.invalid/desktop',
},
{
r2Bucket: 'valid-bucket',
r2Prefix: 'desktop',
updateUrl: 'http://downloads.example.invalid/desktop',
},
]) {
const malformed = brand();
malformed.distribution.desktop = {
...desktopDestination('acme'),
legacyDestination,
};
expect(() => parseBrandBuildMatrix(matrix(malformed))).toThrow();
}
});

it('rejects standard and legacy destination overlap within and across brands', () => {
const first = brand('acme');
first.distribution.desktop = desktopDestination('acme');
first.distribution.desktop.legacyDestination = {
r2Bucket: 'release-acme',
r2Prefix: 'desktop/acme',
updateUrl: 'https://legacy.example.invalid/desktop/acme',
};
expect(() => parseBrandBuildMatrix(matrix(first))).toThrow(RE_SHARED_DESTINATION);

first.distribution.desktop.legacyDestination = {
r2Bucket: 'legacy-acme',
r2Prefix: 'desktop',
updateUrl: 'https://legacy.example.invalid/desktop',
};
const second = brand('zenith');
second.distribution.desktop = desktopDestination('zenith');
second.distribution.desktop.legacyDestination = {
r2Bucket: 'legacy-acme',
r2Prefix: 'desktop/archive',
updateUrl: 'https://zenith-legacy.example.invalid/desktop/archive',
};
expect(() => parseBrandBuildMatrix(matrix(first, second))).toThrow(RE_SHARED_DESTINATION);

second.distribution.desktop.legacyDestination = {
r2Bucket: 'legacy-zenith',
r2Prefix: 'desktop/archive',
updateUrl: 'https://legacy.example.invalid/desktop/archive',
};
expect(() => parseBrandBuildMatrix(matrix(first, second))).toThrow(RE_SHARED_DESTINATION);

first.distribution.desktop.legacyDestination.updateUrl =
'https://legacy.example.invalid/desktop/';
expect(() => parseBrandBuildMatrix(matrix(first, second))).toThrow(RE_SHARED_DESTINATION);
});

it('rejects unknown fields and divergent immutable source bindings', () => {
const extra = matrix(brand());
extra.brands[0].releaseManifests.desktop.hidden = true;
Expand Down Expand Up @@ -380,6 +526,32 @@ describe('release brand matrix workflow', () => {
).toHaveLength(4);
});

it('uses an explicit reviewed legacy Desktop destination for packaging and upload', async () => {
const workflow = await readFile(
new URL('../workflows/release-brand-matrix.yml', import.meta.url),
'utf8',
);
const desktop = workflow.slice(
workflow.indexOf(' desktop:'),
workflow.indexOf(' desktop-validation:'),
);
const publish = workflow.slice(workflow.indexOf(' publish-desktop:'));

expect(desktop).toContain(
`update_url: ${ACTIONS_EXPRESSION}{{ matrix.distribution.desktop.legacyDestination.updateUrl || matrix.distribution.desktop.updateUrl || '' }}`,
);
expect(publish).toContain(
`R2_BUCKET: ${ACTIONS_EXPRESSION}{{ matrix.distribution.desktop.legacyDestination.r2Bucket || matrix.distribution.desktop.r2Bucket }}`,
);
expect(publish).toContain(
`R2_PREFIX: ${ACTIONS_EXPRESSION}{{ matrix.distribution.desktop.legacyDestination.r2Prefix || matrix.distribution.desktop.r2Prefix }}`,
);
expect(publish).toMatch(RE_LEGACY_DESKTOP_UPLOAD);
expect(workflow).toContain('--release-ref "$GITHUB_REF"');
expect(workflow).toContain('--desktop-version "$(jq -er .version apps/desktop/package.json)"');
expect(workflow).not.toContain("brandId == 'linkcode'");
});

it('mints scoped read tokens before any selected client checkout', async () => {
const [action, desktop, mobile, workflow] = await Promise.all([
readFile(new URL('../actions/render-release-config/action.yml', import.meta.url), 'utf8'),
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/release-brand-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ jobs:
--matrix-file "$matrix_file" \
--build "${{ inputs.build }}" \
--sign "${{ inputs.sign }}" \
--upload "${{ inputs.upload }}"
--upload "${{ inputs.upload }}" \
--release-ref "$GITHUB_REF" \
--desktop-version "$(jq -er .version apps/desktop/package.json)"

credential-free-validation:
name: Credential-free ${{ matrix.platform }}
Expand Down Expand Up @@ -501,7 +503,7 @@ jobs:
delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }}
release_environment: ${{ matrix.distribution.desktop.credentialEnvironment }}
rendered_artifact: brand-render-${{ matrix.brandId }}
update_url: ${{ matrix.distribution.desktop.updateUrl || '' }}
update_url: ${{ matrix.distribution.desktop.legacyDestination.updateUrl || matrix.distribution.desktop.updateUrl || '' }}

desktop-validation:
name: Desktop validation ${{ matrix.brandId }}
Expand Down Expand Up @@ -866,8 +868,10 @@ jobs:
AWS_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
R2_BUCKET: ${{ matrix.distribution.desktop.legacyDestination.r2Bucket || matrix.distribution.desktop.r2Bucket }}
R2_PREFIX: ${{ matrix.distribution.desktop.legacyDestination.r2Prefix || matrix.distribution.desktop.r2Prefix }}
Comment thread
AprilNEA marked this conversation as resolved.
run: |
aws s3 sync "artifacts/${{ matrix.brandId }}/" \
"s3://${{ matrix.distribution.desktop.r2Bucket }}/${{ matrix.distribution.desktop.r2Prefix }}/" \
"s3://${R2_BUCKET}/${R2_PREFIX}/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--no-progress
9 changes: 8 additions & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,14 @@ nonproduction fixture; no other path is accepted.
- `distribution.desktop` may be `null` only for plan validation. Every build requires an object containing
`credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. `credentialEnvironment` must be
exactly `release`; no per-brand Environment is part of this contract. Both URL and prefix must end
in the same brand/channel path, and prefixes in one bucket must not overlap.
in the same brand/channel path. An entry may additionally contain the exact-key
`legacyDestination` object `{ r2Bucket, r2Prefix, updateUrl }`. Its prefix need not contain the
brand/channel segments, allowing an immutable pre-matrix updater feed to remain reachable; when
present, Desktop packaging and upload use that destination instead of the standard one. Uploading
to a legacy destination additionally requires the workflow ref to be the exact `v<package-version>`
tag, preventing a manual `master` dispatch from replacing an installed app's feed. The field is
generic and is accepted only from the reviewed matrix entry—brand IDs do not imply it. Standard and
legacy R2 bucket/prefix pairs and update URL paths must not overlap within or across brands.
- `distribution.mobile` may be `null` only for plan validation. Every build requires `easProjectId`, its
exact `https://u.expo.dev/<id>` URL, iOS `appleTeamId`/`ascAppId`, and Android
`track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands.
Expand Down
Loading