From 9dc15d016c4c2eb2f3c492562395a02cd0841228 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:16:58 -0400 Subject: [PATCH 1/6] fix(update): ship and fetch native binaries from the GitHub release The release manifest and the CDN latest.json name a bare binary per platform, but the release only uploaded zip/zst/tar.gz, so every native update from 2.1.0 hit HTTP 404. Clients since #323 also fetched from a CDN /binaries/ route that only serves the site HTML. - produce-manifest uploads the bare binary + sha256 sidecar it names - client resolves manifest and binaries on the GitHub release again - release gate HEADs every advertised download, not only the version - brew bump drops the one-shot npm view that raced publish propagation (red X on the 2.4.0 and 2.4.1 release runs); the tarball poll gates it --- .changeset/native-update-release-assets.md | 5 ++ .../scripts/native/produce-manifest.mjs | 12 ++- .../src/cli/update/native-manifest.ts | 8 +- .../src/cli/update/native-stage.ts | 9 +- apps/pythinker-code/src/constant/app.ts | 14 +-- .../test/cli/update/native-manifest.test.ts | 10 +-- .../scripts/native/release-artifacts.test.ts | 8 ++ scripts/release/cdn-consistency.mjs | 44 ++++++++++ scripts/release/cdn-consistency.test.mjs | 88 +++++++++++++++++++ scripts/release/update-brew-formula.mjs | 24 +---- scripts/release/update-brew-formula.test.mjs | 27 +----- .../release/verify-release-consistency.mjs | 39 +++++++- 12 files changed, 219 insertions(+), 69 deletions(-) create mode 100644 .changeset/native-update-release-assets.md create mode 100644 scripts/release/cdn-consistency.test.mjs diff --git a/.changeset/native-update-release-assets.md b/.changeset/native-update-release-assets.md new file mode 100644 index 000000000..462fdb753 --- /dev/null +++ b/.changeset/native-update-release-assets.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +Native `pythinker update` downloads the new binary from the GitHub release again, instead of a CDN path that does not exist. diff --git a/apps/pythinker-code/scripts/native/produce-manifest.mjs b/apps/pythinker-code/scripts/native/produce-manifest.mjs index 948c350a9..0e7b1bdd0 100644 --- a/apps/pythinker-code/scripts/native/produce-manifest.mjs +++ b/apps/pythinker-code/scripts/native/produce-manifest.mjs @@ -9,6 +9,10 @@ * (produced by package.mjs across the 6 native-build matrix runners). The * zip is the only form in which binaries leave the matrix runners, so this * script extracts each bare executable and emits next to it: + * pythinker-code-[.exe] the bare binary; `filename` in the + * manifest and `url` in the CDN latest.json + * both name it, and updaters without zstd + * support download it * pythinker-code-.zst zstd -19, consumed by the staged updater * pythinker-code-.tar.gz consumed by install.sh / install.ps1 * .sha256 sidecars in ` ` format @@ -23,7 +27,7 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -71,8 +75,8 @@ for (const sumFile of sumFiles.sort()) { const target = basename(sumFile, '.sha256').replace(/^pythinker-code-/, '').replace(/\.zip$/, ''); const zipName = `pythinker-code-${target}.zip`; const exeName = target.startsWith('win32') ? 'pythinker.exe' : 'pythinker'; - // The CDN bare-binary layout carries the .exe suffix on Windows - // (src/constant/app.ts); the updater's fallback downloads this filename. + // Windows keeps the .exe suffix. Every file the manifest names is uploaded + // to the release, so the updater can always fetch it. const binaryName = target.startsWith('win32') ? `pythinker-code-${target}.exe` : `pythinker-code-${target}`; const artifactBase = `pythinker-code-${target}`; const zstName = `${artifactBase}.zst`; @@ -83,6 +87,8 @@ for (const sumFile of sumFiles.sort()) { await run('unzip', ['-o', resolve(inputDir, zipName), '-d', workDir]); const exePath = join(workDir, exeName); const binaryChecksum = await sha256File(exePath); + await copyFile(exePath, resolve(inputDir, binaryName)); + await writeFile(resolve(inputDir, `${binaryName}.sha256`), `${binaryChecksum} ${binaryName}\n`); await run('zstd', ['-T0', '-19', '-q', '-f', '-o', resolve(inputDir, zstName), exePath]); await run('tar', ['-C', workDir, '-czf', resolve(inputDir, tarballName), exeName]); diff --git a/apps/pythinker-code/src/cli/update/native-manifest.ts b/apps/pythinker-code/src/cli/update/native-manifest.ts index b7911b58f..8ff442d0f 100644 --- a/apps/pythinker-code/src/cli/update/native-manifest.ts +++ b/apps/pythinker-code/src/cli/update/native-manifest.ts @@ -1,5 +1,5 @@ /** - * Per-release native artifact manifest (`/binaries//manifest.json`). + * Per-release native artifact manifest (`manifest.json` on the GitHub release). * * Published alongside the release and consumed by the install scripts; the * staged updater reuses the same file so checksums and file names have a @@ -12,7 +12,7 @@ import { valid } from 'semver'; import { z } from 'zod'; -import { pythinkerCodeCdnBinariesBase } from '#/constant/app'; +import { pythinkerCodeReleaseAssetUrl } from '#/constant/app'; const MANIFEST_FETCH_TIMEOUT_MS = 10_000; @@ -47,11 +47,11 @@ export type NativeReleaseManifest = z.infer; export type NativePlatformEntry = z.infer; export function nativeManifestUrl(version: string): string { - return `${pythinkerCodeCdnBinariesBase()}/${version}/manifest.json`; + return pythinkerCodeReleaseAssetUrl(version, 'manifest.json'); } export function nativeBinaryUrl(version: string, filename: string): string { - return `${pythinkerCodeCdnBinariesBase()}/${version}/${filename}`; + return pythinkerCodeReleaseAssetUrl(version, filename); } /** diff --git a/apps/pythinker-code/src/cli/update/native-stage.ts b/apps/pythinker-code/src/cli/update/native-stage.ts index 034a9e259..6af79a6b4 100644 --- a/apps/pythinker-code/src/cli/update/native-stage.ts +++ b/apps/pythinker-code/src/cli/update/native-stage.ts @@ -3,9 +3,10 @@ * without touching the running executable. The actual swap happens on the * next startup (see `native-swap.ts`). * - * The CDN serves the bare platform binary (e.g. `pythinker-code-win32-x64.exe`), - * whose sha256 comes from the per-release manifest over HTTPS — a staged - * binary is byte-exact what the release pipeline produced. + * The GitHub release serves the bare platform binary (e.g. + * `pythinker-code-win32-x64.exe`) and its `.zst` variant, whose sha256 comes + * from the per-release manifest over HTTPS — a staged binary is byte-exact + * what the release pipeline produced. */ import { createHash } from 'node:crypto'; @@ -440,7 +441,7 @@ export async function stageNativeUpdate( // would still be adopted here and reported as success, only for the // startup swap's claim-time re-verify to reject and discard it. Compare // the actual digest before adopting; a mismatch falls through and - // re-stages from the CDN (published under a new generation name — the + // re-stages from the release (published under a new generation name — the // damaged exe is left for the age-gated orphan cleanup). const digest = await hashFileSha256(stagedExePath(options.exePath, existing)); if (digest === existing.sha256) { diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index debe14659..500ac38d9 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -108,11 +108,15 @@ export function pythinkerCodeCdnLatestUrl(): string { export function pythinkerCodeCdnLatestJsonUrl(): string { return `${pythinkerCodeCdnBase()}/latest.json`; } -// Per-release native artifacts: `/binaries//manifest.json` + -// `/binaries//pythinker-code-[.exe]` — the bare platform binary -// (same layout install.ps1 consumes). -export function pythinkerCodeCdnBinariesBase(): string { - return `${pythinkerCodeCdnBase()}/binaries`; +// Per-release native artifacts live on the GitHub release for that version: +// `manifest.json` plus every file it names (bare binary and `.zst`). The +// release pipeline uploads them and `latest.json` points at the same URLs. +// The CDN serves no `/binaries/` route — it answers any unknown path with the +// site's HTML and a 200. +const PYTHINKER_CODE_GITHUB_RELEASES_BASE = 'https://github.com/PyModel/pythinker-code/releases/download'; +export function pythinkerCodeReleaseAssetUrl(version: string, filename: string): string { + const tag = encodeURIComponent(`${NPM_PACKAGE_NAME}@${version}`); + return `${PYTHINKER_CODE_GITHUB_RELEASES_BASE}/${tag}/${filename}`; } // The marketplace env override name lives in the shared agent-core-v2 plugin // domain (agent-gateway consumes it from there). Deep-path import: this module is diff --git a/apps/pythinker-code/test/cli/update/native-manifest.test.ts b/apps/pythinker-code/test/cli/update/native-manifest.test.ts index efb3ecbba..2e9756827 100644 --- a/apps/pythinker-code/test/cli/update/native-manifest.test.ts +++ b/apps/pythinker-code/test/cli/update/native-manifest.test.ts @@ -6,7 +6,6 @@ import { nativeManifestUrl, selectPlatformEntry, } from '#/cli/update/native-manifest'; -import { pythinkerCodeCdnBinariesBase } from '#/constant/app'; const VERSION = '0.7.0'; @@ -195,10 +194,9 @@ describe('selectPlatformEntry', () => { }); describe('url helpers', () => { - it('builds the manifest and binary URLs from the binaries base', () => { - expect(nativeManifestUrl(VERSION)).toBe(`${pythinkerCodeCdnBinariesBase()}/${VERSION}/manifest.json`); - expect(nativeBinaryUrl(VERSION, 'pythinker-code-win32-x64.zip')).toBe( - `${pythinkerCodeCdnBinariesBase()}/${VERSION}/pythinker-code-win32-x64.zip`, - ); + it('points the manifest and binaries at the GitHub release for the version', () => { + const base = `https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%40${VERSION}`; + expect(nativeManifestUrl(VERSION)).toBe(`${base}/manifest.json`); + expect(nativeBinaryUrl(VERSION, 'pythinker-code-win32-x64.exe')).toBe(`${base}/pythinker-code-win32-x64.exe`); }); }); diff --git a/apps/pythinker-code/test/scripts/native/release-artifacts.test.ts b/apps/pythinker-code/test/scripts/native/release-artifacts.test.ts index 2977c3238..dd5618461 100644 --- a/apps/pythinker-code/test/scripts/native/release-artifacts.test.ts +++ b/apps/pythinker-code/test/scripts/native/release-artifacts.test.ts @@ -97,6 +97,8 @@ describe('native release artifacts', () => { for (const name of [ `pythinker-code-${target}.zip`, `pythinker-code-${target}.zip.sha256`, + `pythinker-code-${target}`, + `pythinker-code-${target}.sha256`, `pythinker-code-${target}.zst`, `pythinker-code-${target}.zst.sha256`, `pythinker-code-${target}.tar.gz`, @@ -168,6 +170,11 @@ describe('native release artifacts', () => { }, }, }); + const bare = resolve(artifactsDir, `pythinker-code-${target}`); + expect(readFileSync(bare, 'utf-8')).toBe(binaryContent); + expect(readFileSync(`${bare}.sha256`, 'utf-8')).toBe( + `${sha256(Buffer.from(binaryContent))} pythinker-code-${target}\n`, + ); }); it('keeps the .exe suffix in Windows manifest filenames', async () => { @@ -199,6 +206,7 @@ describe('native release artifacts', () => { expect(entry.filename).toBe('pythinker-code-win32-x64.exe'); expect(entry.checksum).toBe(sha256(binaryContent)); expect(entry.compressed.filename).toBe('pythinker-code-win32-x64.zst'); + expect(await readFile(join(releaseDir, entry.filename))).toEqual(binaryContent); } finally { rmSync(releaseDir, { recursive: true, force: true }); } diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs index 5b524a664..e9e1967ab 100644 --- a/scripts/release/cdn-consistency.mjs +++ b/scripts/release/cdn-consistency.mjs @@ -114,3 +114,47 @@ export async function pollCdnUntilCaughtUp(options) { await sleep(intervalMs); } } + +/** + * Every download URL a client can be sent to for `version`: each + * `platforms[*].url` in the CDN `latest.json`, plus every file the release + * `manifest.json` names, resolved against the release asset base. + * + * A matching version string proves nothing about these: 2.3.0 through 2.4.1 + * shipped with the CDN in sync while every bare-binary URL returned 404. + */ +export function collectReleaseDownloadUrls({ latestJson, releaseManifest, releaseAssetUrl }) { + const urls = new Set(); + for (const entry of Object.values(latestJson?.platforms ?? {})) { + if (typeof entry?.url === 'string') urls.add(entry.url); + } + for (const entry of Object.values(releaseManifest?.platforms ?? {})) { + for (const name of [entry?.filename, entry?.compressed?.filename, entry?.zstd?.file]) { + if (typeof name === 'string') urls.add(releaseAssetUrl(name)); + } + } + return [...urls].sort((left, right) => left.localeCompare(right)); +} + +/** + * HEAD each URL and return the ones that do not answer 2xx. A transport error + * or 5xx is retried `attempts` times in total; a 4xx is final at once. + */ +export async function findUnreachableUrls({ fetchImpl, sleep, urls, attempts = 3, retryDelayMs = 5_000 }) { + const unreachable = []; + for (const url of urls) { + let status = 'unreachable'; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetchImpl(url, { method: 'HEAD' }); + status = response.status; + if (response.ok || (status >= 400 && status < 500)) break; + } catch (error) { + status = error instanceof Error ? error.message : 'unreachable'; + } + if (attempt < attempts) await sleep(retryDelayMs); + } + if (typeof status !== 'number' || status < 200 || status >= 300) unreachable.push({ url, status }); + } + return unreachable; +} diff --git a/scripts/release/cdn-consistency.test.mjs b/scripts/release/cdn-consistency.test.mjs new file mode 100644 index 000000000..19bd42917 --- /dev/null +++ b/scripts/release/cdn-consistency.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { collectReleaseDownloadUrls, findUnreachableUrls } from './cdn-consistency.mjs'; + +const BASE = 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%402.4.1'; +const releaseAssetUrl = (name) => `${BASE}/${name}`; + +void test('collects latest.json urls and every file the release manifest names', () => { + const urls = collectReleaseDownloadUrls({ + latestJson: { + version: '2.4.1', + platforms: { 'darwin-arm64': { url: `${BASE}/pythinker-code-darwin-arm64`, sha256: 'a' } }, + }, + releaseManifest: { + platforms: { + 'darwin-arm64': { + filename: 'pythinker-code-darwin-arm64', + compressed: { filename: 'pythinker-code-darwin-arm64.zst' }, + }, + 'win32-x64': { filename: 'pythinker-code-win32-x64.exe', zstd: { file: 'pythinker-code-win32-x64.zst' } }, + }, + }, + releaseAssetUrl, + }); + assert.deepEqual(urls, [ + `${BASE}/pythinker-code-darwin-arm64`, + `${BASE}/pythinker-code-darwin-arm64.zst`, + `${BASE}/pythinker-code-win32-x64.exe`, + `${BASE}/pythinker-code-win32-x64.zst`, + ]); +}); + +void test('collects nothing from manifests without platforms', () => { + assert.deepEqual(collectReleaseDownloadUrls({ latestJson: {}, releaseManifest: {}, releaseAssetUrl }), []); +}); + +void test('reports a 404 at once and passes a 200', async () => { + const calls = []; + const unreachable = await findUnreachableUrls({ + fetchImpl: async (url, init) => { + calls.push([url, init.method]); + return new Response(null, { status: url.endsWith('.zst') ? 200 : 404 }); + }, + sleep: async () => {}, + urls: ['a.zst', 'a'], + }); + assert.deepEqual(unreachable, [{ url: 'a', status: 404 }]); + assert.deepEqual(calls, [ + ['a.zst', 'HEAD'], + ['a', 'HEAD'], + ]); +}); + +void test('retries transport errors and 5xx, then reports the last failure', async () => { + let calls = 0; + const sleeps = []; + const unreachable = await findUnreachableUrls({ + fetchImpl: async () => { + calls += 1; + if (calls === 1) throw new Error('socket hang up'); + return new Response(null, { status: 503 }); + }, + sleep: async (ms) => { + sleeps.push(ms); + }, + urls: ['a'], + attempts: 3, + retryDelayMs: 10, + }); + assert.equal(calls, 3); + assert.deepEqual(sleeps, [10, 10]); + assert.deepEqual(unreachable, [{ url: 'a', status: 503 }]); +}); + +void test('recovers when a retry succeeds', async () => { + let calls = 0; + const unreachable = await findUnreachableUrls({ + fetchImpl: async () => { + calls += 1; + return new Response(null, { status: calls === 1 ? 502 : 200 }); + }, + sleep: async () => {}, + urls: ['a'], + }); + assert.deepEqual(unreachable, []); + assert.equal(calls, 2); +}); diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index c725cbbb3..ef1c2af3c 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -3,7 +3,10 @@ * 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). + * poll is unit-testable without a network or a real wait). The poll is also + * the only "is it on npm" gate: `npm view` lags the same way (2.4.0 and 2.4.1 + * both failed a one-shot check that ran seconds after publish), and a version + * that never appears still fails closed once the budget runs out. */ import { createHash } from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; @@ -55,28 +58,9 @@ export async function downloadNpmTarball(options) { } } -export function assertPublishedNpmVersion({ name, version, execFile = execFileSync }) { - try { - const out = execFile( - 'npm', - ['view', `${name}@${version}`, 'version', '--registry=https://registry.npmjs.org'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, - ).trim(); - if (out !== version) { - throw new Error(`npm view returned ${out}`); - } - } catch (error) { - throw new Error( - `${name}@${version} is not on npm. Identity freeze: refuse to poll a tarball that will never appear.`, - { cause: error }, - ); - } -} - async function main() { const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8')); const version = packageJson.version; - assertPublishedNpmVersion({ name: '@pymodel/pythinker-code', version }); const tarballUrl = `https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-${version}.tgz`; const { tarball } = await downloadNpmTarball({ url: tarballUrl, diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs index a5f66b023..cf8bd7485 100644 --- a/scripts/release/update-brew-formula.test.mjs +++ b/scripts/release/update-brew-formula.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { assertPublishedNpmVersion, downloadNpmTarball } from './update-brew-formula.mjs'; +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'); @@ -36,31 +36,6 @@ function pollOptions(clock, fetchImpl) { }; } -void test('assertPublishedNpmVersion fails closed when npm does not have the version', () => { - assert.throws( - () => - assertPublishedNpmVersion({ - name: '@pymodel/pythinker-code', - version: '0.43.0', - execFile: () => { - throw new Error('404 Not Found'); - }, - }), - /is not on npm/, - ); -}); - -void test('assertPublishedNpmVersion accepts a matching npm view', () => { - assert.equal( - assertPublishedNpmVersion({ - name: '@pymodel/pythinker-code', - version: '2.1.0', - execFile: () => '2.1.0\n', - }), - undefined, - ); -}); - void test('returns the tarball when the first fetch is HTTP 200', async () => { const clock = pollClock(); let fetches = 0; diff --git a/scripts/release/verify-release-consistency.mjs b/scripts/release/verify-release-consistency.mjs index 1f7368822..0f5c5ddf7 100644 --- a/scripts/release/verify-release-consistency.mjs +++ b/scripts/release/verify-release-consistency.mjs @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import { pollCdnUntilCaughtUp } from './cdn-consistency.mjs'; +import { collectReleaseDownloadUrls, findUnreachableUrls, pollCdnUntilCaughtUp } from './cdn-consistency.mjs'; const PACKAGE_NAME = '@pymodel/pythinker-code'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; @@ -134,4 +134,41 @@ console.log( `${cdnPoll.retriggers} rebuild request(s)`, ); +// Clients download what latest.json and the release manifest name, so every +// one of those URLs must resolve. A version match alone let releases ship +// whose binaries 404ed for every installed native client. +const releaseAssetUrl = (name) => + `https://github.com/PyModel/pythinker-code/releases/download/${encodeURIComponent(releaseTag)}/${name}`; +async function fetchJson(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return JSON.parse(await response.text()); +} +let latestJson; +let releaseManifest; +try { + latestJson = await fetchJson(CDN_MANIFEST_URL); + releaseManifest = await fetchJson(releaseAssetUrl('manifest.json')); +} catch (error) { + fail(`cannot read latest.json or the ${releaseTag} manifest.json: ${error.message}`); +} +const downloadUrls = collectReleaseDownloadUrls({ latestJson, releaseManifest, releaseAssetUrl }); +if (downloadUrls.length === 0) fail(`latest.json and the ${releaseTag} manifest.json name no downloads`); +const unreachable = await findUnreachableUrls({ + fetchImpl: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(15_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + urls: downloadUrls, +}); +if (unreachable.length > 0) { + fail( + `${unreachable.length} of ${downloadUrls.length} advertised download(s) do not resolve — ` + + 'native updates for those platforms fail:\n' + + unreachable.map(({ url, status }) => ` ${status} ${url}`).join('\n'), + ); +} +console.log(`All ${downloadUrls.length} advertised downloads resolve`); + console.log(`consistency OK: latest=${distTags.latest} beta=${distTags.beta ?? '-'} dev=${distTags.dev ?? '-'}`); From d6d3310a5cedff4601837947339cce26506adbe5 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:20:30 -0400 Subject: [PATCH 2/6] fix(release): retry rate-limited HEADs in the download gate --- scripts/release/cdn-consistency.mjs | 7 ++++--- scripts/release/cdn-consistency.test.mjs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs index e9e1967ab..7be0e0c01 100644 --- a/scripts/release/cdn-consistency.mjs +++ b/scripts/release/cdn-consistency.mjs @@ -137,8 +137,9 @@ export function collectReleaseDownloadUrls({ latestJson, releaseManifest, releas } /** - * HEAD each URL and return the ones that do not answer 2xx. A transport error - * or 5xx is retried `attempts` times in total; a 4xx is final at once. + * HEAD each URL and return the ones that do not answer 2xx. A transport error, + * 5xx, 403 or 429 (GitHub rate limiting) is retried `attempts` times in total; + * any other 4xx is final at once. */ export async function findUnreachableUrls({ fetchImpl, sleep, urls, attempts = 3, retryDelayMs = 5_000 }) { const unreachable = []; @@ -148,7 +149,7 @@ export async function findUnreachableUrls({ fetchImpl, sleep, urls, attempts = 3 try { const response = await fetchImpl(url, { method: 'HEAD' }); status = response.status; - if (response.ok || (status >= 400 && status < 500)) break; + if (response.ok || (status >= 400 && status < 500 && status !== 403 && status !== 429)) break; } catch (error) { status = error instanceof Error ? error.message : 'unreachable'; } diff --git a/scripts/release/cdn-consistency.test.mjs b/scripts/release/cdn-consistency.test.mjs index 19bd42917..f5ca9e0ad 100644 --- a/scripts/release/cdn-consistency.test.mjs +++ b/scripts/release/cdn-consistency.test.mjs @@ -86,3 +86,17 @@ void test('recovers when a retry succeeds', async () => { assert.deepEqual(unreachable, []); assert.equal(calls, 2); }); + +void test('retries a rate-limited 429 instead of reporting it at once', async () => { + let calls = 0; + const unreachable = await findUnreachableUrls({ + fetchImpl: async () => { + calls += 1; + return new Response(null, { status: calls === 1 ? 429 : 200 }); + }, + sleep: async () => {}, + urls: ['a'], + }); + assert.deepEqual(unreachable, []); + assert.equal(calls, 2); +}); From a4ba77c153ccd3960efc50ffbcd388090c02a1fe Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:20:39 -0400 Subject: [PATCH 3/6] docs(release): name the releases the download gate would have caught --- scripts/release/cdn-consistency.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release/cdn-consistency.mjs b/scripts/release/cdn-consistency.mjs index 7be0e0c01..6adaf8a4d 100644 --- a/scripts/release/cdn-consistency.mjs +++ b/scripts/release/cdn-consistency.mjs @@ -120,7 +120,7 @@ export async function pollCdnUntilCaughtUp(options) { * `platforms[*].url` in the CDN `latest.json`, plus every file the release * `manifest.json` names, resolved against the release asset base. * - * A matching version string proves nothing about these: 2.3.0 through 2.4.1 + * A matching version string proves nothing about these: 2.4.0 and 2.4.1 * shipped with the CDN in sync while every bare-binary URL returned 404. */ export function collectReleaseDownloadUrls({ latestJson, releaseManifest, releaseAssetUrl }) { From 5dc72ec2165972d2991bbb4354b1ea5d49554172 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:35:45 -0400 Subject: [PATCH 4/6] docs: point stuck native installs to the one-time reinstall --- .changeset/native-update-release-assets.md | 2 +- README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/native-update-release-assets.md b/.changeset/native-update-release-assets.md index 462fdb753..ccd141171 100644 --- a/.changeset/native-update-release-assets.md +++ b/.changeset/native-update-release-assets.md @@ -2,4 +2,4 @@ '@pymodel/pythinker-code': patch --- -Native `pythinker update` downloads the new binary from the GitHub release again, instead of a CDN path that does not exist. +Native `pythinker update` downloads the new binary from the GitHub release again; native installs on 2.2.0–2.4.1 need one reinstall to receive it (see #354). diff --git a/README.md b/README.md index 5a4ec66a0..e586616a1 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ The CLI ships as a native binary, so there is no Node.js prerequisite. | Nix | `nix run github:PyModel/pythinker-code` | | npm | `npm install -g @pymodel/pythinker-code` (needs Node.js 24.15+) | +> Native install on 2.2.0–2.4.1? `pythinker update` cannot download new versions there. Run the install command again once to get a later version ([#354](https://github.com/PyModel/pythinker-code/issues/354)). + ```sh cd your-project pythinker From 769f49363eeb3f804729c623240f4fdec1f07b2a Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:29:58 -0400 Subject: [PATCH 5/6] feat(release): ship the Homebrew formula from native release tarballs Port the CodexBar Homebrew channel: the formula installs the native per-platform tarball (macOS/Linux x arm64/x64) with a --version test instead of the npm tarball plus a node dependency. - update-brew-formula renders the whole formula, hashes downloaded tarballs (404 poll), pushes with rebase retry, reads it back - update-brew-tap waits for publish-native-assets - verify-brew-install installs from the public tap on macOS + Linux and is the Homebrew lane result in the release summary - a native binary under a Homebrew Cellar reports source homebrew and never stages or swaps itself - release skill documents the lane --- .agents/skills/release/SKILL.md | 14 +- .changeset/brew-native-formula.md | 5 + .github/workflows/release.yml | 54 ++++- apps/pythinker-code/src/cli/update/source.ts | 24 ++- .../test/cli/update/source.test.ts | 30 +++ scripts/release/update-brew-formula.mjs | 195 ++++++++++++------ scripts/release/update-brew-formula.test.mjs | 69 +++++-- 7 files changed, 308 insertions(+), 83 deletions(-) create mode 100644 .changeset/brew-native-formula.md diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index e0d3dd26b..a066399a0 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -43,7 +43,8 @@ workspace, set its `private` and changesets policy explicitly and update `flake. | `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance | | `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` | | `Redeploy CDN` + verify | native assets published | Webhook may retry; verification is the hard gate | -| `Update Homebrew tap` | CLI was published | App token scoped to `homebrew-tap` contents | +| `Update Homebrew tap` | native assets published | Renders `Formula/pythinker-code.rb` from the four native `.tar.gz` (macOS/Linux × arm64/x64), hashes downloaded bytes, pushes with an App token scoped to `homebrew-tap` contents, reads the formula back from the tap | +| `Verify Homebrew install` | tap updated | `brew install` + `brew test` from `pymodel/tap` on macOS and Linux; `pythinker --version` must equal the release. This is the Homebrew lane result in the summary | | `Release lane summary` | always | One table with provenance state; fails when an expected enabled lane failed or skipped | Set `RELEASE_LANE_DESKTOP`, `RELEASE_LANE_VSCODE`, `RELEASE_LANE_CDN`, or @@ -70,6 +71,17 @@ otherwise errors. `beta`/`dev` tags). A mismatch means the checkout in the job predates the release commit or npm propagation lag — check `npm view @pymodel/pythinker-code dist-tags` before touching anything. Dokploy deploy specifics: see memory `cdn-dokploy-deploy-pipeline`. +- **Homebrew lane red.** `Update Homebrew tap` polls each native tarball for 10 minutes, so a + failure there means the release has no tarball for that target: check `Publish native release + assets` first. `Verify Homebrew install` red with the bump green means the formula installs but the + binary fails in a keg on that OS; reproduce with `HOMEBREW_NO_AUTOREMOVE=1 brew install + pymodel/tap/pythinker-code` (plain `brew uninstall` afterwards autoremoves orphaned dependencies). + A native binary under a Homebrew `Cellar/` reports install source `homebrew` and never + self-updates; `brew upgrade pythinker-code` is its only update path. +- **Native update 404s.** `verify-release-consistency.mjs` HEADs every URL in the CDN `latest.json` + and every file the release `manifest.json` names. A red gate lists the missing assets; the + updater fetches exactly those URLs from the GitHub release (`pythinkerCodeReleaseAssetUrl`). The + CDN has no `/binaries/` route — it answers unknown paths with the site HTML and HTTP 200. - **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check `.nvmrc` before debugging anything else. - **Identity freeze / version rewind.** Copying another product's `CHANGELOG.md`, `package.json` diff --git a/.changeset/brew-native-formula.md b/.changeset/brew-native-formula.md new file mode 100644 index 000000000..7dbf9ecfc --- /dev/null +++ b/.changeset/brew-native-formula.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Homebrew installs the native `pythinker` binary on macOS and Linux and no longer requires Node.js; a Homebrew install updates only through `brew upgrade`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48914c699..12151cf04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -494,7 +494,11 @@ jobs: permissions: contents: read name: Update Homebrew tap - needs: release + # The formula installs the native tarballs, so it can only point at them + # once publish-native-assets has put them on the release. + needs: + - release + - publish-native-assets if: >- needs.release.outputs.pythinker_native_release == 'true' && vars.RELEASE_LANE_BREW != 'disabled' @@ -526,6 +530,51 @@ jobs: TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }} run: node scripts/release/update-brew-formula.mjs + # Installs the bumped formula from the public tap on a real Homebrew, the + # way users get it, and checks the binary reports the released version. + # Homebrew relocates and may re-sign what it installs, so this is the only + # proof the native binary survives a keg on each OS. + verify-brew-install: + timeout-minutes: 20 + name: Verify Homebrew install (${{ matrix.os }}) + needs: + - update-brew-tap + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 + with: + persist-credentials: false + sparse-checkout: apps/pythinker-code/package.json + sparse-checkout-cone-mode: false + + - name: Install pythinker-code from PyModel/tap + shell: bash + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' + run: | + set -euo pipefail + if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + fi + expected="$(jq -r .version apps/pythinker-code/package.json)" + brew tap pymodel/tap + brew install --formula pymodel/tap/pythinker-code + brew test pymodel/tap/pythinker-code + actual="$("$(brew --prefix)/bin/pythinker" --version)" + if [ "$actual" != "$expected" ]; then + echo "::error::Homebrew installed pythinker $actual, expected $expected." + exit 1 + fi + echo "Homebrew installs pythinker $actual on ${{ matrix.os }}." + deploy-docs: name: Deploy docs needs: release @@ -663,6 +712,7 @@ jobs: - redeploy-cdn - verify-cdn-release - update-brew-tap + - verify-brew-install runs-on: ubuntu-latest permissions: contents: read @@ -687,7 +737,7 @@ jobs: CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }} CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }} BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }} - BREW_RESULT: ${{ needs.update-brew-tap.result }} + BREW_RESULT: ${{ needs.verify-brew-install.result }} DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }} DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }} DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }} diff --git a/apps/pythinker-code/src/cli/update/source.ts b/apps/pythinker-code/src/cli/update/source.ts index 464e32318..fb3404ed0 100644 --- a/apps/pythinker-code/src/cli/update/source.ts +++ b/apps/pythinker-code/src/cli/update/source.ts @@ -26,8 +26,7 @@ function loadSeaModule(): NodeSeaModule | null { return cachedSea; } -/** Runtime SEA detection — true when running as a packaged native binary. */ -export function detectNativeInstall(): boolean { +function isSeaBinary(): boolean { const sea = loadSeaModule(); if (sea === null) return false; try { @@ -37,6 +36,19 @@ export function detectNativeInstall(): boolean { } } +/** + * True for a self-updating native install: a packaged native binary that no + * package manager owns. A native binary Homebrew installed lives in its + * Cellar; staging or swapping it there would desync Homebrew's records, so + * `brew upgrade` stays its only update path. + */ +export function detectNativeInstall( + execPath: string = process.execPath, + isSea: () => boolean = isSeaBinary, +): boolean { + return isSea() && classifyByPathHeuristic(execPath) !== 'homebrew'; +} + // Path heuristic markers (compared in lowercase; both forward and backward slashes accepted). const PNPM_PATH_SEGMENT = 'pnpm/global/'; const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/']; @@ -70,6 +82,7 @@ export interface DetectInstallSourceDeps { readonly getPackageRoot: () => string; readonly getGlobalPrefix: () => Promise; readonly detectNative: () => boolean; + readonly execPath: string; readonly platform: NodeJS.Platform; } @@ -153,11 +166,14 @@ export async function detectInstallSource( getGlobalPrefix: deps.getGlobalPrefix ?? (() => npmGlobalPrefix(platform)), - detectNative: deps.detectNative ?? detectNativeInstall, + detectNative: deps.detectNative ?? isSeaBinary, + execPath: deps.execPath ?? process.execPath, platform, }; - if (resolved.detectNative()) return 'native'; + if (resolved.detectNative()) { + return classifyByPathHeuristic(resolved.execPath) === 'homebrew' ? 'homebrew' : 'native'; + } const packageRoot = resolved.getPackageRoot(); const heuristic = classifyByPathHeuristic(packageRoot); diff --git a/apps/pythinker-code/test/cli/update/source.test.ts b/apps/pythinker-code/test/cli/update/source.test.ts index 881a65f4e..b2ecc8234 100644 --- a/apps/pythinker-code/test/cli/update/source.test.ts +++ b/apps/pythinker-code/test/cli/update/source.test.ts @@ -4,6 +4,7 @@ import { classifyByPathHeuristic, classifyInstallSource, detectInstallSource, + detectNativeInstall, } from '#/cli/update/source'; import { resolveCommandPath } from '#/utils/process/resolve-command'; @@ -158,6 +159,18 @@ describe('detectInstallSource', () => { ).resolves.toBe('native'); }); + it('returns homebrew for a native binary that Homebrew installed in its Cellar', async () => { + await expect( + detectInstallSource({ + getPackageRoot: () => '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin', + getGlobalPrefix: async () => '/opt/homebrew', + detectNative: () => true, + execPath: '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker', + platform: 'darwin', + }), + ).resolves.toBe('homebrew'); + }); + it('returns unsupported when nothing matches', async () => { await expect( detectInstallSource({ @@ -197,3 +210,20 @@ describe('detectInstallSource', () => { expect(resolveCommandPath).toHaveBeenCalledWith('npm'); }); }); + +describe('detectNativeInstall', () => { + it('is true for a native binary outside any package manager', () => { + expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => true)).toBe(true); + }); + + it('is false for a native binary that Homebrew owns, so it never stages or swaps itself', () => { + expect(detectNativeInstall('/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true)).toBe(false); + expect( + detectNativeInstall('/home/linuxbrew/.linuxbrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true), + ).toBe(false); + }); + + it('is false when the process is not a native binary', () => { + expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => false)).toBe(false); + }); +}); diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index ef1c2af3c..65af1bee8 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -1,12 +1,17 @@ /** - * 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). The poll is also - * the only "is it on npm" gate: `npm view` lags the same way (2.4.0 and 2.4.1 - * both failed a one-shot check that ran seconds after publish), and a version - * that never appears still fails closed once the budget runs out. + * Write Formula/pythinker-code.rb in PyModel/homebrew-tap for the released + * version. The formula installs the native per-platform tarball from the + * GitHub release (macOS and Linux, arm64 and x64), so Homebrew users need no + * Node.js and run the same binary the native installer ships. + * + * The job runs after publish-native-assets, but the release download URL can + * still lag the upload, so each tarball is polled until it is fetchable and + * hashed from the downloaded bytes (fetch, sleep, and clock are injected so + * the poll is unit-testable without a network or a real wait). A tarball that + * never appears fails the job once the budget runs out. + * + * After the push, the formula is read back from the tap's main branch and + * compared byte for byte, so a green job proves the tap serves this version. */ import { createHash } from 'node:crypto'; import { execFileSync, spawnSync } from 'node:child_process'; @@ -14,8 +19,25 @@ 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; +export const ASSET_POLL_BUDGET_MS = 600_000; +export const ASSET_POLL_INTERVAL_MS = 15_000; +const PUSH_ATTEMPTS = 3; + +const RELEASES_BASE = 'https://github.com/PyModel/pythinker-code/releases/download'; +const FORMULA_PATH = 'Formula/pythinker-code.rb'; + +/** Homebrew platform branch → native release target. */ +export const BREW_TARGETS = { + macosArm: 'darwin-arm64', + macosIntel: 'darwin-x64', + linuxArm: 'linux-arm64', + linuxIntel: 'linux-x64', +}; + +export function nativeTarballUrl(version, target) { + const tag = encodeURIComponent(`@pymodel/pythinker-code@${version}`); + return `${RELEASES_BASE}/${tag}/pythinker-code-${target}.tar.gz`; +} function redactGitOutput(value, token) { const redacted = String(value ?? '').replaceAll(/\/\/x-access-token:[^@\s]*@/gu, '//***@'); @@ -26,7 +48,7 @@ function errorMessage(error) { return error instanceof Error ? error.message : String(error); } -export async function downloadNpmTarball(options) { +export async function downloadWhenAvailable(options) { const { url, fetchImpl, sleep, now, budgetMs, intervalMs, log = console.log } = options; const deadline = now() + budgetMs; let attempts = 0; @@ -37,17 +59,17 @@ export async function downloadNpmTarball(options) { 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'); + const body = Buffer.from(await response.arrayBuffer()); + if (body.length > 0) return { body, attempts }; + lastError = new Error(`Failed to download ${url}: empty body`); } else { - lastError = new Error(`Failed to download npm tarball: HTTP ${response.status}`); + lastError = new Error(`Failed to download ${url}: HTTP ${response.status}`); } } catch (error) { - lastError = new Error(`Failed to download npm tarball: ${errorMessage(error)}`, { cause: error }); + lastError = new Error(`Failed to download ${url}: ${errorMessage(error)}`, { cause: error }); } - const message = lastError?.message ?? 'Failed to download npm tarball'; + const message = lastError?.message ?? `Failed to download ${url}`; const remainingMs = deadline - now(); if (remainingMs <= 0) { throw new Error(`${message} after ${attempts} attempt(s)`); @@ -58,50 +80,88 @@ export async function downloadNpmTarball(options) { } } +/** `assets` maps each BREW_TARGETS value to `{ url, sha256 }`. */ +export function renderFormula({ version, assets }) { + const pair = (target, indent) => { + const asset = assets[target]; + if (asset === undefined) throw new Error(`missing Homebrew asset for ${target}`); + if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`invalid sha256 for ${target}`); + return `${indent}url "${asset.url}"\n${indent}sha256 "${asset.sha256}"`; + }; + const branch = (armTarget, intelTarget) => + [ + ' if Hardware::CPU.arm?', + pair(armTarget, ' '), + ' else', + pair(intelTarget, ' '), + ' end', + ].join('\n'); + + return `class PythinkerCode < Formula + desc "Terminal-native AI engineering agent by PyModel" + homepage "https://code.pythinker.com" + version "${version}" + license "MIT" + + on_macos do +${branch(BREW_TARGETS.macosArm, BREW_TARGETS.macosIntel)} + end + + on_linux do +${branch(BREW_TARGETS.linuxArm, BREW_TARGETS.linuxIntel)} + end + + def install + bin.install "pythinker" + end + + test do + assert_equal version.to_s, shell_output("#{bin}/pythinker --version").strip + end +end +`; +} + +function git(args, options, token) { + try { + return execFileSync('git', args, { stdio: 'pipe', encoding: 'utf8', ...options }); + } catch (error) { + const stderr = redactGitOutput(error.stderr, token).trim(); + const stdout = redactGitOutput(error.stdout, token).trim(); + const message = redactGitOutput(error.message, token).trim(); + throw new Error(`git ${args[0]} failed: ${stderr || stdout || message}`, { cause: error }); + } +} + 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 { 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 assets = {}; + for (const target of Object.values(BREW_TARGETS)) { + const url = nativeTarballUrl(version, target); + const { body } = await downloadWhenAvailable({ + url, + fetchImpl: (input) => fetch(input, { signal: AbortSignal.timeout(120_000) }), + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + now: () => Date.now(), + budgetMs: ASSET_POLL_BUDGET_MS, + intervalMs: ASSET_POLL_INTERVAL_MS, + }); + assets[target] = { url, sha256: createHash('sha256').update(body).digest('hex') }; + } + const formula = renderFormula({ version, assets }); const token = process.env.TAP_GITHUB_TOKEN; if (!token) throw new Error('TAP_GITHUB_TOKEN is required'); const tapDir = mkdtempSync(join(tmpdir(), 'tap-')); try { - try { - execFileSync('git', ['clone', `https://x-access-token:${token}@github.com/PyModel/homebrew-tap.git`, tapDir], { - stdio: 'pipe', - }); - } catch (error) { - const stderr = redactGitOutput(error.stderr, token).trim(); - const stdout = redactGitOutput(error.stdout, token).trim(); - const message = redactGitOutput(error.message, token).trim(); - throw new Error(`Failed to clone Homebrew tap: ${stderr || stdout || message}`, { cause: error }); - } - - const formulaPath = join(tapDir, 'Formula/pythinker-code.rb'); - const formula = readFileSync(formulaPath, 'utf8'); - const urlPattern = /^([ \t]*)url "[^"\r\n]*"(\r?)$/gm; - const shaPattern = /^([ \t]*)sha256 "[^"\r\n]*"(\r?)$/gm; - if ([...formula.matchAll(urlPattern)].length !== 1) throw new Error('Expected exactly one formula url line'); - if ([...formula.matchAll(shaPattern)].length !== 1) throw new Error('Expected exactly one formula sha256 line'); - - const updatedFormula = formula - .replace(urlPattern, (_, indent, eol) => `${indent}url "${tarballUrl}"${eol}`) - .replace(shaPattern, (_, indent, eol) => `${indent}sha256 "${sha256}"${eol}`); - writeFileSync(formulaPath, updatedFormula); + git(['clone', `https://x-access-token:${token}@github.com/PyModel/homebrew-tap.git`, tapDir], {}, token); + writeFileSync(join(tapDir, FORMULA_PATH), formula); const diff = spawnSync('git', ['diff', '--quiet'], { cwd: tapDir, stdio: 'ignore' }); if (diff.error || (diff.status !== 0 && diff.status !== 1)) throw new Error('Failed to inspect Homebrew tap changes'); @@ -110,9 +170,8 @@ async function main() { return; } - execFileSync('git', ['add', 'Formula/pythinker-code.rb'], { cwd: tapDir, stdio: 'inherit' }); - execFileSync( - 'git', + git(['add', FORMULA_PATH], { cwd: tapDir }, token); + git( [ '-c', 'user.name=github-actions[bot]', @@ -122,16 +181,26 @@ async function main() { '-m', `pythinker-code ${version}`, ], - { cwd: tapDir, stdio: 'inherit' }, + { cwd: tapDir }, + token, ); - try { - execFileSync('git', ['push', 'origin', 'main'], { cwd: tapDir, stdio: 'pipe' }); - } catch (error) { - const stderr = redactGitOutput(error.stderr, token).trim(); - const stdout = redactGitOutput(error.stdout, token).trim(); - const message = redactGitOutput(error.message, token).trim(); - throw new Error(`Failed to push Homebrew tap: ${stderr || stdout || message}`, { cause: error }); + for (let attempt = 1; ; attempt += 1) { + try { + git(['push', 'origin', 'HEAD:main'], { cwd: tapDir }, token); + break; + } catch (error) { + if (attempt >= PUSH_ATTEMPTS) throw error; + console.log(`${errorMessage(error)}; rebasing onto the tap's main and retrying`); + git(['pull', '--rebase', 'origin', 'main'], { cwd: tapDir }, token); + } + } + + git(['fetch', 'origin', 'main'], { cwd: tapDir }, token); + const published = git(['show', `origin/main:${FORMULA_PATH}`], { cwd: tapDir }, token); + if (published !== formula) { + throw new Error(`The tap's main does not serve the ${version} formula after the push`); } + console.log(`Homebrew tap serves pythinker-code ${version}`); } finally { rmSync(tapDir, { recursive: true, force: true }); } diff --git a/scripts/release/update-brew-formula.test.mjs b/scripts/release/update-brew-formula.test.mjs index cf8bd7485..3bb4160d6 100644 --- a/scripts/release/update-brew-formula.test.mjs +++ b/scripts/release/update-brew-formula.test.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { downloadNpmTarball } from './update-brew-formula.mjs'; +import { BREW_TARGETS, downloadWhenAvailable, nativeTarballUrl, renderFormula } from './update-brew-formula.mjs'; -const TARBALL_URL = 'https://registry.npmjs.org/@pymodel/pythinker-code/-/pythinker-code-2.0.0.tgz'; +const TARBALL_URL = nativeTarballUrl('2.0.0', 'darwin-arm64'); const BODY = Buffer.from('pythinker-tarball'); function response(status, body = BODY) { @@ -39,7 +39,7 @@ function pollOptions(clock, fetchImpl) { void test('returns the tarball when the first fetch is HTTP 200', async () => { const clock = pollClock(); let fetches = 0; - const result = await downloadNpmTarball( + const result = await downloadWhenAvailable( pollOptions(clock, async () => { fetches += 1; return response(200); @@ -48,31 +48,31 @@ void test('returns the tarball when the first fetch is HTTP 200', async () => { assert.equal(fetches, 1); assert.equal(result.attempts, 1); - assert.deepEqual(result.tarball, BODY); + assert.deepEqual(result.body, BODY); assert.deepEqual(clock.sleeps, []); }); -void test('retries after HTTP 404 and returns the tarball once npm serves it', async () => { +void test('retries after HTTP 404 and returns the tarball once the release serves it', async () => { const clock = pollClock(); const statuses = [404, 404, 200]; - const result = await downloadNpmTarball( + const result = await downloadWhenAvailable( pollOptions(clock, async () => response(statuses.shift() ?? 500)), ); assert.equal(result.attempts, 3); - assert.deepEqual(result.tarball, BODY); + assert.deepEqual(result.body, 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( + const result = await downloadWhenAvailable( pollOptions(clock, async () => response(200, bodies.shift() ?? BODY)), ); assert.equal(result.attempts, 2); - assert.deepEqual(result.tarball, BODY); + assert.deepEqual(result.body, BODY); assert.deepEqual(clock.sleeps, [15_000]); }); @@ -81,13 +81,13 @@ void test('throws after the budget when every fetch is HTTP 404', async () => { let fetches = 0; await assert.rejects( () => - downloadNpmTarball( + downloadWhenAvailable( pollOptions(clock, async () => { fetches += 1; return response(404); }), ), - { message: 'Failed to download npm tarball: HTTP 404 after 4 attempt(s)' }, + { message: `Failed to download ${TARBALL_URL}: HTTP 404 after 4 attempt(s)` }, ); assert.equal(fetches, 4); assert.deepEqual(clock.sleeps, [15_000, 15_000, 15_000]); @@ -96,11 +96,54 @@ void test('throws after the budget when every fetch is HTTP 404', async () => { 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({ + const result = await downloadWhenAvailable({ ...pollOptions(clock, async () => response(statuses.shift() ?? 500)), budgetMs: 40_000, }); assert.equal(result.attempts, 4); - assert.deepEqual(result.tarball, BODY); + assert.deepEqual(result.body, BODY); assert.deepEqual(clock.sleeps, [15_000, 15_000, 10_000]); }); + +void test('nativeTarballUrl encodes the release tag', () => { + assert.equal( + nativeTarballUrl('2.5.0', 'linux-x64'), + 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%402.5.0/pythinker-code-linux-x64.tar.gz', + ); +}); + +function allAssets(version) { + const assets = {}; + for (const [index, target] of Object.values(BREW_TARGETS).entries()) { + assets[target] = { url: nativeTarballUrl(version, target), sha256: String(index).repeat(64) }; + } + return assets; +} + +void test('renderFormula pins one native tarball per macOS and Linux CPU branch', () => { + const formula = renderFormula({ version: '2.5.0', assets: allAssets('2.5.0') }); + assert.match(formula, /^ version "2\.5\.0"$/mu); + assert.doesNotMatch(formula, /depends_on "node"/u); + assert.match(formula, /bin\.install "pythinker"/u); + assert.match(formula, /assert_equal version\.to_s, shell_output\("#\{bin\}\/pythinker --version"\)\.strip/u); + const pairs = [...formula.matchAll(/url "([^"]+)"\n\s+sha256 "([a-f0-9]{64})"/gu)].map(([, url, sha]) => [url, sha]); + assert.deepEqual(pairs, [ + [nativeTarballUrl('2.5.0', 'darwin-arm64'), '0'.repeat(64)], + [nativeTarballUrl('2.5.0', 'darwin-x64'), '1'.repeat(64)], + [nativeTarballUrl('2.5.0', 'linux-arm64'), '2'.repeat(64)], + [nativeTarballUrl('2.5.0', 'linux-x64'), '3'.repeat(64)], + ]); + const macos = formula.slice(formula.indexOf('on_macos do'), formula.indexOf('on_linux do')); + assert.ok(macos.indexOf('darwin-arm64') < macos.indexOf('else')); + assert.ok(macos.indexOf('darwin-x64') > macos.indexOf('else')); +}); + +void test('renderFormula refuses a missing platform or a malformed checksum', () => { + const assets = allAssets('2.5.0'); + delete assets['linux-arm64']; + assert.throws(() => renderFormula({ version: '2.5.0', assets }), /missing Homebrew asset for linux-arm64/u); + assert.throws( + () => renderFormula({ version: '2.5.0', assets: { ...allAssets('2.5.0'), 'darwin-x64': { url: 'x', sha256: 'nope' } } }), + /invalid sha256 for darwin-x64/u, + ); +}); From 3a2d3d3b65a4307d34bd8853d5cd2e6186ffc929 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 1 Oct 2026 20:33:01 -0400 Subject: [PATCH 6/6] fix(release): share one poll budget across brew tarballs and pin the brew jobs --- .changeset/brew-native-formula.md | 2 +- scripts/release/release-workflows.test.mjs | 4 ++++ scripts/release/update-brew-formula.mjs | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/brew-native-formula.md b/.changeset/brew-native-formula.md index 7dbf9ecfc..c5cf17e6e 100644 --- a/.changeset/brew-native-formula.md +++ b/.changeset/brew-native-formula.md @@ -2,4 +2,4 @@ '@pymodel/pythinker-code': minor --- -Homebrew installs the native `pythinker` binary on macOS and Linux and no longer requires Node.js; a Homebrew install updates only through `brew upgrade`. +Homebrew now installs the native `pythinker` binary on macOS and Linux, without Node.js. diff --git a/scripts/release/release-workflows.test.mjs b/scripts/release/release-workflows.test.mjs index 8b266c536..8699b86ee 100644 --- a/scripts/release/release-workflows.test.mjs +++ b/scripts/release/release-workflows.test.mjs @@ -32,6 +32,10 @@ void test('release workflow uses full push-boundary lane signals and isolated jo 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); + const brewJob = workflow.slice(workflow.indexOf(' update-brew-tap:'), workflow.indexOf(' verify-brew-install:')); + assert.match(brewJob, /needs:\n - release\n - publish-native-assets\n/u); + assert.match(workflow, /^ verify-brew-install:/mu); + assert.match(workflow, /BREW_RESULT: \$\{\{ needs\.verify-brew-install\.result \}\}/u); }); void test('VS Code release supports isolated recovery and attests verified VSIX files', () => { diff --git a/scripts/release/update-brew-formula.mjs b/scripts/release/update-brew-formula.mjs index 65af1bee8..a040ed444 100644 --- a/scripts/release/update-brew-formula.mjs +++ b/scripts/release/update-brew-formula.mjs @@ -137,6 +137,9 @@ async function main() { const packageJson = JSON.parse(readFileSync(new URL('../../apps/pythinker-code/package.json', import.meta.url), 'utf8')); const version = packageJson.version; + // One budget for all four tarballs, so the poll always ends inside the + // job's timeout. + const deadline = Date.now() + ASSET_POLL_BUDGET_MS; const assets = {}; for (const target of Object.values(BREW_TARGETS)) { const url = nativeTarballUrl(version, target); @@ -148,7 +151,7 @@ async function main() { setTimeout(resolve, ms); }), now: () => Date.now(), - budgetMs: ASSET_POLL_BUDGET_MS, + budgetMs: Math.max(0, deadline - Date.now()), intervalMs: ASSET_POLL_INTERVAL_MS, }); assets[target] = { url, sha256: createHash('sha256').update(body).digest('hex') };