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..c5cf17e6e --- /dev/null +++ b/.changeset/brew-native-formula.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Homebrew now installs the native `pythinker` binary on macOS and Linux, without Node.js. 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/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 ef1c2af3c..a040ed444 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,91 @@ 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'); + + // 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); + 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: Math.max(0, deadline - Date.now()), + 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 +173,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 +184,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, + ); +});