Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/scripts/release-unsynced-fixes.mjs
Original file line number Diff line number Diff line change
@@ -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 <main> <release>`.
* @returns {string[]} "<sha> <subject>" 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);
}
37 changes: 37 additions & 0 deletions .github/scripts/release-unsynced-fixes.test.mjs
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
30 changes: 30 additions & 0 deletions .github/scripts/version-at-least.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
28 changes: 28 additions & 0 deletions .github/scripts/version-at-least.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
79 changes: 56 additions & 23 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,48 +152,79 @@ 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:
GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }}
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'
}
# 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
# 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if main_is_current; then
echo "main is at $(main_version), not behind ${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. 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_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 does not contain ${BRANCH}."
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
Expand Down Expand Up @@ -226,6 +257,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: |
Expand All @@ -238,8 +270,9 @@ 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 "> 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 "> **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."
fi
} >> "$GITHUB_STEP_SUMMARY"
4 changes: 2 additions & 2 deletions technical-documentation/engineering/release-and-secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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.
Expand Down
Loading