From e2efbb51189f75c6fad7e1aef16be902fe7c53ca Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 10:56:27 +0200 Subject: [PATCH 1/2] fix(release): publish before syncing main, and stop racing the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote.yml ran the main-sync before the two steps that actually ship the release. The sync opens a PR and merges it ~3s later, which races GitHub's asynchronous mergeability computation and has failed every time since v1.7.0: v1.9.0 got "This branch can't be rebased" 2.9s after create, v1.8.0 "has merge conflicts" after 1.7s. Neither was a real conflict — both PRs were rebase-merged by hand, unchanged, minutes later. Because the step had no continue-on-error and nothing below it was if: always(), each failure silently skipped "Trigger build workflow" and "Announce stable on Discord". v1.7.0, v1.8.0 and v1.9.0 all shipped with the build dispatched by hand and no #releases announcement. Two changes, independent on purpose: - Order. Build trigger and Discord announce now run right after the stable tag is pushed, before the sync. They depend on nothing the sync produces — the build is dispatched with --ref on the stable tag, not on main — so a stuck PR can no longer suppress the release. - Reliability. The sync polls mergeStateStatus until it leaves UNKNOWN, then retries the merge up to 5 times. MERGED is set only by a successful merge: a bare `&& break` loop exits 0 after five failures and reports a merge that never happened. The step is continue-on-error since the release is already out by then, and the job summary says so when it fails rather than letting it pass unnoticed. --- .github/workflows/promote.yml | 113 ++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 3e8214b9..8301909c 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -101,7 +101,50 @@ jobs: git tag "$STABLE_TAG" git push origin "$STABLE_TAG" + # ORDER MATTERS. Everything that actually publishes the release runs before the + # main-sync below. The stable tag is already pushed at this point, so the build + # and the announcement depend on nothing the sync produces — while the sync is a + # PR round-trip against a remote that fails for reasons of its own. It used to + # run first, and every one of its failures silently skipped both steps under it: + # v1.7.0, v1.8.0 and v1.9.0 all shipped without an automated build (dispatched by + # hand minutes later) and without a #releases announcement. + - name: Trigger build workflow + env: + GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_TAG: ${{ steps.version.outputs.stable_tag }} + run: | + set -euo pipefail + # GITHUB_TOKEN tag pushes don't fire build.yml in this setup, dispatch it. + # + # --ref pins the build to the stable tag (the frozen release-branch tip), which + # is also what makes running this before the main-sync correct: the tag already + # points at the released snapshot, so checkout gets the matching package.json + + # code whatever main looks like, and signing/notarization is enabled (tag has + # no '-'). + gh workflow run build.yml \ + --ref "${STABLE_TAG}" \ + -f release_tag="${STABLE_TAG}" \ + -f arch=both \ + --repo "$GITHUB_REPOSITORY" + + - name: Announce stable on Discord + if: success() + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_RELEASE_CHANNEL_ID: ${{ vars.DISCORD_RELEASE_CHANNEL_ID }} + GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} + STABLE_TAG: ${{ steps.version.outputs.stable_tag }} + RC_TAG: ${{ steps.version.outputs.rc_tag }} + EXTRA: ${{ inputs.release_notes_extra }} + KIND: stable + run: node .github/scripts/discord-release-announce.mjs + + # 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 + id: sync_main + continue-on-error: true env: GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} STABLE_VERSION: ${{ steps.version.outputs.stable_version }} @@ -127,44 +170,41 @@ jobs: --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." \ --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 [[ -n "$PR_NUMBER" ]]; then - gh pr merge "$PR_NUMBER" --rebase --delete-branch --admin \ - --repo "$GITHUB_REPOSITORY" + if [[ -z "$PR_NUMBER" ]]; then + echo "No open sync PR found — nothing to merge." + exit 0 + fi + # GitHub computes mergeability asynchronously. Merging ~3s after creating the + # PR raced that computation every time: v1.9.0 got "This branch can't be + # rebased" 2.9s after create, v1.8.0 "has merge conflicts" after 1.7s — and + # both merged by hand, unchanged, minutes later. Wait for a real verdict. + for _ in $(seq 1 30); do + STATE=$(gh pr view "$PR_NUMBER" --json mergeStateStatus -q .mergeStateStatus \ + --repo "$GITHUB_REPOSITORY" 2>/dev/null || echo UNKNOWN) + echo "mergeStateStatus=${STATE}" + if [[ "$STATE" != "UNKNOWN" ]]; then break; fi + sleep 5 + done + # Retry anyway: leaving UNKNOWN is necessary, not sufficient. MERGED must be + # set by a successful merge — a bare `&& break` loop exits 0 after five + # failures and reports a merge that never happened. + MERGED=0 + for _ in $(seq 1 5); do + if gh pr merge "$PR_NUMBER" --rebase --delete-branch --admin \ + --repo "$GITHUB_REPOSITORY"; then + MERGED=1 + break + fi + sleep 15 + done + if [[ "$MERGED" -ne 1 ]]; then + echo "::error::Could not rebase-merge sync PR #${PR_NUMBER} into main after 5 attempts. The release itself is published; merge it by hand." + exit 1 fi # 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. - - name: Trigger build workflow - env: - GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - STABLE_TAG: ${{ steps.version.outputs.stable_tag }} - run: | - set -euo pipefail - # GITHUB_TOKEN tag pushes don't fire build.yml in this setup, dispatch it. - # - # --ref pins the build to the stable tag (the frozen release-branch tip). The - # prior main-merge step usually leaves main at the stable version already, but - # relying on that is fragile; building the tag guarantees checkout has the - # matching package.json + code and enables signing/notarization (tag has no '-'). - gh workflow run build.yml \ - --ref "${STABLE_TAG}" \ - -f release_tag="${STABLE_TAG}" \ - -f arch=both \ - --repo "$GITHUB_REPOSITORY" - - - name: Announce stable on Discord - if: success() - env: - DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_RELEASE_CHANNEL_ID: ${{ vars.DISCORD_RELEASE_CHANNEL_ID }} - GITHUB_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} - STABLE_TAG: ${{ steps.version.outputs.stable_tag }} - RC_TAG: ${{ steps.version.outputs.rc_tag }} - EXTRA: ${{ inputs.release_notes_extra }} - KIND: stable - run: node .github/scripts/discord-release-announce.mjs - - name: Workflow summary run: | { @@ -173,4 +213,11 @@ jobs: echo "- Stable tag: \`${{ steps.version.outputs.stable_tag }}\`" echo "- Promoted from: \`${{ steps.version.outputs.rc_tag }}\`" echo "- Tier 3 (homebrew/winget/nix/aur) will fire on the published release via OPENSCREEN_RELEASE_TOKEN." + 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." + fi } >> "$GITHUB_STEP_SUMMARY" From 5dc0f87675ae4cf82d10ec3f769f9fbe47379661 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 7 Aug 2026 12:01:15 +0200 Subject: [PATCH 2/2] fix(release): fail sync_main when no PR exists and main is still behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'échec de `gh pr create` est converti en echo, donc la branche « aucune PR ouverte » couvrait aussi le cas où la création avait échoué pour une autre raison. Elle sortait alors en 0 alors que le test d'ascendance du haut venait d'établir que main ne contient pas la branche : sync_main était reporté en succès et l'avertissement de fin sautait. On re-teste l'ascendance plutôt que de supposer — quelqu'un a pu merger la synchro à la main entre-temps — et on sort en 1 sinon. Exercé avec git et gh stubbés sur les quatre chemins : création échouée avec main en retard sort bien en 1, création échouée avec main à jour en 0, et les deux chemins nominaux ne régressent pas. --- .github/workflows/promote.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 8301909c..7e76a6c7 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -171,8 +171,20 @@ jobs: --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 - echo "No open sync PR found — nothing to merge." - exit 0 + # 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 + fi + echo "::error::No open sync PR exists and main does not contain ${BRANCH}." + exit 1 fi # GitHub computes mergeability asynchronously. Merging ~3s after creating the # PR raced that computation every time: v1.9.0 got "This branch can't be