537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
/**
|
|
* Creates a versioned docs branch containing only the pre-built static site.
|
|
*
|
|
* Usage:
|
|
* node ./scripts/create-versioned-docs.mts v22
|
|
* node ./scripts/create-versioned-docs.mts 22
|
|
* node ./scripts/create-versioned-docs.mts v16 --redirect-to-prod
|
|
*
|
|
* --redirect-to-prod: skip the build and produce a branch that 301s every
|
|
* path to https://nx.dev/docs. Used to retire an old versioned subdomain
|
|
* (e.g. 16.nx.dev) without maintaining its docs — old paths won't map
|
|
* cleanly anymore, so everything goes to the /docs root.
|
|
*
|
|
* What it does:
|
|
* 1. Fetches tags from origin, finds the latest stable release for that major
|
|
* 2. Checks out that tag, builds the docs site
|
|
* 3. Creates an orphan branch `v{major}` with ONLY the pre-built static site
|
|
* plus minimal scaffolding so Netlify can "build" (no-op)
|
|
* 4. Returns to the original branch
|
|
*
|
|
* For v21+: builds astro-docs (Astro/Starlight)
|
|
* For v18-v20: builds nx-dev (Next.js with static export)
|
|
*
|
|
* The Netlify nx-dev app UI expects:
|
|
* - Build command: npx nx run nx-dev:deploy-build:netlify --skip-nx-cache
|
|
* - Publish dir: ./nx-dev/nx-dev/.next
|
|
*
|
|
* The versioned branch keeps that layout (project named "nx-dev" at
|
|
* nx-dev/nx-dev/, static files at nx-dev/nx-dev/.next/) but uses a root
|
|
* netlify.toml to override the build command to a no-op and — critically —
|
|
* NOT include @netlify/plugin-nextjs, so Netlify serves pure static files.
|
|
*
|
|
* The resulting branch is deployed via Netlify branch deploys at v{major}.nx.dev.
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
const ROOT = resolve(import.meta.dirname, '..');
|
|
|
|
// TODO: Remove FIRST_ASTRO_MAJOR and buildAndStageNextjs() once v21+ are the
|
|
// only versioned websites we maintain. At that point, delete the entire legacy
|
|
// function and the branching logic that calls it.
|
|
const FIRST_ASTRO_MAJOR = 21;
|
|
|
|
// Disable non-JS plugins that require external toolchains (Java, .NET)
|
|
process.env.NX_GRADLE_DISABLE = 'true';
|
|
process.env.NX_MAVEN_DISABLE = 'true';
|
|
process.env.NX_DOTNET_DISABLE = 'true';
|
|
|
|
// Must match the Netlify UI publish directory setting
|
|
const PUBLISH_DIR = 'nx-dev/nx-dev/.next';
|
|
|
|
// When useMise is true, commands are wrapped with `mise exec --` so they
|
|
// use the Node version from .mise.toml (needed for legacy Next.js builds).
|
|
let useMise = false;
|
|
|
|
function run(cmd: string, opts?: { cwd?: string }) {
|
|
const fullCmd = useMise ? `mise exec -- ${cmd}` : cmd;
|
|
console.log(`$ ${fullCmd}`);
|
|
execSync(fullCmd, { stdio: 'inherit', cwd: opts?.cwd ?? ROOT });
|
|
}
|
|
|
|
function parseVersion(input: string): string {
|
|
const match = input.replace(/^v/i, '');
|
|
if (!/^\d+$/.test(match)) {
|
|
console.error(
|
|
`Invalid version: "${input}". Expected a major version like "22" or "v22".`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
return match;
|
|
}
|
|
|
|
function findLatestStableTag(major: string): string | null {
|
|
const tags = execSync(`git tag -l '${major}.*'`, { cwd: ROOT })
|
|
.toString()
|
|
.trim()
|
|
.split('\n')
|
|
.filter((t) => t && !t.includes('-'));
|
|
|
|
if (tags.length === 0) return null;
|
|
|
|
tags.sort((a, b) => {
|
|
const pa = a.split('.').map(Number);
|
|
const pb = b.split('.').map(Number);
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
if (diff !== 0) return diff;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
return tags[tags.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Write the shared scaffolding every versioned branch needs.
|
|
* Writes headers/redirects netlify.toml content to the publish dir.
|
|
*/
|
|
function writeSharedScaffolding(
|
|
tmpDir: string,
|
|
headersAndRedirects: string
|
|
): void {
|
|
const rootPkg = JSON.parse(
|
|
readFileSync(resolve(ROOT, 'package.json'), 'utf-8')
|
|
);
|
|
const nxVersion =
|
|
rootPkg.devDependencies?.nx ?? rootPkg.dependencies?.nx ?? 'latest';
|
|
|
|
// Root package.json with nx so Netlify install succeeds
|
|
writeFileSync(
|
|
resolve(tmpDir, 'package.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: '@nx/nx-docs-versioned',
|
|
version: '0.0.1',
|
|
private: true,
|
|
devDependencies: { nx: nxVersion },
|
|
},
|
|
null,
|
|
2
|
|
) + '\n'
|
|
);
|
|
|
|
writeFileSync(resolve(tmpDir, 'nx.json'), JSON.stringify({}, null, 2) + '\n');
|
|
|
|
// Generate a real pnpm-lock.yaml from the stub package.json.
|
|
// Must use --no-frozen-lockfile since there's no existing lockfile yet.
|
|
console.log('Generating pnpm-lock.yaml for versioned branch...');
|
|
execSync('pnpm install --lockfile-only --no-frozen-lockfile', {
|
|
cwd: tmpDir,
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
// Project named "nx-dev" at nx-dev/nx-dev/ to match the Netlify build command:
|
|
// npx nx run nx-dev:deploy-build:netlify --skip-nx-cache
|
|
mkdirSync(resolve(tmpDir, 'nx-dev/nx-dev'), { recursive: true });
|
|
writeFileSync(
|
|
resolve(tmpDir, 'nx-dev/nx-dev/project.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: 'nx-dev',
|
|
targets: {
|
|
'deploy-build': {
|
|
command: "echo 'Pre-built static site'",
|
|
configurations: {
|
|
netlify: {},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
) + '\n'
|
|
);
|
|
|
|
// Root netlify.toml overrides UI settings for this branch.
|
|
// NETLIFY_NEXT_PLUGIN_SKIP disables the auto-installed @netlify/plugin-nextjs
|
|
// so Netlify serves the publish dir as pure static files.
|
|
writeFileSync(
|
|
resolve(tmpDir, 'netlify.toml'),
|
|
`# Auto-generated for versioned docs branch — overrides Netlify UI settings.
|
|
# NETLIFY_NEXT_PLUGIN_SKIP disables @netlify/plugin-nextjs = pure static serving.
|
|
|
|
[build]
|
|
command = "npx nx run nx-dev:deploy-build:netlify --skip-nx-cache"
|
|
publish = "${PUBLISH_DIR}"
|
|
|
|
[build.environment]
|
|
NETLIFY_NEXT_PLUGIN_SKIP = "true"
|
|
|
|
${headersAndRedirects}`
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stage: redirect-to-prod (no build)
|
|
// Produces a dummy publish dir + netlify.toml that 301s everything to nx.dev/docs.
|
|
// Used to retire an old versioned subdomain without maintaining its docs.
|
|
// ---------------------------------------------------------------------------
|
|
function stageRedirectToProd(tmpDir: string): void {
|
|
// Publish dir must match the Netlify UI setting (nx-dev/nx-dev/.next) —
|
|
// the UI value is authoritative; a toml `publish` override isn't honored.
|
|
const publishDir = resolve(tmpDir, PUBLISH_DIR);
|
|
mkdirSync(publishDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
resolve(publishDir, 'index.html'),
|
|
`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Redirecting to nx.dev/docs</title>
|
|
<meta http-equiv="refresh" content="0; url=https://nx.dev/docs">
|
|
<link rel="canonical" href="https://nx.dev/docs">
|
|
<script>window.location.replace("https://nx.dev/docs");</script>
|
|
</head>
|
|
<body>
|
|
Redirecting to <a href="https://nx.dev/docs">https://nx.dev/docs</a>.
|
|
</body>
|
|
</html>
|
|
`
|
|
);
|
|
|
|
// netlify.toml: overrides the UI build command to a no-op and force-301s
|
|
// every path to nx.dev/docs. Publish dir stays as the UI-configured
|
|
// ${PUBLISH_DIR}. No package.json / lockfile / nx scaffolding needed.
|
|
writeFileSync(
|
|
resolve(tmpDir, 'netlify.toml'),
|
|
`# Auto-generated for redirect-to-prod versioned branch.
|
|
# Overrides Netlify UI build command so no install/build runs.
|
|
# Publish dir stays as the UI-configured ${PUBLISH_DIR}.
|
|
|
|
[build]
|
|
command = "echo 'redirect-only branch — no build'"
|
|
|
|
[build.environment]
|
|
NETLIFY_NEXT_PLUGIN_SKIP = "true"
|
|
|
|
[[redirects]]
|
|
from = "/*"
|
|
to = "https://nx.dev/docs"
|
|
status = 301
|
|
force = true
|
|
`
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build + stage: Astro (v21+)
|
|
// ---------------------------------------------------------------------------
|
|
function buildAndStageAstro(tmpDir: string): void {
|
|
run(`pnpm nx build astro-docs --force`);
|
|
|
|
const distDir = resolve(ROOT, 'astro-docs/dist');
|
|
if (!existsSync(distDir)) {
|
|
console.error('Build failed: astro-docs/dist not found');
|
|
process.exit(1);
|
|
}
|
|
|
|
cpSync(distDir, resolve(tmpDir, PUBLISH_DIR), { recursive: true });
|
|
|
|
// Read the existing astro-docs/netlify.toml for headers/redirects
|
|
const tomlContent = readFileSync(
|
|
resolve(ROOT, 'astro-docs/netlify.toml'),
|
|
'utf-8'
|
|
);
|
|
// Strip any [build] section from it — we provide our own
|
|
const headersAndRedirects = tomlContent
|
|
.replace(/\[build\.environment\][\s\S]*?(?=\n\[|\n#|\n\[\[|$)/g, '')
|
|
.replace(/\[build\][\s\S]*?(?=\n\[|\n#|\n\[\[|$)/g, '')
|
|
.trim();
|
|
|
|
writeSharedScaffolding(tmpDir, headersAndRedirects);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build + stage: Next.js (v18-v20)
|
|
// TODO: Remove this entire function once v21+ are the only maintained versions.
|
|
// ---------------------------------------------------------------------------
|
|
function buildAndStageNextjs(tmpDir: string): void {
|
|
// Patch next.config.js to enable static export
|
|
const nextConfigPath = resolve(ROOT, 'nx-dev/nx-dev/next.config.js');
|
|
let nextConfig = readFileSync(nextConfigPath, 'utf-8');
|
|
|
|
if (!nextConfig.includes("output: 'export'")) {
|
|
nextConfig = nextConfig.replace(
|
|
/module\.exports\s*=\s*withNx\(\{/,
|
|
"module.exports = withNx({\n output: 'export',\n images: { unoptimized: true },"
|
|
);
|
|
writeFileSync(nextConfigPath, nextConfig);
|
|
}
|
|
|
|
// Remove pages that can't be statically exported (dirs or .tsx files)
|
|
const pagesDir = resolve(ROOT, 'nx-dev/nx-dev/pages');
|
|
for (const page of ['ai-chat', 'api', 'plugin-registry']) {
|
|
for (const candidate of [
|
|
resolve(pagesDir, page),
|
|
resolve(pagesDir, `${page}.tsx`),
|
|
]) {
|
|
if (existsSync(candidate)) {
|
|
console.log(
|
|
`Removing non-static page: ${candidate.split('nx-dev/nx-dev/')[1]}`
|
|
);
|
|
rmSync(candidate, { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Older Nx versions didn't use pnpm workspaces. Force hoisted node_modules
|
|
// so all deps (e.g. @heroicons/react) are resolvable from nested nx-dev libs.
|
|
writeFileSync(resolve(ROOT, '.npmrc'), 'node-linker=hoisted\n');
|
|
run('pnpm install --no-frozen-lockfile');
|
|
|
|
// Run build-base (actual Next.js build) directly — skip sitemap and
|
|
// link-checker which fail with static export.
|
|
run(`pnpm nx run nx-dev:build-base --force`);
|
|
|
|
// @nx/next:build puts the Next.js build cache at dist/nx-dev/nx-dev/
|
|
// and the static export (from output:'export') at dist/nx-dev/nx-dev/.next/
|
|
// We want the static export HTML, not the build cache.
|
|
const exportDir = resolve(ROOT, 'dist/nx-dev/nx-dev/.next');
|
|
if (!existsSync(exportDir)) {
|
|
console.error('Build failed: dist/nx-dev/nx-dev/.next not found');
|
|
process.exit(1);
|
|
}
|
|
|
|
cpSync(exportDir, resolve(tmpDir, PUBLISH_DIR), {
|
|
recursive: true,
|
|
filter: (src) => !src.includes('/cache/') && !src.endsWith('/project.json'),
|
|
});
|
|
|
|
const headersAndRedirects = `[[headers]]
|
|
for = "/*"
|
|
[headers.values]
|
|
X-Frame-Options = "DENY"
|
|
Content-Security-Policy = "frame-ancestors 'none'"
|
|
|
|
[[redirects]]
|
|
from = "/docs"
|
|
to = "/getting-started/intro"
|
|
|
|
[[redirects]]
|
|
from = "/"
|
|
to = "/getting-started/intro"`;
|
|
|
|
writeSharedScaffolding(tmpDir, headersAndRedirects);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Main
|
|
// ===========================================================================
|
|
|
|
const positionalArgs = process.argv.slice(2).filter((a) => !a.startsWith('-'));
|
|
const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('-')));
|
|
const force = flags.has('--force');
|
|
const redirectToProd = flags.has('--redirect-to-prod');
|
|
|
|
if (positionalArgs.length === 0) {
|
|
console.error(
|
|
'Usage: node ./scripts/create-versioned-docs.mts <version> [--force] [--redirect-to-prod]'
|
|
);
|
|
console.error(' e.g. node ./scripts/create-versioned-docs.mts v22');
|
|
console.error(
|
|
' node ./scripts/create-versioned-docs.mts v16 --redirect-to-prod'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Fail fast if working tree is dirty
|
|
const status = execSync('git status --porcelain', { cwd: ROOT })
|
|
.toString()
|
|
.trim();
|
|
if (status) {
|
|
console.error(
|
|
'Working tree is dirty. Commit or stash changes before running this script.\n'
|
|
);
|
|
console.error(status);
|
|
process.exit(1);
|
|
}
|
|
|
|
const majorVersion = parseVersion(positionalArgs[0]);
|
|
const majorNum = Number(majorVersion);
|
|
const branchName = majorVersion;
|
|
const isAstro = majorNum >= FIRST_ASTRO_MAJOR;
|
|
|
|
// Check if branch already exists (locally or on origin)
|
|
const branchExists =
|
|
execSync(`git branch --list ${branchName}`, { cwd: ROOT })
|
|
.toString()
|
|
.trim() !== '' ||
|
|
execSync(`git ls-remote --heads origin ${branchName}`, { cwd: ROOT })
|
|
.toString()
|
|
.trim() !== '';
|
|
|
|
if (branchExists && !force) {
|
|
console.error(
|
|
`Branch "${branchName}" already exists. Use --force to overwrite.\n` +
|
|
` node ./scripts/create-versioned-docs.mts ${positionalArgs[0]} --force`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\nCreating versioned docs branch: ${branchName}`);
|
|
console.log(
|
|
`Site type: ${
|
|
redirectToProd
|
|
? 'redirect-to-prod (no build)'
|
|
: isAstro
|
|
? 'Astro (v21+)'
|
|
: 'Next.js (legacy v18-v20)'
|
|
}\n`
|
|
);
|
|
|
|
// Save current ref to return to later
|
|
const currentRef = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ROOT })
|
|
.toString()
|
|
.trim();
|
|
|
|
// --- Step 0: Find and checkout the latest stable tag ---
|
|
// Skipped in --redirect-to-prod mode: there's nothing to build, so the source
|
|
// ref doesn't matter — stay on the current branch for commit provenance.
|
|
let sourceRef: string;
|
|
if (redirectToProd) {
|
|
sourceRef = currentRef;
|
|
console.log(
|
|
'=== Step 0: Skipped (redirect-to-prod) ===\n' +
|
|
`Using current ref: ${sourceRef}\n`
|
|
);
|
|
} else {
|
|
console.log('=== Step 0: Finding latest stable release tag ===\n');
|
|
run('git fetch --tags --force origin');
|
|
|
|
const latestTag = findLatestStableTag(majorVersion);
|
|
if (latestTag) {
|
|
sourceRef = latestTag;
|
|
console.log(`Found latest stable tag: ${sourceRef}`);
|
|
run(`git checkout ${sourceRef}`);
|
|
} else {
|
|
sourceRef = currentRef;
|
|
console.log(
|
|
`No stable tags found for major version ${majorVersion}. Using current branch: ${currentRef}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Resolve GITHUB_TOKEN (needed for GitHub stars in docs build) ---
|
|
if (!redirectToProd && !process.env.GITHUB_TOKEN) {
|
|
try {
|
|
const token = execSync(
|
|
"op read --account tuskteam 'op://Employee/API Keys/github_token'",
|
|
{
|
|
cwd: ROOT,
|
|
}
|
|
)
|
|
.toString()
|
|
.trim();
|
|
if (token) {
|
|
process.env.GITHUB_TOKEN = token;
|
|
console.log('GITHUB_TOKEN resolved from 1Password.\n');
|
|
}
|
|
} catch {
|
|
console.warn(
|
|
'Could not resolve GITHUB_TOKEN from 1Password.\n' +
|
|
'Re-run with: GITHUB_TOKEN="$(op read --account tuskteam \'op://Employee/API Keys/github_token\')" node ./scripts/create-versioned-docs.mts ...\n'
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- Step 1: Install + build ---
|
|
console.log('\n=== Step 1: Building docs site ===\n');
|
|
|
|
if (!redirectToProd) {
|
|
// Legacy Next.js builds need Node 20 — pin via mise so child processes use it
|
|
if (!isAstro) {
|
|
console.log('Pinning Node 20 via mise for legacy build...');
|
|
run('mise use node@20');
|
|
useMise = true;
|
|
}
|
|
|
|
// Remove node_modules to avoid stale deps from a different version/branch
|
|
const nodeModulesDir = resolve(ROOT, 'node_modules');
|
|
if (existsSync(nodeModulesDir)) {
|
|
console.log('Removing node_modules for clean install...');
|
|
rmSync(nodeModulesDir, { recursive: true });
|
|
}
|
|
|
|
run('pnpm install --frozen-lockfile');
|
|
}
|
|
|
|
const tmpDir = resolve(ROOT, 'tmp/versioned-docs');
|
|
if (existsSync(tmpDir)) {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
|
|
if (redirectToProd) {
|
|
stageRedirectToProd(tmpDir);
|
|
} else if (isAstro) {
|
|
buildAndStageAstro(tmpDir);
|
|
} else {
|
|
buildAndStageNextjs(tmpDir);
|
|
}
|
|
|
|
// --- Step 2: Create orphan branch ---
|
|
console.log('\n=== Step 2: Creating orphan branch ===\n');
|
|
|
|
try {
|
|
run(`git checkout --orphan ${branchName}-tmp`);
|
|
run('git rm -rf .');
|
|
|
|
// Copy staged files into the working tree
|
|
if (redirectToProd) {
|
|
cpSync(resolve(tmpDir, 'netlify.toml'), resolve(ROOT, 'netlify.toml'));
|
|
// Publish dir tree lives at nx-dev/nx-dev/.next/ — matches Netlify UI.
|
|
cpSync(resolve(tmpDir, 'nx-dev'), resolve(ROOT, 'nx-dev'), {
|
|
recursive: true,
|
|
});
|
|
run('git add netlify.toml nx-dev/');
|
|
} else {
|
|
cpSync(resolve(tmpDir, 'package.json'), resolve(ROOT, 'package.json'));
|
|
cpSync(resolve(tmpDir, 'nx.json'), resolve(ROOT, 'nx.json'));
|
|
cpSync(resolve(tmpDir, 'pnpm-lock.yaml'), resolve(ROOT, 'pnpm-lock.yaml'));
|
|
cpSync(resolve(tmpDir, 'netlify.toml'), resolve(ROOT, 'netlify.toml'));
|
|
cpSync(resolve(tmpDir, 'nx-dev'), resolve(ROOT, 'nx-dev'), {
|
|
recursive: true,
|
|
});
|
|
run('git add package.json nx.json pnpm-lock.yaml netlify.toml nx-dev/');
|
|
}
|
|
run(
|
|
`git commit -m "docs: versioned docs snapshot for ${branchName} (from ${sourceRef})"`
|
|
);
|
|
|
|
run(`git branch -M ${branchName}`);
|
|
|
|
console.log(`\nBranch "${branchName}" created successfully.`);
|
|
console.log(`\nTo push: git push -f origin ${branchName}`);
|
|
} finally {
|
|
useMise = false; // git commands don't need mise wrapping
|
|
console.log(`\nReturning to: ${currentRef}`);
|
|
// Clean untracked files left by orphan branch before switching back
|
|
run('git clean -fd');
|
|
run(`git checkout ${currentRef}`);
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log('\nDone!');
|