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
24 changes: 16 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,26 @@ Once `staging` is verified and fit for release:
gh pr create --base main --head staging --title "Promote staging to production" --fill
```

2. The `Promotion guard` check rejects any PR into `main` whose source branch is
not `staging` (it runs when GitHub Actions is available).
2. Two guards reject any PR into `main` whose source branch is not `staging`:
the `Promotion guard` check (when GitHub Actions is available) and
`scripts/vercel-promotion-guard.mjs` in the Vercel install step (always).

3. Get the required checks green, then merge. `main` deploys to production.

## Staging environment

`staging` is deployed to <https://staging.stackstream.xyz> and runs against
Stacks **testnet**, not mainnet. Production (`main`) runs against mainnet.
Stacks **testnet**, not mainnet. All other Vercel preview deployments (work
branches) also run against testnet; only production (`main`) runs against
mainnet.

The testnet configuration is stored as branch-scoped Vercel environment
variables for the `staging` branch:
The testnet configuration is stored as preview-scoped Vercel environment
variables, so it applies to `staging` and every other preview deployment:

- `NEXT_PUBLIC_NETWORK=testnet`
- `NEXT_PUBLIC_CONTRACT_DEPLOYER=ST36MRYTKD8X92Q6386RA9QV62A14NJMM9D9DYM3V`
- `NEXT_PUBLIC_CHAIN_ID=2147483648`
- `NEXT_PUBLIC_RPC_URL=https://api.testnet.hiro.so`

`NEXT_PUBLIC_CHAIN_ID` and `NEXT_PUBLIC_RPC_URL` are not read by the frontend.

The testnet contracts are deployed with:

Expand All @@ -94,7 +97,8 @@ through **Deployment Protection Exceptions** when the team grows.
Hotfixes follow the same path: branch off `staging`, open a PR into `staging`,
verify, then promote `staging` to `main`. If production is broken and an
expedited release is unavoidable, an administrator may temporarily relax branch
protection, but the fix must still be merged back into `staging` afterwards.
protection for a `staging` to `main` promotion, but the fix must still pass the
Vercel promotion guard and be merged back into `staging` afterwards.

## CI

Expand All @@ -108,6 +112,10 @@ The checks live in `scripts/ci.sh` and run in two places:
- Vercel runs `scripts/ci.sh all` as part of every deployment through
`frontend/vercel.json`, so the required `Vercel` check covers the same three
groups.
- The Vercel install step (`scripts/vercel-install.sh`) runs
`scripts/vercel-promotion-guard.mjs` before installing dependencies. It fails
the build when an open PR targets `main` from a branch other than `staging`,
so the promotion rule is enforced even while GitHub Actions is unavailable.

While GitHub Actions is unavailable (for example, an account billing lock),
branch protection requires only the `Vercel` check. Once Actions is healthy,
Expand Down
2 changes: 1 addition & 1 deletion frontend/vercel.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"installCommand": "cd .. && npm ci --include=dev --prefer-offline --no-audit --no-fund && npm ci --prefix frontend --include=dev --prefer-offline --no-audit --no-fund && npm ci --prefix openclaw-service --include=dev --prefer-offline --no-audit --no-fund",
"installCommand": "cd .. && ./scripts/vercel-install.sh",
"buildCommand": "cd .. && ./scripts/ci.sh all"
}
8 changes: 8 additions & 0 deletions scripts/vercel-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash

set -euo pipefail

node scripts/vercel-promotion-guard.mjs
npm ci --include=dev --prefer-offline --no-audit --no-fund
npm ci --prefix frontend --include=dev --prefer-offline --no-audit --no-fund
npm ci --prefix openclaw-service --include=dev --prefer-offline --no-audit --no-fund
98 changes: 98 additions & 0 deletions scripts/vercel-promotion-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env node

const owner = process.env.VERCEL_GIT_REPO_OWNER;
const repo = process.env.VERCEL_GIT_REPO_SLUG;
const ref = process.env.VERCEL_GIT_COMMIT_REF;
const commitSha = process.env.VERCEL_GIT_COMMIT_SHA;
const pullRequestId = process.env.VERCEL_GIT_PULL_REQUEST_ID;

const fail = (message) => {
console.error(`Promotion guard: ${message}`);
process.exit(1);
};

const allow = (message) => {
console.log(`Promotion guard: ${message}`);
process.exit(0);
};

if (!owner || !repo || !ref) {
allow("not a Vercel Git build; skipping");
}

const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls`;
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "stackstream-promotion-guard",
};

const request = async (url) => {
let response;

try {
response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
fail(`GitHub request failed: ${error.message}`);
}

if (!response.ok) {
fail(`GitHub API returned ${response.status}`);
}

return response.json();
};

const isSameRepoStaging = (pr) =>
pr.head?.repo?.full_name === `${owner}/${repo}` && pr.head?.ref === "staging";

if (pullRequestId) {
const pr = await request(`${apiUrl}/${pullRequestId}`);

if (pr.base?.ref === "main" && !isSameRepoStaging(pr)) {
fail(
`PR #${pr.number} targets main from ${pr.head?.repo?.full_name}:${pr.head?.ref}; only ${owner}/${repo}:staging may target main`
);
}

allow(
pr.base?.ref === "main"
? `PR #${pr.number} is a staging promotion`
: `PR #${pr.number} targets ${pr.base?.ref}, not main`
);
}

if (ref === "main" || ref === "staging") {
allow(`${ref} is a protected deployment branch`);
}

if (!commitSha) {
fail("missing VERCEL_GIT_COMMIT_SHA");
}

const url = new URL(apiUrl);
url.searchParams.set("state", "open");
url.searchParams.set("base", "main");
url.searchParams.set("per_page", "100");

const openPullRequests = await request(url);
const matchingPullRequests = openPullRequests.filter(
(pr) => pr.head?.sha === commitSha
);
const blockedPullRequest = matchingPullRequests.find(
(pr) => !isSameRepoStaging(pr)
);

if (blockedPullRequest) {
fail(
`PR #${blockedPullRequest.number} targets main from ${blockedPullRequest.head?.repo?.full_name}:${blockedPullRequest.head?.ref}; only ${owner}/${repo}:staging may target main`
);
}

if (matchingPullRequests.length > 0) {
allow("commit is part of a staging promotion");
}

allow(`${ref} has no open promotion PR to main`);
Loading