Skip to content
Merged
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
12 changes: 0 additions & 12 deletions .github/workflows/build-cli-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,6 @@ jobs:
echo "Checking dist/..."
ls -la dist/

- name: Verify macOS signatures
run: |
set -euo pipefail
for bin in packages/cli-darwin-*/bin/supabase packages/cli-darwin-*/bin/supabase-go; do
[ -f "$bin" ] || continue
echo "::group::$bin"
info="$(rcodesign print-signature-info "$bin")"
echo "$info" | grep -E 'identifier:|flags:'
echo "$info" | grep -q 'identifier: com.supabase.cli' || { echo "::error::$bin missing supabase identifier"; exit 1; }
if echo "$info" | grep -q 'LINKER_SIGNED'; then echo "::error::$bin still linker-signed"; exit 1; fi
echo "::endgroup::"
done

- name: Free space before saving GitHub-hosted artifacts cache
if: inputs.cache_key_suffix == '-github'
Expand Down
34 changes: 10 additions & 24 deletions apps/cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import process from "node:process";
import { parseArgs } from "node:util";
import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts";
import { darwinBinariesForShell, MACOS_IDENTIFIERS } from "./macos-signing.ts";

const MUSL_TARGETS = [
{
Expand Down Expand Up @@ -112,18 +113,6 @@ const GO_TARGETS: Record<BunTarget, { goos: string; goarch: string }> = {
"bun-windows-arm64": { goos: "windows", goarch: "arm64" },
};

// macOS code-signing identifiers per binary. `bun build --compile` and the Go
// linker emit a degenerate "linker-signed" ad-hoc signature (identifier
// `a.out`, no requirements blob) that macOS 26+ AMFI rejects, SIGKILLing the
// process at launch (CLI-1621 / GitHub #5556). Re-signing with a full ad-hoc
// signature — a complete CodeDirectory + RequirementSet + (empty) CMS, the
// same shape `codesign --sign -` produces — fixes it without any Apple
// credentials. Phase 2 will add Developer ID signing + notarization on top.
const MACOS_IDENTIFIERS: Record<string, string> = {
supabase: "com.supabase.cli",
"supabase-go": "com.supabase.cli-go",
};

type SignMode = "adhoc" | "off";

function libcForBunTarget(target: string): "glibc" | "musl" | "" {
Expand Down Expand Up @@ -227,26 +216,20 @@ function resolveSignMode(): SignMode {
return "off";
}

async function signDarwinBinaries(mode: SignMode) {
async function signDarwinBinaries(mode: SignMode, shell: "legacy" | "next") {
if (mode === "off") {
return;
}

const darwinTargets = TARGETS.filter((target) => target.bunTarget.startsWith("bun-darwin"));
const binaries = darwinBinariesForShell(shell);

for (const target of darwinTargets) {
const binDir = path.join(root, "packages", target.pkg, "bin");
const binaries = ["supabase"];
if (shell === "legacy") {
binaries.push("supabase-go");
}

for (const binary of binaries) {
const binPath = path.join(binDir, binary);
const identifier = MACOS_IDENTIFIERS[binary];
if (!identifier) {
throw new Error(`No macOS signing identifier configured for ${binary}`);
}

console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`);
// No key material => rcodesign emits a complete ad-hoc signature
Expand All @@ -255,11 +238,14 @@ async function signDarwinBinaries(mode: SignMode) {
await $`rcodesign sign --binary-identifier ${identifier} ${binPath}`;

// Linux-side verification that runs on every build: the signature must
// carry our identifier and must no longer be linker-signed.
// carry exactly our identifier (matched on the whole value, so the SFE's
// `com.supabase.cli` can't satisfy the sidecar's `com.supabase.cli-go`)
// and must no longer be linker-signed.
const info = await $`rcodesign print-signature-info ${binPath}`.text();
if (!info.includes(`identifier: ${identifier}`)) {
const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1];
if (signedIdentifier !== identifier) {
throw new Error(
`Signing ${binPath} failed: identifier ${identifier} not found in signature`,
`Signing ${binPath} failed: expected identifier ${identifier}, got ${signedIdentifier ?? "none"}`,
);
}
if (info.includes("LINKER_SIGNED")) {
Expand Down Expand Up @@ -444,7 +430,7 @@ if (shell === "legacy") {
// bytes. Must run before archiveTarget / buildLinuxPackages / generateChecksums.
const signMode = resolveSignMode();
console.log(`\nSigning macOS binaries (mode: ${signMode})...`);
await signDarwinBinaries(signMode);
await signDarwinBinaries(signMode, shell);

await mkdir(distDir, { recursive: true });
await Promise.all(TARGETS.map(archiveTarget));
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/scripts/macos-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* macOS code-signing identifiers per shipped binary. Single source of truth
* shared by the signer (`build.ts`) and the verifier (the macOS smoke-test
* helper) so the identifier a binary is signed with can't drift from the one it
* is verified against.
*
* Why signing exists at all: `bun build --compile` and the Go linker emit a
* degenerate "linker-signed" ad-hoc signature (identifier `a.out`, no
* requirements blob) that macOS 26+ AMFI rejects, SIGKILLing the process at
* launch (CLI-1621 / GitHub #5556). Re-signing with a full ad-hoc signature —
* a complete CodeDirectory + RequirementSet + (empty) CMS, the same shape
* `codesign --sign -` produces — fixes it without any Apple credentials. Phase
* 2 will add Developer ID signing + notarization on top.
*/
export type MacBinaryName = "supabase" | "supabase-go";

export const MACOS_IDENTIFIERS: Record<MacBinaryName, string> = {
supabase: "com.supabase.cli",
"supabase-go": "com.supabase.cli-go",
};

/**
* Look up the expected identifier for a binary basename. Returns `undefined`
* for anything that isn't a signed macOS binary, so callers verifying an
* arbitrary file path can fail closed.
*/
export function macIdentifierFor(binary: string): string | undefined {
return binary === "supabase" || binary === "supabase-go" ? MACOS_IDENTIFIERS[binary] : undefined;
}

/**
* The macOS binaries shipped for a given shell. The legacy shell ships the Go
* sidecar alongside the Bun SFE; the next shell is SFE-only. Keeping this here
* (rather than reading a module-level `shell` inside the signer) lets the signer
* stand on its own and makes the legacy/next split explicit in one place.
*/
export function darwinBinariesForShell(shell: "legacy" | "next"): MacBinaryName[] {
return shell === "legacy" ? ["supabase", "supabase-go"] : ["supabase"];
}
14 changes: 7 additions & 7 deletions apps/cli/tests/helpers/macos-signature.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { macIdentifierFor } from "../../scripts/macos-signing.ts";
import { runCli } from "./release-shell.ts";

export type SignatureCheckResult = {
readonly passed: boolean;
readonly detail: string;
};

const EXPECTED_IDENTIFIERS: Record<string, string> = {
supabase: "com.supabase.cli",
"supabase-go": "com.supabase.cli-go",
};

/**
* Verify a macOS binary carries a valid signature. Runs on the macOS smoke-test
* runners via `codesign` / `spctl`.
Expand All @@ -25,7 +21,7 @@ const EXPECTED_IDENTIFIERS: Record<string, string> = {
*/
export async function verifyMacSignature(binPath: string): Promise<SignatureCheckResult> {
const binary = path.basename(binPath);
const expectedId = EXPECTED_IDENTIFIERS[binary];
const expectedId = macIdentifierFor(binary);
if (!expectedId) {
return { passed: false, detail: `no expected identifier configured for ${binary}` };
}
Expand All @@ -42,7 +38,11 @@ export async function verifyMacSignature(binPath: string): Promise<SignatureChec
const display = await runCli("codesign", ["-dvv", binPath]);
const info = [display.stdout, display.stderr].filter(Boolean).join("\n");

if (!info.includes(`Identifier=${expectedId}`)) {
// Match the whole identifier value (codesign prints `Identifier=<id>` on its
// own line) so the SFE's `com.supabase.cli` can't satisfy the sidecar's
// `com.supabase.cli-go` by substring.
const actualId = info.match(/^Identifier=(.+)$/m)?.[1]?.trim();
if (actualId !== expectedId) {
return { passed: false, detail: `expected Identifier=${expectedId}, got:\n${info}` };
}
if (info.includes("linker-signed")) {
Expand Down
Loading