Skip to content

Commit 368314e

Browse files
fix(upgrade): resolve stable channel to latest release tag, not a stable branch (#2167)
`buddy upgrade` (default channel and `--stable`) resolved the stable channel to a git ref named `stable` and fetched `api.github.com/repos/stacksjs/stacks/tarball/stable`. No `stable` branch or tag exists — releases are cut as `vX.Y.Z` tags by release.yml — so every default upgrade 404'd. Only `--from` and `--version` worked. Resolve the stable channel to the latest published `vX.Y.Z` tag instead: - add `resolveLatestStableTag()` (git ls-remote --tags) + a pure `pickLatestStableTag()` semver picker - `resolveUpgradeContext()` takes the resolved tag (stays pure/sync); the async caller resolves it only when the run lands on stable, falling back to the installed version tag when offline - teach `getRemoteSha()` to resolve tags (refs/tags + deref) so the up-to-date short-circuit works for the tag-based stable channel Fixes #2117
1 parent 460440a commit 368314e

3 files changed

Lines changed: 166 additions & 35 deletions

File tree

storage/framework/core/actions/src/upgrade/framework-utils.ts

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@ export interface UpgradeContext {
1414
* Resolve the target channel and git ref from CLI options and persisted channel.
1515
* Priority: --version > --canary > --stable > persisted channel
1616
*
17-
* Branch model: `main` is the bleeding-edge "dump everything" branch; `stable`
18-
* is the vetted branch that PRs are merged into from `main` once main is proven.
19-
* So the `stable` channel tracks the `stable` branch (vetted) and the `canary`
20-
* channel tracks the `main` branch (bleeding edge). A pinned `--version` still
21-
* resolves to the matching `vX` tag.
17+
* Release model: `main` is the bleeding-edge "dump everything" branch, and each
18+
* release is cut as a `vX.Y.Z` git tag (see `.github/workflows/release.yml`,
19+
* which triggers on `push: tags: ['v*']`). There is deliberately no long-lived
20+
* `stable` branch. So the `canary` channel tracks the `main` branch (bleeding
21+
* edge) and the `stable` channel tracks the latest published `vX.Y.Z` tag —
22+
* passed in as `latestStableRef` because resolving it needs the network, which
23+
* this pure function stays free of. A pinned `--version` resolves directly to
24+
* the matching `vX` tag.
2225
*/
2326
export function resolveUpgradeContext(options: {
2427
version?: string
2528
canary?: boolean
2629
stable?: boolean
27-
}, currentChannel: 'stable' | 'canary'): UpgradeContext {
30+
}, currentChannel: 'stable' | 'canary', latestStableRef: string): UpgradeContext {
2831
const targetVersion = options.version
2932

3033
if (targetVersion) {
@@ -40,8 +43,8 @@ export function resolveUpgradeContext(options: {
4043
}
4144

4245
if (options.stable) {
43-
// stable tracks the vetted `stable` branch.
44-
return { channel: 'stable', ref: 'stable' }
46+
// stable tracks the latest published release tag.
47+
return { channel: 'stable', ref: latestStableRef }
4548
}
4649

4750
if (currentChannel === 'canary') {
@@ -50,7 +53,67 @@ export function resolveUpgradeContext(options: {
5053
return { channel: 'canary', ref: 'main' }
5154
}
5255

53-
return { channel: 'stable', ref: 'stable' }
56+
return { channel: 'stable', ref: latestStableRef }
57+
}
58+
59+
/** Compare two [major, minor, patch] tuples. Returns >0 if `a` is newer. */
60+
function compareVersionParts(a: [number, number, number], b: [number, number, number]): number {
61+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
62+
}
63+
64+
/**
65+
* Pick the highest stable release tag from a list of tag names. Only clean
66+
* `vMAJOR.MINOR.PATCH` tags qualify — pre-releases (`v1.0.0-beta.1`), annotated
67+
* deref entries (`…^{}`), and non-version tags are ignored. Returns null when
68+
* nothing qualifies.
69+
*/
70+
export function pickLatestStableTag(tags: string[]): string | null {
71+
const re = /^v(\d+)\.(\d+)\.(\d+)$/
72+
let best: { tag: string, parts: [number, number, number] } | null = null
73+
74+
for (const tag of tags) {
75+
const m = re.exec(tag.trim())
76+
if (!m) continue
77+
const parts: [number, number, number] = [Number(m[1]), Number(m[2]), Number(m[3])]
78+
if (!best || compareVersionParts(parts, best.parts) > 0)
79+
best = { tag: tag.trim(), parts }
80+
}
81+
82+
return best?.tag ?? null
83+
}
84+
85+
/**
86+
* Resolve the latest published stable release tag (e.g. `"v0.70.163"`) by
87+
* listing the remote's `v*` tags. The stable channel installs this tag rather
88+
* than a branch, because releases are cut as tags and no `stable` branch
89+
* exists. Returns null if the tags can't be listed (offline / no git), letting
90+
* the caller fall back to the currently-installed version.
91+
*/
92+
export async function resolveLatestStableTag(): Promise<string | null> {
93+
try {
94+
const proc = Bun.spawn({
95+
// `--refs` drops the `^{}` dereferenced annotated-tag entries, leaving
96+
// clean `refs/tags/vX.Y.Z` lines.
97+
cmd: ['git', 'ls-remote', '--tags', '--refs', 'https://github.com/stacksjs/stacks.git', 'v*'],
98+
stdout: 'pipe',
99+
stderr: 'pipe',
100+
})
101+
const out = await new Response(proc.stdout).text()
102+
if ((await proc.exited) !== 0)
103+
return null
104+
105+
const tags = out
106+
.split('\n')
107+
.map(line => line.split('\t')[1]?.replace('refs/tags/', ''))
108+
.filter((t): t is string => !!t)
109+
110+
return pickLatestStableTag(tags)
111+
}
112+
catch {
113+
// git missing / offline — fail soft; the caller falls back to the
114+
// installed version tag.
115+
return null
116+
}
54117
}
55118

56119
/**

storage/framework/core/actions/src/upgrade/framework.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
readChannel,
2121
readSyncedVersion,
2222
readVersion,
23+
resolveLatestStableTag,
2324
resolveSuccessMessage,
2425
resolveUpgradeContext,
2526
resolveUpgradeMessage,
@@ -79,11 +80,25 @@ if (!existsSync(p.projectPath('storage/framework/core'))) {
7980
const channelFile = join(projectRoot, '.stacks-channel')
8081
const versionFile = join(projectRoot, '.stacks-version')
8182
const currentChannel = readChannel(channelFile)
82-
const ctx = resolveUpgradeContext(options, currentChannel)
8383

8484
const corePkgPath = p.projectPath('storage/framework/core/buddy/package.json')
8585
const currentVersion = readVersion(corePkgPath)
8686

87+
// The stable channel installs the latest published `vX.Y.Z` tag (there is no
88+
// `stable` branch — releases are cut as tags). Only resolve it when the run
89+
// will actually land on stable: a pinned `--version` and the canary channel
90+
// don't need it, so we skip the network call. If the remote tags can't be
91+
// listed (offline / no git), fall back to the currently-installed version tag
92+
// so we never target a non-existent ref.
93+
const willResolveStable = !options.version && !options.canary && (!!options.stable || currentChannel !== 'canary')
94+
let latestStableRef = ''
95+
if (willResolveStable) {
96+
latestStableRef = (await resolveLatestStableTag())
97+
?? (currentVersion ? `v${currentVersion}` : 'main')
98+
}
99+
100+
const ctx = resolveUpgradeContext(options, currentChannel, latestStableRef)
101+
87102
// Source resolution. Explicit `--from` wins. Otherwise auto-detect a sibling
88103
// stacks checkout for instant offline updates. If neither is available,
89104
// fall back to gitit (GitHub).
@@ -530,16 +545,31 @@ async function getRemoteSha(context: UpgradeContext, fromLocal: boolean, stacksR
530545
return (await head.exited) === 0 && SHA_RE.test(h) ? h.toLowerCase() : null
531546
}
532547

533-
// GitHub: ls-remote prints "<sha>\trefs/heads/<ref>" lines.
548+
// GitHub: ls-remote prints "<sha>\t<ref>" lines. The ref can be a branch
549+
// (canary → refs/heads/main) or a tag (stable → refs/tags/vX.Y.Z), so we
550+
// ask for both. For an annotated tag, git also emits a `refs/tags/…^{}`
551+
// line carrying the dereferenced *commit* sha — prefer it so the short-
552+
// circuit compares like-for-like against the synced commit.
534553
const proc = Bun.spawn({
535-
cmd: ['git', 'ls-remote', 'https://github.com/stacksjs/stacks.git', `refs/heads/${context.ref}`],
554+
cmd: [
555+
'git',
556+
'ls-remote',
557+
'https://github.com/stacksjs/stacks.git',
558+
`refs/heads/${context.ref}`,
559+
`refs/tags/${context.ref}`,
560+
`refs/tags/${context.ref}^{}`,
561+
],
536562
stdout: 'pipe',
537563
stderr: 'pipe',
538564
})
539565
const out = await new Response(proc.stdout).text()
540566
if ((await proc.exited) !== 0)
541567
return null
542-
const m = out.match(/^([0-9a-f]{40})\s/i)
568+
569+
const lines = out.split('\n').filter(Boolean)
570+
const deref = lines.find(l => l.includes('^{}'))
571+
const chosen = deref ?? lines[0]
572+
const m = chosen?.match(/^([0-9a-f]{40})\s/i)
543573
return m?.[1] ? m[1].toLowerCase() : null
544574
}
545575
catch {

storage/framework/core/actions/tests/upgrade-framework.test.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path'
55

66
const {
77
resolveUpgradeContext,
8+
pickLatestStableTag,
89
buildTemplateString,
910
readVersion,
1011
readChannel,
@@ -36,17 +37,22 @@ afterEach(() => {
3637

3738
// ─── resolveUpgradeContext ───────────────────────────────────────────────────
3839

40+
// The resolved latest stable tag is passed into resolveUpgradeContext (the
41+
// network lookup lives in the async caller). Tests inject a fixture tag so the
42+
// pure resolution stays deterministic.
43+
const STABLE = 'v9.9.9'
44+
3945
describe('resolveUpgradeContext', () => {
4046
describe('default behavior', () => {
41-
it('should default to the stable channel + stable branch when no options and channel is stable', () => {
42-
const ctx = resolveUpgradeContext({}, 'stable')
47+
it('should default to the stable channel + latest stable tag when no options and channel is stable', () => {
48+
const ctx = resolveUpgradeContext({}, 'stable', STABLE)
4349
expect(ctx.channel).toBe('stable')
44-
expect(ctx.ref).toBe('stable')
50+
expect(ctx.ref).toBe(STABLE)
4551
expect(ctx.targetVersion).toBeUndefined()
4652
})
4753

4854
it('should stay on canary (main branch) if current channel is canary and no flags', () => {
49-
const ctx = resolveUpgradeContext({}, 'canary')
55+
const ctx = resolveUpgradeContext({}, 'canary', STABLE)
5056
expect(ctx.channel).toBe('canary')
5157
expect(ctx.ref).toBe('main')
5258
expect(ctx.targetVersion).toBeUndefined()
@@ -55,94 +61,126 @@ describe('resolveUpgradeContext', () => {
5561

5662
describe('--canary flag', () => {
5763
it('should switch to canary (main branch) when --canary is set', () => {
58-
const ctx = resolveUpgradeContext({ canary: true }, 'stable')
64+
const ctx = resolveUpgradeContext({ canary: true }, 'stable', STABLE)
5965
expect(ctx.channel).toBe('canary')
6066
expect(ctx.ref).toBe('main')
6167
})
6268

6369
it('should stay on canary (main branch) when already on canary and --canary is set', () => {
64-
const ctx = resolveUpgradeContext({ canary: true }, 'canary')
70+
const ctx = resolveUpgradeContext({ canary: true }, 'canary', STABLE)
6571
expect(ctx.channel).toBe('canary')
6672
expect(ctx.ref).toBe('main')
6773
})
6874
})
6975

7076
describe('--stable flag', () => {
71-
it('should switch to the stable branch when --stable is set from canary', () => {
72-
const ctx = resolveUpgradeContext({ stable: true }, 'canary')
77+
it('should switch to the latest stable tag when --stable is set from canary', () => {
78+
const ctx = resolveUpgradeContext({ stable: true }, 'canary', STABLE)
7379
expect(ctx.channel).toBe('stable')
74-
expect(ctx.ref).toBe('stable')
80+
expect(ctx.ref).toBe(STABLE)
7581
})
7682

77-
it('should stay on the stable branch when already stable and --stable is set', () => {
78-
const ctx = resolveUpgradeContext({ stable: true }, 'stable')
83+
it('should stay on the latest stable tag when already stable and --stable is set', () => {
84+
const ctx = resolveUpgradeContext({ stable: true }, 'stable', STABLE)
7985
expect(ctx.channel).toBe('stable')
80-
expect(ctx.ref).toBe('stable')
86+
expect(ctx.ref).toBe(STABLE)
8187
})
8288
})
8389

8490
describe('--version flag', () => {
8591
it('should use version tag without v prefix', () => {
86-
const ctx = resolveUpgradeContext({ version: '0.70.23' }, 'stable')
92+
const ctx = resolveUpgradeContext({ version: '0.70.23' }, 'stable', STABLE)
8793
expect(ctx.channel).toBe('stable')
8894
expect(ctx.ref).toBe('v0.70.23')
8995
expect(ctx.targetVersion).toBe('0.70.23')
9096
})
9197

9298
it('should use version tag with v prefix as-is', () => {
93-
const ctx = resolveUpgradeContext({ version: 'v0.70.23' }, 'stable')
99+
const ctx = resolveUpgradeContext({ version: 'v0.70.23' }, 'stable', STABLE)
94100
expect(ctx.channel).toBe('stable')
95101
expect(ctx.ref).toBe('v0.70.23')
96102
expect(ctx.targetVersion).toBe('v0.70.23')
97103
})
98104

99105
it('should take priority over --canary', () => {
100-
const ctx = resolveUpgradeContext({ version: '0.70.23', canary: true }, 'stable')
106+
const ctx = resolveUpgradeContext({ version: '0.70.23', canary: true }, 'stable', STABLE)
101107
expect(ctx.channel).toBe('stable')
102108
expect(ctx.ref).toBe('v0.70.23')
103109
expect(ctx.targetVersion).toBe('0.70.23')
104110
})
105111

106112
it('should take priority over --stable', () => {
107-
const ctx = resolveUpgradeContext({ version: '0.70.23', stable: true }, 'canary')
113+
const ctx = resolveUpgradeContext({ version: '0.70.23', stable: true }, 'canary', STABLE)
108114
expect(ctx.channel).toBe('stable')
109115
expect(ctx.ref).toBe('v0.70.23')
110116
})
111117

112118
it('should take priority over persisted canary channel', () => {
113-
const ctx = resolveUpgradeContext({ version: '0.70.23' }, 'canary')
119+
const ctx = resolveUpgradeContext({ version: '0.70.23' }, 'canary', STABLE)
114120
expect(ctx.channel).toBe('stable')
115121
expect(ctx.ref).toBe('v0.70.23')
116122
})
117123

118124
it('should handle semver-only strings', () => {
119-
const ctx = resolveUpgradeContext({ version: '1.0.0' }, 'stable')
125+
const ctx = resolveUpgradeContext({ version: '1.0.0' }, 'stable', STABLE)
120126
expect(ctx.ref).toBe('v1.0.0')
121127
})
122128

123129
it('should handle pre-release version strings', () => {
124-
const ctx = resolveUpgradeContext({ version: '1.0.0-beta.1' }, 'stable')
130+
const ctx = resolveUpgradeContext({ version: '1.0.0-beta.1' }, 'stable', STABLE)
125131
expect(ctx.ref).toBe('v1.0.0-beta.1')
126132
})
127133
})
128134

129135
describe('flag priority', () => {
130136
it('--version beats --canary beats --stable beats persisted channel', () => {
131137
// version > canary
132-
const v = resolveUpgradeContext({ version: '1.0.0', canary: true, stable: true }, 'canary')
138+
const v = resolveUpgradeContext({ version: '1.0.0', canary: true, stable: true }, 'canary', STABLE)
133139
expect(v.ref).toBe('v1.0.0')
134140

135141
// canary > stable (when no version)
136-
const c = resolveUpgradeContext({ canary: true, stable: true }, 'stable')
142+
const c = resolveUpgradeContext({ canary: true, stable: true }, 'stable', STABLE)
137143
expect(c.channel).toBe('canary')
138144

139145
// stable > persisted canary (when no version/canary)
140-
const s = resolveUpgradeContext({ stable: true }, 'canary')
146+
const s = resolveUpgradeContext({ stable: true }, 'canary', STABLE)
141147
expect(s.channel).toBe('stable')
148+
expect(s.ref).toBe(STABLE)
142149
})
143150
})
144151
})
145152

153+
// ─── pickLatestStableTag ─────────────────────────────────────────────────────
154+
155+
describe('pickLatestStableTag', () => {
156+
it('picks the highest vX.Y.Z tag by semver, not lexically', () => {
157+
expect(pickLatestStableTag(['v0.70.9', 'v0.70.162', 'v0.70.52', 'v0.9.99'])).toBe('v0.70.162')
158+
})
159+
160+
it('compares major/minor/patch numerically', () => {
161+
expect(pickLatestStableTag(['v1.0.0', 'v0.99.99', 'v1.2.0', 'v1.10.0'])).toBe('v1.10.0')
162+
})
163+
164+
it('ignores pre-releases, deref entries, and non-version tags', () => {
165+
expect(pickLatestStableTag([
166+
'v0.70.163-beta.1',
167+
'v0.70.163^{}',
168+
'latest',
169+
'browser-extension-v2.0.0',
170+
'v0.70.162',
171+
])).toBe('v0.70.162')
172+
})
173+
174+
it('trims whitespace around tags', () => {
175+
expect(pickLatestStableTag([' v0.70.10 ', 'v0.70.2'])).toBe('v0.70.10')
176+
})
177+
178+
it('returns null when no clean version tag qualifies', () => {
179+
expect(pickLatestStableTag(['latest', 'v1.0.0-rc.1', ''])).toBeNull()
180+
expect(pickLatestStableTag([])).toBeNull()
181+
})
182+
})
183+
146184
describe('local framework source selection', () => {
147185
it('does not auto-detect a checkout for an exact version pin', () => {
148186
expect(shouldAutoDetectLocalStacks({ version: '0.70.104' })).toBe(false)

0 commit comments

Comments
 (0)