From 6659122f24a6d7b6ae5eb54a28aebdcb7ba643d3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 18 Jun 2026 11:27:36 +0200 Subject: [PATCH] chore(cli): support Winget publishing --- .github/workflows/release-shared.yml | 44 +++++++++++-- .github/workflows/release.yml | 11 ++++ apps/cli/docs/release-process.md | 39 +++++++++-- apps/cli/scripts/update-winget.ps1 | 64 +++++++++++++++++++ .../deploy/deploy.integration.test.ts | 28 ++++---- apps/cli/src/shared/functions/deploy.ts | 12 +--- ...1-cli-release-and-distribution-strategy.md | 14 +++- 7 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 apps/cli/scripts/update-winget.ps1 diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 1b6f7cd607..fc0b8ffc44 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -42,6 +42,16 @@ on: required: false type: string default: supabase + publish_winget: + description: Whether to submit a Winget manifest PR + required: false + type: boolean + default: false + winget_package_id: + description: Winget package identifier + required: false + type: string + default: Supabase.CLI secrets: SENTRY_DSN: required: false @@ -55,6 +65,8 @@ on: required: false DF_FIREWALL_TOKEN: required: false + WINGET_CREATE_GITHUB_TOKEN: + required: false jobs: build-blacksmith: name: Build CLI artifacts (Blacksmith) @@ -536,20 +548,44 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + publish-winget: + needs: publish + if: ${{ !inputs.dry_run && inputs.publish_winget }} + runs-on: windows-2025 + env: + VERSION: ${{ inputs.version }} + WINGET_PACKAGE_ID: ${{ inputs.winget_package_id }} + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Submit Winget manifest + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:WINGET_CREATE_GITHUB_TOKEN)) { + Write-Error "WINGET_CREATE_GITHUB_TOKEN is required to submit Winget manifests." + exit 1 + fi + ./apps/cli/scripts/update-winget.ps1 -Version $env:VERSION -PackageId $env:WINGET_PACKAGE_ID -Submit + # 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 - # package and GitHub release are already live and the brew/scoop pushes + # publish-homebrew / publish-scoop / publish-winget — by the time the smoke + # runs, the npm package and GitHub release are already live and downstream pushes # have either succeeded or skipped, so a setup-cli regression surfaces as # a red post-release signal without holding back the rest of the channel. # # Depends on the publish jobs only via `needs` for ordering; the `if` # uses `always() && needs.publish.result == 'success'` so the smoke still - # runs when publish-homebrew / publish-scoop are skipped (alpha) or fail. + # runs when publish-homebrew / publish-scoop / publish-winget are skipped + # (alpha) or fail. # The reusable workflow can also be dispatched manually against any # already-published version when debugging setup-cli regressions. setup-cli-smoke: - needs: [publish, publish-homebrew, publish-scoop] + needs: [publish, publish-homebrew, publish-scoop, publish-winget] if: ${{ always() && !inputs.dry_run && needs.publish.result == 'success' }} uses: ./.github/workflows/setup-cli-smoke-test.yml with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e72b5d6c9..50a8473d01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,7 @@ jobs: brew_name: ${{ steps.compute.outputs.brew_name }} scoop_name: ${{ steps.compute.outputs.scoop_name }} publish_brew_scoop: ${{ steps.compute.outputs.publish_brew_scoop }} + publish_winget: ${{ steps.compute.outputs.publish_winget }} dry_run: ${{ steps.compute.outputs.dry_run }} channel: ${{ steps.compute.outputs.channel }} steps: @@ -128,6 +129,7 @@ jobs: DISPATCH_DRY_RUN: ${{ inputs.dry_run }} SR_PUBLISHED: ${{ steps.semantic-release.outputs.new_release_published }} SR_VERSION: ${{ steps.semantic-release.outputs.new_release_version }} + WINGET_PUBLISH_ENABLED: ${{ vars.WINGET_PUBLISH_ENABLED }} run: | set -euo pipefail if [[ "$EVENT" == "workflow_dispatch" ]]; then @@ -157,6 +159,7 @@ jobs: brew_name="" scoop_name="" publish_brew_scoop=false + publish_winget=false ;; beta) shell=legacy @@ -165,6 +168,7 @@ jobs: brew_name=supabase-beta scoop_name=supabase-beta publish_brew_scoop=true + publish_winget=false ;; stable) shell=legacy @@ -173,6 +177,10 @@ jobs: brew_name=supabase scoop_name=supabase publish_brew_scoop=true + publish_winget=false + if [[ "$WINGET_PUBLISH_ENABLED" == "true" ]]; then + publish_winget=true + fi ;; *) echo "Unknown channel: $channel" >&2 @@ -188,6 +196,7 @@ jobs: echo "brew_name=$brew_name" echo "scoop_name=$scoop_name" echo "publish_brew_scoop=$publish_brew_scoop" + echo "publish_winget=$publish_winget" echo "dry_run=$dry_run" echo "channel=$channel" } >> "$GITHUB_OUTPUT" @@ -214,6 +223,7 @@ jobs: publish_brew_scoop: ${{ needs.plan.outputs.publish_brew_scoop == 'true' }} brew_name: ${{ needs.plan.outputs.brew_name }} scoop_name: ${{ needs.plan.outputs.scoop_name }} + publish_winget: ${{ needs.plan.outputs.publish_winget == 'true' }} dry_run: ${{ needs.plan.outputs.dry_run == 'true' }} channel: ${{ needs.plan.outputs.channel }} secrets: @@ -223,6 +233,7 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} # Posts to the release Slack channel once the pipeline succeeds. Listing # `release` in `needs` without a status function in `if:` keeps the implicit diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 84c6801897..03fa895eef 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -4,13 +4,13 @@ This document is the operational playbook for releasing the Supabase CLI TypeScr 1. **Ring 1 — Local Verdaccio.** Fastest feedback loop. Build and install the CLI from a local npm registry on your own machine. No network side-effects; no repo pushes. 2. **Ring 2 — User-owned PoC repos.** End-to-end validation through the exact same Homebrew / Scoop / GitHub-Release code paths production uses, but pointed at a reviewer's own GitHub account and a non-`supabase` artifact name. This is how ADR 0011 gates 2 and 3 are validated without risking the real production channels. -3. **Ring 3 — Production.** The real `supabase` npm package + `supabase/homebrew-tap` + `supabase/scoop-bucket` + GitHub Releases on `supabase/cli`. Driven by GitHub Actions (`release.yml`, which dispatches three channels — `alpha`, `beta`, `stable` — into the shared `release-shared.yml`). +3. **Ring 3 — Production.** The real `supabase` npm package + `supabase/homebrew-tap` + `supabase/scoop-bucket` + a Winget PR to `microsoft/winget-pkgs` + GitHub Releases on `supabase/cli`. Driven by GitHub Actions (`release.yml`, which dispatches three channels — `alpha`, `beta`, `stable` — into the shared `release-shared.yml`). ```mermaid flowchart LR local["Ring 1: Local Verdaccio
pnpm cli-release
--next or --legacy"] poc["Ring 2: User-owned PoC repos
avallete/supabase-cli-release-poc
avallete/homebrew-supabase-shim-poc
avallete/scoop-bucket
--name supabase-shim-poc"] - prod["Ring 3: Production
supabase/cli
supabase/homebrew-tap
supabase/scoop-bucket
(default name: supabase)"] + prod["Ring 3: Production
supabase/cli
supabase/homebrew-tap
supabase/scoop-bucket
microsoft/winget-pkgs PR
(default name: supabase)"] local --> poc --> prod ``` @@ -86,7 +86,7 @@ gh auth status # verify: ✓ Logged in to github.com account ### Dry-run: generate artifacts without pushing -The `--dry-run` flag on both updater scripts produces the `Formula/.rb` and `.json` in `dist/` and prints them, without cloning or pushing to the tap/bucket. Good for inspecting changes before they go out. +The `--dry-run` flag on the updater scripts produces the generated downstream files in `dist/` and prints them, without cloning or pushing to the tap/bucket. Good for inspecting changes before they go out. ```sh # Build all eight platform archives + linux packages + checksums.txt. @@ -107,10 +107,13 @@ bun apps/cli/scripts/update-scoop.ts --version 0.0.1 \ --bucket avallete/scoop-bucket \ --name supabase-shim-poc \ --dry-run + ``` Inspect `dist/supabase-shim-poc.rb` and `dist/supabase-shim-poc.json`. The `sha256` / `hash` fields resolve against `dist/checksums.txt`; the `url` fields point at `https://github.com/avallete/supabase-cli-release-poc/releases/download/v0.0.1/...` (the release host specified by `--repo`). +Winget is intentionally not part of Ring 2: WingetCreate's non-interactive update path depends on an existing package identifier in `microsoft/winget-pkgs`, so production automation starts only after the one-time `Supabase.CLI` bootstrap PR is accepted. + ### Upload the GitHub Release The updater scripts do **not** create the GitHub Release or upload `dist/`\* — in production that's `[release-shared.yml](../../../.github/workflows/release-shared.yml)`'s `publish` job. For a PoC run, do it manually with `gh release create`: @@ -230,7 +233,7 @@ flowchart TD ff --> pushMain dispatch[workflow_dispatch
channel + version] --> plan - plan["plan (ubuntu-latest)
cycjimmy/semantic-release-action --dry-run
computes channel, version, shell, npm_tag, brew/scoop name"] + plan["plan (ubuntu-latest)
cycjimmy/semantic-release-action --dry-run
computes channel, version, shell, npm_tag, downstream names"] plan --> shared shared["release-shared.yml"] @@ -240,8 +243,22 @@ flowchart TD pub --> rel["softprops/action-gh-release
(draft) → gh release edit --draft=false"] rel --> hb["publish-homebrew
App-token-authed clone of homebrew-tap
update-homebrew.ts --name "] rel --> sc["publish-scoop
App-token-authed clone of scoop-bucket
update-scoop.ts --name "] + rel --> wg["publish-winget
WingetCreate update --submit
update-winget.ps1 -PackageId Supabase.CLI"] ``` +Only the stable channel is submitted to Winget (`Supabase.CLI`). Beta / alpha are intentionally skipped until there is a concrete need for separate prerelease package identifiers. + +### One-time Winget bootstrap + +Before enabling `publish-winget` in production, submit the first `Supabase.CLI` version to `microsoft/winget-pkgs`. The initial manifest should use the existing GitHub Release ZIP assets: + +- `https://github.com/supabase/cli/releases/download/v/supabase__windows_amd64.zip` +- `https://github.com/supabase/cli/releases/download/v/supabase__windows_arm64.zip` + +The installer manifest must use `InstallerType: zip`, `NestedInstallerType: portable`, and `NestedInstallerFiles` entries for `supabase.exe` with `PortableCommandAlias: supabase`. While stable releases still ship the legacy shell, also include `supabase-go.exe` as a nested portable file. + +Open the bootstrap PR from the GitHub account that should own the Microsoft CLA and future WingetCreate submissions. After that PR is accepted and `winget show --id Supabase.CLI` resolves, configure the release repository with `WINGET_CREATE_GITHUB_TOKEN` and set `WINGET_PUBLISH_ENABLED=true`; stable release CI can then use WingetCreate's non-interactive `update` command for every later version. + ### Trigger Most releases are automatic — merge a PR into `develop` (beta) or approve the weekly Prod-Deploy PR into `main` (stable). For an `alpha` cut or a one-off override, dispatch manually: @@ -283,7 +300,7 @@ The matrix does not yet include `windows-11-arm` (gate 6) or an Alpine musl runn 3. `softprops/action-gh-release` creates a **draft** Release `v` on `supabase/cli` with all tar / zip / deb / rpm / apk + `checksums.txt`. 4. `gh release edit v --draft=false` finalises it (immutable from this point). -### Post-publish: Homebrew + Scoop +### Post-publish: Homebrew + Scoop + Winget Both updaters run automatically from `release-shared.yml`'s `publish-homebrew` and `publish-scoop` jobs after the GitHub Release is finalised. Each job mints a GitHub App token scoped to `homebrew-tap` / `scoop-bucket` (via `actions/create-github-app-token` with the `GH_APP_CLIENT_ID` + `GH_APP_PRIVATE_KEY` secrets), runs `gh auth setup-git` + sets a `github-actions[bot]` git identity, then invokes `apps/cli/scripts/update-homebrew.ts` / `update-scoop.ts` with `--name ` / `--name `: @@ -291,9 +308,11 @@ Both updaters run automatically from `release-shared.yml`'s `publish-homebrew` a - `beta` → `--name supabase-beta` (a separate formula / manifest for the prerelease channel) - `alpha` → skipped (Homebrew + Scoop are not part of the v3 alpha story; npm only) +Stable releases also run `publish-winget` when `WINGET_PUBLISH_ENABLED=true`, invoking `apps/cli/scripts/update-winget.ps1 -PackageId Supabase.CLI -Submit` on a Windows runner. The script downloads WingetCreate, calls `wingetcreate update` with the released Windows ZIP URLs and explicit `x64` / `arm64` architecture overrides, and submits the PR against `microsoft/winget-pkgs`. The job requires a `WINGET_CREATE_GITHUB_TOKEN` secret for the account that should submit Winget PRs. + ### Verification -After `release-shared.yml` finishes (all jobs including `publish-homebrew` and `publish-scoop`): +After `release-shared.yml` finishes (all jobs including `publish-homebrew`, `publish-scoop`, and `publish-winget`): ```sh npm view supabase@0.1.0 dist-tags # expect: latest: 0.1.0 (or beta: 0.1.0-beta.N for beta channel) @@ -307,6 +326,11 @@ supabase --version # expect: supabase v0.1.0 scoop update scoop install supabase # or: scoop install supabase-beta supabase --version # expect: supabase v0.1.0 + +# Windows (Winget, stable only, after the microsoft/winget-pkgs PR is accepted): +winget source update +winget install --id Supabase.CLI +supabase --version # expect: supabase v0.1.0 ``` The npm package page should also show **Provenance** linking back to `supabase/cli` + `release.yml` (OIDC-attested build). @@ -319,6 +343,7 @@ The per-channel artifacts are immutable once published, so rollback = point user 2. **GitHub Release:** `gh release delete v --repo supabase/cli` (or mark it `prerelease: true` via `gh release edit` to keep the tag around). Artifacts remain downloadable unless the release itself is deleted. 3. **Homebrew:** in `supabase/homebrew-tap`, `git revert ` + push. `brew update` picks it up. 4. **Scoop:** same pattern in `supabase/scoop-bucket` — `git revert` the manifest commit. +5. **Winget:** there is no dist-tag equivalent. Submit a follow-up PR to `microsoft/winget-pkgs` removing the broken version or publish a fixed newer CLI version and let Winget select the highest version. Rollback is straightforward because each channel is its own commit / release. There's no cross-channel state to reconcile. @@ -329,5 +354,5 @@ Rollback is straightforward because each channel is its own commit / release. Th - [ADR 0011](../../../docs/adr/0011-cli-release-and-distribution-strategy.md) — the decision record. Channel choices, signing rationale, open pre-cutover gates. - `[apps/cli/docs/binary-distribution.md](./binary-distribution.md)` — why each platform package contains two binaries (`supabase` SFE + `supabase-go` sidecar) and how they're resolved at runtime. - `[tools/release/local-release.ts](../../../tools/release/local-release.ts)` — Ring 1 implementation. -- `[apps/cli/scripts/build.ts](../scripts/build.ts)`, `[publish.ts](../scripts/publish.ts)`, `[sync-versions.ts](../scripts/sync-versions.ts)`, `[update-homebrew.ts](../scripts/update-homebrew.ts)`, `[update-scoop.ts](../scripts/update-scoop.ts)` — release script implementations. +- `[apps/cli/scripts/build.ts](../scripts/build.ts)`, `[publish.ts](../scripts/publish.ts)`, `[sync-versions.ts](../scripts/sync-versions.ts)`, `[update-homebrew.ts](../scripts/update-homebrew.ts)`, `[update-scoop.ts](../scripts/update-scoop.ts)`, `[update-winget.ps1](../scripts/update-winget.ps1)` — release script implementations. - `[.github/workflows/release.yml](../../../.github/workflows/release.yml)`, `[release-shared.yml](../../../.github/workflows/release-shared.yml)`, `[deploy.yml](../../../.github/workflows/deploy.yml)`, `[deploy-check.yml](../../../.github/workflows/deploy-check.yml)` — Ring 3 pipeline. diff --git a/apps/cli/scripts/update-winget.ps1 b/apps/cli/scripts/update-winget.ps1 new file mode 100644 index 0000000000..0ec8e6b960 --- /dev/null +++ b/apps/cli/scripts/update-winget.ps1 @@ -0,0 +1,64 @@ +param( + [Parameter(Mandatory = $true)] + [string] $Version, + + [string] $PackageId = 'Supabase.CLI', + [string] $Repo = 'supabase/cli', + [string] $WingetCreatePath = '', + [string] $OutDir = '', + [switch] $Submit, + [switch] $DryRun +) + +$ErrorActionPreference = 'Stop' + +if (-not $IsWindows) { + throw 'WingetCreate must run on Windows.' +} + +if ($Submit -and [string]::IsNullOrWhiteSpace($env:WINGET_CREATE_GITHUB_TOKEN)) { + throw 'WINGET_CREATE_GITHUB_TOKEN is required when submitting Winget manifests.' +} + +if ([string]::IsNullOrWhiteSpace($OutDir)) { + $OutDir = Join-Path $PWD 'dist\winget' +} + +function Resolve-WingetCreate { + if (-not [string]::IsNullOrWhiteSpace($WingetCreatePath)) { + return $WingetCreatePath + } + + $downloadPath = Join-Path ([System.IO.Path]::GetTempPath()) 'wingetcreate.exe' + Invoke-WebRequest 'https://aka.ms/wingetcreate/latest' -OutFile $downloadPath + return $downloadPath +} + +$wingetCreate = Resolve-WingetCreate +$releaseUrl = "https://github.com/$Repo/releases/tag/v$Version" +$amd64Url = "https://github.com/$Repo/releases/download/v$Version/supabase_${Version}_windows_amd64.zip|x64" +$arm64Url = "https://github.com/$Repo/releases/download/v$Version/supabase_${Version}_windows_arm64.zip|arm64" + +$wingetCreateArgs = @( + 'update' + $PackageId + '--version' + $Version + '--urls' + $amd64Url + $arm64Url + '--release-notes-url' + $releaseUrl + '--out' + $OutDir + '--prtitle' + "New version: $PackageId version $Version" + '--no-open' +) + +if ($Submit -and -not $DryRun) { + $wingetCreateArgs += '--submit' +} + +Write-Host "Running WingetCreate for $PackageId $Version" +& $wingetCreate @wingetCreateArgs diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 3f19ed9573..79dc69997c 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, sep } from "node:path"; import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; @@ -343,6 +343,14 @@ function resolveDockerOutputPath(args: ReadonlyArray): string { throw new Error(`unable to resolve host output path for ${dockerOutputPath}`); } +function toDockerBindPath(hostPath: string): string { + return hostPath.replaceAll("\\", "/").replace(/^[A-Za-z]:/, ""); +} + +function readonlyDockerBind(hostPath: string): string { + return `${hostPath}:${toDockerBindPath(hostPath)}:ro`; +} + function mockChildProcessSpawner( opts: { readonly exitCode?: number; @@ -1163,6 +1171,9 @@ describe("functions deploy", () => { yield* Effect.promise(() => writeFile(join(tempDir, "supabase", "custom_import_map.json"), '{"imports":{}}\n'), ); + const importMapPath = yield* Effect.promise(() => + realpath(join(tempDir, "supabase", "custom_import_map.json")), + ); const { out, api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1207,15 +1218,7 @@ describe("functions deploy", () => { expect(api.requests[1]?.urlParams).toContain("slug=hello-world"); expect(api.requests[1]?.urlParams).toContain("verify_jwt=false"); expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); - expect(child.spawned.at(-1)?.args).toContain( - `${join(tempDir, "supabase", "custom_import_map.json")}:${join( - tempDir, - "supabase", - "custom_import_map.json", - ) - .replaceAll("\\", "/") - .replace(/^[A-Za-z]:/, "")}:ro`, - ); + expect(child.spawned.at(-1)?.args).toContain(readonlyDockerBind(importMapPath)); expect(out.stderrText).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); expect(out.stdoutText).toContain( @@ -1498,6 +1501,7 @@ describe("functions deploy", () => { yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); yield* Effect.promise(() => mkdir(dirname(staticFile), { recursive: true })); yield* Effect.promise(() => writeFile(staticFile, "

hello

\n")); + const realStaticFile = yield* Effect.promise(() => realpath(staticFile)); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1511,9 +1515,7 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer)); expect(child.spawned).toHaveLength(4); - expect(child.spawned.at(-1)?.args).toContain( - `${staticFile}:${staticFile.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:ro`, - ); + expect(child.spawned.at(-1)?.args).toContain(readonlyDockerBind(realStaticFile)); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 17291cd764..cb4d4e43e3 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -670,12 +670,7 @@ async function walkImportPaths( modulePath = toSlash(join(dirname(current), modulePath)); } - const resolvedModule = resolve(modulePath); - if (!isContainedInAnyPath(allowedRoots, resolvedModule)) { - await onWarning(`WARN: Skipping import path outside project root: ${modulePath}\n`); - continue; - } - queue.push(toSlash(resolvedModule)); + queue.push(toSlash(resolve(modulePath))); } } } @@ -846,7 +841,6 @@ async function resolveImportMapAllowedRoots(projectRoot: string, importMapPath: } async function writeSourceDeployForm( - cwd: string, projectRoot: string, config: ResolvedDeployFunctionConfig, metadata: SourceDeployMetadata, @@ -863,7 +857,7 @@ async function writeSourceDeployForm( return; } uploadedAssets.add(realPathname); - const relativePath = toApiRelativePath(cwd, pathname); + const relativePath = toApiRelativePath(realProjectRoot, realPathname); await Effect.runPromise(outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`)); form.append("file", new File([contents], relativePath)); }; @@ -1445,7 +1439,7 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( const output = yield* Output; const files = yield* Effect.tryPromise({ try: async () => { - const form = await writeSourceDeployForm(cwd, projectRoot, config, metadata, (text) => + const form = await writeSourceDeployForm(projectRoot, config, metadata, (text) => output.raw(text, "stderr"), ); return form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); diff --git a/docs/adr/0011-cli-release-and-distribution-strategy.md b/docs/adr/0011-cli-release-and-distribution-strategy.md index f2a8e51f2f..d4245315da 100644 --- a/docs/adr/0011-cli-release-and-distribution-strategy.md +++ b/docs/adr/0011-cli-release-and-distribution-strategy.md @@ -12,6 +12,7 @@ The CLI is currently distributed through: - **npm / npx** — the primary install path - **Homebrew** — `supabase/homebrew-tap` - **Scoop** — `supabase/scoop-bucket` +- **Winget** — `microsoft/winget-pkgs` manifest PRs for stable Windows installs - **apt / rpm / apk** — Linux package manager files hosted on GitHub Releases - **GitHub Releases** — platform archives + checksums for direct download @@ -30,6 +31,7 @@ We replace GoReleaser with a pipeline built on **Bun `--compile` single-file exe | Linux packages | **`nfpm`** (binary only, installed from GoReleaser's apt repo) for `.deb` / `.rpm` / `.apk` | | Homebrew | Existing `supabase/homebrew-tap`, updated by [`apps/cli/scripts/update-homebrew.ts`](../../apps/cli/scripts/update-homebrew.ts) | | Scoop | Existing `supabase/scoop-bucket`, updated by [`apps/cli/scripts/update-scoop.ts`](../../apps/cli/scripts/update-scoop.ts) | +| Winget | Stable-only `Supabase.CLI` manifests submitted to `microsoft/winget-pkgs` by WingetCreate through [`apps/cli/scripts/update-winget.ps1`](../../apps/cli/scripts/update-winget.ps1) | | apt / rpm repo hosting | **None** — stay GitHub-Release-downloads-only | | Dist-tags | `latest` = stable legacy shell, `beta` = prerelease legacy shell from `develop` (`X.Y.Z-beta.N`), `alpha` = next shell (TS-native). No separate `next` or `canary` tags | | CI | `[release.yml](../../.github/workflows/release.yml)` (pushes to `develop` / `main` plus manual dispatch) invokes `[release-shared.yml](../../.github/workflows/release-shared.yml)` — build → smoke-test matrix → publish → draft Release → finalize | @@ -145,6 +147,7 @@ flowchart TD finalize["gh release edit --draft=false"] hbUpdate["update-homebrew.ts
Formula push"] scoopUpdate["update-scoop.ts
manifest push"] + wingetUpdate["update-winget.ps1
WingetCreate manifest PR"] dispatch --> releaseYml pushDev --> releaseYml @@ -153,9 +156,10 @@ flowchart TD shared --> build --> smoke --> publish --> ghRelease --> finalize finalize -.follow-up.-> hbUpdate finalize -.follow-up.-> scoopUpdate + finalize -.stable only.-> wingetUpdate ``` -`release.yml` selects the channel (stable / beta / alpha); `release-shared.yml` performs build → smoke-test → publish → GitHub Release. Homebrew and Scoop updates follow stable and beta publishes (alpha stays npm-only). Operational detail: [`apps/cli/docs/release-process.md`](../../apps/cli/docs/release-process.md). +`release.yml` selects the channel (stable / beta / alpha); `release-shared.yml` performs build → smoke-test → publish → GitHub Release. Homebrew and Scoop updates follow stable and beta publishes (alpha stays npm-only). Winget follows stable only through the `Supabase.CLI` package identifier. Operational detail: [`apps/cli/docs/release-process.md`](../../apps/cli/docs/release-process.md). ### Per-channel publish mechanisms @@ -191,6 +195,12 @@ Windows-arm64 is live in the TS pipeline as of this branch — `packages/cli-win Production bucket: `supabase/scoop-bucket`. +#### Winget + +The first `Supabase.CLI` manifest is a one-time bootstrap PR to `microsoft/winget-pkgs`, using the existing `supabase__windows_amd64.zip` and `supabase__windows_arm64.zip` GitHub Release assets. The installer type is `zip` with `NestedInstallerType: portable`, so the manifest exposes `supabase.exe` as the `supabase` command without introducing an MSI/MSIX build. Legacy-shell bootstrap manifests also list the colocated `supabase-go.exe` sidecar so proxied commands keep working after Winget extraction. + +After the bootstrap PR is accepted, production stable releases run [`apps/cli/scripts/update-winget.ps1`](../../apps/cli/scripts/update-winget.ps1) when `WINGET_PUBLISH_ENABLED=true`. The script downloads WingetCreate and calls `wingetcreate update Supabase.CLI` with the released Windows ZIP URLs and explicit architecture overrides. The job submits a PR using `WINGET_CREATE_GITHUB_TOKEN`. Beta and alpha are skipped for now; if they become necessary, use separate package identifiers rather than publishing prereleases into `Supabase.CLI`. + #### apt / rpm / apk `nfpm` runs inside `[apps/cli/scripts/build.ts](../../apps/cli/scripts/build.ts)` and emits six Linux packages (deb/rpm/apk × arm64/amd64). All six are uploaded to the GitHub Release as direct downloads; there is no hosted repo. Installation is `dpkg -i supabase__linux_amd64.deb` or equivalent. @@ -322,7 +332,7 @@ This section tracks the work that has landed against the pre-cutover gates. Deta - [`apps/cli/docs/release-process.md`](../../apps/cli/docs/release-process.md) — the operational playbook: local Verdaccio → user-owned PoC repos → production pipeline. - [`apps/cli/docs/binary-distribution.md`](../../apps/cli/docs/binary-distribution.md) — runtime resolution details for the two-binary legacy model (TS SFE + Go binary). - [`.github/workflows/release-shared.yml`](../../.github/workflows/release-shared.yml), [`release.yml`](../../.github/workflows/release.yml) — the pipeline implementation (`release.yml` selects stable / beta / alpha channel per trigger). -- [`apps/cli/scripts/build.ts`](../../apps/cli/scripts/build.ts), [`publish.ts`](../../apps/cli/scripts/publish.ts), [`sync-versions.ts`](../../apps/cli/scripts/sync-versions.ts), [`update-homebrew.ts`](../../apps/cli/scripts/update-homebrew.ts), [`update-scoop.ts`](../../apps/cli/scripts/update-scoop.ts) — release scripts. +- [`apps/cli/scripts/build.ts`](../../apps/cli/scripts/build.ts), [`publish.ts`](../../apps/cli/scripts/publish.ts), [`sync-versions.ts`](../../apps/cli/scripts/sync-versions.ts), [`update-homebrew.ts`](../../apps/cli/scripts/update-homebrew.ts), [`update-scoop.ts`](../../apps/cli/scripts/update-scoop.ts), [`update-winget.ps1`](../../apps/cli/scripts/update-winget.ps1) — release scripts. - [`apps/cli-go/.goreleaser.yml`](../../apps/cli-go/.goreleaser.yml), [`release.yml`](../../apps/cli-go/.github/workflows/release.yml), [`release-beta.yml`](../../apps/cli-go/.github/workflows/release-beta.yml) — the Go CLI release config we mirror (no signing, `windows_arm64` target, GitHub-App-scoped publish tokens). - [CLI-1330](https://linear.app/supabase/issue/CLI-1330) — origin ticket. - [CLI-1344](https://linear.app/supabase/issue/CLI-1344/investigate-if-we-can-consolidate-local-release-logic-and-pipeline) — sub-issue: consolidate local and pipeline release logic.