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
9 changes: 9 additions & 0 deletions .github/production-repositories.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Public GitHub repositories served by the production remote cache.
// Each key is a cache namespace. Its endpoint is
// https://voidzero-remote-cache.<subdomain>.workers.dev/projects/<namespace>
// Each production deployment enables every listed namespace and disables every other namespace,
// overriding `pnpm operator policy`. Data in a removed namespace expires with its retention.
{
"rolldown": "rolldown/rolldown",
"vite-plus": "voidzero-dev/vite-plus"
}
2 changes: 1 addition & 1 deletion .github/workflows/remote-cache-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
- name: Create or update persistent staging resources
id: deploy
run: pnpm ci:deploy
run: pnpm ci:deploy staging
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/remote-cache-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Remote cache production

on:
workflow_dispatch:

permissions:
contents: read

# Finish one production deployment before starting the next.
concurrency:
group: remote-cache-production
cancel-in-progress: false

jobs:
deploy:
name: Deploy production
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1
- run: pnpm check
- run: pnpm smoke
# The script accepts only manual runs of this repository's main branch.
- name: Deploy the production Worker, D1, and R2
id: deploy
run: pnpm ci:deploy production
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The repository created by the deployment button runs the same commands. `pnpm ch

For your own service and application repository, use the [self-hosting guide](docs/self-hosting.md). The commands below are the operator reference.

For continuous deployment and smoke tests against one persistent Cloudflare staging environment, follow the [deployment and e2e plan](docs/e2e-plan.md). Internal PRs and main-branch pushes share the same Worker, D1 database, and R2 bucket. The plan includes GitHub configuration, the complete test matrix, and manual verification. Closing a PR leaves staging available.
For continuous deployment and smoke tests against one persistent Cloudflare staging environment, follow the [deployment and e2e plan](docs/e2e-plan.md). Internal PRs and main-branch pushes share the same Worker, D1 database, and R2 bucket. The plan includes GitHub configuration, the complete test matrix, and manual verification. Closing a PR leaves staging available. Maintainers deploy this repository's production cache manually; see [Production deployment](docs/e2e-plan.md#production-deployment).

Use a dedicated Worker, D1 database, and bucket. The operator requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in its environment. The token needs account permissions for Workers Scripts, D1, and Workers R2 Storage, plus route/zone permissions if using a custom domain. Enable R2 in the account first. Credentials stay in the operator process and Wrangler; they are never stored in namespace policy or passed as command arguments.

Expand Down
12 changes: 12 additions & 0 deletions docs/e2e-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ Subsequent runs restore CI-owned policy switches in case a prior process stopped

Staging policies belong to CI. Do not use these names for an operator-managed production cache. Monitor storage, deletion backlogs, and Cloudflare allowances. Resource budgets do not guarantee zero cost. Set `REMOTE_CACHE_DEPLOY_ENABLED=false` to stop automatic deployments; the existing staging environment remains available.

## Production deployment

`.github/workflows/remote-cache-production.yml` deploys this repository's production cache. It runs only when a maintainer starts it from **Actions → Remote cache production → Run workflow** on `main`. Pushes and PRs never deploy production. Before starting it, check that the commit's main-branch staging run passed.

The workflow repeats `pnpm check` and `pnpm smoke`, then runs `pnpm ci:deploy production`. `scripts/ci/production.ts` names the Worker, D1 database, and R2 bucket. `.github/production-repositories.jsonc` maps each cache namespace to a public GitHub repository. The command refuses other repositories, branches, and events, so repositories created by Deploy to Cloudflare cannot run it. Staging and production share `scripts/ci/deploy.ts`. Setup creates or reuses the named resources, applies migrations, and binds each namespace; then the Worker deploys once. Every namespace endpoint must then serve the new deployment ID. The run summary and the `production` environment list the endpoints, for example `https://voidzero-remote-cache.<subdomain>.workers.dev/projects/rolldown`.

To bind another repository, add `"namespace": "owner/repository"` to `.github/production-repositories.jsonc`, merge the change, and run the workflow. Only public repositories can publish. The list decides which namespaces serve reads and accept uploads: each deployment enables every listed namespace and disables every other namespace, so a `pnpm operator policy` change lasts only until the next deployment. To withdraw a repository, remove its entry and run the workflow. For an immediate stop, also run `pnpm operator policy --namespace <namespace> --enabled off`. Data in a disabled namespace expires with its retention; `pnpm operator purge` deletes it sooner. Deployments never change the cache-wide `pnpm operator deployment` switches or move a namespace to another repository. To roll back, re-run an earlier successful production run. A re-run deploys its original commit.

Production uses the same `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` secrets as staging. Secrets in the `production` environment take precedence, so a production-only token can replace them without workflow changes. Internal PR staging runs receive the repository secrets; while both environments share one token, anyone who can push a branch here can change production resources. Add required reviewers to the `production` environment to approve each deployment.

