diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 726967779..25da8bc25 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -346,3 +346,46 @@ jobs: run: pnpm exec bun apps/cli/scripts/update-scoop.ts --version "${VERSION}" --name "${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 + # 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 + + - 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 0f5041fb3..760effbe7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -95,7 +95,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}`);