Skip to content

Commit dfd7349

Browse files
committed
feat(FR-2719): build per-language User Guide PDFs in CI release workflow (#7014)
resolves #NNN (FR-MMM) <!-- replace NNN, MMM with the GitHub issue number and the corresponding Jira issue number. --> <!-- Please precisely, concisely, and concretely describe what this PR changes, the rationale behind codes, and how it affects the users and other developers. --> **Checklist:** (if applicable) - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after
1 parent a16cb10 commit dfd7349

6 files changed

Lines changed: 272 additions & 23 deletions

File tree

.github/workflows/package.yml

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
# Build and release workflow for Backend.AI Desktop and WebUI bundle.
22
#
3-
# Architecture: 3 parallel jobs after a shared web build step.
3+
# Architecture: 4 parallel jobs (build_docs is fully independent, the others
4+
# share a web build step).
45
#
56
# build_web (ubuntu) ──┬──> build_mac (macos) → DMG x64/arm64 + local proxy
67
# ├──> build_desktop (ubuntu) → Win/Linux ZIP x64/arm64 + local proxy
78
# └──> upload web bundle
9+
# build_docs (ubuntu, independent) → User Guide PDFs (en/ko/ja/th)
810
#
911
# Key optimizations over the previous single-job approach:
10-
# 1. Parallel jobs: macOS + win/linux builds run concurrently (~10 min saved)
12+
# 1. Parallel jobs: macOS + win/linux + docs builds run concurrently (~10 min saved)
1113
# 2. No double React build: publicPath patching replaces full rebuild (~5 min saved)
1214
# 3. Parallel local proxy compilation within each job (~3 min saved)
1315
# 4. Optimized ZIP compression level (-6 vs -9, marginal size diff, ~1 min saved)
16+
# 5. PDF generation reuses one Chromium instance and renders languages in
17+
# parallel; Playwright browsers are cached across runs.
1418

1519
name: Build and Release Packages
1620
on:
@@ -204,3 +208,123 @@ jobs:
204208
run: node upload-release.js app
205209
env:
206210
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
211+
212+
# ──────────────────────────────────────────────────────────────────────
213+
# Job 4: Build per-language User Guide PDFs (ubuntu, target ~5 min)
214+
#
215+
# Independent of the web/desktop jobs — only needs the docs sources under
216+
# `packages/backend.ai-webui-docs/src/`. Runs in parallel with build_mac
217+
# and build_desktop, so a healthy run does not extend the overall wall
218+
# clock (mac is the current critical path at ~10 min).
219+
#
220+
# Failure handling is scoped to the PDF build step itself (not the whole
221+
# job), so partial per-language failures degrade gracefully but a full
222+
# generator failure (all languages failed → generate-pdf.ts exits non-
223+
# zero) still fails the job. This prevents shipping a release with zero
224+
# PDF assets going unnoticed.
225+
# ──────────────────────────────────────────────────────────────────────
226+
build_docs:
227+
permissions:
228+
contents: write
229+
runs-on: ubuntu-latest
230+
steps:
231+
- name: Check out Git repository
232+
uses: actions/checkout@v5
233+
234+
- uses: pnpm/action-setup@v5
235+
name: Install pnpm
236+
with:
237+
version: latest
238+
run_install: false
239+
240+
- name: Install Node.js
241+
uses: actions/setup-node@v5
242+
with:
243+
node-version-file: '.nvmrc'
244+
cache: 'pnpm'
245+
246+
- name: Install Dependencies
247+
run: pnpm install --no-frozen-lockfile
248+
249+
# backend.ai-docs-toolkit ships its `docs-toolkit` CLI as the package
250+
# bin, but it lives at dist/cli.js which only exists after `tsc` runs.
251+
# On a fresh `pnpm install` the dist directory is empty, so pnpm
252+
# cannot create the .bin/docs-toolkit symlink and later `pdf:all`
253+
# fails with `spawn ENOENT`. Building the toolkit and re-running
254+
# install populates dist/ and lets pnpm wire the bin link.
255+
- name: Build docs-toolkit and link CLI
256+
run: |
257+
pnpm --filter backend.ai-docs-toolkit run build
258+
pnpm install --no-frozen-lockfile
259+
260+
# Cache the Playwright browser binaries (~150 MB Chromium download).
261+
# The lockfile hash keys the cache so a Playwright version bump
262+
# naturally invalidates the cache without manual intervention.
263+
- name: Cache Playwright browsers
264+
id: playwright-cache
265+
uses: actions/cache@v4
266+
with:
267+
path: ~/.cache/ms-playwright
268+
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
269+
270+
- name: Install Playwright Chromium
271+
run: pnpm --filter backend.ai-webui-docs exec playwright install --with-deps chromium
272+
273+
# PDF rendering uses two distinct font paths that need different fonts:
274+
#
275+
# 1. Body text — rendered by Chromium from HTML. Resolves the CSS
276+
# font-family (see styles.ts) via fontconfig, so it understands
277+
# .ttc collections. `fonts-noto-cjk` provides "Noto Sans CJK KR/JP/TC"
278+
# which the CSS lists as the canonical CJK body font.
279+
#
280+
# 2. Header/footer — stamped post-render by pdf-lib, which cannot embed
281+
# .ttc collections. So we additionally install language-specific
282+
# single-face packages whose paths are listed in
283+
# DEFAULT_CJK_FONT_CANDIDATES (pdf-renderer.ts):
284+
# - fonts-nanum → /usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf (ko)
285+
# - fonts-takao-gothic → /usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf (ja)
286+
# - fonts-thai-tlwg → /usr/share/fonts/opentype/tlwg/Loma.otf | Garuda.ttf (th)
287+
#
288+
# Languages whose pdf-lib font is missing will be reported as a failure by
289+
# generate-pdf.ts but will not abort the other language renders.
290+
- name: Install CJK + Thai fonts (best effort)
291+
run: |
292+
sudo apt-get update
293+
sudo apt-get install -y --no-install-recommends \
294+
fonts-noto-cjk \
295+
fonts-nanum fonts-takao-gothic fonts-thai-tlwg || true
296+
fc-cache -f || true
297+
298+
# generate-pdf.ts continues past per-language failures and exits
299+
# non-zero only if *every* requested language failed. Combined with
300+
# `continue-on-error: true` on the job, a partial PDF run still
301+
# uploads the languages that succeeded.
302+
- name: Build PDFs (en/ko/ja/th)
303+
run: pnpm --filter backend.ai-webui-docs run pdf:all
304+
305+
- name: Stage PDFs for upload
306+
run: |
307+
mkdir -p ./app
308+
if compgen -G "packages/backend.ai-webui-docs/dist/*.pdf" > /dev/null; then
309+
cp packages/backend.ai-webui-docs/dist/*.pdf ./app/
310+
ls -lh ./app/*.pdf
311+
else
312+
echo "::warning::No PDFs were produced; nothing to upload."
313+
fi
314+
315+
- name: Upload PDF release assets
316+
if: inputs.dry_run != true
317+
run: node upload-release.js app
318+
env:
319+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
320+
321+
# Always retain the built PDFs as a workflow artifact for inspection
322+
# (handy during dry runs and when debugging the release path).
323+
- name: Upload PDFs as workflow artifact
324+
if: always()
325+
uses: actions/upload-artifact@v5
326+
with:
327+
name: webui-docs-pdfs
328+
path: packages/backend.ai-webui-docs/dist/*.pdf
329+
if-no-files-found: warn
330+
retention-days: 14

packages/backend.ai-docs-toolkit/src/generate-pdf.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'fs';
22
import path from 'path';
33
import { parse as parseYaml } from 'yaml';
4+
import { chromium } from 'playwright';
45
import { processMarkdownFiles } from './markdown-processor.js';
56
import { buildFullDocument } from './html-builder.js';
67
import { renderPdf } from './pdf-renderer.js';
@@ -20,6 +21,12 @@ export interface GeneratePdfOptions {
2021
theme: string;
2122
chapters?: string[];
2223
note?: string;
24+
/**
25+
* Maximum number of languages to render concurrently.
26+
* Defaults to 2 (safe for GitHub-hosted ubuntu runners with 2 vCPU).
27+
* Override via the `BAI_DOCS_PDF_CONCURRENCY` env var.
28+
*/
29+
concurrency?: number;
2330
}
2431

