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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/release-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,15 @@ 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 }}
VERSION: ${{ inputs.version }}
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:
Expand Down Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions apps/cli/scripts/backfill-release-notes.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>, cwd: string, env: Record<string, string | undefined>) =>
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<string>) => Effect.Effect<string>;
readonly commit: (message: string) => Effect.Effect<string>;
readonly release: (tag: string, channels: string) => Effect.Effect<void>;
}

/**
* 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<void>) =>
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<string>) =>
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)),
);
});
79 changes: 53 additions & 26 deletions apps/cli/scripts/backfill-release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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`;
Expand All @@ -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"]) {
Expand Down Expand Up @@ -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
Expand All @@ -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<string, ReturnType<typeof channelForTag>>();
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
Expand Down Expand Up @@ -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,
Expand All @@ -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}; ` +
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/scripts/npm-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/scripts/npm-registry.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading