Skip to content

Commit 769f493

Browse files
committed
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
1 parent 5dc72ec commit 769f493

7 files changed

Lines changed: 308 additions & 83 deletions

File tree

‎.agents/skills/release/SKILL.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ workspace, set its `private` and changesets policy explicitly and update `flake.
4343
| `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance |
4444
| `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` |
4545
| `Redeploy CDN` + verify | native assets published | Webhook may retry; verification is the hard gate |
46-
| `Update Homebrew tap` | CLI was published | App token scoped to `homebrew-tap` contents |
46+
| `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 |
47+
| `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 |
4748
| `Release lane summary` | always | One table with provenance state; fails when an expected enabled lane failed or skipped |
4849

4950
Set `RELEASE_LANE_DESKTOP`, `RELEASE_LANE_VSCODE`, `RELEASE_LANE_CDN`, or
@@ -70,6 +71,17 @@ otherwise errors.
7071
`beta`/`dev` tags). A mismatch means the checkout in the job predates the release commit or npm
7172
propagation lag — check `npm view @pymodel/pythinker-code dist-tags` before touching anything.
7273
Dokploy deploy specifics: see memory `cdn-dokploy-deploy-pipeline`.
74+
- **Homebrew lane red.** `Update Homebrew tap` polls each native tarball for 10 minutes, so a
75+
failure there means the release has no tarball for that target: check `Publish native release
76+
assets` first. `Verify Homebrew install` red with the bump green means the formula installs but the
77+
binary fails in a keg on that OS; reproduce with `HOMEBREW_NO_AUTOREMOVE=1 brew install
78+
pymodel/tap/pythinker-code` (plain `brew uninstall` afterwards autoremoves orphaned dependencies).
79+
A native binary under a Homebrew `Cellar/` reports install source `homebrew` and never
80+
self-updates; `brew upgrade pythinker-code` is its only update path.
81+
- **Native update 404s.** `verify-release-consistency.mjs` HEADs every URL in the CDN `latest.json`
82+
and every file the release `manifest.json` names. A red gate lists the missing assets; the
83+
updater fetches exactly those URLs from the GitHub release (`pythinkerCodeReleaseAssetUrl`). The
84+
CDN has no `/binaries/` route — it answers unknown paths with the site HTML and HTTP 200.
7385
- **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check
7486
`.nvmrc` before debugging anything else.
7587
- **Identity freeze / version rewind.** Copying another product's `CHANGELOG.md`, `package.json`

‎.changeset/brew-native-formula.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': minor
3+
---
4+
5+
Homebrew installs the native `pythinker` binary on macOS and Linux and no longer requires Node.js; a Homebrew install updates only through `brew upgrade`.

‎.github/workflows/release.yml‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,11 @@ jobs:
494494
permissions:
495495
contents: read
496496
name: Update Homebrew tap
497-
needs: release
497+
# The formula installs the native tarballs, so it can only point at them
498+
# once publish-native-assets has put them on the release.
499+
needs:
500+
- release
501+
- publish-native-assets
498502
if: >-
499503
needs.release.outputs.pythinker_native_release == 'true'
500504
&& vars.RELEASE_LANE_BREW != 'disabled'
@@ -526,6 +530,51 @@ jobs:
526530
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
527531
run: node scripts/release/update-brew-formula.mjs
528532

533+
# Installs the bumped formula from the public tap on a real Homebrew, the
534+
# way users get it, and checks the binary reports the released version.
535+
# Homebrew relocates and may re-sign what it installs, so this is the only
536+
# proof the native binary survives a keg on each OS.
537+
verify-brew-install:
538+
timeout-minutes: 20
539+
name: Verify Homebrew install (${{ matrix.os }})
540+
needs:
541+
- update-brew-tap
542+
permissions:
543+
contents: read
544+
strategy:
545+
fail-fast: false
546+
matrix:
547+
os: [macos-latest, ubuntu-latest]
548+
runs-on: ${{ matrix.os }}
549+
steps:
550+
- name: Checkout
551+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
552+
with:
553+
persist-credentials: false
554+
sparse-checkout: apps/pythinker-code/package.json
555+
sparse-checkout-cone-mode: false
556+
557+
- name: Install pythinker-code from PyModel/tap
558+
shell: bash
559+
env:
560+
HOMEBREW_NO_AUTO_UPDATE: '1'
561+
HOMEBREW_NO_INSTALL_CLEANUP: '1'
562+
run: |
563+
set -euo pipefail
564+
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
565+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
566+
fi
567+
expected="$(jq -r .version apps/pythinker-code/package.json)"
568+
brew tap pymodel/tap
569+
brew install --formula pymodel/tap/pythinker-code
570+
brew test pymodel/tap/pythinker-code
571+
actual="$("$(brew --prefix)/bin/pythinker" --version)"
572+
if [ "$actual" != "$expected" ]; then
573+
echo "::error::Homebrew installed pythinker $actual, expected $expected."
574+
exit 1
575+
fi
576+
echo "Homebrew installs pythinker $actual on ${{ matrix.os }}."
577+
529578
deploy-docs:
530579
name: Deploy docs
531580
needs: release
@@ -663,6 +712,7 @@ jobs:
663712
- redeploy-cdn
664713
- verify-cdn-release
665714
- update-brew-tap
715+
- verify-brew-install
666716
runs-on: ubuntu-latest
667717
permissions:
668718
contents: read
@@ -687,7 +737,7 @@ jobs:
687737
CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }}
688738
CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }}
689739
BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }}
690-
BREW_RESULT: ${{ needs.update-brew-tap.result }}
740+
BREW_RESULT: ${{ needs.verify-brew-install.result }}
691741
DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }}
692742
DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }}
693743
DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }}