2532
function parseArgs(argv: string[]): GeneratePdfOptions {
@@ -85,22 +92,40 @@ export async function generatePdf(
8592
}
8693
}
8794

95+
const envConcurrency = Number.parseInt(
96+
process.env.BAI_DOCS_PDF_CONCURRENCY ?? '',
97+
10,
98+
);
99+
const optionConcurrency = Number.isFinite(args.concurrency)
100+
? args.concurrency
101+
: undefined;
102+
const concurrency = Math.max(
103+
1,
104+
optionConcurrency ?? (Number.isFinite(envConcurrency) ? envConcurrency : 2),
105+
);
106+
88107
const productName = config.productName;
89108
console.log(`${productName} PDF Generator`);
90109
console.log(`Title: ${title}`);
91110
console.log(`Version: ${version}`);
92111
console.log(`Theme: ${theme.name}`);
93112
console.log(`Languages: ${languages.join(', ')}`);
113+
console.log(`Concurrency: ${concurrency}`);
94114
console.log('');
95115

96-
for (const lang of languages) {
116+
// Launch a single Chromium instance shared across all language renders.
117+
// Each renderPdf call opens its own page on this browser, which avoids
118+
// paying the launch cost (~1–2s) per language.
119+
const sharedBrowser = await chromium.launch();
120+
121+
const renderOne = async (lang: string): Promise<void> => {
97122
const startTime = Date.now();
98123
console.log(`[${lang}] Generating PDF...`);
99124

100125
let navigation = bookConfig.navigation[lang];
101126
if (!navigation) {
102127
console.warn(`[${lang}] No navigation found, skipping`);
103-
continue;
128+
return;
104129
}
105130

106131
// Filter chapters if --chapters option is specified
@@ -129,7 +154,7 @@ export async function generatePdf(
129154
`[${lang}] Chapter identifiers can be a full path (e.g. overview/overview.md), directory name, or filename.`,
130155
);
131156
console.error(`[${lang}] Available chapters: ${available}`);
132-
process.exit(1);
157+
throw new Error(`No chapters matched filter for "${lang}"`);
133158
}
134159
navigation = filtered;
135160
console.log(
@@ -173,6 +198,7 @@ export async function generatePdf(
173198
lang,
174199
theme,
175200
config,
201+
browser: sharedBrowser,
176202
});
177203

178204
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -181,7 +207,55 @@ export async function generatePdf(
181207
`[${lang}] Done: ${outputPath} (${fileSize} MB, ${elapsed}s)`,
182208
);
183209
console.log('');
210+
};
211+
212+
// Track per-language outcome so a single language failure (e.g. missing
213+
// CJK font on a runner) does not kill renders that have already started
214+
// or are still queued. The script exits non-zero only if *every*
215+
// requested language failed.
216+
const failures: Array<{ lang: string; error: Error }> = [];
217+
218+
try {
219+
// Concurrency-limited fan-out: process up to `concurrency` languages at
220+
// a time. Each worker pulls the next language from a shared cursor.
221+
const queue = [...languages];
222+
const workers: Promise<void>[] = [];
223+
const workerCount = Math.min(concurrency, queue.length);
224+
for (let i = 0; i < workerCount; i++) {
225+
workers.push(
226+
(async () => {
227+
while (queue.length > 0) {
228+
const lang = queue.shift();
229+
if (!lang) break;
230+
try {
231+
await renderOne(lang);
232+
} catch (err) {
233+
const error = err instanceof Error ? err : new Error(String(err));
234+
console.error(`[${lang}] FAILED: ${error.message}`);
235+
failures.push({ lang, error });
236+
}
237+
}
238+
})(),
239+
);
240+
}
241+
await Promise.all(workers);
242+
} finally {
243+
await sharedBrowser.close();
184244
}
185245

