From fea7b9e771fc52d2bc2e0602297c64f892cae14d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 3 Nov 2026 12:57:42 +0000 Subject: [PATCH 1/3] ci(release): fix recurring release failures since the v3 cut (CLI-2913) - Skip next's test and release when next has no commits beyond develop, since semantic-release cannot tell a next tag from a beta tag on the same commit (semantic-release#4073); refuse such a next dispatch. - Rebuild the backfill's channel notes per commit from this channel's tags, and floor its release type at the tag's own version so forced maintenance versions get notes. - Pin setup-cli v3.2.0 in the smoke test, wait per runner for npm to serve the version and platform package, and assert the npm install path; add a forced GitHub-release fallback job. - Raise the platform-package wait to 30 minutes. - Make develop/next plans wait for main's in-flight publish job. - Drop the issues permission propose-release-notes cannot get. Closes CLI-2913, CLI-2914, CLI-2915, CLI-2916, CLI-2917 --- .gitattributes | 3 + .github/MAINTAINERS.md | 1 + .github/scripts/wait-for-npm-version.sh | 34 ++++ .github/workflows/propose-release-notes.yml | 1 - .github/workflows/release-shared.yml | 2 +- .github/workflows/release.yml | 92 ++++++++-- .github/workflows/setup-cli-smoke-test.yml | 109 ++++++++++- apps/cli/docs/release-process.md | 15 +- .../backfill-release-notes.e2e.test.ts | 173 ++++++++++++++++++ apps/cli/scripts/backfill-release-notes.ts | 82 ++++++--- apps/cli/scripts/npm-registry.ts | 2 +- apps/cli/scripts/npm-registry.unit.test.ts | 2 +- apps/cli/scripts/release-channels.ts | 30 +++ .../cli/scripts/release-channels.unit.test.ts | 42 +++++ ...-release-branches-and-maintenance-lines.md | 6 +- 15 files changed, 534 insertions(+), 60 deletions(-) create mode 100755 .github/scripts/wait-for-npm-version.sh create mode 100644 apps/cli/scripts/backfill-release-notes.e2e.test.ts diff --git a/.gitattributes b/.gitattributes index 19f3e8920a..017dd6d47b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,9 @@ patches/*.patch text eol=lf # can't leave a trailing `\r` on every entry and make every tag mismatch. apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt text eol=lf +# The Windows smoke-test runs this under Git Bash; a CRLF checkout breaks `set -euo pipefail`. +.github/scripts/wait-for-npm-version.sh text eol=lf + # Generated artifacts. `linguist-generated` keeps these out of GitHub's language # statistics and collapses them in pull request diffs -- they are build output to # be regenerated, not code to review, and together they outweigh the hand-written diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index 25c044e909..50ec58f4c5 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -187,6 +187,7 @@ Fast-forward PRs are the deploy PR (`develop` → `main`), the major cut (`next` → `develop`), and the two sync PRs. **Dispatch guards.** `release.yml` refuses `channel=next` off `next`, +`channel=next` while `next` has no commits beyond `develop`, `channel=maintenance` off `v.x`, `beta`/`stable` on `next` or `v.x`, a non-dry-run `stable` outside `main`, and a `version` that is not `X.Y.Z[-prerelease]`. diff --git a/.github/scripts/wait-for-npm-version.sh b/.github/scripts/wait-for-npm-version.sh new file mode 100755 index 0000000000..617828f0e6 --- /dev/null +++ b/.github/scripts/wait-for-npm-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Usage: wait-for-npm-version.sh ... +# +# npm lists a new version minutes after publish, and the registry CDN caches each packument per +# region and Accept header, so both the npm client and the abbreviated packument must serve it. +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "usage: $0 ..." >&2 + exit 2 +fi + +version="$1" +shift +timeout="${WAIT_TIMEOUT_SECONDS:-900}" + +npm_serves_version() { + local pkg="$1" + npm view "${pkg}@${version}" version --prefer-online > /dev/null && + curl -fsSL --max-time 30 -H 'Accept: application/vnd.npm.install-v1+json' "https://registry.npmjs.org/${pkg//\//%2f}" | + jq -e --arg v "$version" '.versions | has($v)' > /dev/null +} + +deadline=$((SECONDS + timeout)) +for pkg in "$@"; do + until npm_serves_version "$pkg"; do + if (( SECONDS >= deadline )); then + echo "::error::npm does not serve ${pkg}@${version} after ${timeout} seconds" + exit 1 + fi + echo "Waiting for npm to serve ${pkg}@${version}..." + sleep 15 + done +done diff --git a/.github/workflows/propose-release-notes.yml b/.github/workflows/propose-release-notes.yml index 3ba3ad70fc..8ddc40d29c 100644 --- a/.github/workflows/propose-release-notes.yml +++ b/.github/workflows/propose-release-notes.yml @@ -62,7 +62,6 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - permission-issues: read permission-pull-requests: write - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 32bde58beb..add707ada2 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -275,7 +275,7 @@ jobs: # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 env: CHANNEL: ${{ inputs.channel }} NPM_TAG: ${{ inputs.npm_tag }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a1ce8040a..fe82e061a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,9 +47,36 @@ permissions: contents: read jobs: + # semantic-release reads channel notes per commit (semantic-release/semantic-release#4073), so + # releasing `next` on a commit develop already tagged misreads both tags. + next-ahead: + name: Check next has its own commits + if: github.ref_name == 'next' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + ahead: ${{ steps.compare.outputs.ahead }} + steps: + - id: compare + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + ahead_by="$(gh api "repos/${REPO}/compare/develop...${SHA}" --jq .ahead_by)" + if [[ "$ahead_by" -gt 0 ]]; then + echo "ahead=true" >> "$GITHUB_OUTPUT" + else + echo "ahead=false" >> "$GITHUB_OUTPUT" + fi + test-next: name: Test next - if: github.event_name == 'push' && github.ref_name == 'next' + needs: next-ahead + if: github.event_name == 'push' && github.ref_name == 'next' && needs.next-ahead.outputs.ahead == 'true' permissions: contents: read actions: read @@ -61,12 +88,20 @@ jobs: plan: name: Plan release - needs: test-next - # A skipped test-next (every non-next trigger) makes the implicit success() false, so the - # result is checked explicitly. - if: ${{ !cancelled() && contains(fromJSON('["success","skipped"]'), needs.test-next.result) }} + needs: [next-ahead, test-next] + # Skipped next-ahead and test-next jobs (every non-next trigger) make the implicit success() + # false, so their results are checked explicitly. A next push with no commits beyond develop + # plans nothing. + if: >- + ${{ !cancelled() && + contains(fromJSON('["success","skipped"]'), needs.next-ahead.result) && + contains(fromJSON('["success","skipped"]'), needs.test-next.result) && + !(github.event_name == 'push' && github.ref_name == 'next' && needs.next-ahead.outputs.ahead != 'true') }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 75 + permissions: + contents: read + actions: read outputs: should_release: ${{ steps.compute.outputs.should_release }} version: ${{ steps.compute.outputs.version }} @@ -78,6 +113,40 @@ jobs: dry_run: ${{ steps.compute.outputs.dry_run }} channel: ${{ steps.compute.outputs.channel }} steps: + - name: Refuse a next dispatch with no commits beyond develop + if: ${{ github.event_name == 'workflow_dispatch' && github.ref_name == 'next' && needs.next-ahead.outputs.ahead != 'true' }} + run: | + echo "::error::next has no commits beyond develop, so a next release would tag a commit develop already released" + exit 1 + # The stable tag is pushed by main's publish job, so a prerelease planned before that job + # finishes misses it. The rest of the run (brew, scoop, smoke tests) does not affect the plan. + - name: Wait for an in-flight stable release + if: ${{ (github.ref_name == 'develop' || github.ref_name == 'next') && (github.event_name == 'push' || inputs.version == '') }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + deadline=$((SECONDS + 3600)) + while true; do + run_ids="$(gh run list --repo "$REPO" --workflow release.yml --branch main --json databaseId,status --jq '.[] | select(.status != "completed") | .databaseId')" + pending=0 + for run_id in $run_ids; do + publish_status="$(gh api "repos/${REPO}/actions/runs/${run_id}/jobs" --paginate --jq '.jobs[] | select(.name == "Release / publish") | .status')" + if [[ "$publish_status" != completed ]]; then + pending=$((pending + 1)) + fi + done + if [[ "$pending" -eq 0 ]]; then + break + fi + if (( SECONDS >= deadline )); then + echo "::error::The publish job of a Release run on main is still in progress after 60 minutes. Re-run this job once it finishes." + exit 1 + fi + echo "Waiting for the publish job of ${pending} Release run(s) on main to finish..." + sleep 30 + done # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in # `dry_run: true` mode, so the token needs push access to the protected develop/main # branches — the default GITHUB_TOKEN doesn't have it. @@ -394,13 +463,14 @@ jobs: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} # Reports a broken release on every channel. `failure()` evaluates against the full `needs` - # chain, so this fires whenever `test-next`, `plan` or `release` (including the reusable - # release-shared workflow) fails. Skipped jobs don't count as failures. Dry runs are excluded - # via the dispatch input directly, so even a plan job that dies before recording its `dry_run` - # output stays quiet. A failed `test-next` skips `plan`, so the channel falls back to the ref. + # chain, so this fires whenever `next-ahead`, `test-next`, `plan` or `release` (including the + # reusable release-shared workflow) fails. Skipped jobs don't count as failures. Dry runs are + # excluded via the dispatch input directly, so even a plan job that dies before recording its + # `dry_run` output stays quiet. A failed `next-ahead` or `test-next` skips `plan`, so the + # channel falls back to the ref. notify-slack-failure: name: Notify Slack (failure) - needs: [test-next, plan, release] + needs: [next-ahead, test-next, plan, release] if: ${{ failure() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} uses: ./.github/workflows/slack-notify.yml with: diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index 5e00dad37b..7250265fcd 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -1,9 +1,11 @@ name: setup-cli Smoke Test # Smoke test for the `supabase/setup-cli` GitHub Action against a specific published CLI -# version. Runs automatically after every beta release and also manually via workflow_dispatch -# when debugging setup-cli regressions. Catches regressions like supabase/setup-cli#427, where -# musl libc + archive layout changes silently broke the action on Alpine. +# version. Runs automatically after every published release and also manually via +# workflow_dispatch when debugging setup-cli regressions. Catches regressions like +# supabase/setup-cli#427, where musl libc + archive layout changes silently broke the action on +# Alpine. v3 installs from npm: each v3 runner first waits until npm serves the version, and the +# job fails if setup-cli fell back to the GitHub release. A separate job forces that fallback. on: workflow_call: @@ -39,7 +41,7 @@ jobs: - v2 - v3 runs-on: ${{ matrix.runner }} - timeout-minutes: 10 + timeout-minutes: 25 env: VERSION: ${{ inputs.version }} steps: @@ -53,9 +55,24 @@ jobs: uses: supabase/setup-cli@afb1b15109756ea5cf9d8985a359d9095235ca2b # v2.1.2 with: version: ${{ inputs.version }} + - name: Checkout the npm wait script + if: matrix.major-version == 'v3' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .smoke-tooling + sparse-checkout: .github/scripts + persist-credentials: false + - name: Wait for npm to serve the version + if: matrix.major-version == 'v3' + shell: bash + run: | + set -euo pipefail + platform="$(node -p 'process.platform + "-" + process.arch')" + bash .smoke-tooling/.github/scripts/wait-for-npm-version.sh "${VERSION#v}" supabase "@supabase/cli-${platform/win32/windows}" - name: Install Supabase CLI via setup-cli@v3 + id: setup-cli-v3 if: matrix.major-version == 'v3' - uses: supabase/setup-cli@45a513f8c64c0bc8e0e3dfe572b5c95be85f6359 # v3.0.1 + uses: supabase/setup-cli@689adcfb80d947100a1eab127289b97d1fb07b13 # v3.2.0 with: version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version @@ -72,6 +89,17 @@ jobs: echo "Version mismatch: expected ${expected}, got ${actual}" >&2 exit 1 fi + - name: Verify setup-cli installed from npm + if: matrix.major-version == 'v3' + shell: bash + env: + INSTALL_SOURCE: ${{ steps.setup-cli-v3.outputs.install-source }} + run: | + set -euo pipefail + if [ "${INSTALL_SOURCE}" != "npm" ]; then + echo "::error::setup-cli fell back to ${INSTALL_SOURCE} because installing supabase@${VERSION#v} from npm failed; check the setup-cli step log" + exit 1 + fi # A separate job because GitHub Actions only honours `container:` on Linux runners, and mixing # container + non-container entries in one matrix is fragile when the value is empty. The @@ -87,7 +115,7 @@ jobs: - v2 - v3 runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 # `node:20-alpine` ships a musl-linked Node, required for JavaScript-based actions to launch # here — the runner's mounted glibc Node won't execute inside an Alpine container. container: @@ -99,7 +127,7 @@ jobs: VERSION: ${{ inputs.version }} steps: - name: Install Alpine prerequisites - run: apk add --no-cache bash curl tar + run: apk add --no-cache bash curl git jq tar - name: Install Supabase CLI via setup-cli@v1 if: matrix.major-version == 'v1' uses: supabase/setup-cli@1dedf2c611547ede7232d26866dd3c56ab903bbb # v1.7.3 @@ -110,9 +138,64 @@ jobs: uses: supabase/setup-cli@afb1b15109756ea5cf9d8985a359d9095235ca2b # v2.1.2 with: version: ${{ inputs.version }} + - name: Checkout the npm wait script + if: matrix.major-version == 'v3' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .smoke-tooling + sparse-checkout: .github/scripts + persist-credentials: false + - name: Wait for npm to serve the version + if: matrix.major-version == 'v3' + shell: bash + run: | + set -euo pipefail + platform="$(node -p 'process.platform + "-" + process.arch')" + bash .smoke-tooling/.github/scripts/wait-for-npm-version.sh "${VERSION#v}" supabase "@supabase/cli-${platform}-musl" - name: Install Supabase CLI via setup-cli@v3 + id: setup-cli-v3 + if: matrix.major-version == 'v3' + uses: supabase/setup-cli@689adcfb80d947100a1eab127289b97d1fb07b13 # v3.2.0 + with: + version: ${{ inputs.version }} + - name: Verify supabase --version matches the expected version + shell: bash + run: | + set -euo pipefail + # `supabase --version` prints the version without the leading `v`, + # while release tags / setup-cli inputs include it, so strip it + # before comparing. + expected="${VERSION#v}" + actual="$(supabase --version | tr -d '\r' | head -n1)" + echo "supabase --version: ${actual}" + if [ "${actual}" != "${expected}" ]; then + echo "Version mismatch: expected ${expected}, got ${actual}" >&2 + exit 1 + fi + - name: Verify setup-cli installed from npm if: matrix.major-version == 'v3' - uses: supabase/setup-cli@45a513f8c64c0bc8e0e3dfe572b5c95be85f6359 # v3.0.1 + shell: bash + env: + INSTALL_SOURCE: ${{ steps.setup-cli-v3.outputs.install-source }} + run: | + set -euo pipefail + if [ "${INSTALL_SOURCE}" != "npm" ]; then + echo "::error::setup-cli fell back to ${INSTALL_SOURCE} because installing supabase@${VERSION#v} from npm failed; check the setup-cli step log" + exit 1 + fi + + setup-cli-smoke-test-github-release: + name: setup-cli v3 GitHub release fallback (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + VERSION: ${{ inputs.version }} + # An npm executable that always fails sends setup-cli down its GitHub release fallback. + SUPABASE_SETUP_CLI_NPM: "false" + steps: + - name: Install Supabase CLI via setup-cli@v3 + id: setup-cli-v3 + uses: supabase/setup-cli@689adcfb80d947100a1eab127289b97d1fb07b13 # v3.2.0 with: version: ${{ inputs.version }} - name: Verify supabase --version matches the expected version @@ -129,3 +212,13 @@ jobs: echo "Version mismatch: expected ${expected}, got ${actual}" >&2 exit 1 fi + - name: Verify setup-cli installed from the GitHub release + shell: bash + env: + INSTALL_SOURCE: ${{ steps.setup-cli-v3.outputs.install-source }} + run: | + set -euo pipefail + if [ "${INSTALL_SOURCE}" != "github-release" ]; then + echo "::error::setup-cli installed from ${INSTALL_SOURCE} instead of falling back to the GitHub release with npm disabled" + exit 1 + fi diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 55cc60a409..14aa6f9a60 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -213,7 +213,7 @@ Production releases live in a single `[.github/workflows/release.yml](../../../. | next | push: next (after `Test next` passes) | `next` | none | prerelease | `X.Y.Z-next.N` | | maintenance | manual dispatch on a `v.x` ref | `v.stable` | `supabase@` / `supabase-v-stable` | published, never latest | `N.Y.Z` | -`beta` auto-publishes on every merge to `develop` (CLI). `stable` auto-publishes after a develop→main fast-forward, which itself happens when the weekly `[deploy.yml](../../../.github/workflows/deploy.yml)` cron PR is approved (the FF push to `main` re-fires `release.yml` via the `push: branches: [main]` trigger). `next` auto-publishes on every push to the `next` branch, and `maintenance` is dispatched by hand (see [Maintenance lines](#maintenance-lines)). +`beta` auto-publishes on every merge to `develop` (CLI). `stable` auto-publishes after a develop→main fast-forward, which itself happens when the weekly `[deploy.yml](../../../.github/workflows/deploy.yml)` cron PR is approved (the FF push to `main` re-fires `release.yml` via the `push: branches: [main]` trigger). `next` auto-publishes on every push to the `next` branch that carries commits beyond `develop` (see [The `next` channel](#the-next-channel)), and `maintenance` is dispatched by hand (see [Maintenance lines](#maintenance-lines)). Versions are computed by `cycjimmy/semantic-release-action` from the first line of each squash commit (the PR title). Conventional commit titles use `feat:` → minor, `fix:`, `perf:`, or `revert:` → patch, and `!` after the type or scope (`feat!:` / `fix(scope)!:`) → major. Commit bodies, including `BREAKING CHANGE` notes, are ignored for version calculation. The `release` config lives in `apps/cli/package.json`: `develop` is a `beta` prerelease branch, `next` is a `next` prerelease branch, `main` emits plain `X.Y.Z`, and a wildcard entry `v+([0-9]).x` turns every `v.x` branch into a maintenance line on the `v.stable` channel (the entry is ignored while no such branch exists). @@ -245,7 +245,7 @@ flowchart LR flowchart TD pushDev[push: develop] --> plan pushMain[push: main] --> plan - pushNext[push: next] --> testNext["Test next
test.yml (reusable)"] + pushNext[push: next] -->|"only with commits beyond develop"| testNext["Test next
test.yml (reusable)"] testNext -->|passes| plan pr["approved PR
develop→main, next→develop, sync/*"] --> ff["fast-forward.yml
checks + git push via App token"] ff --> pushMain @@ -281,11 +281,14 @@ gh workflow run release.yml \ Auto-trigger paths leave `version` empty: semantic-release computes it from commits since the last tag. A dispatch with an empty `version` does the same for the dispatched ref, and fails if the channel does not match the ref or if semantic-release computes no release. +The `develop` and `next` plans wait, up to 60 minutes, until the `publish` job of any in-flight `Release` run on `main` has finished before reading tags: that job pushes the stable tag after the npm publish, so a prerelease planned earlier would miss it and compute a stale version. The rest of the `main` run (Homebrew, Scoop, smoke tests) does not delay the plan. If the wait times out, the plan job fails; re-run it once that job has finished. + The plan job refuses a dispatch when: - `channel=next` is not on the `next` ref, or `channel=maintenance` is not on a `v.x` ref; - `next` or a `v.x` ref is dispatched with `beta` or `stable` (those refs release only their own channel); - `channel=stable` is not a dry run and the ref is not `main` (any other ref, including `hotfix/*`, may only dry-run); +- `channel=next` is dispatched while `next` has no commits beyond `develop` (see [The `next` channel](#the-next-channel)); - a non-empty `version` is not semver (no `v` prefix, no build metadata) or does not match the channel: `next` needs `X.Y.Z-next.N`, `beta` needs `X.Y.Z-beta.N`, and `stable` and `maintenance` need plain `X.Y.Z`. ### Hotfix release flow @@ -315,9 +318,11 @@ Do not use `workflow_dispatch dry_run=false` as a hotfix path. A stable release `next` is one long-lived branch reused for every future major. Breaking PRs target `next` instead of `develop`, with a `type(scope)!:` title; they use the same merge queue, required checks, and review as `develop`. The `Lint Pull Request` check fails any `!` title whose base is not `next`, with one exception: the `next` → `develop` cut PR. -Every push to `next` first runs the full `test.yml` suite (the `Test next` job in `release.yml`). Only when it passes does the pipeline plan and publish `X.Y.Z-next.N` to the npm `next` dist-tag and a GitHub prerelease. `next` has no Homebrew, Scoop, or Linear publication. It does get the raw semantic-release changelog on its GitHub Release (`backfill-release-notes`); the LLM-proposed notes PR is stable-only, and `apply-release-notes` still accepts `-next` tags for a manual proposal. +Every push to `next` that carries commits beyond `develop` first runs the full `test.yml` suite (the `Test next` job in `release.yml`). Only when it passes does the pipeline plan and publish `X.Y.Z-next.N` to the npm `next` dist-tag and a GitHub prerelease. `next` has no Homebrew, Scoop, or Linear publication. It does get the raw semantic-release changelog on its GitHub Release (`backfill-release-notes`); the LLM-proposed notes PR is stable-only, and `apply-release-notes` still accepts `-next` tags for a manual proposal. + +`next` publishes use the `next` prerelease identifier so versions never collide with `beta` on npm after a cut. After a stable release, semantic-release on `next` computes past it on the following push with commits beyond `develop`. The npm `next` dist-tag is not moved by the stable release, because publishing is OIDC-only and `npm dist-tag add` needs a token the pipeline does not hold; `supabase@next` catches up on the next release from `next`, which needs commits beyond `develop`. A release can be re-cut by dispatching `release.yml` with `channel=next` on the `next` ref. -`next` publishes use the `next` prerelease identifier so versions never collide with `beta` on npm after a cut. After a stable release, semantic-release on `next` computes past it on the following push. The npm `next` dist-tag is not moved by the stable release, because publishing is OIDC-only and `npm dist-tag add` needs a token the pipeline does not hold; `supabase@next` catches up on the next push to `next`. A release can be re-cut by dispatching `release.yml` with `channel=next` on the `next` ref. +A push to `next` with no commits beyond `develop` skips `Test next` and the release. That is the usual state between majors, because the sync fast-forwards `next` onto `develop`. semantic-release reads channel notes per commit ([semantic-release#4073](https://github.com/semantic-release/semantic-release/issues/4073)), so a `next` tag on a commit `develop` already tagged misreads both tags. `supabase@next` stays on the last `next` release until a breaking change lands on `next`, and `supabase@beta` carries the newest code meanwhile. A `channel=next` dispatch is refused in that state. The nightly live e2e run covers `main`, `develop`, and `next`. @@ -529,7 +534,7 @@ Both updaters run automatically from `release-shared.yml`'s `publish-homebrew` a Once the channels are live, two reusable workflows run automatically (last in `release-shared.yml`, non-gating — by the time they run the artifacts are already published, so a failure surfaces as a red post-release signal rather than blocking distribution): -- `[setup-cli-smoke-test.yml](../../../.github/workflows/setup-cli-smoke-test.yml)` (`setup-cli-smoke` job) — installs the released version through `supabase/setup-cli` v1 and v2 (the GitHub Release download path) and v3 (the npm package) on Linux, macOS, Windows, and Alpine. +- `[setup-cli-smoke-test.yml](../../../.github/workflows/setup-cli-smoke-test.yml)` (`setup-cli-smoke` job) — installs the released version through `supabase/setup-cli` v1 and v2 (the GitHub Release download path) and v3 (the npm package) on Linux, macOS, Windows, and Alpine. v3 waits on each runner until npm serves the version (up to 15 minutes) and fails unless setup-cli installed from npm; one ubuntu job forces the GitHub release fallback. - `[verify-install-channels.yml](../../../.github/workflows/verify-install-channels.yml)` (`verify-install-channels` job) — runs a **real** `brew install` (macOS **and** Linux, so both the `on_macos` and `on_linux` stanzas of the formula are exercised), `scoop install`, and `curl|bash` install of the **published** install script (fetched from the release asset, not the repo checkout) against the just-published Homebrew tap, Scoop bucket, and GitHub Release. Each leg then asserts `supabase --version` matches and runs `supabase --help` to exercise the actual command tree. brew, scoop, and the install script each verify the published `sha256`/`hash` against the downloaded tarball, so this is the signal that would have caught CLI v2.107.0 (where the brew/scoop manifests shipped checksums that did not match the release tarballs and every `brew install` / `scoop install` failed). It only runs for `beta`/`stable`/`maintenance` (the channels that publish brew/scoop) and can be dispatched manually against any already-published version via the Actions tab. ### Code signing (macOS) diff --git a/apps/cli/scripts/backfill-release-notes.e2e.test.ts b/apps/cli/scripts/backfill-release-notes.e2e.test.ts new file mode 100644 index 0000000000..23d48543c6 --- /dev/null +++ b/apps/cli/scripts/backfill-release-notes.e2e.test.ts @@ -0,0 +1,173 @@ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(new URL("./backfill-release-notes.ts", import.meta.url)); +const cliPackagePath = fileURLToPath(new URL("../package.json", import.meta.url)); +const analyzerPath = fileURLToPath(new URL("./analyze-commits-title.js", import.meta.url)); +const platformLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); + +const decodeCliPackage = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ release: Schema.Unknown })), +); +const encodePackage = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const isolatedGit = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "seed", + GIT_AUTHOR_EMAIL: "seed@example.com", + GIT_COMMITTER_NAME: "seed", + GIT_COMMITTER_EMAIL: "seed@example.com", +}; + +const spawn = (argv: ReadonlyArray, cwd: string, env: Record) => + Effect.gen(function* () { + const child = Bun.spawn([...argv], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }); + +interface SeedHistory { + readonly git: (...args: ReadonlyArray) => Effect.Effect; + readonly commit: (message: string) => Effect.Effect; + readonly release: (tag: string, channels: string) => Effect.Effect; +} + +/** + * A working repository on `main` with an `origin` bare remote, `v1.0.0` on its first commit and + * the history `seed` adds on top. Every branch and tag is pushed, and every release also gets + * the per-tag channel note ref the release pipeline pushes. + */ +const repositoryWithHistory = (seed: (history: SeedHistory) => Effect.Effect) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "backfill-release-notes-" }); + const origin = path.join(root, "origin.git"); + const work = path.join(root, "work"); + const env = { ...process.env, ...isolatedGit }; + + const git = (cwd: string, ...args: ReadonlyArray) => + Effect.gen(function* () { + const result = yield* spawn(["git", ...args], cwd, env); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; + }); + const commit = (message: string) => + git(work, "commit", "--quiet", "--allow-empty", "-m", message); + const release = (tag: string, channels: string) => + Effect.gen(function* () { + yield* git(work, "tag", "-a", tag, "-m", `Release ${tag}`); + yield* git( + work, + "notes", + `--ref=semantic-release-${tag}`, + "add", + "-m", + `{"channels":${channels}}`, + `${tag}^{commit}`, + ); + }); + + const cliPackage = yield* decodeCliPackage(yield* fs.readFileString(cliPackagePath)); + const packageJson = yield* encodePackage({ + name: "supabase", + version: "0.0.0-automated", + repository: { type: "git", url: "https://github.com/supabase/cli" }, + release: cliPackage.release, + }); + yield* fs.makeDirectory(path.join(work, "apps/cli/scripts"), { recursive: true }); + yield* fs.writeFileString(path.join(work, "apps/cli/package.json"), `${packageJson}\n`); + yield* fs.copyFile(analyzerPath, path.join(work, "apps/cli/scripts/analyze-commits-title.js")); + + yield* git(root, "init", "--quiet", "--bare", "--initial-branch=main", origin); + yield* git(work, "init", "--quiet", "--initial-branch=main"); + yield* git(work, "remote", "add", "origin", origin); + yield* git(work, "add", "-A"); + yield* commit("feat: initial"); + yield* release("v1.0.0", "[null]"); + + yield* seed({ git: (...args) => git(work, ...args), commit, release }); + + yield* git( + work, + "push", + "--quiet", + "origin", + "refs/heads/*:refs/heads/*", + "refs/tags/*:refs/tags/*", + "refs/notes/*:refs/notes/*", + ); + + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + const { FORCE_COLOR: _forceColor, NO_COLOR: _noColor, ...inherited } = process.env; + + return { + backfill: (tag: string) => + spawn([bunExecutable, scriptPath, "--tag", tag], work, { ...inherited, ...isolatedGit }), + }; + }); + +/** + * `develop` and `next` share the commit that carries both `v1.1.0-beta.1` and `v1.1.0-next.1` + * (`next` is fast-forwarded onto `develop`), and `develop` moves on to `v1.1.0-beta.2`. + */ +const sharedBetaAndNextHistory = repositoryWithHistory(({ git, commit, release }) => + Effect.gen(function* () { + yield* git("switch", "--quiet", "--create", "develop"); + yield* commit("feat: add shared thing"); + yield* release("v1.1.0-beta.1", '["beta"]'); + yield* release("v1.1.0-next.1", '["next"]'); + yield* git("branch", "next"); + yield* commit("fix: patch beta"); + yield* release("v1.1.0-beta.2", '["beta"]'); + }), +); + +const forcedMaintenanceHistory = repositoryWithHistory(({ git, commit, release }) => + Effect.gen(function* () { + yield* commit("feat!: drop the legacy flag"); + yield* release("v2.0.0", "[null]"); + yield* git("switch", "--quiet", "--create", "v1.x", "v1.0.0"); + yield* commit("ci: pin the runner image"); + yield* release("v1.0.1", '["v1.stable"]'); + }), +); + +describe("backfill-release-notes.ts on a repository whose beta and next tags share a commit", () => { + it.live("backfills the next beta from the beta line instead of the next channel's tags", () => + Effect.gen(function* () { + const repository = yield* sharedBetaAndNextHistory; + + const { exitCode, stdout, stderr } = yield* repository.backfill("v1.1.0-beta.2"); + + expect(exitCode, stderr).toBe(0); + expect(stdout, stderr).toContain("compare/v1.1.0-beta.1...v1.1.0-beta.2"); + expect(stdout, stderr).toContain("patch beta"); + expect(stdout, stderr).not.toContain("add shared thing"); + }).pipe(Effect.provide(platformLayer)), + ); +}); + +describe("backfill-release-notes.ts on a maintenance line", () => { + it.live("backfills a forced maintenance release whose commits imply no release", () => + Effect.gen(function* () { + const repository = yield* forcedMaintenanceHistory; + + const { exitCode, stdout, stderr } = yield* repository.backfill("v1.0.1"); + + expect(exitCode, stderr).toBe(0); + expect(stdout, stderr).toContain("compare/v1.0.0...v1.0.1"); + }).pipe(Effect.provide(platformLayer)), + ); +}); diff --git a/apps/cli/scripts/backfill-release-notes.ts b/apps/cli/scripts/backfill-release-notes.ts index e5b81aed56..265b9b9874 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -20,11 +20,16 @@ // Without it, raw markdown notes are printed to stdout. import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { Array as Arr, Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; -import semanticRelease from "semantic-release"; -import { channelForTag, releaseBranchForTag } from "./release-channels.ts"; +import semanticRelease, { type AnalyzeCommitsContext } from "semantic-release"; +import { + channelForTag, + isInChannelHistory, + releaseBranchForTag, + releaseTypeBetween, +} from "./release-channels.ts"; class JsonParseFailed extends Data.TaggedError("JsonParseFailed")<{ readonly cause: unknown }> {} @@ -151,19 +156,6 @@ const main = Effect.gen(function* () { $`git -C ${repoRoot} remote get-url origin`.text(), )).trim(); - // Notes refs aren't fetched by `git clone`. Pull from the source repo first - // (network-free), then from origin so even tags whose notes haven't been - // sync'd locally yet are available. - yield* shell(() => - $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(), - ); - yield* shell(() => - $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(), - ); // One fetch per branch: git aborts a multi-ref fetch when any named ref is missing, and `next` // may not exist. The `v*.x` glob matches zero or more branches without failing. for (const branchSpec of ["main", "develop", "next", "v*.x"]) { @@ -207,6 +199,13 @@ const main = Effect.gen(function* () { for (const ct of coincidentTags) { yield* shell(() => $`git -C ${clone} tag -d ${ct}`.quiet().nothrow()); } + const targetChannel = channelForTag(tag); + const tagsOutsideChannel = (yield* shell(() => $`git -C ${clone} tag --list`.text())) + .split("\n") + .filter((t) => t && !isInChannelHistory(t, targetChannel)); + if (tagsOutsideChannel.length > 0) { + yield* shell(() => $`git -C ${clone} tag -d ${tagsOutsideChannel}`.quiet()); + } yield* shell(() => $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`); // The clone only carries refs/heads/$BRANCH locally; seed the other @@ -224,23 +223,34 @@ const main = Effect.gen(function* () { } } - // semantic-release's notes reader returns channels=[null] for any tag - // missing an annotation, and the prerelease filter drops those tags - // entirely — walking lastRelease back far enough to drag unrelated commits - // into the changelog. Seed a channel note for every reachable tag that - // lacks one, inferring the channel from the tag name. + // semantic-release reads one channel note per commit (semantic-release/semantic-release#4073), + // so the clone keeps only this channel's prereleases and rebuilds one note per tagged + // commit from tag names. const mergedTagsOut = yield* shell(() => $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(), ); const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); - for (const prevTag of mergedTags) { - const noteCheck = yield* shell(() => - $`git -C ${clone} notes --ref semantic-release show ${prevTag}`.nothrow().quiet(), + const taggedCommits = (yield* shell(() => + $`git -C ${clone} rev-parse ${mergedTags.map((t) => `refs/tags/${t}^{commit}`)}`.text(), + )) + .split("\n") + .filter(Boolean); + if (taggedCommits.length !== mergedTags.length) { + yield* Console.error( + `Resolved ${taggedCommits.length} commits for ${mergedTags.length} tags merged into ${tag}`, ); - if (noteCheck.exitCode === 0) continue; - const payload = yield* encodeJson({ channels: [channelForTag(prevTag)] }); + return yield* Effect.sync(() => process.exit(1)); + } + const channelByCommit = new Map>(); + for (const [prevTag, commit] of Arr.zip(mergedTags, taggedCommits)) { + if (prevTag.includes("-") || !channelByCommit.has(commit)) { + channelByCommit.set(commit, channelForTag(prevTag)); + } + } + for (const [commit, channel] of channelByCommit) { + const payload = yield* encodeJson({ channels: [channel] }); yield* shell(() => - $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(), + $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${commit}`.quiet(), ); } @@ -280,10 +290,25 @@ const main = Effect.gen(function* () { delete childEnv[key]; } + const expected = tag.replace(/^v/, ""); + // A dispatch `version` can force a release its commits do not imply (e.g. only `ci:` commits). + const releaseFloor = { + analyzeCommits: (_config: unknown, context: AnalyzeCommitsContext) => + releaseTypeBetween(context.lastRelease.version, expected), + }; + const configuredPlugins = Array.isArray(rootPkg.release?.plugins) + ? rootPkg.release.plugins + : []; + const result = yield* Effect.tryPromise({ try: () => semanticRelease( - { dryRun: true, noCi: true, repositoryUrl: repoUrl }, + { + dryRun: true, + noCi: true, + repositoryUrl: repoUrl, + plugins: [...configuredPlugins, releaseFloor], + }, { cwd: path.join(clone, "apps/cli"), env: childEnv, @@ -299,7 +324,6 @@ const main = Effect.gen(function* () { return yield* Effect.sync(() => process.exit(1)); } - const expected = tag.replace(/^v/, ""); if (result.nextRelease.version !== expected) { yield* Console.error( `semantic-release computed v${result.nextRelease.version} but expected ${tag}; ` + diff --git a/apps/cli/scripts/npm-registry.ts b/apps/cli/scripts/npm-registry.ts index 3759b8858c..2f4863be96 100644 --- a/apps/cli/scripts/npm-registry.ts +++ b/apps/cli/scripts/npm-registry.ts @@ -2,7 +2,7 @@ import { Data, Effect, Ref, Schedule, Schema } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const POLL_INTERVAL = "10 seconds"; -const POLL_BUDGET = "15 minutes"; +const POLL_BUDGET = "30 minutes"; const PROBE_TIMEOUT = "30 seconds"; const RETRYABLE_CLIENT_STATUSES = new Set([404, 408, 429]); diff --git a/apps/cli/scripts/npm-registry.unit.test.ts b/apps/cli/scripts/npm-registry.unit.test.ts index f2639a2822..56923cd397 100644 --- a/apps/cli/scripts/npm-registry.unit.test.ts +++ b/apps/cli/scripts/npm-registry.unit.test.ts @@ -108,7 +108,7 @@ describe("waitUntilInstallable", () => { onInstallable: (pkg) => Effect.sync(() => void installable.push(pkg.name)), }).pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust("14 minutes"); + yield* TestClock.adjust("29 minutes"); expect(fiber.pollUnsafe()).toBeUndefined(); yield* TestClock.adjust("2 minutes"); diff --git a/apps/cli/scripts/release-channels.ts b/apps/cli/scripts/release-channels.ts index bf3746c553..b06c248f36 100644 --- a/apps/cli/scripts/release-channels.ts +++ b/apps/cli/scripts/release-channels.ts @@ -38,3 +38,33 @@ export function channelForTag(tag: string): "beta" | "next" | "alpha" | "latest" if (tag.includes("-alpha.")) return "alpha"; return "latest"; } + +/** Whether `tag` is a CLI release tag in `channel`'s history: stable tags always, prereleases only on their own channel. */ +export function isInChannelHistory( + tag: string, + channel: ReturnType, +): boolean { + return /^v\d/.test(tag) && (!tag.includes("-") || channelForTag(tag) === channel); +} + +function versionBase(version: string): readonly [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; +} + +/** The smallest release type that takes `lastVersion` to `version`, or null when `version` is not later. */ +export function releaseTypeBetween( + lastVersion: string | undefined, + version: string, +): "major" | "minor" | "patch" | null { + const last = lastVersion === undefined ? undefined : versionBase(lastVersion); + const next = versionBase(version); + if (!last || !next) return null; + + const [lastMajor, lastMinor, lastPatch] = last; + const [major, minor, patch] = next; + if (major !== lastMajor) return major > lastMajor ? "major" : null; + if (minor !== lastMinor) return minor > lastMinor ? "minor" : null; + if (patch !== lastPatch) return patch > lastPatch ? "patch" : null; + return lastVersion !== version ? "patch" : null; +} diff --git a/apps/cli/scripts/release-channels.unit.test.ts b/apps/cli/scripts/release-channels.unit.test.ts index 5a157f5517..0f777abc08 100644 --- a/apps/cli/scripts/release-channels.unit.test.ts +++ b/apps/cli/scripts/release-channels.unit.test.ts @@ -3,8 +3,10 @@ import { channelForTag, homebrewClassName, homebrewConflictsLine, + isInChannelHistory, isPublishableNpmTag, releaseBranchForTag, + releaseTypeBetween, } from "./release-channels.ts"; describe("isPublishableNpmTag", () => { @@ -72,3 +74,43 @@ describe("channelForTag", () => { expect(channelForTag(tag)).toBe(expected); }); }); + +describe("isInChannelHistory", () => { + test.each([ + ["v3.1.0-beta.1", "beta", true], + ["v3.0.0", "beta", true], + ["v3.1.0-next.1", "beta", false], + ["v2.99.0-alpha.1", "beta", false], + ["v3.1.0-next.1", "next", true], + ["v3.0.0", "next", true], + ["v3.1.0-beta.1", "next", false], + ["v2.99.0-alpha.1", "next", false], + ["v3.0.0", "latest", true], + ["v2.151.1", "latest", true], + ["v3.1.0-beta.1", "latest", false], + ["v3.1.0-next.1", "latest", false], + ["v2.99.0-alpha.1", "latest", false], + ["config-v1.2.0", "latest", false], + ["apps/cli-go/pkg/v1.0.0", "latest", false], + ["config-v1.2.0", "beta", false], + ] as const)("%s in the %s history -> %s", (tag, channel, expected) => { + expect(isInChannelHistory(tag, channel)).toBe(expected); + }); +}); + +describe("releaseTypeBetween", () => { + test.each([ + ["2.122.0", "2.122.1", "patch"], + ["2.122.1", "2.123.0", "minor"], + ["2.123.4", "3.0.0", "major"], + ["3.1.0-beta.4", "3.1.0-beta.5", "patch"], + ["3.0.0", "3.1.0-beta.1", "minor"], + ["2.151.0", "3.0.0-beta.1", "major"], + [undefined, "3.0.0", null], + ["2.122.1", "2.122.1", null], + ["2.122.1", "2.122.0", null], + ["3.1.0", "2.151.0", null], + ] as const)("%s -> %s requires %s", (lastVersion, version, expected) => { + expect(releaseTypeBetween(lastVersion, version)).toBe(expected); + }); +}); diff --git a/docs/adr/0028-release-branches-and-maintenance-lines.md b/docs/adr/0028-release-branches-and-maintenance-lines.md index 8a56bd3c3d..fb59b90e2e 100644 --- a/docs/adr/0028-release-branches-and-maintenance-lines.md +++ b/docs/adr/0028-release-branches-and-maintenance-lines.md @@ -18,7 +18,7 @@ Add two release channels and the branches behind them. `develop` and `main` are | `next` | Breaking-change integration, tracks `develop` | `X.Y.Z-next.N` | `next` | none | prerelease | | `v.x` | Maintenance for a past major | `N.Y.Z` | `v.stable` | `supabase@` / `supabase-v-stable` | published, never latest | -- **`next`** is one long-lived branch reused for every major. Breaking PRs target it with a `type(scope)!:` title, enforced by the PR title lint. Every push runs the full test suite before publishing. The prerelease identifier is `next`, not `beta`, so versions do not collide on npm after a cut. Publishing stays OIDC-only, so the stable release does not move the `next` dist-tag; `supabase@next` catches up on the next push to `next`. +- **`next`** is one long-lived branch reused for every major. Breaking PRs target it with a `type(scope)!:` title, enforced by the PR title lint. Every push that carries commits beyond `develop` runs the full test suite before publishing; a push without any releases nothing, because semantic-release reads channel notes per commit ([semantic-release#4073](https://github.com/semantic-release/semantic-release/issues/4073)) and cannot tell a `next` tag from a `beta` tag on the same commit. The prerelease identifier is `next`, not `beta`, so versions do not collide on npm after a cut. Publishing stays OIDC-only, so the stable release does not move the `next` dist-tag; `supabase@next` catches up on the next release from `next`. - **`develop` → `next`** is kept in sync on every push, and `main` → `develop` after every stable release. A clean merge is pushed directly. A conflict opens a PR from `sync/-into-` (at the source tip) into the target, and approving it fast-forwards the target to the PR head. - **Fast-forward on approval** applies to the deploy PR (`develop` → `main`), the major cut (`next` → `develop`), and sync PRs. The approver must have write access; the approved commit must be the PR head with the required checks green; the target must fast-forward (for a sync PR that is stale because the target or the source moved, a clean merge lands directly, as for a clean sync; only a new conflict or a target that keeps moving sends it back for re-approval); and a major version bump on the deploy PR needs the `release-major` label. The merge button is blocked on these PRs because the repository is squash-only and squashing destroys the ancestry. - **`v.x`** is created by hand from the last stable `N.*` tag at the cut of the next major. It is released by dispatching `release.yml` on that ref with `channel=maintenance`; the workflow, scripts, and live e2e tests come from the frozen commit. A wildcard maintenance entry in the semantic-release config covers every such branch. Maintenance releases publish `v.stable` on npm and are never marked latest. @@ -48,8 +48,8 @@ The operational runbook, including the major-cut steps, is [`apps/cli/docs/relea ### Negative - Release-infrastructure fixes must be cherry-picked to each active `v.x` by hand; Dependabot targets `develop` only. -- The `next` dist-tag can lag behind `latest` until the next push to `next`. -- Every `develop` push triggers a sync and, once `next` exists, a `next` test run and release. +- The `next` dist-tag can lag behind `latest` until the next release from `next`. +- Every `develop` push triggers a sync into `next`; `next` runs its tests and releases only when it carries commits beyond `develop`. - Rulesets for `next` and `v*.x` and the `release-major` label are manual setup. - `supabase@` and `supabase` both install `bin/supabase`, so Homebrew can link only one. - Homebrew and Scoop for `next` are not published. From 5d26ab9837e0442ba138f67a92abf0cfb402f65a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 3 Nov 2026 13:11:40 +0000 Subject: [PATCH 2/3] ci(release): harden release waits and the next gate after review - Re-check that next is ahead of develop after the tests and the stable wait, so a major cut in between cannot tag a shared commit. - Wait up to 120 minutes for main's publish job. - Bound each npm probe and stop probing once the budget is spent. - Return no release type for equal or earlier prerelease versions. --- .github/scripts/wait-for-npm-version.sh | 5 +-- .github/workflows/release.yml | 31 +++++++++++++++++-- apps/cli/docs/release-process.md | 2 +- apps/cli/scripts/release-channels.ts | 16 +++++----- .../cli/scripts/release-channels.unit.test.ts | 2 ++ 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/scripts/wait-for-npm-version.sh b/.github/scripts/wait-for-npm-version.sh index 617828f0e6..dfb6e07ad7 100755 --- a/.github/scripts/wait-for-npm-version.sh +++ b/.github/scripts/wait-for-npm-version.sh @@ -16,18 +16,19 @@ timeout="${WAIT_TIMEOUT_SECONDS:-900}" npm_serves_version() { local pkg="$1" - npm view "${pkg}@${version}" version --prefer-online > /dev/null && + npm view "${pkg}@${version}" version --prefer-online --fetch-timeout=30000 --fetch-retries=0 > /dev/null && curl -fsSL --max-time 30 -H 'Accept: application/vnd.npm.install-v1+json' "https://registry.npmjs.org/${pkg//\//%2f}" | jq -e --arg v "$version" '.versions | has($v)' > /dev/null } deadline=$((SECONDS + timeout)) for pkg in "$@"; do - until npm_serves_version "$pkg"; do + while true; do if (( SECONDS >= deadline )); then echo "::error::npm does not serve ${pkg}@${version} after ${timeout} seconds" exit 1 fi + npm_serves_version "$pkg" && break echo "Waiting for npm to serve ${pkg}@${version}..." sleep 15 done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe82e061a2..772aa381b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,7 @@ jobs: contains(fromJSON('["success","skipped"]'), needs.test-next.result) && !(github.event_name == 'push' && github.ref_name == 'next' && needs.next-ahead.outputs.ahead != 'true') }} runs-on: ubuntu-latest - timeout-minutes: 75 + timeout-minutes: 135 permissions: contents: read actions: read @@ -127,7 +127,7 @@ jobs: REPO: ${{ github.repository }} run: | set -euo pipefail - deadline=$((SECONDS + 3600)) + deadline=$((SECONDS + 7200)) while true; do run_ids="$(gh run list --repo "$REPO" --workflow release.yml --branch main --json databaseId,status --jq '.[] | select(.status != "completed") | .databaseId')" pending=0 @@ -141,12 +141,27 @@ jobs: break fi if (( SECONDS >= deadline )); then - echo "::error::The publish job of a Release run on main is still in progress after 60 minutes. Re-run this job once it finishes." + echo "::error::The publish job of a Release run on main is still in progress after 120 minutes. Re-run this job once it finishes." exit 1 fi echo "Waiting for the publish job of ${pending} Release run(s) on main to finish..." sleep 30 done + # A major cut can fast-forward develop onto next while the tests and the wait above run. + - id: next-still-ahead + if: ${{ github.ref_name == 'next' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + ahead_by="$(gh api "repos/${REPO}/compare/develop...${SHA}" --jq .ahead_by)" + if [[ "$ahead_by" -gt 0 ]]; then + echo "ahead=true" >> "$GITHUB_OUTPUT" + else + echo "ahead=false" >> "$GITHUB_OUTPUT" + fi # semantic-release runs `git push --dry-run HEAD:` as part of verifyAuth even in # `dry_run: true` mode, so the token needs push access to the protected develop/main # branches — the default GITHUB_TOKEN doesn't have it. @@ -179,6 +194,7 @@ jobs: DISPATCH_CHANNEL: ${{ inputs.channel }} DISPATCH_VERSION: ${{ inputs.version }} DISPATCH_DRY_RUN: ${{ inputs.dry_run }} + NEXT_AHEAD: ${{ steps.next-still-ahead.outputs.ahead }} SR_PUBLISHED: ${{ steps.semantic-release.outputs.new_release_published }} SR_VERSION: ${{ steps.semantic-release.outputs.new_release_version }} run: | @@ -195,6 +211,15 @@ jobs: next) natural=next ;; *) [[ "$REF_NAME" =~ $maint_re ]] && natural=maintenance ;; esac + if [[ "$REF_NAME" == next && "$NEXT_AHEAD" != true ]]; then + if [[ "$EVENT" == "workflow_dispatch" ]]; then + echo "::error::next has no commits beyond develop, so a next release would tag a commit develop already released" + exit 1 + fi + echo "::notice::next has no commits beyond develop, so there is nothing to release" + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi if [[ "$EVENT" == "workflow_dispatch" ]]; then channel="$DISPATCH_CHANNEL" dry_run="$DISPATCH_DRY_RUN" diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 14aa6f9a60..7ade1efbf7 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -281,7 +281,7 @@ gh workflow run release.yml \ Auto-trigger paths leave `version` empty: semantic-release computes it from commits since the last tag. A dispatch with an empty `version` does the same for the dispatched ref, and fails if the channel does not match the ref or if semantic-release computes no release. -The `develop` and `next` plans wait, up to 60 minutes, until the `publish` job of any in-flight `Release` run on `main` has finished before reading tags: that job pushes the stable tag after the npm publish, so a prerelease planned earlier would miss it and compute a stale version. The rest of the `main` run (Homebrew, Scoop, smoke tests) does not delay the plan. If the wait times out, the plan job fails; re-run it once that job has finished. +The `develop` and `next` plans wait, up to 120 minutes, until the `publish` job of any in-flight `Release` run on `main` has finished before reading tags: that job pushes the stable tag after the npm publish, so a prerelease planned earlier would miss it and compute a stale version. The rest of the `main` run (Homebrew, Scoop, smoke tests) does not delay the plan. If the wait times out, the plan job fails; re-run it once that job has finished. The plan job refuses a dispatch when: diff --git a/apps/cli/scripts/release-channels.ts b/apps/cli/scripts/release-channels.ts index b06c248f36..b3c31384e3 100644 --- a/apps/cli/scripts/release-channels.ts +++ b/apps/cli/scripts/release-channels.ts @@ -1,3 +1,5 @@ +import { semver } from "bun"; + const MAINTENANCE_TAG = /^v[1-9]\d*\.stable$/; const PUBLISHABLE_TAGS = new Set(["latest", "alpha", "beta", "next"]); @@ -52,19 +54,17 @@ function versionBase(version: string): readonly [number, number, number] | undef return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; } -/** The smallest release type that takes `lastVersion` to `version`, or null when `version` is not later. */ +/** The smallest release type that takes `lastVersion` to `version`, or null unless `version` is later by SemVer precedence. */ export function releaseTypeBetween( lastVersion: string | undefined, version: string, ): "major" | "minor" | "patch" | null { - const last = lastVersion === undefined ? undefined : versionBase(lastVersion); + if (lastVersion === undefined || semver.order(version, lastVersion) !== 1) return null; + const last = versionBase(lastVersion); const next = versionBase(version); if (!last || !next) return null; - const [lastMajor, lastMinor, lastPatch] = last; - const [major, minor, patch] = next; - if (major !== lastMajor) return major > lastMajor ? "major" : null; - if (minor !== lastMinor) return minor > lastMinor ? "minor" : null; - if (patch !== lastPatch) return patch > lastPatch ? "patch" : null; - return lastVersion !== version ? "patch" : null; + if (next[0] !== last[0]) return "major"; + if (next[1] !== last[1]) return "minor"; + return "patch"; } diff --git a/apps/cli/scripts/release-channels.unit.test.ts b/apps/cli/scripts/release-channels.unit.test.ts index 0f777abc08..439c21c027 100644 --- a/apps/cli/scripts/release-channels.unit.test.ts +++ b/apps/cli/scripts/release-channels.unit.test.ts @@ -110,6 +110,8 @@ describe("releaseTypeBetween", () => { ["2.122.1", "2.122.1", null], ["2.122.1", "2.122.0", null], ["3.1.0", "2.151.0", null], + ["3.1.0-beta.5", "3.1.0-beta.4", null], + ["3.1.0", "3.1.0-beta.1", null], ] as const)("%s -> %s requires %s", (lastVersion, version, expected) => { expect(releaseTypeBetween(lastVersion, version)).toBe(expected); }); From d3c2e5e7e4d3569aa422b009e4805b4cf8ec9931 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 3 Nov 2026 13:35:34 +0000 Subject: [PATCH 3/3] ci(release): retry failed polls in the stable wait and reap backfill test children --- .github/workflows/release.yml | 18 ++++++----- .../backfill-release-notes.e2e.test.ts | 31 ++++++++++++------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 772aa381b7..de02c7ed9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,14 +129,18 @@ jobs: set -euo pipefail deadline=$((SECONDS + 7200)) while true; do - run_ids="$(gh run list --repo "$REPO" --workflow release.yml --branch main --json databaseId,status --jq '.[] | select(.status != "completed") | .databaseId')" pending=0 - for run_id in $run_ids; do - publish_status="$(gh api "repos/${REPO}/actions/runs/${run_id}/jobs" --paginate --jq '.jobs[] | select(.name == "Release / publish") | .status')" - if [[ "$publish_status" != completed ]]; then - pending=$((pending + 1)) - fi - done + if run_ids="$(gh run list --repo "$REPO" --workflow release.yml --branch main --json databaseId,status --jq '.[] | select(.status != "completed") | .databaseId')"; then + for run_id in $run_ids; do + publish_status="$(gh api "repos/${REPO}/actions/runs/${run_id}/jobs" --paginate --jq '.jobs[] | select(.name == "Release / publish") | .status')" || publish_status=unknown + if [[ "$publish_status" != completed ]]; then + pending=$((pending + 1)) + fi + done + else + echo "::warning::Could not list Release runs on main; retrying." + pending=1 + fi if [[ "$pending" -eq 0 ]]; then break fi diff --git a/apps/cli/scripts/backfill-release-notes.e2e.test.ts b/apps/cli/scripts/backfill-release-notes.e2e.test.ts index 23d48543c6..3323fcc37c 100644 --- a/apps/cli/scripts/backfill-release-notes.e2e.test.ts +++ b/apps/cli/scripts/backfill-release-notes.e2e.test.ts @@ -23,18 +23,25 @@ const isolatedGit = { }; const spawn = (argv: ReadonlyArray, cwd: string, env: Record) => - Effect.gen(function* () { - const child = Bun.spawn([...argv], { cwd, env, stdout: "pipe", stderr: "pipe" }); - const [exitCode, stdout, stderr] = yield* Effect.all( - [ - Effect.promise(() => child.exited), - Effect.promise(() => new Response(child.stdout).text()), - Effect.promise(() => new Response(child.stderr).text()), - ], - { concurrency: "unbounded" }, - ); - return { exitCode, stdout, stderr }; - }); + Effect.acquireUseRelease( + Effect.sync(() => Bun.spawn([...argv], { cwd, env, stdout: "pipe", stderr: "pipe" })), + (child) => + Effect.gen(function* () { + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr }; + }), + (child) => + child.exitCode === null && child.signalCode === null + ? Effect.sync(() => child.kill()).pipe(Effect.andThen(Effect.promise(() => child.exited))) + : Effect.void, + ); interface SeedHistory { readonly git: (...args: ReadonlyArray) => Effect.Effect;