From 9e8bcf06160013644539800f9abfeb0654c61077 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 3 Nov 2026 14:19:19 +0000 Subject: [PATCH 1/2] ci(release): backport build provenance attestations to v2.x (#7467) Adds a build provenance attestation for every GitHub Release asset, created in the `publish` job right before the release is made immutable. - `actions/attest` signs the versioned tarballs, zips, deb/rpm/apk packages, `checksums.txt` and the `install` script. The unversioned `supabase__.tar.gz` aliases are byte-identical copies, so the same attestations cover them by digest. - `attestations: write` is granted to the `publish` job and to both callers of `release-shared.yml` (`release.yml` and `release-smoke-test.yml`), since a reusable workflow cannot exceed its caller's permissions. Anyone can now verify a downloaded asset with `gh attestation verify --repo supabase/cli`. Once a release ships with attestations, the aqua registry entry for `supabase/cli` can add `github_artifact_attestations` with `signer_workflow: supabase/cli/.github/workflows/release-shared.yml`, which makes mise and aqua verify each download against it. This touches release infrastructure, so it needs a cherry-pick to `v2.x`. (cherry picked from commit 96eab1d048ffe4ea4dfd7035f6430217ca463631) --- .github/workflows/release-shared.yml | 11 +++++++++++ .github/workflows/release-smoke-test.yml | 1 + .github/workflows/release.yml | 1 + 3 files changed, 13 insertions(+) diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 7e6f3c6b47..6cce7e3d4a 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -283,6 +283,7 @@ jobs: LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_STABLE_RELEASE_ACCESS_KEY }} LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} permissions: + attestations: write contents: write id-token: write steps: @@ -397,6 +398,16 @@ jobs: GH_TOKEN: ${{ github.token }} run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" + # Lets installers such as aqua and mise verify each asset with `gh attestation verify`; the + # unversioned tarball aliases share their versioned twin's digest, so they are covered too. + - name: Attest release assets + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/supabase_${{ inputs.version }}_* + dist/checksums.txt + install + - name: Publish GitHub Release (immutable) env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-smoke-test.yml b/.github/workflows/release-smoke-test.yml index 27ca8a2ebc..b23b630f47 100644 --- a/.github/workflows/release-smoke-test.yml +++ b/.github/workflows/release-smoke-test.yml @@ -30,6 +30,7 @@ permissions: # release-shared.yml declares privileged publish jobs. They are gated by # dry_run here, but GitHub validates nested-workflow permissions at startup. actions: read + attestations: write contents: write id-token: write pull-requests: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c49328b629..c4cdf624f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -243,6 +243,7 @@ jobs: # those granted to the calling job, so this must be declared here too. permissions: actions: read + attestations: write contents: write id-token: write pull-requests: write From 36037dd8f36e048a3e7b9db7fbd0c9a818cfa4b0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 3 Nov 2026 13:51:44 +0000 Subject: [PATCH 2/2] ci(release): backport the release failure fixes to v2.x (#7465) Backports the parts of #7465 that apply to a maintenance line: - the platform-package wait in publish.ts polls for up to 30 minutes and the publish job timeout is 60 minutes - backfill-release-notes drops other channels' prerelease tags and rebuilds one channel note per tagged commit from tag names, and forces the release type implied by the tag's own version so a forced maintenance version still gets release notes The next-ahead gate, the in-flight stable release wait, the setup-cli v3 smoke test changes and the next channel docs are left out: v2.x has no next channel, its release.yml only runs as a maintenance dispatch, and its setup-cli smoke test covers v1 and v2 only. (cherry picked from commit 497bf42f5ee71458d5345fdb6168b1abcf69c242) --- .github/workflows/release-shared.yml | 2 +- .../backfill-release-notes.e2e.test.ts | 180 ++++++++++++++++++ apps/cli/scripts/backfill-release-notes.ts | 79 +++++--- 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 | 44 +++++ 7 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 apps/cli/scripts/backfill-release-notes.e2e.test.ts diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 6cce7e3d4a..d2f23adf6a 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/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..3323fcc37c --- /dev/null +++ b/apps/cli/scripts/backfill-release-notes.e2e.test.ts @@ -0,0 +1,180 @@ +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.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; + 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 e578450a53..15b6bcce97 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -24,8 +24,13 @@ import { tmpdir } from "node:os"; import path from "node:path"; 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"; const { values } = parseArgs({ options: { @@ -103,7 +108,7 @@ try { console.error(`==> Cloning ${repoRoot} -> ${clone}`); await $`git clone --quiet --no-local ${repoRoot} ${clone}`; - // `git notes add` (used below to seed channel notes) requires a committer + // `git notes add` (used below to rebuild channel notes) requires a committer // identity, which CI runners don't ship in ~/.gitconfig. await $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`; await $`git -C ${clone} config --local user.name backfill-release-notes`; @@ -114,15 +119,6 @@ try { const originUrl = (await $`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. - await $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(); - await $`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"]) { @@ -161,6 +157,13 @@ try { for (const ct of coincidentTags) { await $`git -C ${clone} tag -d ${ct}`.quiet().nothrow(); } + const targetChannel = channelForTag(tag); + const tagsOutsideChannel = (await $`git -C ${clone} tag --list`.text()) + .split("\n") + .filter((t) => t && !isInChannelHistory(t, targetChannel)); + if (tagsOutsideChannel.length > 0) { + await $`git -C ${clone} tag -d ${tagsOutsideChannel}`.quiet(); + } await $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`; // The clone only carries refs/heads/$BRANCH locally; seed the other @@ -176,20 +179,32 @@ try { } } - // 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 = await $`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 = await $`git -C ${clone} notes --ref semantic-release show ${prevTag}` - .nothrow() - .quiet(); - if (noteCheck.exitCode === 0) continue; - const payload = JSON.stringify({ channels: [channelForTag(prevTag)] }); - await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(); + const taggedCommits = ( + await $`git -C ${clone} rev-parse ${mergedTags.map((t) => `refs/tags/${t}^{commit}`)}`.text() + ) + .split("\n") + .filter(Boolean); + if (taggedCommits.length !== mergedTags.length) { + console.error( + `Resolved ${taggedCommits.length} commits for ${mergedTags.length} tags merged into ${tag}`, + ); + process.exit(1); + } + const channelByCommit = new Map>(); + for (const [i, prevTag] of mergedTags.entries()) { + const commit = taggedCommits[i]!; + if (prevTag.includes("-") || !channelByCommit.has(commit)) { + channelByCommit.set(commit, channelForTag(prevTag)); + } + } + for (const [commit, channel] of channelByCommit) { + const payload = JSON.stringify({ channels: [channel] }); + await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${commit}`.quiet(); } // Apply the current release config to the historical checkout, so the notes @@ -226,8 +241,21 @@ try { 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 = await semanticRelease( - { dryRun: true, noCi: true, repositoryUrl: repoUrl }, + { + dryRun: true, + noCi: true, + repositoryUrl: repoUrl, + plugins: [...configuredPlugins, releaseFloor], + }, { cwd: path.join(clone, "apps/cli"), env: childEnv, @@ -241,7 +269,6 @@ try { process.exit(1); } - const expected = tag.replace(/^v/, ""); if (result.nextRelease.version !== expected) { 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..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"]); @@ -38,3 +40,31 @@ 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 unless `version` is later by SemVer precedence. */ +export function releaseTypeBetween( + lastVersion: string | undefined, + version: string, +): "major" | "minor" | "patch" | null { + if (lastVersion === undefined || semver.order(version, lastVersion) !== 1) return null; + const last = versionBase(lastVersion); + const next = versionBase(version); + if (!last || !next) return 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 5a157f5517..439c21c027 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,45 @@ 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], + ["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); + }); +});