From 1ba98015745c8db70ebd56ba10da9d6e8fe263c7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:33:04 -0700 Subject: [PATCH] fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): stop hourly versions dropping below a tagged or already-shipped build Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When v1.4.202 was tagged and then its GitHub release vanished, the next hourlies shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly builds already installed, so electron-updater stopped offering updates. Read main's v* tags and already-published channel tags instead. * docs(ci): record that 1.4.202's release was unpublished for a bug The leftover tag is what hourly must still honor; this was not a failed cut. --- .github/workflows/adhoc-mac-build.yml | 13 +++++--- .github/workflows/daily-mac-build.yml | 22 +++++++------ .github/workflows/hourly-mac-build.yml | 32 ++++++++++++------- config/scripts/dev-channel-base-version.mjs | 7 ++-- .../scripts/dev-channel-base-version.test.mjs | 21 ++++++++++++ config/scripts/hourly-build-version.test.mjs | 24 ++++++++++++++ .../workflow-ref-mirror-case-safety.test.mjs | 19 +++++++++++ 7 files changed, 110 insertions(+), 28 deletions(-) diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 53501add289..4f667bf2778 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -235,11 +235,14 @@ jobs: fi done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - # 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)" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag, which still owns that number. + # Releases-only let adhoc sit on a number already taken, so the updater + # would not install it. Empty on failure — the script then falls back + # to package.json. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index a758d8db3a1..41b87526fea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,7 @@ jobs: uses: actions/checkout@v6 with: ref: main - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. @@ -209,17 +209,21 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # 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 + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. That dragged hourlies backwards so + # electron-updater stopped offering them; dailies would do the same. A # separate token because GH_TOKEN above is the App's, scoped to the # daily 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")" + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor so unpublishing a + # buggy main release cannot drag this series below a daily already out. + channel_tags="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ node config/scripts/daily-build-version.mjs \ >"$RUNNER_TEMP/daily-identity.txt" diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index b4bdd803a6a..1aed485a666 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. @@ -213,17 +213,25 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # 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")" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's + # release for a bug; hourlies had already climbed to 1.4.203, then + # `gh release list` fell back to v1.4.201 and the next hourlies shipped + # as 1.4.202-hourly — which electron-updater will not install over + # 1.4.203-hourly or over the still-tagged 1.4.202. 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. + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor: even if main's tag + # list is empty this run, a 1.4.203-hourly already out must not be + # followed by a 1.4.202-hourly. + channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs index 62a28c6a374..074af9ec3d9 100644 --- a/config/scripts/dev-channel-base-version.mjs +++ b/config/scripts/dev-channel-base-version.mjs @@ -25,8 +25,11 @@ function compareTriples(a, b) { * 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. + * already running. Git tags (not GitHub releases) are the honest answer to "what + * number is taken": unpublishing a buggy cut deletes the GitHub release and + * leaves the tag, which still owns that number. Channel tags (`1.4.203-hourly.*`) + * are a second floor so that unpublish cannot drag the series backwards. + * package.json is a floor, not a source of truth. */ export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { const fromPackage = parseVersionTriple(packageVersion) diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs index d2631eff0e9..00c13f6817a 100644 --- a/config/scripts/dev-channel-base-version.test.mjs +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -39,6 +39,27 @@ describe('dev channel base version', () => { ) }) + // Why tags rather than GitHub releases: unpublishing a buggy cut deletes the + // GitHub release and leaves the tag. Releases-only then treated 1.4.202 as + // free, so hourlies sat on 1.4.202-hourly and sorted below that tagged stable. + it('climbs past a tagged stable that has no GitHub release', () => { + expect(resolveDevChannelBaseVersion('1.4.197', ['v1.4.201', 'v1.4.202'])).toBe('1.4.203') + }) + + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had already shipped as 1.4.203. Without the channel tags as a floor, the + // next hourlies would have been 1.4.202-hourly, which electron-updater will + // not install over 1.4.203-hourly. + it('does not drop below an already-published channel version', () => { + expect( + resolveDevChannelBaseVersion('1.4.197', [ + 'v1.4.201', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ]) + ).toBe('1.4.203') + }) + 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') }) diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 08fc9d28b81..7438b16acbd 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createHourlyBuildVersion, formatHourlyReleaseName, + getHourlyBuildIdentity, nextHourlyBuildNumber } from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' @@ -120,3 +121,26 @@ describe('nextHourlyBuildNumber', () => { expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) }) }) + +describe('getHourlyBuildIdentity', () => { + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had climbed to 1.4.203. Passing the leftover tag and the already-shipped + // hourly keeps the next build on 1.4.203 so electron-updater will still + // install it. + it('stays on the already-shipped hourly base after a buggy main release is unpublished', () => { + const identity = getHourlyBuildIdentity(new Date('2026-09-14T20:00:00Z'), { + publishedVersions: [ + 'v1.4.201', + 'v1.4.202', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ], + releaseNames: [ + '1.4.202 • 14 • Sep 14, 12:12PM • 875b86d', + '1.4.203 • 04 • Sep 13, 9:17PM • 2ce252f' + ] + }) + expect(identity.version).toBe('1.4.203-hourly.202609142000') + expect(identity.buildNumber).toBe(5) + }) +}) diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 497a58653a0..8ebb2fced9d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -30,6 +30,25 @@ describe('ref-mirroring vet steps', () => { ).toBe(true) }) + // Why matching-refs rather than `gh release list` on the main repo: a tagged + // stable still owns its number after its GitHub release is unpublished for a + // bug, and that unpublish must not drag the channel backwards. + it.each(['daily', 'hourly', 'adhoc'])( + '%s versions from git tags, not main GitHub releases', + (channel) => { + const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ].steps.find((candidate) => candidate.name === `Compute ${channel} version`) + expect(step.run).toContain('git/matching-refs/tags/v') + expect(step.run).not.toMatch( + /gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/ + ) + if (channel !== 'adhoc') { + expect(step.run).toContain('channel_tags=') + } + } + ) + it('retains release-cut history for version reservation and retry ancestry', () => { const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( (step) => step.uses === 'actions/checkout@v6'