feat: add clawhub staging deploy workflow
Some checks failed
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Has been cancelled

This commit is contained in:
Patrick Erichsen 2026-05-07 19:24:41 -07:00
parent 4d3b7dedba
commit a45b4c970b
8 changed files with 555 additions and 1 deletions

187
.github/workflows/deploy-staging.yml vendored Normal file
View File

@ -0,0 +1,187 @@
name: Deploy Staging
on:
push:
branches: [main]
workflow_dispatch:
inputs:
reset_seed:
description: "Reset seeded staging fixtures before smoke tests"
required: true
default: false
type: boolean
allow_deleting_large_indexes:
description: "Allow Convex to delete large indexes"
required: true
default: false
type: boolean
concurrency:
group: deploy-staging
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy-staging:
name: deploy-staging
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: Staging
url: https://staging.hub.openclaw.ai
env:
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
PLAYWRIGHT_BASE_URL: https://staging.hub.openclaw.ai
RESET_STAGING_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.reset_seed || false }}
STAGING_CONVEX_SITE_URL: ${{ vars.STAGING_CONVEX_SITE_URL }}
STAGING_CONVEX_URL: ${{ vars.STAGING_CONVEX_URL }}
STAGING_SITE_URL: https://staging.hub.openclaw.ai
VERCEL_CLI_VERSION: 52.0.0
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
ALLOW_DELETING_LARGE_INDEXES: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_deleting_large_indexes || false }}
steps:
- name: Check staging deploy configuration
id: config
run: |
set -euo pipefail
missing=()
for name in \
CONVEX_DEPLOY_KEY \
STAGING_CONVEX_SITE_URL \
STAGING_CONVEX_URL \
VERCEL_ORG_ID \
VERCEL_PROJECT_ID \
VERCEL_TOKEN
do
if [[ -z "${!name}" ]]; then
missing+=("$name")
fi
done
if (( ${#missing[@]} > 0 )); then
echo "configured=false" >> "$GITHUB_OUTPUT"
printf '::notice::Skipping staging deploy; missing Staging environment values: %s\n' "${missing[*]}"
exit 0
fi
echo "configured=true" >> "$GITHUB_OUTPUT"
echo "Deploying staging site: $STAGING_SITE_URL"
echo "Using staging Convex site URL: $STAGING_CONVEX_SITE_URL"
- uses: actions/checkout@v6
if: steps.config.outputs.configured == 'true'
- uses: ./.github/actions/setup-bun
if: steps.config.outputs.configured == 'true'
- name: Stamp Convex staging metadata
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
bunx convex env set APP_BUILD_SHA "$GITHUB_SHA"
bunx convex env set APP_DEPLOYED_AT "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
bunx convex env set SITE_URL "$STAGING_SITE_URL"
bunx convex env set VITE_SITE_URL "$STAGING_SITE_URL"
bunx convex env set CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
- name: Deploy Convex staging backend
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
if [[ "$ALLOW_DELETING_LARGE_INDEXES" == "true" ]]; then
bunx convex deploy --typecheck=disable --yes --allow-deleting-large-indexes
else
bun run convex:deploy
fi
- name: Verify Convex staging contract
if: steps.config.outputs.configured == 'true'
run: bun run verify:convex-contract
- name: Seed staging fixtures
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
if [[ "$RESET_STAGING_SEED" == "true" ]]; then
bunx convex run --no-push devSeed:seedNixSkills '{"reset":true}'
else
bunx convex run --no-push devSeed:seedNixSkills
fi
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
- name: Prepare staging deploy config
if: steps.config.outputs.configured == 'true'
run: |
bun run deploy:prepare-config -- \
--target staging \
--site-url "$STAGING_SITE_URL" \
--convex-site-url "$STAGING_CONVEX_SITE_URL"
- name: Pull Vercel staging project settings
if: steps.config.outputs.configured == 'true'
run: |
bunx "vercel@$VERCEL_CLI_VERSION" pull \
--yes \
--environment=staging \
--token "$VERCEL_TOKEN"
- name: Sync Vercel staging public env
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
set_vercel_env() {
local name="$1"
local value="$2"
bunx "vercel@$VERCEL_CLI_VERSION" env add "$name" staging \
--force \
--yes \
--value "$value" \
--token "$VERCEL_TOKEN"
}
set_vercel_env VITE_CONVEX_URL "$STAGING_CONVEX_URL"
set_vercel_env VITE_CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
set_vercel_env CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
set_vercel_env SITE_URL "$STAGING_SITE_URL"
set_vercel_env VITE_SITE_URL "$STAGING_SITE_URL"
set_vercel_env VITE_APP_BUILD_SHA "$GITHUB_SHA"
- name: Deploy Vercel staging frontend
id: vercel
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
deployment_url="$(bunx "vercel@$VERCEL_CLI_VERSION" deploy \
--target=staging \
--yes \
--token "$VERCEL_TOKEN")"
echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
echo "Vercel staging deployment: $deployment_url"
- name: Smoke test staging HTTP
if: steps.config.outputs.configured == 'true'
env:
CLAWHUB_E2E_SITE: https://staging.hub.openclaw.ai
CLAWHUB_E2E_SKILL_OWNER: local
CLAWHUB_E2E_SKILL_SLUG: padel
run: bun run test:e2e:prod-http
- name: Install Playwright browser
if: steps.config.outputs.configured == 'true'
run: bunx playwright install --with-deps chromium
- name: Smoke test staging UI
if: steps.config.outputs.configured == 'true'
run: bunx playwright test --workers=1 --project=chromium e2e/menu-smoke.pw.test.ts
- name: Report unconfigured staging
if: steps.config.outputs.configured != 'true'
run: |
echo "Staging deploy is wired in the repo but not configured yet."
echo "Add the Staging environment secrets and variables documented in docs/deploy.md."

View File

@ -27,6 +27,7 @@
"deadcode:exports": "KNIP_INCLUDE_TESTS=1 bunx knip@6.8.0 --config knip.config.ts --no-progress --reporter compact --exports --no-config-hints",
"deadcode:files": "bunx knip@6.8.0 --config knip.config.ts --production --no-progress --reporter compact --files --no-config-hints",
"deadcode:knip": "bun run deadcode:files && bun run deadcode:dependencies && bun run deadcode:exports",
"deploy:prepare-config": "bun scripts/prepare-deploy-config.ts",
"dev": "bun --bun vite dev --port 3000",
"dev:worktree": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts",
"docs:list": "bun scripts/docs-list.ts",

View File

@ -0,0 +1,110 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
prepareDeployConfig,
renderRobotsTxt,
renderWellKnownConfig,
resolvePrepareDeployConfigOptions,
rewriteVercelJson,
} from "./prepare-deploy-config";
const tempDirs: string[] = [];
function makeTempProject() {
const rootDir = mkdtempSync(join(tmpdir(), "clawhub-deploy-config-"));
tempDirs.push(rootDir);
mkdirSync(join(rootDir, "public", ".well-known"), { recursive: true });
writeFileSync(
join(rootDir, "vercel.json"),
JSON.stringify({
rewrites: [
{
source: "/api/:path*",
destination: "https://wry-manatee-359.convex.site/api/:path*",
},
],
}),
);
writeFileSync(join(rootDir, "public", ".well-known", "clawhub.json"), "{}\n");
writeFileSync(join(rootDir, "public", ".well-known", "clawdhub.json"), "{}\n");
writeFileSync(join(rootDir, "public", "robots.txt"), "User-agent: *\nDisallow:\n");
return rootDir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("prepare deploy config", () => {
it("rewrites the Vercel API proxy to the selected Convex site URL", () => {
const result = rewriteVercelJson(
JSON.stringify({
headers: [],
rewrites: [{ source: "/api/:path*", destination: "https://prod.convex.site/api/:path*" }],
}),
"https://staging.convex.site/path-is-ignored",
);
expect(JSON.parse(result).rewrites).toEqual([
{
source: "/api/:path*",
destination: "https://staging.convex.site/api/:path*",
},
]);
});
it("renders well-known discovery against the public site origin", () => {
expect(
JSON.parse(
renderWellKnownConfig({
siteUrl: "https://staging.hub.openclaw.ai/some/path",
minCliVersion: "0.1.0",
}),
),
).toEqual({
apiBase: "https://staging.hub.openclaw.ai",
authBase: "https://staging.hub.openclaw.ai",
minCliVersion: "0.1.0",
registry: "https://staging.hub.openclaw.ai",
});
});
it("blocks indexing for staging robots.txt", () => {
expect(renderRobotsTxt("staging")).toContain("Disallow: /");
expect(renderRobotsTxt("production")).toContain("Disallow:\n");
});
it("updates all deploy-time config files for staging", () => {
const rootDir = makeTempProject();
prepareDeployConfig({
rootDir,
target: "staging",
siteUrl: "https://staging.hub.openclaw.ai",
convexSiteUrl: "https://staging.convex.site",
minCliVersion: "0.1.0",
});
const vercelConfig = JSON.parse(readFileSync(join(rootDir, "vercel.json"), "utf8"));
expect(vercelConfig.rewrites[0].destination).toBe("https://staging.convex.site/api/:path*");
const wellKnown = JSON.parse(
readFileSync(join(rootDir, "public", ".well-known", "clawhub.json"), "utf8"),
);
expect(wellKnown.apiBase).toBe("https://staging.hub.openclaw.ai");
expect(readFileSync(join(rootDir, "public", "robots.txt"), "utf8")).toContain("Disallow: /");
});
it("resolves staging defaults from explicit environment values", () => {
expect(
resolvePrepareDeployConfigOptions([], {
DEPLOY_TARGET: "staging",
STAGING_CONVEX_SITE_URL: "https://staging.convex.site",
}).siteUrl,
).toBe("https://staging.hub.openclaw.ai");
});
});

View File

@ -0,0 +1,197 @@
#!/usr/bin/env bun
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export type DeployConfigTarget = "production" | "staging";
export type PrepareDeployConfigOptions = {
rootDir: string;
target: DeployConfigTarget;
siteUrl: string;
convexSiteUrl: string;
minCliVersion: string;
dryRun?: boolean;
};
const DEFAULT_PRODUCTION_SITE_URL = "https://clawhub.ai";
const DEFAULT_PRODUCTION_CONVEX_SITE_URL = "https://wry-manatee-359.convex.site";
const DEFAULT_STAGING_SITE_URL = "https://staging.hub.openclaw.ai";
const DEFAULT_MIN_CLI_VERSION = "0.1.0";
function readString(value: unknown) {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function normalizeOrigin(value: string, name: string) {
try {
return new URL(value).origin;
} catch {
throw new Error(`${name} must be an absolute URL; received ${JSON.stringify(value)}.`);
}
}
function parseTarget(value: string | undefined): DeployConfigTarget {
if (!value || value === "production") return "production";
if (value === "staging") return "staging";
throw new Error(
`Unsupported deploy target ${JSON.stringify(value)}. Expected production or staging.`,
);
}
export function renderWellKnownConfig(options: { siteUrl: string; minCliVersion: string }) {
const siteUrl = normalizeOrigin(options.siteUrl, "siteUrl");
return `${JSON.stringify(
{
apiBase: siteUrl,
authBase: siteUrl,
minCliVersion: options.minCliVersion,
registry: siteUrl,
},
null,
2,
)}\n`;
}
export function renderRobotsTxt(target: DeployConfigTarget) {
if (target === "staging") {
return "# Staging environment\nUser-agent: *\nDisallow: /\n";
}
return "# https://www.robotstxt.org/robotstxt.html\nUser-agent: *\nDisallow:\n";
}
export function rewriteVercelJson(content: string, convexSiteUrl: string) {
const parsed = JSON.parse(content) as {
rewrites?: Array<Record<string, unknown>>;
};
const rewrites = Array.isArray(parsed.rewrites) ? parsed.rewrites : [];
const convexOrigin = normalizeOrigin(convexSiteUrl, "convexSiteUrl");
let foundApiRewrite = false;
parsed.rewrites = rewrites.map((rewrite) => {
if (rewrite.source !== "/api/:path*") return rewrite;
foundApiRewrite = true;
return {
...rewrite,
destination: `${convexOrigin}/api/:path*`,
};
});
if (!foundApiRewrite) {
parsed.rewrites.push({
source: "/api/:path*",
destination: `${convexOrigin}/api/:path*`,
});
}
return `${JSON.stringify(parsed, null, 2)}\n`;
}
function writeMaybe(path: string, content: string, dryRun: boolean) {
if (dryRun) {
console.log(`[dry-run] would write ${path}`);
return;
}
writeFileSync(path, content);
console.log(`Wrote ${path}`);
}
export function prepareDeployConfig(options: PrepareDeployConfigOptions) {
const siteUrl = normalizeOrigin(options.siteUrl, "siteUrl");
const convexSiteUrl = normalizeOrigin(options.convexSiteUrl, "convexSiteUrl");
const dryRun = options.dryRun ?? false;
const vercelJsonPath = join(options.rootDir, "vercel.json");
const wellKnownPath = join(options.rootDir, "public", ".well-known", "clawhub.json");
const legacyWellKnownPath = join(options.rootDir, "public", ".well-known", "clawdhub.json");
const robotsPath = join(options.rootDir, "public", "robots.txt");
if (!existsSync(vercelJsonPath)) {
throw new Error(`Missing ${vercelJsonPath}`);
}
const wellKnown = renderWellKnownConfig({
siteUrl,
minCliVersion: options.minCliVersion,
});
writeMaybe(
vercelJsonPath,
rewriteVercelJson(readFileSync(vercelJsonPath, "utf8"), convexSiteUrl),
dryRun,
);
writeMaybe(wellKnownPath, wellKnown, dryRun);
writeMaybe(legacyWellKnownPath, wellKnown, dryRun);
writeMaybe(robotsPath, renderRobotsTxt(options.target), dryRun);
}
function parseCliArgs(argv: string[]) {
const values: Record<string, string | boolean> = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--dry-run") {
values.dryRun = true;
continue;
}
if (!arg.startsWith("--")) {
throw new Error(`Unexpected argument ${JSON.stringify(arg)}`);
}
const key = arg.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
throw new Error(`Missing value for ${arg}`);
}
values[key] = next;
index += 1;
}
return values;
}
function readOptionValue(values: Record<string, string | boolean>, key: string) {
return typeof values[key] === "string" ? values[key] : undefined;
}
export function resolvePrepareDeployConfigOptions(
argv: string[],
env: NodeJS.ProcessEnv = process.env,
): PrepareDeployConfigOptions {
const values = parseCliArgs(argv);
const target = parseTarget(
readOptionValue(values, "target") ?? readString(env.DEPLOY_TARGET) ?? "production",
);
const siteUrl =
readOptionValue(values, "site-url") ??
readString(env.STAGING_SITE_URL) ??
readString(env.SITE_URL) ??
readString(env.VITE_SITE_URL) ??
(target === "staging" ? DEFAULT_STAGING_SITE_URL : DEFAULT_PRODUCTION_SITE_URL);
const convexSiteUrl =
readOptionValue(values, "convex-site-url") ??
readString(env.STAGING_CONVEX_SITE_URL) ??
readString(env.VITE_CONVEX_SITE_URL) ??
(target === "production" ? DEFAULT_PRODUCTION_CONVEX_SITE_URL : undefined);
if (!convexSiteUrl) {
throw new Error(
"Missing staging Convex site URL. Pass --convex-site-url or set STAGING_CONVEX_SITE_URL.",
);
}
return {
rootDir: readOptionValue(values, "root") ?? process.cwd(),
target,
siteUrl,
convexSiteUrl,
minCliVersion:
readOptionValue(values, "min-cli-version") ??
readString(env.CLAWHUB_MIN_CLI_VERSION) ??
DEFAULT_MIN_CLI_VERSION,
dryRun: values.dryRun === true,
};
}
if (import.meta.main) {
prepareDeployConfig(resolvePrepareDeployConfigOptions(process.argv.slice(2)));
}

View File

@ -51,6 +51,13 @@ Production-only checks stay in the manual deploy workflow:
- `bun run test:e2e:prod-http`
- production Playwright smoke tests
Staging checks live in `.github/workflows/deploy-staging.yml`. That workflow runs
on `main` pushes once the GitHub `Staging` environment is configured, deploys the
staging Convex/Vercel stack, seeds deterministic fixtures, and runs HTTP plus
Chromium UI smoke tests against `https://staging.hub.openclaw.ai`. Until the
required staging secrets and variables exist, it exits successfully with a
notice instead of failing unrelated merges.
Successful `full` and `frontend` production deploys create two annotated Git
tags:

View File

@ -41,6 +41,57 @@ Production deploy notes:
- Required `Production` environment secret: `CONVEX_DEPLOY_KEY`.
- Optional `Production` environment secret: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON` for authenticated smoke coverage.
## Staging
Shared staging is intended to run at:
- `https://staging.hub.openclaw.ai`
The repo-side workflow is `.github/workflows/deploy-staging.yml`.
- It runs on every push to `main`.
- It can also be run manually.
- It exits successfully with a notice until the required `Staging` environment values exist.
- It deploys a separate Convex backend, prepares staging-specific Vercel config, deploys Vercel with `--target=staging`, seeds deterministic fixtures, and runs staging smoke tests.
Required GitHub `Staging` environment secrets:
- `CONVEX_DEPLOY_KEY` - deploy key for the permanent staging Convex deployment.
- `VERCEL_TOKEN` - Vercel token with access to the ClawHub project.
- `VERCEL_ORG_ID` - Vercel team/org id.
- `VERCEL_PROJECT_ID` - Vercel project id.
Required GitHub `Staging` environment variables:
- `STAGING_CONVEX_URL` - Convex client URL, for example `https://<deployment>.convex.cloud`.
- `STAGING_CONVEX_SITE_URL` - Convex site URL, for example `https://<deployment>.convex.site`.
One-time setup that requires dashboard access:
1. Create a separate Convex project/deployment for staging.
2. Configure staging Convex env:
- `AUTH_GITHUB_ID`
- `AUTH_GITHUB_SECRET`
- `CONVEX_SITE_URL` set to the staging Convex site URL
- `JWT_PRIVATE_KEY`
- `JWKS`
- `OPENAI_API_KEY`
- `SITE_URL=https://staging.hub.openclaw.ai`
- Optional webhook env (see `docs/webhook.md`)
3. Create or configure a Vercel custom environment target named `staging`.
4. Attach `staging.hub.openclaw.ai` to that Vercel staging target and point DNS at Vercel.
5. Configure the staging GitHub OAuth App:
- Homepage URL: `https://staging.hub.openclaw.ai`
- Authorization callback URL: `<STAGING_CONVEX_SITE_URL>/api/auth/callback/github`
The staging workflow rewrites deployment artifacts in the CI workspace before uploading to Vercel:
- `vercel.json` routes `/api/*` to `STAGING_CONVEX_SITE_URL`.
- `public/.well-known/clawhub.json` and `public/.well-known/clawdhub.json` point CLI discovery at `https://staging.hub.openclaw.ai`.
- `public/robots.txt` disallows indexing.
Manual staging runs support `reset_seed=true`, which resets deterministic fixtures before smoke tests. Normal `main` pushes seed idempotently without resetting existing staging state.
## CLI npm release
The `clawhub` CLI package is released separately from the app deploy.

View File

@ -93,6 +93,7 @@ describe("site helpers", () => {
expect(isClawHubHost("clawhub.ai")).toBe(true);
expect(isClawHubHost("www.clawhub.ai")).toBe(true);
expect(isClawHubHost("hub.openclaw.ai")).toBe(true);
expect(isClawHubHost("staging.hub.openclaw.ai")).toBe(true);
expect(isClawHubHost("clawdhub.com")).toBe(false);
expect(isClawHubHost("example.com")).toBe(false);
});

View File

@ -6,7 +6,7 @@ const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
const DEFAULT_ONLYCRABS_SITE_URL = "https://onlycrabs.ai";
const DEFAULT_ONLYCRABS_HOST = "onlycrabs.ai";
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
const OPENCLAW_CLAWHUB_HOSTS = new Set(["hub.openclaw.ai"]);
const OPENCLAW_CLAWHUB_HOSTS = new Set(["hub.openclaw.ai", "staging.hub.openclaw.ai"]);
export function normalizeClawHubSiteOrigin(value?: string | null) {
if (!value) return null;