-
Notifications
You must be signed in to change notification settings - Fork 19
Resolve product docs from the caller's checkout #5473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
3d55d90
Resolve product docs from the caller's checkout
dimitrieh fe17fc9
Set FLOWFUSE_DOCS_SNAPSHOT in the existing build.environment table
dimitrieh d2ad6fb
Merge remote-tracking branch 'origin/main' into docs-source-resolution
dimitrieh 801f6f2
Drop the live-branch docs snapshot, clone docs at build time instead
ZJvandeWeg bf92f1d
Merge remote-tracking branch 'origin/main' into docs-source-resolution
ZJvandeWeg 4eb0cd8
Remove redundant TMPDIR workaround note from README
ZJvandeWeg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| // Resolves the FlowFuse product docs for a build and copies them into nuxt/content/docs. | ||
| // Kept free of Nuxt imports so `scripts/sync_docs.mjs` can run it before `npm install`. | ||
|
|
||
| import { execFileSync } from 'node:child_process' | ||
| import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { join, relative } from 'node:path' | ||
| import { tmpdir } from 'node:os' | ||
|
|
||
| import { processMarkdown } from './docs-markdown.mjs' | ||
|
|
||
| const REPO_URL = 'https://github.com/FlowFuse/flowfuse.git' | ||
| const DEFAULT_REF = 'main' | ||
| const CLONE_ATTEMPTS = 3 | ||
| const CLONE_BACKOFF_MS = 2000 | ||
|
|
||
| // Whatever checkout sits next to the website repo wins. CI puts the flowfuse repo there, | ||
| // so a build validates the docs of the caller's checkout rather than whatever main | ||
| // happens to be. The release pipeline depends on this. | ||
| const SIBLING_PATHS = ['../dev-env/packages/flowfuse', '../flowfuse', '../flowforge'] | ||
|
|
||
| export const MANIFEST_FILE = '.source.json' | ||
|
|
||
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) | ||
|
|
||
| /** | ||
| * Decide where the docs come from. Pure: touches nothing, so the precedence is testable. | ||
| * | ||
| * 1. `FLOWFUSE_DOCS_LOCAL` - an explicit checkout path | ||
| * 2. a sibling checkout of flowfuse | ||
| * 3. a clone of `FLOWFUSE_DOCS_REF` | ||
| */ | ||
| export function resolveSource ({ repoRoot, env = process.env, exists = existsSync }) { | ||
| const local = env.FLOWFUSE_DOCS_LOCAL | ||
| if (local) { | ||
| const docsDir = local.endsWith('/docs') ? local : join(local, 'docs') | ||
| // A typo here would otherwise fall through and quietly publish main's docs. | ||
| if (!exists(docsDir)) { | ||
| throw new Error(`FLOWFUSE_DOCS_LOCAL is set but ${docsDir} does not exist`) | ||
| } | ||
| return { kind: 'local', docsDir } | ||
| } | ||
|
|
||
| for (const sibling of SIBLING_PATHS) { | ||
| const docsDir = join(repoRoot, sibling, 'docs') | ||
| if (exists(docsDir)) { | ||
| return { kind: 'sibling', docsDir } | ||
| } | ||
| } | ||
|
|
||
| return { kind: 'clone', ref: env.FLOWFUSE_DOCS_REF || DEFAULT_REF } | ||
| } | ||
|
|
||
| /** | ||
| * Sparse-clone the docs into a temp dir and return its path. | ||
| * | ||
| * A transient network failure here would otherwise fail the entire production deploy, so | ||
| * each attempt gets a clean temp dir and the network steps are retried with backoff. The | ||
| * caller owns cleanup of the returned dir. | ||
| */ | ||
| async function cloneDocs (ref, logger) { | ||
| let lastError | ||
|
|
||
| for (let attempt = 1; attempt <= CLONE_ATTEMPTS; attempt++) { | ||
| const tmpDir = join(tmpdir(), `flowfuse-docs-${process.pid}-${attempt}`) | ||
| if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) | ||
|
|
||
| try { | ||
| // Blobless but not shallow: dating a page needs that page's history, and a | ||
| // --depth=1 clone stamps every page with the same commit date. | ||
| execFileSync('git', ['clone', '--filter=blob:none', '--no-checkout', REPO_URL, tmpDir], { stdio: 'pipe' }) | ||
| execFileSync('git', ['sparse-checkout', 'set', 'docs'], { cwd: tmpDir, stdio: 'pipe' }) | ||
| execFileSync('git', ['checkout', ref], { cwd: tmpDir, stdio: 'pipe' }) | ||
| return tmpDir | ||
| } catch (err) { | ||
| lastError = err | ||
| if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) | ||
|
|
||
| if (attempt === CLONE_ATTEMPTS) break | ||
|
|
||
| const wait = CLONE_BACKOFF_MS * attempt | ||
| logger.warn(`Docs clone attempt ${attempt}/${CLONE_ATTEMPTS} failed, retrying in ${wait}ms`) | ||
| await sleep(wait) | ||
| } | ||
| } | ||
|
|
||
| const reason = lastError instanceof Error ? lastError.message : String(lastError) | ||
| throw new Error(`Failed to clone FlowFuse docs from ${REPO_URL} after ${CLONE_ATTEMPTS} attempts: ${reason}`) | ||
| } | ||
|
|
||
| function gitOutput (cwd, args) { | ||
| try { | ||
| return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() | ||
| } catch { | ||
| return '' | ||
| } | ||
| } | ||
|
|
||
| function copyDocsDir (srcDir, repoRoot, contentDir, publicDir, version) { | ||
| mkdirSync(contentDir, { recursive: true }) | ||
| mkdirSync(publicDir, { recursive: true }) | ||
|
|
||
| for (const entry of readdirSync(srcDir, { withFileTypes: true })) { | ||
| if (entry.name.startsWith('.')) continue | ||
|
|
||
| const srcPath = join(srcDir, entry.name) | ||
| const destName = entry.name === 'README.md' ? 'index.md' : entry.name | ||
|
|
||
| if (entry.isDirectory()) { | ||
| copyDocsDir(srcPath, repoRoot, join(contentDir, entry.name), join(publicDir, entry.name), version) | ||
| } else if (entry.name.endsWith('.md')) { | ||
| const relFromRepo = relative(repoRoot, srcPath) | ||
| const originalPath = relative(join(repoRoot, 'docs'), srcPath) | ||
|
|
||
| // Argument array, not a shell string: relFromRepo comes from filenames in the | ||
| // source repo, so quoting it into a shell command would be an injection path. | ||
| const updated = gitOutput(repoRoot, ['log', '-1', '--pretty=format:%ci', '--', relFromRepo]) | ||
|
|
||
| const raw = readFileSync(srcPath, 'utf8') | ||
| writeFileSync(join(contentDir, destName), processMarkdown(raw, originalPath, updated, version), 'utf8') | ||
| } else { | ||
| cpSync(srcPath, join(publicDir, entry.name)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function writeDocs ({ docsDir, sourceRoot, contentDocsDir, publicDocsDir, kind, ref }) { | ||
| let version = '' | ||
| try { | ||
| version = JSON.parse(readFileSync(join(sourceRoot, 'package.json'), 'utf8')).version || '' | ||
| } catch { /* not fatal */ } | ||
|
|
||
| rmSync(contentDocsDir, { recursive: true, force: true }) | ||
| rmSync(publicDocsDir, { recursive: true, force: true }) | ||
| copyDocsDir(docsDir, sourceRoot, contentDocsDir, publicDocsDir, version) | ||
|
|
||
| const manifest = { | ||
| source: kind, | ||
| ref: ref || gitOutput(sourceRoot, ['rev-parse', '--abbrev-ref', 'HEAD']), | ||
| sha: gitOutput(sourceRoot, ['rev-parse', 'HEAD']), | ||
| version, | ||
| syncedAt: new Date().toISOString(), | ||
| } | ||
| writeFileSync(join(contentDocsDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') | ||
|
|
||
| return manifest | ||
| } | ||
|
|
||
| /** | ||
| * Populate nuxt/content/docs and nuxt/public/docs, and return the manifest describing | ||
| * what was published. | ||
| */ | ||
| export async function syncDocs ({ repoRoot, nuxtRoot, env = process.env, logger = console } = {}) { | ||
| const contentDocsDir = join(nuxtRoot, 'content', 'docs') | ||
| const publicDocsDir = join(nuxtRoot, 'public', 'docs') | ||
| const source = resolveSource({ repoRoot, env }) | ||
|
|
||
| let manifest | ||
| if (source.kind === 'clone') { | ||
| logger.info(`Cloning FlowFuse docs from ${source.ref}...`) | ||
| const tmpDir = await cloneDocs(source.ref, logger) | ||
| try { | ||
| manifest = writeDocs({ | ||
| docsDir: join(tmpDir, 'docs'), | ||
| sourceRoot: tmpDir, | ||
| contentDocsDir, | ||
| publicDocsDir, | ||
| kind: source.kind, | ||
| ref: source.ref, | ||
| }) | ||
| } finally { | ||
| if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) | ||
| } | ||
| } else { | ||
| logger.info(`Using ${source.kind} docs from ${source.docsDir}`) | ||
| manifest = writeDocs({ | ||
| docsDir: source.docsDir, | ||
| sourceRoot: join(source.docsDir, '..'), | ||
| contentDocsDir, | ||
| publicDocsDir, | ||
| kind: source.kind, | ||
| }) | ||
| } | ||
|
|
||
| logger.info(`Docs synced from ${manifest.source} (${manifest.ref} ${manifest.sha.slice(0, 8) || 'unknown'}, version ${manifest.version || 'unknown'})`) | ||
| return manifest | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { test } from 'node:test' | ||
| import assert from 'node:assert/strict' | ||
|
|
||
| import { resolveSource } from './docs-sync.mjs' | ||
|
|
||
| const repoRoot = '/repo/website' | ||
|
|
||
| const resolve = (env, present = []) => resolveSource({ | ||
| repoRoot, | ||
| env, | ||
| exists: (path) => present.includes(path), | ||
| }) | ||
|
|
||
| test('an explicit path wins over a sibling checkout', () => { | ||
| const source = resolve( | ||
| { FLOWFUSE_DOCS_LOCAL: '/elsewhere/flowfuse' }, | ||
| ['/elsewhere/flowfuse/docs', '/repo/flowfuse/docs'], | ||
| ) | ||
|
|
||
| assert.deepEqual(source, { kind: 'local', docsDir: '/elsewhere/flowfuse/docs' }) | ||
| }) | ||
|
|
||
| test('an explicit path already ending in /docs is used as given', () => { | ||
| const source = resolve( | ||
| { FLOWFUSE_DOCS_LOCAL: '/elsewhere/flowfuse/docs' }, | ||
| ['/elsewhere/flowfuse/docs'], | ||
| ) | ||
|
|
||
| assert.equal(source.docsDir, '/elsewhere/flowfuse/docs') | ||
| }) | ||
|
|
||
| test('a mistyped explicit path throws rather than falling back', () => { | ||
| assert.throws( | ||
| () => resolve({ FLOWFUSE_DOCS_LOCAL: '/typo' }, ['/repo/flowfuse/docs']), | ||
| /FLOWFUSE_DOCS_LOCAL is set but/, | ||
| ) | ||
| }) | ||
|
|
||
| test('a sibling checkout is found without configuration', () => { | ||
| const source = resolve({}, ['/repo/flowfuse/docs']) | ||
|
|
||
| assert.deepEqual(source, { kind: 'sibling', docsDir: '/repo/flowfuse/docs' }) | ||
| }) | ||
|
|
||
| test('the dev-env checkout is preferred over a bare sibling', () => { | ||
| const source = resolve({}, ['/repo/dev-env/packages/flowfuse/docs', '/repo/flowfuse/docs']) | ||
|
|
||
| assert.equal(source.docsDir, '/repo/dev-env/packages/flowfuse/docs') | ||
| }) | ||
|
|
||
| test('cloning falls back to main and honours an explicit ref', () => { | ||
| assert.deepEqual(resolve({}), { kind: 'clone', ref: 'main' }) | ||
| assert.equal(resolve({ FLOWFUSE_DOCS_REF: 'maintenance' }).ref, 'maintenance') | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.