Skip to content

Commit dc3c902

Browse files
committed
feat(FR-2719): build per-language User Guide PDFs in CI release workflow
- Add a new build_docs job to .github/workflows/package.yml that runs independently of build_web in parallel with the desktop jobs, so adding PDFs does not extend the overall release wall-clock (the critical path is still the ~10 min build_mac job). - The job caches Playwright browsers, installs only Chromium, best-effort installs language-specific single-file CJK and Thai fonts referenced by pdf-renderer's DEFAULT_CJK_FONT_CANDIDATES, runs pdf:all, stages PDFs into ./app/, uploads them via upload-release.js (gated on dry_run), and always retains them as a workflow artifact for inspection. - Allow .pdf in upload-release.js's asset extension filter alongside .dmg and .zip. - In the toolkit, share one Chromium instance across languages and fan out per-language renders with a configurable concurrency (default 2, override via BAI_DOCS_PDF_CONCURRENCY). renderPdf accepts an optional injected Browser so the caller owns lifecycle. - Isolate per-language failures in generate-pdf.ts: the script logs each failure, continues with remaining languages, and exits non-zero only if every requested language failed. Combined with continue-on-error on the job, partial CJK font issues no longer block English uploads.
1 parent a16cb10 commit dc3c902

4 files changed

Lines changed: 201 additions & 15 deletions

File tree

.github/workflows/package.yml

Lines changed: 103 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,100 @@ 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+
# `continue-on-error: true` means a PDF failure logs warnings but does
221+
# NOT block the release. Tighten this once the pipeline has soaked.
222+
# ──────────────────────────────────────────────────────────────────────
223+
build_docs:
224+
permissions:
225+
contents: write
226+
runs-on: ubuntu-latest
227+
continue-on-error: true
228+
steps:
229+
- name: Check out Git repository
230+
uses: actions/checkout@v5
231+
232+
- uses: pnpm/action-setup@v5
233+
name: Install pnpm
234+
with:
235+
version: latest
236+
run_install: false
237+
238+
- name: Install Node.js
239+
uses: actions/setup-node@v5
240+
with:
241+
node-version-file: '.nvmrc'
242+
cache: 'pnpm'
243+
244+
- name: Install Dependencies
245+
run: pnpm install --no-frozen-lockfile
246+
247+
# Cache the Playwright browser binaries (~150 MB Chromium download).
248+
# The lockfile hash keys the cache so a Playwright version bump
249+
# naturally invalidates the cache without manual intervention.
250+
- name: Cache Playwright browsers
251+
id: playwright-cache
252+
uses: actions/cache@v4
253+
with:
254+
path: ~/.cache/ms-playwright
255+
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
256+
257+
- name: Install Playwright Chromium
258+
run: pnpm --filter backend.ai-webui-docs exec playwright install --with-deps chromium
259+
260+
# Install single-face CJK + Thai fonts. Ubuntu's `fonts-noto-cjk`
261+
# ships only a TTC bundle that pdf-lib/fontkit cannot embed, so we
262+
# use language-specific single-file packages whose paths are listed
263+
# in DEFAULT_CJK_FONT_CANDIDATES (pdf-renderer.ts):
264+
# - fonts-nanum → /usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf (ko)
265+
# - fonts-takao-gothic → /usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf (ja)
266+
# - fonts-thai-tlwg → /usr/share/fonts/truetype/tlwg/Loma.ttf (th)
267+
# Languages whose font is missing will be reported as a failure by
268+
# generate-pdf.ts but will not abort the other language renders.
269+
- name: Install CJK + Thai fonts (best effort)
270+
run: |
271+
sudo apt-get update
272+
sudo apt-get install -y --no-install-recommends \
273+
fonts-nanum fonts-takao-gothic fonts-thai-tlwg || true
274+
275+
# generate-pdf.ts continues past per-language failures and exits
276+
# non-zero only if *every* requested language failed. Combined with
277+
# `continue-on-error: true` on the job, a partial PDF run still
278+
# uploads the languages that succeeded.
279+
- name: Build PDFs (en/ko/ja/th)
280+
run: pnpm --filter backend.ai-webui-docs run pdf:all
281+
282+
- name: Stage PDFs for upload
283+
run: |
284+
mkdir -p ./app
285+
if compgen -G "packages/backend.ai-webui-docs/dist/*.pdf" > /dev/null; then
286+
cp packages/backend.ai-webui-docs/dist/*.pdf ./app/
287+
ls -lh ./app/*.pdf
288+
else
289+
echo "::warning::No PDFs were produced; nothing to upload."
290+
fi
291+
292+
- name: Upload PDF release assets
293+
if: inputs.dry_run != true
294+
run: node upload-release.js app
295+
env:
296+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
297+
298+
# Always retain the built PDFs as a workflow artifact for inspection
299+
# (handy during dry runs and when debugging the release path).
300+
- name: Upload PDFs as workflow artifact
301+
if: always()
302+
uses: actions/upload-artifact@v5
303+
with:
304+
name: webui-docs-pdfs
305+
path: packages/backend.ai-webui-docs/dist/*.pdf
306+
if-no-files-found: warn
307+
retention-days: 14

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

Lines changed: 75 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,37 @@ export async function generatePdf(
8592
}
8693
}
8794

95+
const envConcurrency = Number.parseInt(
96+
process.env.BAI_DOCS_PDF_CONCURRENCY ?? '',
97+
10,
98+
);
99+
const concurrency = Math.max(
100+
1,
101+
args.concurrency ?? (Number.isFinite(envConcurrency) ? envConcurrency : 2),
102+
);
103+
88104
const productName = config.productName;
89105
console.log(`${productName} PDF Generator`);
90106
console.log(`Title: ${title}`);
91107
console.log(`Version: ${version}`);
92108
console.log(`Theme: ${theme.name}`);
93109
console.log(`Languages: ${languages.join(', ')}`);
110+
console.log(`Concurrency: ${concurrency}`);
94111
console.log('');
95112

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

100122
let navigation = bookConfig.navigation[lang];
101123
if (!navigation) {
102124
console.warn(`[${lang}] No navigation found, skipping`);
103-
continue;
125+
return;
104126
}
105127

106128
// Filter chapters if --chapters option is specified
@@ -129,7 +151,7 @@ export async function generatePdf(
129151
`[${lang}] Chapter identifiers can be a full path (e.g. overview/overview.md), directory name, or filename.`,
130152
);
131153
console.error(`[${lang}] Available chapters: ${available}`);
132-
process.exit(1);
154+
throw new Error(`No chapters matched filter for "${lang}"`);
133155
}
134156
navigation = filtered;
135157
console.log(
@@ -173,6 +195,7 @@ export async function generatePdf(
173195
lang,
174196
theme,
175197
config,
198+
browser: sharedBrowser,
176199
});
177200

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

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

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

Lines changed: 18 additions & 4 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';
@@ -61,6 +61,13 @@ export interface RenderOptions {
6161
lang: string;
6262
theme?: PdfTheme;
6363
config?: ResolvedDocConfig;
64+
/**
65+
* Optional shared Chromium instance. When provided, renderPdf reuses it
66+
* (via newContext/newPage) and the caller owns its lifecycle. This avoids
67+
* paying the per-call launch cost when generating multiple PDFs in one run
68+
* (e.g. one PDF per language during release builds).
69+
*/
70+
browser?: Browser;
6471
}
6572

