Skip to content
Closed
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
95 changes: 95 additions & 0 deletions .github/workflows/publish-scoop-main.yml
Original file line number Diff line number Diff line change
@@ -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[@]}"
15 changes: 15 additions & 0 deletions .github/workflows/release-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"src/**/*.e2e.test.ts"
],
"ignore": [
"scripts/*.ts",
"scripts/**/*.ts",
"tests/**/*.ts",
"src/shared/telemetry/event-catalog.ts"
],
Expand Down
83 changes: 83 additions & 0 deletions apps/cli/scripts/lib/scoop-manifest.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<Map<string, string>> {
const { readFile } = await import("node:fs/promises");
const text = await readFile(path, "utf-8");
const checksums = new Map<string, string>();
for (const line of text.trim().split("\n")) {
const [hash, file] = line.split(/\s+/) as [string, string];
checksums.set(file, hash);
}
return checksums;
}
149 changes: 149 additions & 0 deletions apps/cli/scripts/update-scoop-main.ts
Original file line number Diff line number Diff line change
@@ -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 <version> [--repo <owner/repo>] [--fork <owner/repo>] [--upstream <owner/repo>] [--upstream-branch <branch>] [--pr-target <upstream|fork>] [--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<string, string>;
cleanup?: () => Promise<void>;
}> {
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?.();
}
Loading
Loading