186-
console.log('PDF generation complete!');
246+
if (failures.length > 0) {
247+
console.log('');
248+
console.log(`PDF generation finished with ${failures.length}/${languages.length} failures:`);
249+
for (const { lang, error } of failures) {
250+
console.log(` - [${lang}] ${error.message}`);
251+
}
252+
if (failures.length === languages.length) {
253+
throw new Error('All requested languages failed to build');
254+
}
255+
console.log(
256+
`Continuing because ${languages.length - failures.length} language(s) succeeded.`,
257+
);
258+
} else {
259+
console.log('PDF generation complete!');
260+
}
187261
}

packages/backend.ai-docs-toolkit/src/pdf-renderer.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'fs';
22
import os from 'os';
33
import path from 'path';
44
import { pathToFileURL } from 'url';
5-
import { chromium } from 'playwright';
5+
import { chromium, type Browser } from 'playwright';
66
import { PDFDocument, PDFName, PDFArray, PDFDict, PDFRef, rgb, StandardFonts } from 'pdf-lib';
77
import fontkit from '@pdf-lib/fontkit';
88
import type { PdfTheme } from './theme.js';
@@ -19,10 +19,17 @@ const DEFAULT_CJK_FONT_CANDIDATES = [
1919
path.join(os.homedir(), 'Library/Fonts/NanumBarunGothic.ttf'),
2020
path.join(os.homedir(), 'Library/Fonts/NanumSquareRegular.ttf'),
2121
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
22-
// Linux common paths
22+
// Linux common paths — Korean
2323
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
2424
'/usr/share/fonts/truetype/noto/NotoSansKR-Regular.ttf',
2525
'/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf',
26+
// Linux common paths — Japanese (fonts-takao-gothic on Debian/Ubuntu)
27+
'/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf',
28+
'/usr/share/fonts/truetype/takao-mincho/TakaoMincho.ttf',
29+
// Linux common paths — Thai (fonts-thai-tlwg on Debian/Ubuntu)
30+
'/usr/share/fonts/truetype/tlwg/Loma.ttf',
31+
'/usr/share/fonts/opentype/tlwg/Loma.otf',
32+
'/usr/share/fonts/truetype/tlwg/Garuda.ttf',
2633
];
2734

