diff --git a/.changeset/brew-tap-wait-for-npm.md b/.changeset/brew-tap-wait-for-npm.md new file mode 100644 index 000000000..059a24c51 --- /dev/null +++ b/.changeset/brew-tap-wait-for-npm.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Wait for the npm tarball to become downloadable before updating the Homebrew formula. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 91b845032..1332a1762 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,6 +12,7 @@ concurrency: permissions: contents: read id-token: write + pull-requests: read jobs: publish: @@ -52,6 +53,8 @@ jobs: run: pnpm build - name: Publish dev snapshot + env: + GITHUB_TOKEN: ${{ github.token }} run: | pnpm changeset version --snapshot dev VERSION=$(node -p "require('./apps/pythinker-code/package.json').version") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd0beb385..1766550fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -486,7 +486,7 @@ jobs: run: node scripts/release/verify-release-consistency.mjs update-brew-tap: - timeout-minutes: 15 + timeout-minutes: 20 # Checkout only; the tap push uses a minted app token, not this one. permissions: contents: read diff --git a/scripts/release/release-status.mjs b/scripts/release/release-status.mjs index a79aff544..f95102849 100644 --- a/scripts/release/release-status.mjs +++ b/scripts/release/release-status.mjs @@ -69,6 +69,24 @@ async function fetchJson(fetchImpl, url, label, init = {}) { } } +async function fetchText(fetchImpl, url, label, init = {}) { + const response = await fetchImpl(url, { + ...init, + headers: { + 'user-agent': 'pythinker-release-status', + ...init.headers, + }, + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok) throw new Error(`${label} returned HTTP ${response.status}.`); + return await response.text(); +} + +function brewFormulaVersion(formula) { + const match = typeof formula === 'string' ? /pythinker-code-(\d+\.\d+\.\d+)\.tgz/u.exec(formula) : null; + return match === null ? undefined : validVersion(match[1]); +} + function validVersion(value) { return typeof value === 'string' && semver.exec(value)?.[0] === value ? value : undefined; } @@ -162,6 +180,7 @@ export async function collectReleaseStatus({ npmResult, cdnResult, cliReleaseResult, + brewResult, desktopStableResult, desktopReleasesResult, marketplaceResult, @@ -184,6 +203,11 @@ export async function collectReleaseStatus({ 'CLI GitHub release', { headers: github }, ), + fetchText( + fetchImpl, + 'https://raw.githubusercontent.com/PyModel/homebrew-tap/main/Formula/pythinker-code.rb', + 'Homebrew formula', + ), fetchJson( fetchImpl, 'https://api.github.com/repos/PyModel/pythinker-desktop-releases/releases/latest', @@ -260,6 +284,11 @@ export async function collectReleaseStatus({ coverage: targetCoverage(Object.keys(value.downloads ?? {})), })); + const brew = settledValue(brewResult, (value) => { + const version = brewFormulaVersion(value); + if (version === undefined) throw new Error('Homebrew formula has no pythinker-code tarball version.'); + return { version }; + }); const cliMissing = cliRelease.missing ?? expectedCliAssets; const cliOk = npm.version === cliVersion && cliRelease.tag === cliTag @@ -283,6 +312,13 @@ export async function collectReleaseStatus({ details: cdn.error ?? `platforms ${cdn.coverage?.present ?? 0}/${nativeTargets.length}${missingDetail(cdn.coverage?.missing ?? nativeTargets)}`, }, + { + lane: 'Homebrew', + expected: cliVersion, + observed: brew.version ?? 'unavailable', + ok: brew.version === cliVersion && brew.error === undefined, + details: brew.error ?? `Formula/pythinker-code.rb ${brew.version}`, + }, { lane: 'Desktop Stable', expected: desktopVersion, diff --git a/scripts/release/release-status.test.mjs b/scripts/release/release-status.test.mjs index 0d3549182..79d481d23 100644 --- a/scripts/release/release-status.test.mjs +++ b/scripts/release/release-status.test.mjs @@ -50,10 +50,17 @@ function json(body, status = 200) { function fixtureFetch({ cliAssets = expectedCliAssets, nightlyAssets = desktopAssets(desktopNightlyVersion, 'nightly'), + brewVersion = '1.3.0', } = {}) { return async (input) => { const url = new URL(String(input)); if (url.hostname === 'registry.npmjs.org') return json({ latest: '1.3.0' }); + if (url.hostname === 'raw.githubusercontent.com') { + return new Response( + `url "https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${brewVersion}.tgz"\n`, + { status: 200, headers: { 'content-type': 'text/plain' } }, + ); + } if (url.hostname === 'code.pythinker.com') { return json({ version: '1.3.0', @@ -126,6 +133,7 @@ void test('reports all live release lanes aligned', async (t) => { assert.equal(result.ok, true); assert.equal(result.rows.every((row) => row.ok), true); assert.match(renderReleaseStatus(result), /\| npm CLI \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u); + assert.match(renderReleaseStatus(result), /\| Homebrew \| 1\.3\.0 \| 1\.3\.0 \| PASS \|/u); assert.match(renderReleaseStatus(result), /\| Desktop Nightly \| 0\.2\.2-nightly\.4102 \| 0\.2\.2-nightly\.4102 \| PASS \|/u); }); @@ -143,6 +151,20 @@ void test('fails when a published CLI release is missing one required asset', as assert.match(cli?.details ?? '', /missing manifest\.json/u); }); +void test('fails when the Homebrew formula lags the published CLI version', async (t) => { + const rootDir = await fixtureRoot(t); + const result = await collectReleaseStatus({ + rootDir, + desktopCommitCount, + fetchImpl: fixtureFetch({ brewVersion: '1.2.0' }), + }); + + assert.equal(result.ok, false); + const brew = result.rows.find((row) => row.lane === 'Homebrew'); + assert.equal(brew?.ok, false); + assert.equal(brew?.observed, '1.2.0'); +}); + void test('fails when the current desktop Nightly release is incomplete', async (t) => { const rootDir = await fixtureRoot(t); const result = await collectReleaseStatus({ diff --git a/scripts/release/release-workflows.test.mjs b/scripts/release/release-workflows.test.mjs index 381755a42..82e2e0fe2 100644 --- a/scripts/release/release-workflows.test.mjs +++ b/scripts/release/release-workflows.test.mjs @@ -30,6 +30,7 @@ void test('release workflow uses full push-boundary lane signals and isolated jo assert.match(workflow, /pythinker_release_tag: \$\{\{ steps\.pythinker-release\.outputs\.tag \|\|/u); assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u); assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u); + assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu); }); void test('VS Code release supports isolated recovery and attests verified VSIX files', () => { @@ -56,6 +57,7 @@ void test('native releases fail without requested signing and attest each zip', void test('nightly reconciliation maintains one release drift issue', () => { const workflow = read('.github/workflows/nightly.yml'); + const publishJob = workflow.slice(workflow.indexOf('\n publish:'), workflow.indexOf('\n desktop-nightly:')); assert.match(workflow, /uses: \.\/\.github\/workflows\/desktop-release\.yml/u); assert.match(workflow, /^ desktop-nightly:/mu); assert.match(workflow, /needs: \[publish, desktop-nightly\]/u); @@ -63,6 +65,8 @@ void test('nightly reconciliation maintains one release drift issue', () => { assert.match(workflow, /scripts\/release\/release-status\.mjs/u); assert.match(workflow, /Release lane drift detected/u); assert.match(workflow, /issues: write/u); + assert.match(workflow, /pull-requests: read/u); + assert.equal(publishJob.includes('GITHUB_TOKEN: ${{ github.token }}'), true); assert.doesNotMatch(workflow, /uses: actions\/(checkout|setup-node)@v\d+/u); assert.equal(workflow.match(/persist-credentials: false/gu)?.length, 2); }); diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index 0db0f9576..b1dcd63e3 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -1,23 +1,75 @@ +/** + * Bump Formula/pythinker-code.rb in PyModel/homebrew-tap to the published npm + * tarball. `changeset publish` can succeed several minutes before the public + * GET of `/-/pythinker-code-.tgz` returns 200, so the download polls + * until the tarball is fetchable (fetch, sleep, and clock are injected so the + * poll is unit-testable without a network or a real wait). + */ import { createHash } from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +export const NPM_TARBALL_POLL_BUDGET_MS = 600_000; +export const NPM_TARBALL_POLL_INTERVAL_MS = 15_000; + function redactGitOutput(value, token) { const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@'); return token.length >= 8 ? redacted.replaceAll(token, '***') : redacted; } +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +export async function downloadNpmTarball(options) { + const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options; + const deadline = now() + budgetMs; + let attempts = 0; + let lastError; + + for (;;) { + attempts += 1; + try { + const response = await fetchImpl(url); + if (response.status === 200) { + const tarball = Buffer.from(await response.arrayBuffer()); + if (tarball.length > 0) return { tarball, attempts }; + lastError = new Error('Failed to download npm tarball: empty body'); + } else { + lastError = new Error(`Failed to download npm tarball: HTTP ${response.status}`); + } + } catch (error) { + lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error }); + } + + const message = lastError?.message ?? 'Failed to download npm tarball'; + const remainingMs = deadline - now(); + if (remainingMs <= 0) { + throw new Error(`${message} after ${attempts} attempt(s)`); + } + const waitMs = remainingMs < intervalMs ? remainingMs : intervalMs; + log(`${message} (attempt ${attempts}); retrying in ${waitMs / 1000}s`); + await sleep(waitMs); + } +} + async function main() { const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8')); const version = packageJson.version; const tarballUrl = `https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${version}.tgz`; - const response = await fetch(tarballUrl); - if (response.status !== 200) throw new Error(`Failed to download npm tarball: HTTP ${response.status}`); - - const tarball = Buffer.from(await response.arrayBuffer()); - if (tarball.length === 0) throw new Error('Failed to download npm tarball: empty body'); + const { tarball } = await downloadNpmTarball({ + url: tarballUrl, + fetchImpl: (url) => fetch(url, { signal: AbortSignal.timeout(15_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + now: () => Date.now(), + budgetMs: NPM_TARBALL_POLL_BUDGET_MS, + intervalMs: NPM_TARBALL_POLL_INTERVAL_MS, + }); const sha256 = createHash('sha256').update(tarball).digest('hex'); const token = process.env.TAP_GITHUB_TOKEN; @@ -82,7 +134,9 @@ async function main() { } } -main().catch((error) => { - console.error(error.message); - process.exitCode = 1; -}); +if (process.argv[1] === import.meta.filename) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs new file mode 100644 index 000000000..cf8bd7485 --- /dev/null +++ b/scripts/release/update-brew-formula.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { downloadNpmTarball } from './update-brew-formula.mjs'; + +const TARBALL_URL = 'https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-2.0.0.tgz'; +const BODY = Buffer.from('pythinker-tarball'); + +function response(status, body = BODY) { + return new Response(body, { status }); +} + +function pollClock() { + let now = 0; + const sleeps = []; + const sleep = async (ms) => { + sleeps.push(ms); + now += ms; + }; + return { + now: () => now, + sleeps, + sleep, + }; +} + +function pollOptions(clock, fetchImpl) { + return { + url: TARBALL_URL, + fetchImpl, + sleep: (ms) => clock.sleep(ms), + now: () => clock.now(), + log: () => {}, + budgetMs: 45_000, + intervalMs: 15_000, + }; +} + +void test('returns the tarball when the first fetch is HTTP 200', async () => { + const clock = pollClock(); + let fetches = 0; + const result = await downloadNpmTarball( + pollOptions(clock, async () => { + fetches += 1; + return response(200); + }), + ); + + assert.equal(fetches, 1); + assert.equal(result.attempts, 1); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, []); +}); + +void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => { + const clock = pollClock(); + const statuses = [404, 404, 200]; + const result = await downloadNpmTarball( + pollOptions(clock, async () => response(statuses.shift() ?? 500)), + ); + + assert.equal(result.attempts, 3); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000, 15_000]); +}); + +void test('retries an empty 200 body until a non-empty tarball arrives', async () => { + const clock = pollClock(); + const bodies = [Buffer.alloc(0), BODY]; + const result = await downloadNpmTarball( + pollOptions(clock, async () => response(200, bodies.shift() ?? BODY)), + ); + + assert.equal(result.attempts, 2); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000]); +}); + +void test('throws after the budget when every fetch is HTTP 404', async () => { + const clock = pollClock(); + let fetches = 0; + await assert.rejects( + () => + downloadNpmTarball( + pollOptions(clock, async () => { + fetches += 1; + return response(404); + }), + ), + { message: 'Failed to download npm tarball: HTTP 404 after 4 attempt(s)' }, + ); + assert.equal(fetches, 4); + assert.deepEqual(clock.sleeps, [15_000, 15_000, 15_000]); +}); + +void test('sleeps the leftover budget then fetches again before giving up', async () => { + const clock = pollClock(); + const statuses = [404, 404, 404, 200]; + const result = await downloadNpmTarball({ + ...pollOptions(clock, async () => response(statuses.shift() ?? 500)), + budgetMs: 40_000, + }); + assert.equal(result.attempts, 4); + assert.deepEqual(result.tarball, BODY); + assert.deepEqual(clock.sleeps, [15_000, 15_000, 10_000]); +});