fix: make Actions deployments portable / 修复跨账户模板部署

This commit is contained in:
gdz66601 committed 2026-09-21 03:58:57 +08:00
1 parent 14ce519aca
commit 2916d69799
10 files changed
+431 -11

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import type { Database, DeploymentSettings } from './deployment-config.ts';
export async function ensureDatabase(
settings: Pick<DeploymentSettings, 'accountId' | 'apiToken' | 'databaseName' | 'databaseId'>,
fetcher: typeof fetch = fetch,
): Promise<Database> {
const base = `https://api.cloudflare.com/client/v4/accounts/${settings.accountId}/d1/database`;
async function request<T>(suffix: string, method = 'GET', body?: unknown): Promise<T> {
const response = await fetcher(`${base}${suffix}`, {
method,
headers: { Authorization: `Bearer ${settings.apiToken}`, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
});
// 不回显 API 响应体,防止远端诊断信息意外包含凭据或内部细节。
if (!response.ok) throw new Error(`D1 ${method} 请求失败(HTTP ${response.status})。请检查目标账户、数据库 ID 和 Token 的 D1 权限。`);
const payload = await response.json() as { success: boolean; result: T };
if (!payload.success) throw new Error(`D1 ${method} 请求未成功,请检查 Token 权限与账户资源配额。`);
return payload.result;
}
// 显式 ID 必须属于目标账户;不存在或无权限时直接失败,不能偷偷创建空库替代。
if (settings.databaseId) return await request<Database>(`/${settings.databaseId}`);
// 按名称复用并读取全部分页,避免已有数据库在后续页时误创建同名资源。
const pageSize = 100;
for (let page = 1; ; page++) {
const databases = await request<Database[]>(`?per_page=${pageSize}&page=${page}`);
const existing = databases.find((database) => database.name === settings.databaseName);
if (existing) return existing;
if (databases.length < pageSize) break;
}
return await request<Database>('', 'POST', { name: settings.databaseName });
}
+50
View File
@@ -0,0 +1,50 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { parse, stringify } from 'smol-toml';
import { ensureDatabase } from './cloudflare-d1.ts';
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const generatedConfig = '.wrangler.generated.toml';
const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url));
async function runWrangler(args: string[], input?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [wrangler, ...args, '--config', generatedConfig], {
cwd: root,
env: { ...process.env, CI: 'true' },
stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'],
});
child.on('error', reject);
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Wrangler ${args[0]} 失败(退出码 ${code})。`)));
if (input !== undefined) {
child.stdin!.on('error', reject);
child.stdin!.end(input);
}
});
}
async function main(): Promise<void> {
const template = parse(await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8'));
const settings = readDeploymentSettings(template, process.env);
if (process.argv.includes('--validate-only')) {
console.log('部署配置校验通过。');
return;
}
const database = await ensureDatabase(settings);
const config = createDeploymentConfig(template, settings, database);
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
console.log(`部署 Worker ${settings.workerName},使用 D1 ${database.name}(${database.uuid})。`);
await runWrangler(['d1', 'migrations', 'apply', 'DB', '--remote']);
// Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。
await runWrangler(['secret', 'bulk'], JSON.stringify(settings.secrets));
await runWrangler(['deploy']);
}
main().catch((error: Error) => {
console.error(error.message);
process.exitCode = 1;
});
+84
View File
@@ -0,0 +1,84 @@
import type { TomlTable } from 'smol-toml';
export const runtimeSecretNames = ['ENCRYPTION_KEY', 'ACCESS_TEAM_DOMAIN', 'ACCESS_AUD'] as const;
export type RuntimeSecrets = Record<(typeof runtimeSecretNames)[number], string>;
export interface DeploymentSettings {
accountId: string;
apiToken: string;
workerName: string;
databaseName: string;
databaseId?: string;
customDomain?: string;
secrets: RuntimeSecrets;
}
export interface Database {
uuid: string;
name: string;
}
export function readDeploymentSettings(
template: TomlTable,
env: NodeJS.ProcessEnv,
): DeploymentSettings {
const required = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', ...runtimeSecretNames];
const missing = required.filter((name) => !env[name]?.trim());
if (missing.length) throw new Error(`缺少部署配置:${missing.join(', ')}。请在 GitHub Actions 中配置。`);
const accountId = env.CLOUDFLARE_ACCOUNT_ID!.trim();
if (!/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。');
const workerName = env.WORKER_NAME?.trim() || String(template.name);
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。');
const databases = template.d1_databases as TomlTable[] | undefined;
const database = databases?.find((binding) => binding.binding === 'DB');
if (!database) throw new Error('wrangler.toml 缺少 DB 数据库绑定。');
// 改 Worker 名时默认隔离数据库;只有显式指定名称或 ID 才复用另一实例的数据。
const databaseName = env.D1_DATABASE_NAME?.trim()
|| (workerName === template.name ? String(database.database_name) : `${workerName}-accounts`);
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(databaseName)) throw new Error('D1_DATABASE_NAME 必须是 1–64 位字母、数字、下划线或连字符,且以字母或数字开头。');
const databaseId = env.D1_DATABASE_ID?.trim() || (database.database_id as string | undefined);
if (databaseId && !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(databaseId)) {
throw new Error('D1_DATABASE_ID 必须是有效的数据库 UUID。');
}
const customDomain = env.CUSTOM_DOMAIN?.trim();
if (customDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(customDomain)) {
throw new Error('CUSTOM_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
}
const secrets = Object.fromEntries(runtimeSecretNames.map((name) => [name, env[name]!.trim()])) as RuntimeSecrets;
const key = Buffer.from(secrets.ENCRYPTION_KEY, 'base64');
if (!/^[A-Za-z0-9+/]{43}=?$/.test(secrets.ENCRYPTION_KEY)
|| key.length !== 32
|| key.toString('base64').replace(/=+$/, '') !== secrets.ENCRYPTION_KEY.replace(/=+$/, '')) {
throw new Error('ENCRYPTION_KEY 必须是 Base64 编码的 32 字节密钥;已有数据时必须继续使用原密钥。');
}
if (!/^[a-z0-9-]+\.cloudflareaccess\.com$/.test(secrets.ACCESS_TEAM_DOMAIN)) {
throw new Error('ACCESS_TEAM_DOMAIN 必须形如 team.cloudflareaccess.com,不包含 https://。');
}
if (runtimeSecretNames.some((name) => name in ((template.vars as TomlTable | undefined) ?? {}))) {
throw new Error('运行时 Secret 不得放在 wrangler.toml 的 vars 中,请改用 GitHub Actions Secrets。');
}
return {
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
databaseName, databaseId, customDomain, secrets,
};
}
export function createDeploymentConfig(
template: TomlTable,
settings: DeploymentSettings,
database: Database,
): TomlTable {
return {
...template,
account_id: settings.accountId,
name: settings.workerName,
...(settings.customDomain ? { routes: [{ pattern: settings.customDomain, custom_domain: true }] } : {}),
d1_databases: (template.d1_databases as TomlTable[]).map((binding) => binding.binding === 'DB'
? { ...binding, database_name: database.name, database_id: database.uuid }
: binding),
};
}