‎apps/pythinker-code/src/cli/update/source.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ function loadSeaModule(): NodeSeaModule | null {
2626
return cachedSea;
2727
}
2828

29-
/** Runtime SEA detection — true when running as a packaged native binary. */
30-
export function detectNativeInstall(): boolean {
29+
function isSeaBinary(): boolean {
3130
const sea = loadSeaModule();
3231
if (sea === null) return false;
3332
try {
@@ -37,6 +36,19 @@ export function detectNativeInstall(): boolean {
3736
}
3837
}
3938

39+
/**
40+
* True for a self-updating native install: a packaged native binary that no
41+
* package manager owns. A native binary Homebrew installed lives in its
42+
* Cellar; staging or swapping it there would desync Homebrew's records, so
43+
* `brew upgrade` stays its only update path.
44+
*/
45+
export function detectNativeInstall(
46+
execPath: string = process.execPath,
47+
isSea: () => boolean = isSeaBinary,
48+
): boolean {
49+
return isSea() && classifyByPathHeuristic(execPath) !== 'homebrew';
50+
}
51+
4052
// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
4153
const PNPM_PATH_SEGMENT = 'pnpm/global/';
4254
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
@@ -70,6 +82,7 @@ export interface DetectInstallSourceDeps {
7082
readonly getPackageRoot: () => string;
7183
readonly getGlobalPrefix: () => Promise<string>;
7284
readonly detectNative: () => boolean;
85+
readonly execPath: string;
7386
readonly platform: NodeJS.Platform;
7487
}
7588

@@ -153,11 +166,14 @@ export async function detectInstallSource(
153166
getGlobalPrefix:
154167
deps.getGlobalPrefix ??
155168
(() => npmGlobalPrefix(platform)),
156-
detectNative: deps.detectNative ?? detectNativeInstall,
169+
detectNative: deps.detectNative ?? isSeaBinary,
170+
execPath: deps.execPath ?? process.execPath,
157171
platform,
158172
};
159173

160-
if (resolved.detectNative()) return 'native';
174+
if (resolved.detectNative()) {
175+
return classifyByPathHeuristic(resolved.execPath) === 'homebrew' ? 'homebrew' : 'native';
176+
}
161177

162178
const packageRoot = resolved.getPackageRoot();
163179
const heuristic = classifyByPathHeuristic(packageRoot);

‎apps/pythinker-code/test/cli/update/source.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
classifyByPathHeuristic,
55
classifyInstallSource,
66
detectInstallSource,
7+
detectNativeInstall,
78
} from '#/cli/update/source';
89
import { resolveCommandPath } from '#/utils/process/resolve-command';
910

@@ -158,6 +159,18 @@ describe('detectInstallSource', () => {
158159
).resolves.toBe('native');
159160
});
160161

162+
it('returns homebrew for a native binary that Homebrew installed in its Cellar', async () => {
163+
await expect(
164+
detectInstallSource({
165+
getPackageRoot: () => '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin',
166+
getGlobalPrefix: async () => '/opt/homebrew',
167+
detectNative: () => true,
168+
execPath: '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker',
169+
platform: 'darwin',
170+
}),
171+
).resolves.toBe('homebrew');
172+
});
173+
161174
it('returns unsupported when nothing matches', async () => {
162175
await expect(
163176
detectInstallSource({
@@ -197,3 +210,20 @@ describe('detectInstallSource', () => {
197210
expect(resolveCommandPath).toHaveBeenCalledWith('npm');
198211
});
199212
});
213+
214+
describe('detectNativeInstall', () => {
215+
it('is true for a native binary outside any package manager', () => {
216+
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => true)).toBe(true);
217+
});
218+
219+
it('is false for a native binary that Homebrew owns, so it never stages or swaps itself', () => {
220+
expect(detectNativeInstall('/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true)).toBe(false);
221+
expect(
222+
detectNativeInstall('/home/linuxbrew/.linuxbrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true),
223+
).toBe(false);
224+
});
225+
226+
it('is false when the process is not a native binary', () => {
227+
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => false)).toBe(false);
228+
});
229+
});

0 commit comments

Comments
 (0)