From d638188d2f2b8072232632f9d176ba836043ec08 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 12:45:32 +0200 Subject: [PATCH 1/2] ci(release): sync only the version into main at promote promote.yml rebase-merged the whole release branch into main. Every fix on it is a cherry-pick already on main, so the replay stopped applying once main had moved on: v2.1.0's sync PR (#1312) failed five times and main stayed at 2.0.0 until #1313 set the version by hand. The step now branches from main, sets the stable version with set-release-version.mjs and refreshes the Nix npm hash against main's lockfile. A commit found on the release branch alone (git cherry, version bumps aside) is named and fails the step after the version sync, so a fix made there by mistake is not silently dropped. --- .github/scripts/release-unsynced-fixes.mjs | 31 ++++++++ .../scripts/release-unsynced-fixes.test.mjs | 37 ++++++++++ .github/workflows/promote.yml | 72 +++++++++++++------ .../engineering/release-and-secrets.md | 4 +- 4 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 .github/scripts/release-unsynced-fixes.mjs create mode 100644 .github/scripts/release-unsynced-fixes.test.mjs diff --git a/.github/scripts/release-unsynced-fixes.mjs b/.github/scripts/release-unsynced-fixes.mjs new file mode 100644 index 000000000..918be6085 --- /dev/null +++ b/.github/scripts/release-unsynced-fixes.mjs @@ -0,0 +1,31 @@ +// Prints the release-branch commits that main does not have, version bumps aside. +// Reads `git cherry -v origin/main origin/release/vX.Y.Z` on stdin; prints nothing +// when main already holds every fix. +// +// promote.yml syncs only the version into main: every fix on a release branch is a +// cherry-pick of a commit already on main, so the bumps are all main lacks. A fix +// committed straight to the release branch breaks that contract, and this is what +// names it instead of leaving it behind. A cherry-pick resolved differently from its +// original also shows up here: `git cherry` matches on the patch, not the subject. + +import { readFileSync } from "node:fs"; +import { argv } from "node:process"; + +const VERSION_BUMP = /^chore\(release\): bump to /; + +/** + * @param {string} cherry Output of `git cherry -v
`. + * @returns {string[]} " " for each commit missing from main. + */ +export function unsyncedFixes(cherry) { + return cherry + .split("\n") + .filter((line) => line.startsWith("+ ")) + .map((line) => line.slice(2)) + .filter((commit) => !VERSION_BUMP.test(commit.slice(commit.indexOf(" ") + 1))); +} + +// Only run when invoked directly, so the test can import the function. +if (import.meta.filename === argv[1]) { + for (const commit of unsyncedFixes(readFileSync(0, "utf8"))) console.log(commit); +} diff --git a/.github/scripts/release-unsynced-fixes.test.mjs b/.github/scripts/release-unsynced-fixes.test.mjs new file mode 100644 index 000000000..8aaadbd93 --- /dev/null +++ b/.github/scripts/release-unsynced-fixes.test.mjs @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { unsyncedFixes } from "./release-unsynced-fixes.mjs"; + +// `git cherry -v origin/main origin/release/v2.1.0-sync` at the v2.1.0 promotion: the +// fixes were cherry-picks already on main ("-"), only the version bumps were not ("+"). +const V2_1_0 = [ + "- 421802f3f fix(export): no Full Camera trail at a cut whose chunk start rounds up", + "+ 216aed223 chore(release): bump to 2.1.0-rc.1 [skip ci]", + "- 57686f501 fix(preview): restart the playback clock at the requested time on a held frame", + "+ 36b3814f0 chore(release): bump to 2.1.0-rc.6 [skip ci]", + "+ 514105057 chore(release): bump to 2.1.0 [skip ci]", + "", +].join("\n"); + +describe("unsyncedFixes", () => { + it("finds nothing when main has every fix and lacks only the bumps", () => { + expect(unsyncedFixes(V2_1_0)).toEqual([]); + }); + + it("names a fix committed to the release branch alone", () => { + const cherry = `${V2_1_0}+ 0a1b2c3d4 fix(export): patch made on the release branch only\n`; + expect(unsyncedFixes(cherry)).toEqual([ + "0a1b2c3d4 fix(export): patch made on the release branch only", + ]); + }); + + it("keeps a commit that only mentions a bump further in its subject", () => { + const cherry = "+ 0a1b2c3d4 fix(ci): do not chore(release): bump to twice\n"; + expect(unsyncedFixes(cherry)).toEqual([ + "0a1b2c3d4 fix(ci): do not chore(release): bump to twice", + ]); + }); + + it("finds nothing in empty input", () => { + expect(unsyncedFixes("")).toEqual([]); + }); +}); diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 358ddb739..7be3cdc98 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -152,7 +152,7 @@ jobs: # Bookkeeping, not publishing: the release is already out by now. Allowed to fail # so a stuck PR never masks a successful release — the summary reports it and it # is trivially redone by hand. - - name: Merge release branch into main + - name: Sync the released version into main id: sync_main continue-on-error: true env: @@ -160,40 +160,66 @@ jobs: STABLE_VERSION: ${{ steps.version.outputs.stable_version }} run: | set -euo pipefail - # After the stable tag is published, sync main with the released snapshot via - # a rebase PR (PAT is a ruleset bypass actor so no approval is needed). + # Main gets the release's version, not its history. Every fix on the release + # branch is a cherry-pick of a commit already on main, so the version bumps are + # all main lacks. This step used to rebase-merge the whole release branch, + # replaying those cherry-picks too, and once main had moved on they no longer + # applied: v2.1.0's sync PR (#1312) failed five times and main stayed at 2.0.0 + # until #1313 did by hand what this step does now. The PR is rebase-merged via + # the PAT, a ruleset bypass actor, so no approval is needed. BRANCH="release/v${STABLE_VERSION}" - # If main already contains the release branch (clean fast-forward), there's - # nothing to merge — just make sure the branch is tracked locally. git fetch origin main "$BRANCH" - if git merge-base --is-ancestor "origin/${BRANCH}" origin/main; then - echo "origin/${BRANCH} is already an ancestor of origin/main — nothing to merge." + main_version() { + git show origin/main:package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' + } + # From here on, main's own scripts: a release branch cut before one of them + # existed would not have it. + git checkout -B "${BRANCH}-sync" origin/main + # A fix committed to the release branch alone would be left out of a + # version-only sync. Name it, sync the version anyway, and fail the step so + # the summary says main is incomplete. + UNSYNCED=$(git cherry -v origin/main "origin/${BRANCH}" | node .github/scripts/release-unsynced-fixes.mjs) + finish() { + if [[ -n "$UNSYNCED" ]]; then + echo "::error::origin/${BRANCH} has commits main lacks, and the version sync leaves them out. Cherry-pick them onto main, or check that main has them in another form:" + echo "$UNSYNCED" + exit 1 + fi exit 0 + } + if [[ "$(main_version)" == "$STABLE_VERSION" ]]; then + echo "main is already at ${STABLE_VERSION}: nothing to sync." + finish fi - # Push the release branch's commits onto main as a fresh branch and PR it. - git checkout -b "${BRANCH}-sync" "origin/${BRANCH}" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH}-sync" + node .github/scripts/set-release-version.mjs "${STABLE_VERSION}" + # The lockfile's version is part of Nix's npm hash, and the hash must match + # main's lockfile, not the release's. + bash .github/scripts/refresh-nix-npm-hash.sh + git add package.json package-lock.json nix/package.nix + git commit -m "chore(release): release v${STABLE_VERSION} into main" + # --force: a rerun commits again on top of a newer main. + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH}-sync" gh pr create \ --base main \ --head "${BRANCH}-sync" \ --title "chore(release): release v${STABLE_VERSION} into main" \ - --body "Sync main with the released snapshot (RC + cherry-picked bugfixes + version bump). Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \ + --body "Sets main to ${STABLE_VERSION}, the version just released. The fixes on ${BRANCH} are cherry-picks already on main. Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \ --repo "$GITHUB_REPOSITORY" || echo "(PR already exists — skipping)" PR_NUMBER=$(gh pr list --head "${BRANCH}-sync" --state open --json number -q '.[0].number' --repo "$GITHUB_REPOSITORY") if [[ -z "$PR_NUMBER" ]]; then # L'échec de `gh pr create` ci-dessus est converti en echo, donc on # arrive ici aussi bien quand la PR existait déjà que quand sa création # a échoué pour une autre raison. Sortir en 0 dans le second cas - # reportait sync_main en succès alors que le test d'ascendance du haut - # avait justement établi que main ne contient pas la branche, et - # l'avertissement de fin sautait. On re-teste plutôt que de supposer : - # quelqu'un a pu merger la synchro à la main entre-temps. - git fetch origin main "$BRANCH" - if git merge-base --is-ancestor "origin/${BRANCH}" origin/main; then - echo "origin/${BRANCH} is already an ancestor of origin/main — nothing to merge." - exit 0 + # reportait sync_main en succès alors que le test de version du haut + # avait justement établi que main n'est pas à jour, et l'avertissement + # de fin sautait. On re-teste plutôt que de supposer : quelqu'un a pu + # merger la synchro à la main entre-temps. + git fetch origin main + if [[ "$(main_version)" == "$STABLE_VERSION" ]]; then + echo "main is already at ${STABLE_VERSION}: nothing to sync." + finish fi - echo "::error::No open sync PR exists and main does not contain ${BRANCH}." + echo "::error::No open sync PR exists and main is not at ${STABLE_VERSION}." exit 1 fi # GitHub computes mergeability asynchronously. Merging ~3s after creating the @@ -226,6 +252,7 @@ jobs: # The release branch itself was already used to publish and contains frozen # history — leave it in place for forensics. A v1.6.0 release branch should # never be deleted until the next major cuts over. + finish - name: Workflow summary run: | @@ -239,7 +266,8 @@ jobs: echo "" echo "> [!WARNING]" echo "> **main was not synced.** The release is published and the build was" - echo "> dispatched; only the \`release/v${{ steps.version.outputs.stable_version }}-sync\` → main PR is" - echo "> outstanding. Merge it by hand, then main is back in line." + echo "> dispatched. The \`Sync the released version into main\` step's error says what is" + echo "> left: the \`release/v${{ steps.version.outputs.stable_version }}-sync\` → main PR to merge by hand," + echo "> or release-branch commits that main lacks." fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 8fef17b24..c9b0a1d00 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -32,7 +32,7 @@ Run `Promote RC to stable release` (`promote.yml`) with: - `rc_tag`: required tag matching `vX.Y.Z-(rc|beta|alpha).N`. - `release_notes_extra`: optional text prepended to the stable Discord announcement. -The workflow validates the tag, closes the version milestone, checks out `release/vX.Y.Z`, changes `package.json` to the stable version, tags that branch tip, opens and rebase-merges a release-sync PR into `main`, explicitly dispatches `build.yml` at the stable tag, and announces the stable release. The build publishes signed/notarized artifacts when Apple credentials are complete; publication with `OPENSCREEN_RELEASE_TOKEN` emits the event that starts stable Homebrew, WinGet, Nix, and AUR workflows. +The workflow validates the tag, closes the version milestone, checks out `release/vX.Y.Z`, changes `package.json` to the stable version, tags that branch tip, explicitly dispatches `build.yml` at the stable tag, then sets `main` to the stable version through a rebase-merged PR. That sync carries only the version: every fix on the release branch is a cherry-pick already on `main`. Replaying the whole branch instead stopped applying once `main` had moved on, and left `main` at 2.0.0 after v2.1.0 (#1312). A commit found on the release branch alone fails the step with its name, after the version sync. The build publishes signed/notarized artifacts when Apple credentials are complete; publication with `OPENSCREEN_RELEASE_TOKEN` emits the event that starts stable Homebrew, WinGet, Nix, and AUR workflows. **Before dispatching it, the RC has to have been through [the manual end-to-end checklist](../testing/manual-e2e-checklist.md).** Nothing in `promote.yml` enforces that — it will promote an untested tag exactly as readily — so the gate is the operator. It is not a human-only gate either: the checks need real OS mouse and keyboard events, which an agent with the computer-use MCP supplies, so asking one to run it is a normal way to get it done. For a promote that means the **whole file**, not a risk-picked subset — the checklist asks for it in its own opening, and its results log shows why: webcam PiP, microphone, GIF and the AI sections have been scoped out of every run recorded so far, so "the sections this RC puts at risk" is precisely the judgement that keeps missing them. A `Partial` row is not a green light to dispatch. Mechanics and the rule on partial runs: [AGENTS.md](../../AGENTS.md#desktop-e2e-testing-with-computer-use). @@ -42,7 +42,7 @@ Every released version has **exactly one frozen branch**, named for the stable v ```text release/vX.Y.Z created at rc.1, frozen through promote, kept for backports -release/vX.Y.Z-sync ephemeral, created by promote to merge into main +release/vX.Y.Z-sync ephemeral, created by promote from main to set its version ``` The name carries **no `-rc.N` suffix**. `prerelease.yml` and `promote.yml` must resolve the same ref, and every RC of a version re-cuts from this one branch. From 6b756463dd20c9a948bac8da04d411a28760c8f6 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 17 Oct 2026 13:19:43 +0200 Subject: [PATCH 2/2] ci(release): never set main back, and say the sync is incomplete The sync skipped only when main was at the stable version exactly, so promoting an older version after a newer one would have set main back. version-at-least.mjs compares them, a prerelease below its release. The summary said "main was not synced" even when only the unsynced-commit guard failed, after the version PR had merged; it now says the sync is incomplete. The PR body no longer claims the fixes are on main. --- .github/scripts/version-at-least.mjs | 30 +++++++++++++++++++++++ .github/scripts/version-at-least.test.mjs | 28 +++++++++++++++++++++ .github/workflows/promote.yml | 19 ++++++++------ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/version-at-least.mjs create mode 100644 .github/scripts/version-at-least.test.mjs diff --git a/.github/scripts/version-at-least.mjs b/.github/scripts/version-at-least.mjs new file mode 100644 index 000000000..e80aa889c --- /dev/null +++ b/.github/scripts/version-at-least.mjs @@ -0,0 +1,30 @@ +// Exits 0 when the first version is at or past the second, 1 when it is behind. +// promote.yml asks it whether main already has the stable version: syncing on +// inequality alone would set main back to 2.0.1 if 2.0.1 were promoted after +// 2.1.0. A prerelease sits below the release of the same core: 2.1.0-rc.1 < 2.1.0. + +import { argv, exit } from "node:process"; + +const VERSION = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/; + +const parse = (version) => { + const match = VERSION.exec(version); + if (!match) throw new Error(`invalid version ${JSON.stringify(version)}`); + return { core: match.slice(1, 4).map(Number), prerelease: Boolean(match[4]) }; +}; + +/** + * @param {string} version e.g. main's package.json version. + * @param {string} target e.g. the stable version just released. + */ +export function versionAtLeast(version, target) { + const a = parse(version); + const b = parse(target); + const order = a.core[0] - b.core[0] || a.core[1] - b.core[1] || a.core[2] - b.core[2]; + return order > 0 || (order === 0 && (!a.prerelease || b.prerelease)); +} + +// Only run when invoked directly, so the test can import the function. +if (import.meta.filename === argv[1]) { + exit(versionAtLeast(argv[2], argv[3]) ? 0 : 1); +} diff --git a/.github/scripts/version-at-least.test.mjs b/.github/scripts/version-at-least.test.mjs new file mode 100644 index 000000000..04e41e4b0 --- /dev/null +++ b/.github/scripts/version-at-least.test.mjs @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { versionAtLeast } from "./version-at-least.mjs"; + +describe("versionAtLeast", () => { + it("holds for the same version", () => { + expect(versionAtLeast("2.1.0", "2.1.0")).toBe(true); + }); + + it("holds when main is past the release, so promote never sets it back", () => { + expect(versionAtLeast("2.1.0", "2.0.1")).toBe(true); + expect(versionAtLeast("3.0.0", "2.10.0")).toBe(true); + }); + + it("fails when main is behind", () => { + // v2.1.0's promotion found main at 2.0.0. + expect(versionAtLeast("2.0.0", "2.1.0")).toBe(false); + expect(versionAtLeast("2.9.0", "2.10.0")).toBe(false); + }); + + it("puts a prerelease below the release of the same core", () => { + expect(versionAtLeast("2.1.0-rc.1", "2.1.0")).toBe(false); + expect(versionAtLeast("2.2.0-rc.1", "2.1.0")).toBe(true); + }); + + it("refuses what is not a version", () => { + expect(() => versionAtLeast("v2.1.0", "2.1.0")).toThrow(/invalid version/); + }); +}); diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 7be3cdc98..d72b31c20 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -172,6 +172,11 @@ jobs: main_version() { git show origin/main:package.json | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version' } + # At or past the release, not just equal: promoting an older version after a + # newer one must not set main back. + main_is_current() { + node .github/scripts/version-at-least.mjs "$(main_version)" "$STABLE_VERSION" + } # From here on, main's own scripts: a release branch cut before one of them # existed would not have it. git checkout -B "${BRANCH}-sync" origin/main @@ -187,8 +192,8 @@ jobs: fi exit 0 } - if [[ "$(main_version)" == "$STABLE_VERSION" ]]; then - echo "main is already at ${STABLE_VERSION}: nothing to sync." + if main_is_current; then + echo "main is at $(main_version), not behind ${STABLE_VERSION}: nothing to sync." finish fi node .github/scripts/set-release-version.mjs "${STABLE_VERSION}" @@ -203,7 +208,7 @@ jobs: --base main \ --head "${BRANCH}-sync" \ --title "chore(release): release v${STABLE_VERSION} into main" \ - --body "Sets main to ${STABLE_VERSION}, the version just released. The fixes on ${BRANCH} are cherry-picks already on main. Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \ + --body "Sets main to ${STABLE_VERSION}, the version just released. Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor." \ --repo "$GITHUB_REPOSITORY" || echo "(PR already exists — skipping)" PR_NUMBER=$(gh pr list --head "${BRANCH}-sync" --state open --json number -q '.[0].number' --repo "$GITHUB_REPOSITORY") if [[ -z "$PR_NUMBER" ]]; then @@ -215,11 +220,11 @@ jobs: # de fin sautait. On re-teste plutôt que de supposer : quelqu'un a pu # merger la synchro à la main entre-temps. git fetch origin main - if [[ "$(main_version)" == "$STABLE_VERSION" ]]; then - echo "main is already at ${STABLE_VERSION}: nothing to sync." + if main_is_current; then + echo "main is at $(main_version), not behind ${STABLE_VERSION}: nothing to sync." finish fi - echo "::error::No open sync PR exists and main is not at ${STABLE_VERSION}." + echo "::error::No open sync PR exists and main is behind ${STABLE_VERSION}." exit 1 fi # GitHub computes mergeability asynchronously. Merging ~3s after creating the @@ -265,7 +270,7 @@ jobs: if [[ "${{ steps.sync_main.outcome }}" != "success" ]]; then echo "" echo "> [!WARNING]" - echo "> **main was not synced.** The release is published and the build was" + echo "> **main sync is incomplete.** The release is published and the build was" echo "> dispatched. The \`Sync the released version into main\` step's error says what is" echo "> left: the \`release/v${{ steps.version.outputs.stable_version }}-sync\` → main PR to merge by hand," echo "> or release-branch commits that main lacks."