ci(docs): serve frozen version archives from R2 (#269)

This commit is contained in:
2026-09-24 15:28:58 -07:00
committed by GitHub
parent 5096ca217b
commit bc73a02623
19 changed files with 651 additions and 835 deletions
+107
View File
@@ -0,0 +1,107 @@
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { versionOutputPath } from './docs-versioning';
// Frozen `/v/<version>/` doc builds live in an R2 bucket and are served by the Pages
// Function in `docs-site/functions/v/[[path]].ts`, so they never count toward the Pages
// deployment. Transfers go through the AWS CLI's S3 API (preinstalled on GitHub runners).
// Written last on upload; an archive without it is treated as missing and rebuilt.
export const ARCHIVE_MARKER = '_archive.json';
export type DocsArchiveStore = {
has(version: string): boolean;
upload(version: string, dir: string, builtFrom: string): void;
};
export type DocsArchiveStoreEnv = {
accountId?: string;
accessKeyId?: string;
secretAccessKey?: string;
bucket?: string;
};
export function archiveStoreEnvFromProcess(env: NodeJS.ProcessEnv): DocsArchiveStoreEnv {
return {
accountId: env.CLOUDFLARE_ACCOUNT_ID,
accessKeyId: env.DOCS_ARCHIVE_R2_ACCESS_KEY_ID,
secretAccessKey: env.DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY,
bucket: env.DOCS_ARCHIVE_R2_BUCKET,
};
}
export function archiveKeyPrefix(version: string): string {
return `${versionOutputPath(version)}/`;
}
// Returns null when credentials are absent so local builds can skip archive sync.
export function createArchiveStore(config: DocsArchiveStoreEnv): DocsArchiveStore | null {
const { accountId, accessKeyId, secretAccessKey, bucket } = config;
if (!accountId || !accessKeyId || !secretAccessKey || !bucket) {
return null;
}
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
const env: NodeJS.ProcessEnv = {
...process.env,
AWS_ACCESS_KEY_ID: accessKeyId,
AWS_SECRET_ACCESS_KEY: secretAccessKey,
AWS_DEFAULT_REGION: 'auto',
// AWS CLI >= 2.23 sends CRC checksums by default, which R2 does not fully support.
AWS_REQUEST_CHECKSUM_CALCULATION: 'when_required',
AWS_RESPONSE_CHECKSUM_VALIDATION: 'when_required',
};
function aws(args: string[]) {
return spawnSync('aws', [...args, '--endpoint-url', endpoint], {
env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
return {
has(version) {
const result = aws([
's3api',
'head-object',
'--bucket',
bucket,
'--key',
`${archiveKeyPrefix(version)}${ARCHIVE_MARKER}`,
]);
if (result.error) throw result.error;
if (result.status === 0) return true;
if (/\b404\b|Not Found/i.test(result.stderr)) return false;
throw new Error(`Unable to check docs archive ${version}: ${result.stderr.trim()}`);
},
upload(version, dir, builtFrom) {
const target = `s3://${bucket}/${archiveKeyPrefix(version)}`;
// Unmark first so a rebuild that fails partway is retried by the next deploy
// instead of being skipped as complete. Deleting a missing key succeeds.
const unmark = aws(['s3', 'rm', `${target}${ARCHIVE_MARKER}`, '--only-show-errors']);
if (unmark.error) throw unmark.error;
if (unmark.status !== 0) {
throw new Error(`Unable to unmark docs archive ${version}: ${unmark.stderr.trim()}`);
}
const sync = aws(['s3', 'sync', dir, target, '--only-show-errors']);
if (sync.error) throw sync.error;
if (sync.status !== 0) {
throw new Error(`Unable to upload docs archive ${version}: ${sync.stderr.trim()}`);
}
const markerPath = join(dir, ARCHIVE_MARKER);
writeFileSync(
markerPath,
`${JSON.stringify({ version, builtFrom, builtAt: new Date().toISOString() }, null, 2)}\n`,
);
const marker = aws(['s3', 'cp', markerPath, `${target}${ARCHIVE_MARKER}`]);
if (marker.status !== 0) {
throw new Error(`Unable to mark docs archive ${version}: ${marker.stderr.trim()}`);
}
},
};
}