From ebf1222560af757708f74d7abc454f1d55d2d797 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 10:50:01 +0000 Subject: [PATCH 1/2] ci(cli): open PR to ScoopInstaller/Main on stable release After a stable release, push the manifest to our own scoop-bucket and also open a PR from supabase/scoop-main (our fork of ScoopInstaller/Main) to upstream so users of the default Scoop bucket pick up new versions immediately, rather than waiting for the upstream excavator bot to catch up. The PR rewrites the upstream manifest to match the format we produce for supabase/scoop-bucket (.zip artifacts with version in filename, hashes embedded). A shared builder keeps the two scripts from drifting. --- .github/workflows/release-shared.yml | 40 +++++++++ apps/cli/package.json | 2 +- apps/cli/scripts/lib/scoop-manifest.ts | 83 +++++++++++++++++++ apps/cli/scripts/update-scoop-main.ts | 108 +++++++++++++++++++++++++ apps/cli/scripts/update-scoop.ts | 62 +++----------- 5 files changed, 242 insertions(+), 53 deletions(-) create mode 100644 apps/cli/scripts/lib/scoop-manifest.ts create mode 100644 apps/cli/scripts/update-scoop-main.ts diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index b9b41b6f5..e431e5561 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -307,3 +307,43 @@ jobs: run: pnpm exec bun apps/cli/scripts/update-scoop.ts --version ${{ inputs.version }} --name ${{ inputs.scoop_name }} env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + publish-scoop-main: + needs: publish-scoop + # Stable channel only — beta lives in our own bucket, never PR'd upstream. + if: ${{ !inputs.dry_run && inputs.scoop_name == 'supabase' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup + uses: ./.github/actions/setup + + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: cli-build-${{ inputs.shell }}-${{ inputs.version }} + + - name: Generate scoop-main fork token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + scoop-main + + - name: Configure git for fork push + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh auth setup-git + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Open PR against ScoopInstaller/Main + run: pnpm exec bun apps/cli/scripts/update-scoop-main.ts --version ${{ inputs.version }} + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/apps/cli/package.json b/apps/cli/package.json index adaf2184a..094281457 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -105,7 +105,7 @@ "src/**/*.e2e.test.ts" ], "ignore": [ - "scripts/*.ts", + "scripts/**/*.ts", "tests/**/*.ts" ], "ignoreBinaries": [ diff --git a/apps/cli/scripts/lib/scoop-manifest.ts b/apps/cli/scripts/lib/scoop-manifest.ts new file mode 100644 index 000000000..ff87db2ec --- /dev/null +++ b/apps/cli/scripts/lib/scoop-manifest.ts @@ -0,0 +1,83 @@ +// Shared Scoop manifest builder used by both update-scoop.ts (our own +// supabase/scoop-bucket) and update-scoop-main.ts (PR to upstream +// ScoopInstaller/Main). Producing the same JSON from one place keeps +// the two buckets from drifting in URL format, hashes, or arch list. + +export interface BuildScoopManifestOptions { + version: string; + repo: string; + checksums: Map; + local?: boolean; + distDir?: string; +} + +export interface BuildScoopManifestResult { + manifest: object; + json: string; +} + +const BIN_ENTRY = "supabase.exe"; + +export function buildScoopManifest(opts: BuildScoopManifestOptions): BuildScoopManifestResult { + const { version, repo, checksums, local = false, distDir } = opts; + + if (local && !distDir) { + throw new Error("distDir is required when local=true"); + } + + const baseUrl = local + ? `file:///${distDir!.replace(/\\/g, "/")}` + : `https://github.com/${repo}/releases/download/v${version}`; + + const sha = (file: string): string => { + const hash = checksums.get(file); + if (!hash) throw new Error(`Checksum not found for ${file}`); + return hash; + }; + + const manifest = { + version, + description: "Supabase CLI", + homepage: "https://supabase.com", + license: "MIT", + architecture: { + "64bit": { + url: `${baseUrl}/supabase_${version}_windows_amd64.zip`, + hash: sha(`supabase_${version}_windows_amd64.zip`), + bin: [BIN_ENTRY], + }, + arm64: { + url: `${baseUrl}/supabase_${version}_windows_arm64.zip`, + hash: sha(`supabase_${version}_windows_arm64.zip`), + bin: [BIN_ENTRY], + }, + }, + checkver: { + github: `https://github.com/${repo}`, + }, + autoupdate: { + architecture: { + "64bit": { + url: `https://github.com/${repo}/releases/download/v$version/supabase_$version_windows_amd64.zip`, + }, + arm64: { + url: `https://github.com/${repo}/releases/download/v$version/supabase_$version_windows_arm64.zip`, + }, + }, + }, + }; + + const json = `${JSON.stringify(manifest, null, 4)}\n`; + return { manifest, json }; +} + +export async function readChecksums(path: string): Promise> { + const { readFile } = await import("node:fs/promises"); + const text = await readFile(path, "utf-8"); + const checksums = new Map(); + for (const line of text.trim().split("\n")) { + const [hash, file] = line.split(/\s+/) as [string, string]; + checksums.set(file, hash); + } + return checksums; +} diff --git a/apps/cli/scripts/update-scoop-main.ts b/apps/cli/scripts/update-scoop-main.ts new file mode 100644 index 000000000..d1df8af4c --- /dev/null +++ b/apps/cli/scripts/update-scoop-main.ts @@ -0,0 +1,108 @@ +import { $ } from "bun"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +import { buildScoopManifest, readChecksums } from "./lib/scoop-manifest.ts"; + +const { values } = parseArgs({ + options: { + version: { type: "string" }, + repo: { type: "string", default: "supabase/cli" }, + fork: { type: "string", default: "supabase/scoop-main" }, + upstream: { type: "string", default: "ScoopInstaller/Main" }, + "upstream-branch": { type: "string", default: "master" }, + local: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + }, +}); + +const version = values.version; +if (!version) { + console.error( + "Usage: bun run scripts/update-scoop-main.ts --version [--repo ] [--fork ] [--upstream ] [--upstream-branch ] [--local] [--dry-run]", + ); + process.exit(1); +} + +const repo = values.repo!; +const fork = values.fork!; +const upstream = values.upstream!; +const upstreamBranch = values["upstream-branch"]!; +const local = values.local!; +const dryRun = values["dry-run"]!; + +const root = path.resolve(import.meta.dir, "../../.."); +const distDir = path.join(root, "dist"); + +const checksums = await readChecksums(path.join(distDir, "checksums.txt")); +const { json: manifestJson } = buildScoopManifest({ + version, + repo, + checksums, + local, + distDir, +}); + +console.log(`Built scoop manifest for ${repo}@${version}`); + +if (local || dryRun) { + console.log(manifestJson); + process.exit(0); +} + +const branch = `supabase-${version}`; +const manifestPathInRepo = "bucket/supabase.json"; + +const tmpDir = await mkdtemp(path.join(tmpdir(), "scoop-main-")); +try { + await $`gh repo clone ${fork} ${tmpDir}`; + + // Sync fork's master with upstream so the PR diff is clean. + await $`git -C ${tmpDir} remote add upstream https://github.com/${upstream}.git`; + await $`git -C ${tmpDir} fetch upstream ${upstreamBranch}`; + await $`git -C ${tmpDir} checkout ${upstreamBranch}`; + await $`git -C ${tmpDir} reset --hard upstream/${upstreamBranch}`; + await $`git -C ${tmpDir} push origin ${upstreamBranch} --force-with-lease`; + + // Branch off the synced base. + await $`git -C ${tmpDir} checkout -B ${branch}`; + + await writeFile(path.join(tmpDir, manifestPathInRepo), manifestJson); + + // If the manifest is already current upstream (e.g. the excavator bot + // landed this version first), bail out cleanly. + const diff = await $`git -C ${tmpDir} status --porcelain ${manifestPathInRepo}`.text(); + if (diff.trim() === "") { + console.log(`${upstream}/${manifestPathInRepo} already at ${version}; nothing to do.`); + process.exit(0); + } + + await $`git -C ${tmpDir} add ${manifestPathInRepo}`; + await $`git -C ${tmpDir} commit -m ${`supabase: Update to version ${version}`}`; + await $`git -C ${tmpDir} push origin ${branch} --force-with-lease`; + + // Open a PR upstream. If one already exists for this head ref, gh + // will print an error — treat that as success. + const forkOwner = fork.split("/")[0]; + const title = `supabase@${version}: Update to ${version}`; + const body = `Bumps the \`supabase\` manifest to v${version}.\n\nSee https://github.com/${repo}/releases/tag/v${version}.`; + + const pr = + await $`gh pr create --repo ${upstream} --base ${upstreamBranch} --head ${forkOwner}:${branch} --title ${title} --body ${body}`.nothrow(); + if (pr.exitCode !== 0) { + const stderr = pr.stderr.toString(); + if (stderr.includes("already exists")) { + console.log(`PR for ${forkOwner}:${branch} already open; skipping.`); + } else { + console.error(stderr); + process.exit(pr.exitCode); + } + } else { + console.log(pr.stdout.toString()); + } +} finally { + await rm(tmpDir, { recursive: true }); +} diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index 14bf9a965..04a15d63a 100644 --- a/apps/cli/scripts/update-scoop.ts +++ b/apps/cli/scripts/update-scoop.ts @@ -1,10 +1,12 @@ import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { buildScoopManifest, readChecksums } from "./lib/scoop-manifest.ts"; + const { values } = parseArgs({ options: { version: { type: "string" }, @@ -35,63 +37,19 @@ const dryRun = values["dry-run"]!; // beta can coexist in the same bucket. Matches the Go CLI's historical // scoop-bucket layout (`supabase.json` and `supabase-beta.json` both shim // `supabase.exe`). -const binEntry = "supabase.exe"; const root = path.resolve(import.meta.dir, "../../.."); const distDir = path.join(root, "dist"); -// Parse checksums -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} - -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} - -// Scoop supports file:// URLs for local testing -const baseUrl = local - ? `file:///${distDir.replace(/\\/g, "/")}` - : `https://github.com/${repo}/releases/download/v${version}`; - -const manifest = { +const checksums = await readChecksums(path.join(distDir, "checksums.txt")); +const { json: manifestJson } = buildScoopManifest({ version, - description: "Supabase CLI", - homepage: "https://supabase.com", - license: "MIT", - architecture: { - "64bit": { - url: `${baseUrl}/supabase_${version}_windows_amd64.zip`, - hash: sha(`supabase_${version}_windows_amd64.zip`), - bin: [binEntry], - }, - arm64: { - url: `${baseUrl}/supabase_${version}_windows_arm64.zip`, - hash: sha(`supabase_${version}_windows_arm64.zip`), - bin: [binEntry], - }, - }, - checkver: { - github: `https://github.com/${repo}`, - }, - autoupdate: { - architecture: { - "64bit": { - url: `https://github.com/${repo}/releases/download/v$version/supabase_$version_windows_amd64.zip`, - }, - arm64: { - url: `https://github.com/${repo}/releases/download/v$version/supabase_$version_windows_arm64.zip`, - }, - }, - }, -}; + repo, + checksums, + local, + distDir, +}); const manifestFileName = `${name}.json`; -const manifestJson = `${JSON.stringify(manifest, null, 4)}\n`; const manifestOut = path.join(distDir, manifestFileName); await writeFile(manifestOut, manifestJson); console.log(`Manifest written to ${manifestOut}`); From 8cd5b85ca9c4c1ac59047082eeedaa454dfb6a4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 14:59:59 +0000 Subject: [PATCH 2/2] ci(cli): make publish-scoop-main best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failure to open the upstream PR shouldn't fail the release — the upstream excavator bot will catch up on its own cron. --- .github/workflows/release-shared.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index e431e5561..3a98e871b 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -313,6 +313,9 @@ jobs: # Stable channel only — beta lives in our own bucket, never PR'd upstream. if: ${{ !inputs.dry_run && inputs.scoop_name == 'supabase' }} runs-on: ubuntu-latest + # Best-effort: the upstream excavator bot will catch us up within a few + # hours regardless, so a failure here should not fail the release. + continue-on-error: true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6