2835
type EmbeddedFont = Awaited<ReturnType<PDFDocument['embedFont']>>;
@@ -61,6 +68,13 @@ export interface RenderOptions {
6168
lang: string;
6269
theme?: PdfTheme;
6370
config?: ResolvedDocConfig;
71+
/**
72+
* Optional shared Chromium instance. When provided, renderPdf reuses it
73+
* by opening a new page on the existing browser, and the caller owns its
74+
* lifecycle. This avoids paying the per-call launch cost when generating
75+
* multiple PDFs in one run (e.g. one PDF per language during release builds).
76+
*/
77+
browser?: Browser;
6478
}
6579

6680
interface ChapterInfo {
@@ -382,16 +396,23 @@ export async function renderPdf(options: RenderOptions): Promise<void> {
382396

383397
fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
384398

385-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bai-docs-'));
386-
const tmpHtmlPath = path.join(tmpDir, 'document.html');
387-
fs.writeFileSync(tmpHtmlPath, options.html, 'utf-8');
399+
const ownsBrowser = !options.browser;
400+
const browser = options.browser ?? (await chromium.launch());
388401

389-
const browser = await chromium.launch();
402+
// tmpDir is created after the browser is ready so a chromium.launch()
403+
// failure cannot leave an orphaned temp directory behind. Use an outer
404+
// try/finally so a failure between mkdtempSync and the inner try also
405+
// cleans up the browser when we own it.
406+
let tmpDir: string | undefined;
390407
let pdfBuffer: Buffer;
391408
let chapterInfoList: ChapterInfo[] = [];
392409
let sectionList: SectionInfo[] = [];
410+
let page: Awaited<ReturnType<Browser['newPage']>> | undefined;
393411
try {
394-
const page = await browser.newPage({
412+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bai-docs-'));
413+
const tmpHtmlPath = path.join(tmpDir, 'document.html');
414+
fs.writeFileSync(tmpHtmlPath, options.html, 'utf-8');
415+
page = await browser.newPage({
395416
viewport: { width: PRINT_WIDTH_PX, height: 800 },
396417
});
397418

@@ -479,8 +500,15 @@ export async function renderPdf(options: RenderOptions): Promise<void> {
479500
console.log(' Rendering final PDF...');
480501
pdfBuffer = await page.pdf(PDF_OPTIONS);
481502
} finally {
482-
await browser.close();
483-
fs.rmSync(tmpDir, { recursive: true, force: true });
503+
if (page) {
504+
await page.close().catch(() => {});
505+
}
506+
if (ownsBrowser) {
507+
await browser.close();
508+
}
509+
if (tmpDir) {
510+
fs.rmSync(tmpDir, { recursive: true, force: true });
511+
}
484512
}
485513

486514
// ── Post-processing ──────────────────────────────────────────

packages/backend.ai-docs-toolkit/src/styles-web.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ export function generateWebStyles(lang?: string): string {
5050
--ifm-color-emphasis-1000: #000;
5151
5252
--ifm-font-family-base: system-ui, -apple-system, "Segoe UI", Roboto,
53-
Ubuntu, Cantarell, "Noto Sans", "Noto Sans KR", "Noto Sans JP", "Noto Sans Thai",
53+
Ubuntu, Cantarell, "Noto Sans",
54+
"Noto Sans KR", "Noto Sans CJK KR",
55+
"Noto Sans JP", "Noto Sans CJK JP",
56+
"Noto Sans TC", "Noto Sans CJK TC",
57+
"Noto Sans Thai",
5458
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
5559
--ifm-font-family-monospace: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
5660

0 commit comments

Comments
 (0)