Operator commands read `wrangler.operator.json`, which the workflow does not keep. To recreate it, check out the deployed commit and repeat `pnpm operator setup` with the Worker name from `scripts/ci/production.ts` and an entry from `.github/production-repositories.jsonc`. Setup redeploys that checkout.

## Release and incident exercises

Before a production release, record the commit, workflow URL, Cloudflare plan, region, and outcome for these controlled staging exercises:
Expand Down
190 changes: 80 additions & 110 deletions scripts/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ import assert from 'node:assert/strict';
import { appendFile, mkdir, writeFile } from 'node:fs/promises';
import { URL, pathToFileURL } from 'node:url';
import { encode } from 'cborg';
import { ApiError, operatorIO, query, runOperator, type Config } from './operator.ts';
import {
deployTarget,
reportDeployment,
runContext,
type Deployment,
type RunContext,
} from './ci/deploy.ts';
import { productionTarget } from './ci/production.ts';
import { ApiError, operatorIO, query, type Config } from './operator.ts';
import { retireTestData, seedManual, type Admin } from './e2e/fixtures.ts';
import { runSuite, type Report } from './e2e/suite.ts';
import { readJson } from './http.ts';

const resultsDir = new URL('../e2e-results/', import.meta.url);

interface Settings {
interface Settings extends RunContext {
name: string;
repository: string;
repositoryId: string;
revision: string;
deployment: string;
subdomain: string;
origin: string;
writes: boolean;
profile: 'free' | 'paid';
Expand All @@ -35,33 +40,18 @@ export function settingsFrom(env: Record<string, string | undefined>): Settings
'Set REMOTE_CACHE_WORKERS_SUBDOMAIN to the account subdomain, without workers.dev',
);
const name = `${prefix}-staging`;
const repository = env['GITHUB_REPOSITORY'];
if (!repository || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository))
throw new Error('Invalid repository');
const repositoryId = env['GITHUB_REPOSITORY_ID'];
if (!repositoryId || !/^[1-9][0-9]*$/.test(repositoryId))
throw new Error('Invalid repository ID');
const revision = env['REMOTE_CACHE_SOURCE_SHA'];
if (!revision || !/^[a-f0-9]{40}$/.test(revision))
throw new Error('Use the full source commit SHA');
const run = env['GITHUB_RUN_ID'];
const attempt = env['GITHUB_RUN_ATTEMPT'];
if (!run || !attempt || !/^\d+$/.test(run) || !/^\d+$/.test(attempt))
throw new Error('Invalid workflow run identity');
const context = runContext(env);
const defaultBranch = env['REMOTE_CACHE_DEFAULT_BRANCH'];
const event = env['GITHUB_EVENT_NAME'];
if (!['pull_request', 'push', 'workflow_dispatch'].includes(event ?? ''))
if (!['pull_request', 'push', 'workflow_dispatch'].includes(context.event))
throw new Error('Unsupported staging deployment event');
const onDefaultBranch = env['GITHUB_REF'] === `refs/heads/${defaultBranch}`;
if (event !== 'pull_request' && !onDefaultBranch)
const onDefaultBranch = context.ref === `refs/heads/${defaultBranch}`;
if (context.event !== 'pull_request' && !onDefaultBranch)
throw new Error('Push and manual staging deployments require the default branch');
const writes = event === 'push' && onDefaultBranch;
const writes = context.event === 'push' && onDefaultBranch;
return {
...context,
name,
repository,
repositoryId,
revision,
deployment: `${revision}-${run}-${attempt}`,
subdomain,
origin: `https://${name}.${subdomain}.workers.dev`,
writes,
profile,
Expand Down Expand Up @@ -163,88 +153,64 @@ export function githubTokens(
};
}