6673
interface ChapterInfo {
@@ -386,12 +393,14 @@ export async function renderPdf(options: RenderOptions): Promise<void> {
386393
const tmpHtmlPath = path.join(tmpDir, 'document.html');
387394
fs.writeFileSync(tmpHtmlPath, options.html, 'utf-8');
388395

389-
const browser = await chromium.launch();
396+
const ownsBrowser = !options.browser;
397+
const browser = options.browser ?? (await chromium.launch());
390398
let pdfBuffer: Buffer;
391399
let chapterInfoList: ChapterInfo[] = [];
392400
let sectionList: SectionInfo[] = [];
401+
let page: Awaited<ReturnType<Browser['newPage']>> | undefined;
393402
try {
394-
const page = await browser.newPage({
403+
page = await browser.newPage({
395404
viewport: { width: PRINT_WIDTH_PX, height: 800 },
396405
});
397406

@@ -479,7 +488,12 @@ export async function renderPdf(options: RenderOptions): Promise<void> {
479488
console.log(' Rendering final PDF...');
480489
pdfBuffer = await page.pdf(PDF_OPTIONS);
481490
} finally {
482-
await browser.close();
491+
if (page) {
492+
await page.close().catch(() => {});
493+
}
494+
if (ownsBrowser) {
495+
await browser.close();
496+
}
483497
fs.rmSync(tmpDir, { recursive: true, force: true });
484498
}
485499

upload-release.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ const main = async () => {
5151
process.exit(1)
5252
}
5353
const folder = process.argv[2]
54-
let DMGs = []
54+
let assets = []
5555
try {
56-
DMGs = (await fs.promises.readdir(folder)).filter((s) => !s.startsWith('.') && (s.endsWith('.dmg') || s.endsWith('.zip')))
56+
assets = (await fs.promises.readdir(folder)).filter((s) => !s.startsWith('.') && (s.endsWith('.dmg') || s.endsWith('.zip') || s.endsWith('.pdf')))
5757
} catch (e) {
5858
console.error(e.message)
5959
process.exit(1)
6060
}
6161

62-
console.log(`found ${DMGs.length} DMG(s): ${DMGs}`)
62+
console.log(`found ${assets.length} asset(s): ${assets}`)
6363

6464
const tag = process.env.RELEASE_TAG || (await getLatestTag())
6565
const releaseId = await getReleaseIdFromTag(tag)
@@ -94,8 +94,8 @@ const main = async () => {
9494
}
9595

9696
// Process uploads in batches to avoid overwhelming the API
97-
for (let i = 0; i < DMGs.length; i += CONCURRENCY) {
98-
const batch = DMGs.slice(i, i + CONCURRENCY)
97+
for (let i = 0; i < assets.length; i += CONCURRENCY) {
98+
const batch = assets.slice(i, i + CONCURRENCY)
9999
await Promise.all(batch.map(uploadFile))
100100
}
101101
}

0 commit comments

Comments
 (0)