feat: add clawhub staging deploy workflow
Some checks failed
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Has been cancelled
Some checks failed
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Has been cancelled
This commit is contained in:
parent
4d3b7dedba
commit
a45b4c970b
187
.github/workflows/deploy-staging.yml
vendored
Normal file
187
.github/workflows/deploy-staging.yml
vendored
Normal 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."
|
||||
@ -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",
|
||||
|
||||
110
scripts/prepare-deploy-config.test.ts
Normal file
110
scripts/prepare-deploy-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
197
scripts/prepare-deploy-config.ts
Normal file
197
scripts/prepare-deploy-config.ts
Normal 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)));
|
||||
}
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user