async function deploy(settings: Settings): Promise<void> {
await checkAccountOrigin(settings);
await runOperator(
[
'setup',
'--name',
settings.name,
'--namespace',
'e2e',
'--repo',
settings.repository,
'--origin',
settings.origin,
'--profile',
settings.profile,
'--retention-days',
'1',
'--byte-limit',
'2000000000',
'--entry-limit',
'1000',
'--association-limit',
'2000',
],
async function deploy(settings: Settings): Promise<Deployment> {
const result = await deployTarget(
{
...operatorIO,
async wrangler(args) {
// Install all CI namespace policies before the single final deployment.
if (args[0] !== 'deploy') await operatorIO.wrangler(args);
},
async writeConfig(config) {
config.vars['DEPLOYMENT_ID'] = settings.deployment;
await operatorIO.writeConfig(config);
},
},
);
const config = await operatorIO.readConfig();
checkConfig(config, settings);
const existing = await query(
operatorIO,
config,
'SELECT scope_id, repository_id, endpoint FROM scopes',
);
if (
existing.some(
(scope) =>
!['e2e', 'other', 'manual'].includes(String(scope['scope_id'])) ||
scope['repository_id'] !== settings.repositoryId ||
scope['endpoint'] !== `${settings.origin}/projects/${String(scope['scope_id'])}`,
)
)
throw new Error('CI resources must contain only this repository’s verification namespaces');
for (const scope of ['other', 'manual'])
await query(
operatorIO,
config,
`INSERT INTO scopes
name: settings.name,
subdomain: settings.subdomain,
profile: settings.profile,
bindings: { e2e: settings.repository },
setupArgs: [
'--retention-days',
'1',
'--byte-limit',
'2000000000',
'--entry-limit',
'1000',
'--association-limit',
'2000',
],
async prepare(config, io) {
checkConfig(config, settings);
const existing = await query(
io,
config,
'SELECT scope_id, repository_id, endpoint FROM scopes',
);
if (
existing.some(
(scope) =>
!['e2e', 'other', 'manual'].includes(String(scope['scope_id'])) ||
scope['repository_id'] !== settings.repositoryId ||
scope['endpoint'] !== `${settings.origin}/projects/${String(scope['scope_id'])}`,
)
)
throw new Error(
'CI resources must contain only this repository’s verification namespaces',
);
for (const scope of ['other', 'manual'])
await query(
io,
config,
`INSERT INTO scopes
(scope_id, endpoint, repository, repository_id, repository_owner_id, branch, retention_seconds)
SELECT ?, ?, repository, repository_id, repository_owner_id, branch, 86400 FROM scopes WHERE scope_id = 'e2e'
ON CONFLICT(scope_id) DO NOTHING`,
[scope, `${settings.origin}/projects/${scope}`],
);
// Recover policy changes left by an interrupted e2e run. These resources belong to CI only.
await query(operatorIO, config, 'UPDATE deployment SET enabled = 1, writes_enabled = 1');
await query(
operatorIO,
config,
`UPDATE scopes SET enabled = 1, writes_enabled = 1,
[scope, `${settings.origin}/projects/${scope}`],
);
// Recover policy changes left by an interrupted e2e run. These resources belong to CI only.
await query(io, config, 'UPDATE deployment SET enabled = 1, writes_enabled = 1');
await query(
io,
config,
`UPDATE scopes SET enabled = 1, writes_enabled = 1,
byte_limit = 2000000000, entry_limit = 1000, association_limit = 2000`,
);
},
},
settings,
);
config.vars['NAMESPACES'] = '["e2e","other","manual"]';
await operatorIO.writeConfig(config);
await operatorIO.wrangler(['deploy', '--config', 'wrangler.operator.json']);
const managed = (await operatorIO.api(`/r2/buckets/${settings.name}/domains/managed`)) as {
enabled: boolean;
};
const custom = (await operatorIO.api(`/r2/buckets/${settings.name}/domains/custom`)) as {
domains: unknown[];
};
assert.equal(managed.enabled, false, 'R2 must remain private');
assert.deepEqual(custom.domains, [], 'R2 must have no public custom domain');
const fixture = await seedManual(cloudflareAdmin(config), settings.deployment);
const fixture = await seedManual(cloudflareAdmin(result.config), settings.deployment);
await mkdir(resultsDir, { recursive: true });
await writeFile(
new URL('manual-fetch.cbor', resultsDir),
Expand All @@ -255,7 +221,7 @@ async function deploy(settings: Settings): Promise<void> {
JSON.stringify(
{
deployment: settings.deployment,
source_sha: settings.revision,
source_sha: settings.sha,
endpoint: `${settings.origin}/projects/manual`,
blob_url: `${settings.origin}/projects/manual/blob/${fixture.blobId}`,
value_utf8: Buffer.from(fixture.value).toString(),
Expand All @@ -270,6 +236,7 @@ async function deploy(settings: Settings): Promise<void> {
process.env['GITHUB_OUTPUT'],
`endpoint=${settings.origin}/projects/manual\ndeployment=${settings.deployment}\n`,
);
return result;
}

async function verify(settings: Settings): Promise<void> {
Expand Down Expand Up @@ -309,11 +276,14 @@ async function verify(settings: Settings): Promise<void> {

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const settings = settingsFrom(process.env);
const command = process.argv[2];
if (command === 'deploy') await deploy(settings);
else if (command === 'test') await verify(settings);
else throw new Error('Use deploy or test');
const [command, target] = process.argv.slice(2);
if (command === 'deploy' && target === 'staging')
await reportDeployment(await deploy(settingsFrom(process.env)));
else if (command === 'deploy' && target === 'production') {
const context = runContext(process.env);
await reportDeployment(await deployTarget(productionTarget(context), context));
} else if (command === 'test') await verify(settingsFrom(process.env));
else throw new Error('Use deploy staging, deploy production, or test');
} catch (error) {
console.error(error instanceof Error ? error.message : 'Cloudflare CI failed');
process.exitCode = 1;
Expand Down
Loading
Loading