diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 325ab73a8..a41f464f8 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -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." diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index ebe544436..bc3c5a8af 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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(" • (?[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 diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs index db7c95676..b62f28d1c 100644 --- a/config/scripts/adhoc-build-version.mjs +++ b/config/scripts/adhoc-build-version.mjs @@ -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` diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs new file mode 100644 index 000000000..62a28c6a3 --- /dev/null +++ b/config/scripts/dev-channel-base-version.mjs @@ -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) +} diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs new file mode 100644 index 000000000..d2631eff0 --- /dev/null +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -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([]) + }) +}) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index f4545af08..12257c2b4 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -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`