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:
parent
c9c1683838
commit
8ab7d8a110
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in New Issue