diff --git a/.github/workflows/publish-scoop-main.yml b/.github/workflows/publish-scoop-main.yml new file mode 100644 index 0000000000..53a43e459c --- /dev/null +++ b/.github/workflows/publish-scoop-main.yml @@ -0,0 +1,95 @@ +name: Publish Scoop Main + +# PR-based sync of `bucket/supabase.json` on ScoopInstaller/Main, posted from +# our supabase/scoop-main fork. Called from release-shared.yml on every stable +# release and dispatchable manually against an already-published tag for +# testing — opening the PR on the fork (pr_target=fork) keeps the third-party +# upstream untouched during dry-runs. +# +# Exists as its own workflow (rather than an inline job in release-shared) so +# the manual dispatch surface is available and so the script can self-fetch +# checksums.txt from the GitHub release, no inter-job build artifacts needed. + +on: + workflow_call: + inputs: + version: + description: Supabase CLI version/tag to sync (must already be published to GitHub Releases) + required: true + type: string + secrets: + GH_APP_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + version: + description: Supabase CLI version/tag to sync (must already be published to GitHub Releases) + required: true + type: string + pr_target: + description: Where to open the PR + required: false + type: choice + default: fork + options: + - fork + - upstream + dry_run: + description: Build & print the manifest only; skip fork sync / push / PR + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish-scoop-main: + runs-on: ubuntu-latest + # Best-effort only on the workflow_call (release) path — the upstream + # excavator bot catches us up within hours, so failing the release on a + # transient PR error isn't worth it. Manual dispatch stays strict so test + # failures surface. + continue-on-error: ${{ github.event_name == 'workflow_call' }} + env: + VERSION: ${{ inputs.version }} + PR_TARGET: ${{ inputs.pr_target || 'upstream' }} + DRY_RUN: ${{ inputs.dry_run && 'true' || '' }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Generate scoop-main fork token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + scoop-main + permission-contents: write + permission-pull-requests: write + + - 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 + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + args=(--version "${VERSION}" --pr-target "${PR_TARGET}") + if [ -n "${DRY_RUN}" ]; then + args+=(--dry-run) + fi + pnpm exec bun apps/cli/scripts/update-scoop-main.ts "${args[@]}" diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 8505ba61e0..2620b4b30b 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -431,6 +431,21 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + # Post-stable PR to ScoopInstaller/Main, opened from our supabase/scoop-main + # fork. Lives in its own reusable workflow so it can be re-run manually + # (workflow_dispatch on publish-scoop-main.yml) against any already-published + # tag without re-doing a release. Stable channel only — beta stays in our + # own scoop-bucket. Best-effort: a failure inside the called workflow does + # not fail the release, since the upstream excavator bot will catch us up. + publish-scoop-main: + needs: publish-scoop + if: ${{ !inputs.dry_run && inputs.scoop_name == 'supabase' }} + uses: ./.github/workflows/publish-scoop-main.yml + with: + version: ${{ inputs.version }} + secrets: + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against # the just-released CLI. Runs last and intentionally does not gate # publish-homebrew / publish-scoop — by the time the smoke runs, the npm diff --git a/apps/cli/package.json b/apps/cli/package.json index 4d07c5a39b..2a3d0cccf2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -103,7 +103,7 @@ "src/**/*.e2e.test.ts" ], "ignore": [ - "scripts/*.ts", + "scripts/**/*.ts", "tests/**/*.ts", "src/shared/telemetry/event-catalog.ts" ], diff --git a/apps/cli/scripts/lib/scoop-manifest.ts b/apps/cli/scripts/lib/scoop-manifest.ts new file mode 100644 index 0000000000..ff87db2eca --- /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 0000000000..d90edd9d77 --- /dev/null +++ b/apps/cli/scripts/update-scoop-main.ts @@ -0,0 +1,149 @@ +import { $ } from "bun"; +import { access, 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" }, + "pr-target": { type: "string", default: "upstream" }, + 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 ] [--pr-target ] [--local] [--dry-run]", + ); + process.exit(1); +} + +const repo = values.repo!; +const fork = values.fork!; +const upstream = values.upstream!; +const upstreamBranch = values["upstream-branch"]!; +const prTarget = values["pr-target"]!; +const local = values.local!; +const dryRun = values["dry-run"]!; + +if (prTarget !== "upstream" && prTarget !== "fork") { + console.error(`Invalid --pr-target: ${prTarget} (expected "upstream" or "fork")`); + process.exit(1); +} + +const root = path.resolve(import.meta.dir, "../../.."); +const distDir = path.join(root, "dist"); + +// In-pipeline runs have checksums.txt next to the build artifacts; --local +// builds do too. Manual runs against an already-published tag fetch it from +// the GitHub release — same source of truth, no rebuild required. +async function resolveChecksums(): Promise<{ + checksums: Map; + cleanup?: () => Promise; +}> { + const localPath = path.join(distDir, "checksums.txt"); + try { + await access(localPath); + return { checksums: await readChecksums(localPath) }; + } catch { + if (local) { + throw new Error( + `--local set but ${localPath} not found; build locally before running with --local.`, + ); + } + } + const dlDir = await mkdtemp(path.join(tmpdir(), "scoop-checksums-")); + console.log(`Fetching checksums.txt from ${repo} release v${version}…`); + await $`gh release download v${version} --repo ${repo} --pattern checksums.txt --dir ${dlDir}`; + const checksums = await readChecksums(path.join(dlDir, "checksums.txt")); + return { checksums, cleanup: () => rm(dlDir, { recursive: true }) }; +} + +const { checksums, cleanup: cleanupChecksums } = await resolveChecksums(); +try { + 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 just our bump. + 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`; + + // PR target: + // upstream → cross-repo PR from fork branch to ScoopInstaller/Main + // (the real production flow on stable release) + // fork → in-repo PR within the fork (manual testing path: + // exercises the whole pipeline without touching upstream) + 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 targetRepo = prTarget === "upstream" ? upstream : fork; + const head = prTarget === "upstream" ? `${forkOwner}:${branch}` : branch; + + const pr = + await $`gh pr create --repo ${targetRepo} --base ${upstreamBranch} --head ${head} --title ${title} --body ${body}`.nothrow(); + if (pr.exitCode !== 0) { + const stderr = pr.stderr.toString(); + if (stderr.includes("already exists")) { + console.log(`PR for ${head} → ${targetRepo} already open; skipping.`); + } else { + console.error(stderr); + process.exit(pr.exitCode); + } + } else { + console.log(pr.stdout.toString()); + } + } finally { + await rm(tmpDir, { recursive: true }); + } +} finally { + await cleanupChecksums?.(); +} diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index 14bf9a965f..04a15d63ad 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}`);