fix(updater): base dev builds on published tags, not main's package.json (#12376)

main's version only moves on `release:` commits, and stable patches are cut
from release branches that never merge back. On 2026-08-03 main read
1.4.165-rc.0 for twenty hours while 1.4.165, 1.4.166 and 1.4.167 all shipped,
so every hourly built in that window was stamped 1.4.165-hourly.* while
carrying code newer than 1.4.167 — and sorted below the stable its user was
already running.

Resolve the base from the main repo's published tags instead, taking the patch
above the highest shipped stable. package.json stays a floor for the case where
main leads the tags.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-08-03 18:02:45 -07:00 committed by GitHub
parent c9c1683838
commit 8ab7d8a110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 183 additions and 14 deletions

View File

@ -146,17 +146,26 @@ jobs:
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."

View File

@ -179,6 +179,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
@ -195,7 +196,19 @@ jobs:
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" \
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A

View File

@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createAdhocBuildVersion(base, now)
return {
commit,
version,
@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
const identity = getAdhocBuildIdentity(
new Date(),
process.env.ORCA_ADHOC_LABEL ?? '',
readPublishedVersionsFromEnv()
)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`

View File

@ -0,0 +1,66 @@
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
function parseVersionTriple(value) {
const match = SEMVER.exec(String(value ?? '').trim())
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null
}
}
function compareTriples(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
}
/**
* The base `X.Y.Z` an hourly or adhoc build should carry.
*
* Why not package.json alone: main's version only moves on `release:` commits, and
* stable patches are cut from release branches that never merge back. On
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
if (!fromPackage) {
throw new Error(`Package version is not valid semver: ${packageVersion}`)
}
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
// that predate the current scheme, and one of them must not fail every build.
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
let base = fromPackage
if (published.length > 0) {
const highest = published.reduce((best, entry) =>
compareTriples(entry, best) > 0 ? entry : best
)
// A shipped stable owns its number, so the next dev build belongs on the patch
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
// the version being worked toward, which is exactly what main is building.
const shipped = published.some(
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
)
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
if (compareTriples(next, base) > 0) {
base = next
}
}
return `${base.major}.${base.minor}.${base.patch}`
}
/** Tag list the workflow reads out of the main repo, newline separated. */
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
return String(value ?? '')
.split(/\s+/)
.filter(Boolean)
}

View File

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
describe('dev channel base version', () => {
it('falls back to package.json when no tags are supplied', () => {
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
})
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
// stables shipped, so hourlies claimed a version their users had already passed.
it('climbs past stables that shipped while main stood still', () => {
expect(
resolveDevChannelBaseVersion('1.4.165-rc.0', [
'v1.4.165',
'v1.4.166',
'v1.4.167',
'v1.4.165-rc.0'
])
).toBe('1.4.168')
})
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
})
// Why max and not most-recent: a hotfix on an old line published today would
// otherwise drag every subsequent hourly backwards.
it('reads the highest tag, not the last one listed', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
'1.4.168'
)
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
// Why skipped rather than fatal: the main repo carries legacy tags, and one
// unparseable entry must not fail every hourly build.
it('ignores tags it cannot parse', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
'1.4.168'
)
})
it('rejects a package version that is not semver', () => {
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
})
it('carries major and minor rollovers through the bump', () => {
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
})
it('splits an env tag list on any whitespace', () => {
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
expect(readPublishedVersionsFromEnv('')).toEqual([])
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
})
})

View File

@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-hourly.202607281400` UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@ -45,18 +49,19 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createHourlyBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createHourlyBuildVersion(base, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv())
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`