From 3c449e36f8e7385686a795dbdeb16a9639145fdf Mon Sep 17 00:00:00 2001 From: rogliu Date: Fri, 22 May 2026 16:48:33 +0800 Subject: [PATCH] Update documentation structure and deployment workflow - Added versioning support for documentation, including a version selector in the user guide. - Updated the Docusaurus configuration to handle versioned documentation and maintain backward compatibility. - Modified the GitHub Actions workflow to fetch documentation version references and prepare versioned docs before building the site. - Removed the obsolete versions.json file and adjusted npm scripts for better version management. --- .github/workflows/deploy-docs.yml | 9 +- .gitignore | 4 + docs-site/docusaurus.config.js | 43 ++-- docs-site/package.json | 7 +- docs-site/scripts/prepare-versioned-docs.js | 240 ++++++++++++++++++ .../scripts/prepare-versioned-docs.test.js | 151 +++++++++++ docs-site/versions.json | 1 - docs/user/intro.md | 10 + 8 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 docs-site/scripts/prepare-versioned-docs.js create mode 100644 docs-site/scripts/prepare-versioned-docs.test.js delete mode 100644 docs-site/versions.json diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7a657c5..4f354ed 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -23,6 +23,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch docs version refs + run: git fetch --tags origin "main:refs/remotes/origin/main" "develop:refs/remotes/origin/develop" - uses: actions/setup-node@v4 with: @@ -46,7 +51,9 @@ jobs: working-directory: docs-site env: DOCS_BASE_URL: ${{ steps.baseurl.outputs.base_url }} - run: npm run build + run: | + npm run prepare:versions + npm run build:site - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index 7b7ba51..bc42bfd 100644 --- a/.gitignore +++ b/.gitignore @@ -227,6 +227,10 @@ agent_scratchpad tools sflow_output .sflow_cache +docs-site/.generated/ +docs-site/versioned_docs/ +docs-site/versioned_sidebars/ +docs-site/versions.json sflow_compute_node_venv/ logs/ workflow_outputs/ diff --git a/docs-site/docusaurus.config.js b/docs-site/docusaurus.config.js index 87b497b..db1708b 100644 --- a/docs-site/docusaurus.config.js +++ b/docs-site/docusaurus.config.js @@ -3,7 +3,7 @@ const path = require("path"); const fs = require("fs"); -function readReleasedDocVersions() { +function readVersionedDocIds() { try { const p = path.resolve(__dirname, "versions.json"); return JSON.parse(fs.readFileSync(p, "utf8")); @@ -12,6 +12,11 @@ function readReleasedDocVersions() { } } +function currentDocsPath() { + const generated = path.resolve(__dirname, ".generated", "current-docs"); + return fs.existsSync(generated) ? generated : path.resolve(__dirname, "..", "docs"); +} + const baseUrl = process.env.DOCS_BASE_URL || "/"; /** @type {import('@docusaurus/types').Config} */ @@ -41,16 +46,22 @@ const config = { /** @type {import('@docusaurus/preset-classic').Options} */ ({ docs: { - // Reuse the existing markdown under repo-level docs/ - path: path.resolve(__dirname, "..", "docs"), + // Builds use generated docs from the develop branch. Local dev falls + // back to the repo-level docs/ directory if generation has not run. + path: currentDocsPath(), routeBasePath: "docs", sidebarPath: require.resolve("./sidebars.js"), - // Keep current (unreleased) docs at /docs/... so existing links don't break. - // Released versions will live under /docs//... + // Keep develop docs at /docs/... so existing links don't break. + // Main and released tags live under /docs//... lastVersion: "current", versions: { current: { - label: "dev", + label: "develop", + banner: "none", + }, + main: { + label: "main", + banner: "none", }, }, showLastUpdateAuthor: false, @@ -74,20 +85,22 @@ const config = { [ "@docusaurus/plugin-client-redirects", { - // Add a stable alias path for "dev" so /docs/dev/... redirects to /docs/... - // This is useful for sharing links that always target the latest docs. + // Add a stable alias path for "develop" so /docs/develop/... redirects to /docs/... + // This is useful for sharing links that explicitly target develop docs. createRedirects(existingPath) { - // Only alias CURRENT docs, not released versions (e.g. /docs/0.1/...). - const released = new Set(readReleasedDocVersions()); + // Only alias CURRENT docs, not versioned docs (e.g. /docs/main/...). + const versioned = new Set(readVersionedDocIds()); const parts = existingPath.split("/").filter(Boolean); // ["docs", ...] if (parts[0] !== "docs") return undefined; - if (parts.length >= 2 && released.has(parts[1])) return undefined; + if (parts.length >= 2 && versioned.has(parts[1])) return undefined; - // Alias /docs/<...> => /docs/dev/<...> - return [existingPath.replace(/^\/docs(\/|$)/, "/docs/dev$1")]; + // Alias /docs/<...> => /docs/develop/<...> + return [existingPath.replace(/^\/docs(\/|$)/, "/docs/develop$1")]; }, - // /docs is not a real route by default; redirect to an existing doc page. - redirects: [{ from: "/docs/dev", to: "/docs/user/intro" }], + redirects: [ + // /docs is not a real route by default; redirect to an existing doc page. + { from: "/docs/develop", to: "/docs/user/intro" }, + ], }, ], ], diff --git a/docs-site/package.json b/docs-site/package.json index 00b5793..7974a4b 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -3,8 +3,11 @@ "private": true, "version": "0.0.0", "scripts": { - "start": "docusaurus start --host 0.0.0.0 --port 3000", - "build": "docusaurus build", + "prepare:versions": "node scripts/prepare-versioned-docs.js", + "test:versions": "node --test scripts/prepare-versioned-docs.test.js", + "build:site": "docusaurus build", + "start": "npm run prepare:versions && docusaurus start --host 0.0.0.0 --port 3000", + "build": "npm run prepare:versions && npm run build:site", "serve": "docusaurus serve --host 0.0.0.0 --port 3000", "clear": "docusaurus clear" }, diff --git a/docs-site/scripts/prepare-versioned-docs.js b/docs-site/scripts/prepare-versioned-docs.js new file mode 100644 index 0000000..4b64fd1 --- /dev/null +++ b/docs-site/scripts/prepare-versioned-docs.js @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +const childProcess = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const DOCS_SITE_DIR = path.resolve(__dirname, ".."); +const REPO_ROOT = path.resolve(DOCS_SITE_DIR, ".."); +const GENERATED_CURRENT_DIR = path.join(DOCS_SITE_DIR, ".generated", "current-docs"); +const VERSIONED_DOCS_DIR = path.join(DOCS_SITE_DIR, "versioned_docs"); +const VERSIONED_SIDEBARS_DIR = path.join(DOCS_SITE_DIR, "versioned_sidebars"); +const VERSIONS_JSON = path.join(DOCS_SITE_DIR, "versions.json"); +const SIDEBAR = { + docs: [{ type: "autogenerated", dirName: "." }], +}; + +function docsPaths({ repoRoot = REPO_ROOT, docsSiteDir = DOCS_SITE_DIR } = {}) { + return { + repoRoot, + generatedCurrentDir: path.join(docsSiteDir, ".generated", "current-docs"), + versionedDocsDir: path.join(docsSiteDir, "versioned_docs"), + versionedSidebarsDir: path.join(docsSiteDir, "versioned_sidebars"), + versionsJson: path.join(docsSiteDir, "versions.json"), + }; +} + +function isReleaseTag(ref) { + return /^v\d+\.\d+\.\d+$/.test(ref); +} + +function safeVersionDirName(label) { + return `version-${label.replace(/[^A-Za-z0-9._-]/g, "_")}`; +} + +function semverParts(tag) { + return tag + .replace(/^v/, "") + .split(".") + .map((part) => Number.parseInt(part, 10)); +} + +function compareReleaseTagsDesc(a, b) { + const aa = semverParts(a); + const bb = semverParts(b); + for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) { + const delta = (bb[i] || 0) - (aa[i] || 0); + if (delta !== 0) return delta; + } + return a.localeCompare(b); +} + +function buildDocVersionPlan({ + branches, + tags, + currentRef = "develop", + currentLabel = "develop", + currentSource = currentDocsSource(), +}) { + const branchSet = new Set(branches); + const versioned = []; + + if (branchSet.has("main") && currentRef !== "main") { + versioned.push({ label: "main", ref: "main", source: "gitRef" }); + } + + const releaseVersions = [...new Set(tags)] + .filter(isReleaseTag) + .sort(compareReleaseTagsDesc) + .map((tag) => ({ label: tag, ref: tag, source: "gitRef" })); + + versioned.push(...releaseVersions); + + return { + current: { label: currentLabel, ...currentSource, ref: currentSource.ref || currentRef }, + versioned, + versionsJson: versioned.map((version) => version.label), + }; +} + +function currentDocsSource(env = process.env) { + if (env.GITLAB_CI || env.GITHUB_ACTIONS) { + return { ref: "origin/develop", source: "gitRef" }; + } + return { ref: "develop", source: "workingTree" }; +} + +function gitRefExists(ref) { + try { + run("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +function resolveGitRef(label) { + if (gitRefExists(label)) return label; + const remoteRef = `origin/${label}`; + if (gitRefExists(remoteRef)) return remoteRef; + throw new Error(`Could not resolve git ref for docs version '${label}'`); +} + +function run(command, args, options = {}) { + return childProcess.execFileSync(command, args, { + cwd: REPO_ROOT, + encoding: "utf8", + stdio: options.stdio || ["ignore", "pipe", "pipe"], + }); +} + +function listBranches() { + return ["develop", "main"].filter((branch) => { + try { + resolveGitRef(branch); + return true; + } catch { + return false; + } + }); +} + +function listReleaseTags() { + const out = run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*"]); + return out + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function emptyDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); +} + +function copyDocsFromRef(ref, destination, { repoRoot = REPO_ROOT } = {}) { + emptyDir(destination); + const archive = childProcess.spawnSync( + "git", + ["archive", "--format=tar", ref, "docs"], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "inherit"], + }, + ); + if (archive.status !== 0) { + throw new Error(`Failed to archive docs from ${ref}`); + } + const extract = childProcess.spawnSync("tar", ["-x", "-C", destination, "--strip-components=1"], { + input: archive.stdout, + stdio: ["pipe", "inherit", "inherit"], + }); + if (extract.status !== 0) { + throw new Error(`Failed to extract docs from ${ref}`); + } +} + +function copyDocsFromWorkingTree(destination, { repoRoot = REPO_ROOT } = {}) { + const source = path.join(repoRoot, "docs"); + emptyDir(destination); + fs.cpSync(source, destination, { recursive: true }); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function rewriteVersionedDocsLinks(rootDir, versionLabel) { + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + rewriteVersionedDocsLinks(entryPath, versionLabel); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const text = fs.readFileSync(entryPath, "utf8"); + const rewritten = text.replaceAll("](/docs/", `](/docs/${versionLabel}/`); + if (rewritten !== text) { + fs.writeFileSync(entryPath, rewritten); + } + } + } +} + +function prepareVersionedDocs(plan, options = {}) { + const paths = docsPaths(options); + if (plan.current.source === "workingTree") { + copyDocsFromWorkingTree(paths.generatedCurrentDir, paths); + } else { + copyDocsFromRef(plan.current.ref, paths.generatedCurrentDir, paths); + } + emptyDir(paths.versionedDocsDir); + emptyDir(paths.versionedSidebarsDir); + + for (const version of plan.versioned) { + const versionDir = path.join(paths.versionedDocsDir, safeVersionDirName(version.label)); + copyDocsFromRef( + version.ref, + versionDir, + paths, + ); + rewriteVersionedDocsLinks(versionDir, version.label); + writeJson( + path.join(paths.versionedSidebarsDir, `${safeVersionDirName(version.label)}-sidebars.json`), + SIDEBAR, + ); + } + + writeJson(paths.versionsJson, plan.versionsJson); +} + +function main() { + const branches = listBranches(); + const tags = listReleaseTags(); + const plan = buildDocVersionPlan({ branches, tags }); + if (plan.current.source === "gitRef") { + plan.current.ref = resolveGitRef(plan.current.ref); + } + plan.versioned = plan.versioned.map((version) => ({ + ...version, + ref: isReleaseTag(version.label) ? version.ref : resolveGitRef(version.label), + })); + prepareVersionedDocs(plan); + console.log( + `Prepared docs versions: current=${plan.current.label}; versions=${plan.versionsJson.join(", ") || "(none)"}`, + ); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildDocVersionPlan, + currentDocsSource, + docsPaths, + isReleaseTag, + safeVersionDirName, + resolveGitRef, + prepareVersionedDocs, +}; diff --git a/docs-site/scripts/prepare-versioned-docs.test.js b/docs-site/scripts/prepare-versioned-docs.test.js new file mode 100644 index 0000000..ae99384 --- /dev/null +++ b/docs-site/scripts/prepare-versioned-docs.test.js @@ -0,0 +1,151 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const childProcess = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + buildDocVersionPlan, + isReleaseTag, + safeVersionDirName, + currentDocsSource, + prepareVersionedDocs, +} = require("./prepare-versioned-docs"); + +function git(cwd, args) { + return childProcess.execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function writeFile(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function commitAll(repo, message) { + git(repo, ["add", "."]); + git(repo, [ + "-c", + "user.name=sflow test", + "-c", + "user.email=sflow-test@example.com", + "commit", + "-m", + message, + ]); +} + +test("isReleaseTag accepts stable semantic release tags only", () => { + assert.equal(isReleaseTag("v0.2.2"), true); + assert.equal(isReleaseTag("v10.20.30"), true); + assert.equal(isReleaseTag("v0.2.2-rc.1"), false); + assert.equal(isReleaseTag("feature/foo"), false); +}); + +test("buildDocVersionPlan keeps develop as current and versions main plus release tags", () => { + const plan = buildDocVersionPlan({ + branches: ["develop", "main", "feature/foo"], + tags: ["v0.2.0", "test-tag", "v0.2.2", "v0.2.1-rc.1"], + }); + + assert.deepEqual(plan.current, { + label: "develop", + ref: "develop", + source: "workingTree", + }); + assert.deepEqual(plan.versioned, [ + { label: "main", ref: "main", source: "gitRef" }, + { label: "v0.2.2", ref: "v0.2.2", source: "gitRef" }, + { label: "v0.2.0", ref: "v0.2.0", source: "gitRef" }, + ]); + assert.deepEqual(plan.versionsJson, ["main", "v0.2.2", "v0.2.0"]); +}); + +test("safeVersionDirName maps version labels to Docusaurus directory names", () => { + assert.equal(safeVersionDirName("main"), "version-main"); + assert.equal(safeVersionDirName("v0.2.2"), "version-v0.2.2"); + assert.equal(safeVersionDirName("release/foo"), "version-release_foo"); +}); + +test("currentDocsSource uses local docs outside CI and origin develop in hosted CI", () => { + assert.deepEqual(currentDocsSource({}), { + ref: "develop", + source: "workingTree", + }); + assert.deepEqual(currentDocsSource({ GITLAB_CI: "true" }), { + ref: "origin/develop", + source: "gitRef", + }); + assert.deepEqual(currentDocsSource({ GITHUB_ACTIONS: "true" }), { + ref: "origin/develop", + source: "gitRef", + }); +}); + +test("prepareVersionedDocs materializes current docs, versioned docs, sidebars, and versions.json", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sflow-docs-versions-")); + const repo = path.join(tmp, "repo"); + const docsSite = path.join(repo, "docs-site"); + fs.mkdirSync(repo, { recursive: true }); + git(repo, ["init", "-b", "develop"]); + + writeFile(path.join(repo, "docs", "user", "intro.md"), "# Develop committed\n"); + commitAll(repo, "develop docs"); + + git(repo, ["checkout", "-b", "main"]); + writeFile(path.join(repo, "docs", "user", "intro.md"), "# Main docs\n"); + commitAll(repo, "main docs"); + + git(repo, ["checkout", "develop"]); + writeFile(path.join(repo, "docs", "user", "intro.md"), "# Release docs\n"); + writeFile(path.join(repo, "docs", "plc", "sflow_srd.md"), "See [SPP](/docs/sflow_spp).\n"); + commitAll(repo, "release docs"); + git(repo, ["tag", "v1.2.3"]); + + writeFile(path.join(repo, "docs", "user", "intro.md"), "# Local working docs\n"); + + const plan = { + current: { label: "develop", ref: "develop", source: "workingTree" }, + versioned: [ + { label: "main", ref: "main", source: "gitRef" }, + { label: "v1.2.3", ref: "v1.2.3", source: "gitRef" }, + ], + versionsJson: ["main", "v1.2.3"], + }; + + prepareVersionedDocs(plan, { repoRoot: repo, docsSiteDir: docsSite }); + + assert.equal( + fs.readFileSync(path.join(docsSite, ".generated", "current-docs", "user", "intro.md"), "utf8"), + "# Local working docs\n", + ); + assert.equal( + fs.readFileSync(path.join(docsSite, "versioned_docs", "version-main", "user", "intro.md"), "utf8"), + "# Main docs\n", + ); + assert.equal( + fs.readFileSync(path.join(docsSite, "versioned_docs", "version-v1.2.3", "user", "intro.md"), "utf8"), + "# Release docs\n", + ); + assert.deepEqual( + JSON.parse(fs.readFileSync(path.join(docsSite, "versions.json"), "utf8")), + ["main", "v1.2.3"], + ); + assert.deepEqual( + JSON.parse( + fs.readFileSync( + path.join(docsSite, "versioned_sidebars", "version-v1.2.3-sidebars.json"), + "utf8", + ), + ), + { docs: [{ type: "autogenerated", dirName: "." }] }, + ); + assert.equal( + fs.readFileSync(path.join(docsSite, "versioned_docs", "version-v1.2.3", "plc", "sflow_srd.md"), "utf8"), + "See [SPP](/docs/v1.2.3/sflow_spp).\n", + ); +}); diff --git a/docs-site/versions.json b/docs-site/versions.json deleted file mode 100644 index fe51488..0000000 --- a/docs-site/versions.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/docs/user/intro.md b/docs/user/intro.md index 2ecd180..cdc2b1b 100644 --- a/docs/user/intro.md +++ b/docs/user/intro.md @@ -15,6 +15,16 @@ The current focus is **Slurm**, which — unlike Kubernetes or Docker — lacks ![sflow TUI](/img/sflow_tui.gif) +## Docs versions + +The docs site version selector intentionally shows only maintained documentation streams: + +- **`develop`**: verified pre-release documentation for tested features that are queued for the next release. +- **`main`**: stable documentation aligned with the latest released state. +- **`vX.Y.Z` release tags**: immutable documentation snapshots for a specific release. + +Both `develop` and `main` are kept up to date. Use `main` or a release tag for production/stable behavior, and use `develop` when validating upcoming tested features before the next release. + ## Use Cases ### Complex Slurm Workflows