From 0ec6a1195faeb8dde2a38369aaf08835d28e4e25 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 07:52:42 +0000 Subject: [PATCH 1/2] fix(release): preserve reviewed legacy destinations Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .github/scripts/brand-matrix.cjs | 76 ++++++++++--- .github/scripts/brand-matrix.test.mjs | 123 ++++++++++++++++++++- .github/workflows/release-brand-matrix.yml | 6 +- docs/RELEASE.md | 7 +- 4 files changed, 195 insertions(+), 17 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index d79b9582c..f90ebfaef 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -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, @@ -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) { @@ -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)) { @@ -261,10 +312,9 @@ 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); diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index c5a0bdaba..69323ed39 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -16,10 +16,11 @@ 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_SECRETS_EXPRESSION = /secrets(?:\.|\[)/; const ACTIONS_EXPRESSION = String.fromCodePoint(36); @@ -84,6 +85,15 @@ 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`, + }; +} + describe('parseBrandBuildMatrix', () => { it('pins the CODE-561 credential-free pilot to two brands and all platforms', async () => { const pilot = JSON.parse( @@ -251,6 +261,93 @@ 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('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; @@ -380,6 +477,30 @@ 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).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'), diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index ce4fa4e81..c17f40c99 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -501,7 +501,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 }} @@ -866,8 +866,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 }} 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 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 798c44382..289f55247 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -153,7 +153,12 @@ 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. 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/` URL, iOS `appleTeamId`/`ascAppId`, and Android `track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands. From b29c1df8d78adb32d7a8a55b4ffa833392a3bea6 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 08:06:45 +0000 Subject: [PATCH 2/2] fix(release): require tags for legacy uploads Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .github/scripts/brand-matrix.cjs | 11 +++++ .github/scripts/brand-matrix.test.mjs | 51 ++++++++++++++++++++++ .github/workflows/release-brand-matrix.yml | 4 +- docs/RELEASE.md | 8 ++-- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index f90ebfaef..3f1393fc5 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -322,6 +322,13 @@ function parseBrandBuildMatrix(value, options = {}) { } 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 }; } @@ -351,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' }, }, @@ -368,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'), }); diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 69323ed39..bd3b2e42f 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -21,6 +21,7 @@ const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal relea 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); @@ -94,6 +95,18 @@ function desktopDestination(brandId) { }; } +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( @@ -278,6 +291,42 @@ describe('parseBrandBuildMatrix', () => { }); }); + 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'); @@ -498,6 +547,8 @@ describe('release brand matrix workflow', () => { `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'"); }); diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index c17f40c99..d385c2504 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -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 }} diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 289f55247..9612aad71 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -156,9 +156,11 @@ nonproduction fixture; no other path is accepted. 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. 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. + 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` + 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/` URL, iOS `appleTeamId`/`ascAppId`, and Android `track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands.