From e23884a0249c3b130e3308e026cc3f61dce4b66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:48:18 +0200 Subject: [PATCH 01/18] [RHDHBUGS-3010]: Fix CQA-14 PR build destruction, rewrite deploy as Node.js - Fix CQA-14 recursion: write preliminary build-report.json before CQA so CQA-14 reads lychee results without triggering a full rebuild that wipes PR build output and overwrites main on gh-pages - Remove stale CDN index fetch (fetchUrl, updateRootIndex) from orchestrator - Rewrite deploy-gh-pages.sh as deploy-gh-pages.js with integrated cleanup (stale PR dirs via GitHub API, deleted branches via git ls-remote), index regeneration with release notes links, and rebase retry - Consolidate deploy + cleanup into single gh-pages commit - Add lychee exclusion for VPN-only release notes URL - Add architecture documentation for the publication workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-asciidoc.yml | 33 +- .github/workflows/pr.yml | 2 +- .lycheeignore | 3 + build/scripts/build-orchestrator.js | 73 +--- .../cqa/checks/cqa-14-no-broken-links.js | 24 +- build/scripts/deploy-gh-pages.js | 338 ++++++++++++++++++ build/scripts/deploy-gh-pages.sh | 100 ------ docs/github-publication-workflow.md | 212 +++++++++++ 8 files changed, 582 insertions(+), 203 deletions(-) create mode 100644 build/scripts/deploy-gh-pages.js delete mode 100755 build/scripts/deploy-gh-pages.sh create mode 100644 docs/github-publication-workflow.md diff --git a/.github/workflows/build-asciidoc.yml b/.github/workflows/build-asciidoc.yml index 2cb7ff3b3bc..23593694663 100644 --- a/.github/workflows/build-asciidoc.yml +++ b/.github/workflows/build-asciidoc.yml @@ -70,37 +70,8 @@ jobs: touch .lycheecache build/scripts/build-ccutil.sh -b ${{ env.GIT_BRANCH }} - - name: Deploy to the gh-pages branch + - name: Deploy to gh-pages env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: bash build/scripts/deploy-gh-pages.sh ./titles-generated --message "Deploy ${{ env.GIT_BRANCH }}" - - - name: Cleanup merged PR branches - run: | - PULL_URL="https://api.github.com/repos/redhat-developer/red-hat-developers-documentation-rhdh/pulls" - GITHUB_TOKEN="${{ secrets.RHDH_BOT_TOKEN }}" - git config user.name "rhdh-bot service account" - git config user.email "rhdh-bot@redhat.com" - - git checkout gh-pages; git pull || true - dirs=$(find . -maxdepth 1 -name "pr-*" -type d | sed -r -e "s|^\./pr-||") - refs=$(cat pulls.html | grep pr- | sed -r -e "s|.+.html>pr-([0-9]+).+|\1|") - for d in $(echo -e "$dirs\n$refs" | sort -uV); do - PR="${d}" - echo -n "Check merge status of PR $PR ... " - PR_JSON=$(curl -sSL -H "Accept: application/vnd.github+json" -H "Authorization: Bearer $GITHUB_TOKEN" "$PULL_URL/$PR") - if [[ $(echo "$PR_JSON" | grep merged\") == *"merged\": true"* ]]; then - echo "merged, can delete from pulls.html and remove folder $d" - git rm -fr --quiet "pr-${d}" || rm -fr "pr-${d}" - sed -r -e "/pr-$PR\/index.html>pr-$PRpr-$PR { - httpsGet(url, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - fetchUrl(res.headers.location).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - res.resume(); - reject(new Error(`HTTP ${res.statusCode}`)); - return; - } - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => resolve(data)); - res.on('error', reject); - }).on('error', reject); - }); -} - -async function updateRootIndex(branch, repoRoot) { - const isPR = branch.startsWith('pr-'); - const indexFile = isPR ? 'pulls.html' : 'index.html'; - const indexPath = join(repoRoot, 'titles-generated', indexFile); - const url = `${PAGES_BASE}/${indexFile}`; - - // Fetch existing index from GitHub Pages - try { - const data = await fetchUrl(url); - writeFileSync(indexPath, data); - } catch { - // If fetch fails, create a minimal file - writeFileSync(indexPath, ''); - } - - const content = readFileSync(indexPath, 'utf8'); - const link = `./${branch}/index.html`; - if (!content.includes(link)) { - console.log(`Building root index for ${branch} in titles-generated/${indexFile} ...`); - const entry = `
  • ${branch}
  • `; - const updated = content.replace('', `${entry}\n`); - writeFileSync(indexPath, updated); - } -} - // ── Summary output ─────────────────────────────────────────────────────────── function printFailedTitle(r) { @@ -653,9 +607,6 @@ async function main() { // Generate branch index HTML (only for passed titles) generateBranchIndex(args.branch, buildResults, repoRoot); - // Update root index - await updateRootIndex(args.branch, repoRoot); - // Run lychee link validation console.log('\nRunning link validation (lychee)...'); const lycheeResult = await runLychee(repoRoot, args.branch, args.verbose); @@ -664,20 +615,22 @@ async function main() { } // Run CQA content quality assessment - // Skip when CQA_RUNNING env is set (CQA-14 recursion guard) - const cqaResult = (process.env.CQA_RUNNING) - ? { status: 'skipped', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } } - : await (async () => { - console.log('\nRunning CQA content quality assessment...'); - return runCqa(repoRoot, args.verbose); - })(); + let cqaResult; + if (process.env.CQA_RUNNING) { + cqaResult = { status: 'skipped', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } }; + } else { + // Write preliminary report so CQA-14 can read lychee results without rebuilding + const pendingCqa = { status: 'pending', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } }; + writeReport(args.branch, buildResults, lycheeResult, pendingCqa, args.jobs, 0, repoRoot); + + console.log('\nRunning CQA content quality assessment...'); + process.env.CQA_RUNNING = '1'; + cqaResult = await runCqa(repoRoot, args.verbose); + delete process.env.CQA_RUNNING; + } const totalDuration = Math.round((Date.now() - totalStart) / 1000); - - // Print summary printSummary(buildResults, lycheeResult, cqaResult, patterns, totalDuration); - - // Write JSON report writeReport(args.branch, buildResults, lycheeResult, cqaResult, args.jobs, totalDuration, repoRoot); // Exit with error if any builds, lychee, or CQA failed diff --git a/build/scripts/cqa/checks/cqa-14-no-broken-links.js b/build/scripts/cqa/checks/cqa-14-no-broken-links.js index c30e32e673b..0be187d9ae7 100644 --- a/build/scripts/cqa/checks/cqa-14-no-broken-links.js +++ b/build/scripts/cqa/checks/cqa-14-no-broken-links.js @@ -88,18 +88,20 @@ function getLycheeIssues(root) { if (_lycheeIssuesCache !== null) return _lycheeIssuesCache; _lycheeIssuesCache = []; - try { - // Run build orchestrator (builds fresh HTML + runs lychee with remapping) - // Set CQA_RUNNING to prevent build-orchestrator from running CQA again (recursion) - execFileSync('node', ['build/scripts/build-orchestrator.js', '-b', 'main'], { // NOSONAR — fixed args, no user input - cwd: root, - stdio: 'pipe', - timeout: 600000, // 10 minutes - env: { ...process.env, CQA_RUNNING: '1' }, - }); - } catch { - // Build may exit non-zero if lychee finds broken links — that's expected + if (!process.env.CQA_RUNNING) { + // Standalone mode: build current state to get lychee results + try { + execFileSync('node', ['build/scripts/build-orchestrator.js', '-b', 'main'], { // NOSONAR — fixed args, no user input + cwd: root, + stdio: 'pipe', + timeout: 600000, + env: { ...process.env, CQA_RUNNING: '1' }, + }); + } catch { + // Build may exit non-zero if lychee finds broken links — that's expected + } } + // When CQA_RUNNING is set: preliminary report already exists with lychee results // Read the build report const reportPath = join(root, 'build-report.json'); diff --git a/build/scripts/deploy-gh-pages.js b/build/scripts/deploy-gh-pages.js new file mode 100644 index 00000000000..cc5e399a3ce --- /dev/null +++ b/build/scripts/deploy-gh-pages.js @@ -0,0 +1,338 @@ +/** + * deploy-gh-pages.js — Deploy documentation builds to GitHub Pages + * + * Copyright (c) Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Replaces deploy-gh-pages.sh. Deploys content to the gh-pages branch + * with retry on push rejection, PR/branch cleanup, and index regeneration. + * + * Usage: + * node deploy-gh-pages.js --publish-dir [--message ] + * + * Environment: GITHUB_TOKEN, GITHUB_REPOSITORY (set by GitHub Actions) + */ + +import { readdirSync, statSync, writeFileSync, existsSync, cpSync, rmSync, mkdtempSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { get as httpsGet } from 'node:https'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const MAX_RETRIES = 3; +const RELEASE_NOTES_BASE = 'https://red-hat-developers-documentation.pages.redhat.com/red-hat-developer-hub-release-notes'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function git(cwd, ...args) { + const result = execFileSync('git', args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 120_000, + encoding: 'utf8', + }); + return (result || '').trim(); +} + +function noStagedChanges(cwd) { + try { + git(cwd, 'diff', '--cached', '--quiet'); + return true; + } catch { + return false; + } +} + +function getPRState(owner, repo, prNumber) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + path: `/repos/${owner}/${repo}/pulls/${prNumber}`, + headers: { + 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, + 'User-Agent': 'deploy-gh-pages', + 'Accept': 'application/vnd.github+json', + }, + }; + httpsGet(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + if (res.statusCode !== 200) { + console.log(`GitHub API returned ${res.statusCode} for PR ${prNumber}`); + resolve('unknown'); + return; + } + try { + const json = JSON.parse(data); + resolve(json.state === 'closed' ? (json.merged ? 'merged' : 'closed') : 'open'); + } catch { resolve('unknown'); } + }); + res.on('error', () => resolve('unknown')); + }).on('error', () => resolve('unknown')); + }); +} + +// ── gh-pages branch setup ──────────────────────────────────────────────────── + +function fetchOrCreateGhPages(deployDir) { + try { + git(deployDir, 'fetch', 'origin', 'gh-pages', '--depth=1'); + git(deployDir, 'checkout', '-B', 'gh-pages', 'FETCH_HEAD'); + } catch (err) { + if (/not found|couldn't find|no such remote ref/i.test(err.message || err.stderr || '')) { + console.log('gh-pages branch does not exist, creating orphan'); + git(deployDir, 'checkout', '--orphan', 'gh-pages'); + try { git(deployDir, 'rm', '-rf', '.'); } catch {} + } else { + throw err; + } + } +} + +// ── Content application ────────────────────────────────────────────────────── + +function applyContent(deployDir, publishDir, branchDir) { + cpSync(publishDir, deployDir, { recursive: true }); +} + +// ── Cleanup ────────────────────────────────────────────────────────────────── + +async function cleanup(deployDir) { + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + + // PR cleanup: remove directories for merged/closed PRs + const prDirs = readdirSync(deployDir).filter(d => + d.startsWith('pr-') && !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory() + ); + + for (const dir of prDirs) { + const match = dir.match(/^pr-(\d+)$/); + if (!match) continue; + const prNumber = match[1]; + const state = await getPRState(owner, repo, prNumber); + if (state === 'merged' || state === 'closed') { + console.log(`Removing ${dir} (PR ${state})`); + rmSync(join(deployDir, dir), { recursive: true, force: true }); + } + } + + // Branch cleanup: remove directories for deleted remote branches + const branchDirs = readdirSync(deployDir).filter(d => + !d.startsWith('pr-') && !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory() + ); + + for (const dir of branchDirs) { + try { + const output = git(deployDir, 'ls-remote', '--heads', 'origin', dir); + if (!output) { + console.log(`Removing ${dir} (branch no longer exists on remote)`); + rmSync(join(deployDir, dir), { recursive: true, force: true }); + } + } catch { + // If ls-remote fails, keep the directory to be safe + } + } +} + +// ── Index generation ───────────────────────────────────────────────────────── + +function getReleaseNotesUrl(branch) { + if (branch === 'main') return `${RELEASE_NOTES_BASE}/main/index.html`; + const match = branch.match(/^release-(\d+)\.(\d+)$/); + if (match && (Number(match[1]) > 1 || Number(match[2]) >= 9)) + return `${RELEASE_NOTES_BASE}/release-${match[1]}-${match[2]}/index.html`; + return null; +} + +function writeIndex(deployDir, dirs, filename, title, isPR) { + const entries = dirs.map(dir => { + let entry = `
  • ${dir}`; + if (!isPR) { + const rnUrl = getReleaseNotesUrl(dir); + if (rnUrl) entry += ` | Release Notes`; + } + return entry + '
  • '; + }); + + const html = `RHDH Documentation - ${title}\n\n
      \n${entries.join('\n')}\n
    \n`; + writeFileSync(join(deployDir, filename), html); +} + +function regenerateIndex(deployDir, branchDir) { + const allDirs = readdirSync(deployDir) + .filter(d => !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory()) + .sort(); + + const isPR = branchDir.startsWith('pr-'); + + // Branch deploys regenerate both indexes; PR deploys regenerate pulls.html only + if (!isPR) { + writeIndex(deployDir, allDirs.filter(d => !d.startsWith('pr-')), 'index.html', 'Documentation Branches', false); + } + writeIndex(deployDir, allDirs.filter(d => d.startsWith('pr-')), 'pulls.html', 'PR Previews', true); +} + +// ── Push with retry ────────────────────────────────────────────────────────── + +async function stageAndCommit(deployDir, publishDir, branchDir, message) { + if (!branchDir.startsWith('pr-')) { + await cleanup(deployDir); + } + regenerateIndex(deployDir, branchDir); + + const publishEntries = readdirSync(publishDir).filter(e => !e.startsWith('.')); + const toStage = [...publishEntries]; + if (existsSync(join(deployDir, 'index.html'))) toStage.push('index.html'); + if (existsSync(join(deployDir, 'pulls.html'))) toStage.push('pulls.html'); + git(deployDir, 'add', '--force', '--', ...new Set(toStage)); + git(deployDir, 'add', '-A'); + + if (noStagedChanges(deployDir)) { + console.log('No changes to deploy'); + return false; + } + + console.log('Staged files:'); + try { + const stat = git(deployDir, 'diff', '--cached', '--stat'); + if (stat) console.log(stat); + } catch {} + + git(deployDir, 'commit', '-q', '-m', message); + return true; +} + +async function pushWithRetry(deployDir, publishDir, branchDir, message) { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 1) { + applyContent(deployDir, publishDir, branchDir); + } + + const hasChanges = await stageAndCommit(deployDir, publishDir, branchDir, message); + if (!hasChanges) return; + + try { + git(deployDir, 'push', 'origin', 'gh-pages'); + console.log(`Deployed successfully (attempt ${attempt})`); + return; + } catch { + console.log(`Push rejected (attempt ${attempt}/${MAX_RETRIES})`); + if (attempt < MAX_RETRIES) { + try { + git(deployDir, 'pull', '--rebase', 'origin', 'gh-pages'); + // Rebase succeeded — push immediately without rebuilding + try { + git(deployDir, 'push', 'origin', 'gh-pages'); + console.log(`Deployed successfully (attempt ${attempt}, after rebase)`); + return; + } catch { + console.log('Push failed after rebase, will rebuild'); + // Reset and rebuild on next iteration + fetchOrCreateGhPages(deployDir); + } + } catch { + console.log('Rebase conflict — resetting to remote'); + try { git(deployDir, 'rebase', '--abort'); } catch {} + fetchOrCreateGhPages(deployDir); + } + } + } + } + throw new Error(`Deploy failed after ${MAX_RETRIES} attempts`); +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function deploy() { + // Parse args + const args = process.argv.slice(2); + let publishDir = null; + let message = 'Deploy to GitHub Pages'; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--publish-dir': + publishDir = args[++i]; + break; + case '--message': + message = args[++i]; + break; + default: + console.error(`Unknown option: ${args[i]}`); + process.exit(1); + } + } + + if (!publishDir) { + console.error('Usage: node deploy-gh-pages.js --publish-dir [--message ]'); + process.exit(1); + } + + publishDir = resolve(publishDir); + + // Validate env + if (!process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN is required (set by GitHub Actions)'); + process.exit(1); + } + if (!process.env.GITHUB_REPOSITORY) { + console.error('GITHUB_REPOSITORY is required (set by GitHub Actions)'); + process.exit(1); + } + + // Detect branchDir from publishDir + const entries = readdirSync(publishDir).filter(e => + !e.startsWith('.') && statSync(join(publishDir, e)).isDirectory() + ); + + if (entries.length === 0) { + console.error('No top-level directory found in publish dir'); + process.exit(1); + } + if (entries.length > 1) { + console.log(`Warning: multiple directories in publish dir: ${entries.join(', ')}; using ${entries[0]}`); + } + + const branchDir = entries[0]; + + // Diagnostics + console.log(`PUBLISH_DIR: ${publishDir}`); + console.log('Top-level entries in PUBLISH_DIR:'); + readdirSync(publishDir) + .filter(e => !e.startsWith('.')) + .forEach(e => console.log(` ${e}`)); + console.log(`Branch directory: ${branchDir}`); + + // Create temp git repo + const deployDir = mkdtempSync(join(tmpdir(), 'deploy-')); + process.on('exit', () => { + try { rmSync(deployDir, { recursive: true, force: true }); } catch {} + }); + + git(deployDir, 'init', '-q'); + git(deployDir, 'config', 'user.name', 'github-actions[bot]'); + git(deployDir, 'config', 'user.email', 'github-actions[bot]@users.noreply.github.com'); + const remoteUrl = `https://x-access-token:${process.env.GITHUB_TOKEN}@github.com/${process.env.GITHUB_REPOSITORY}.git`; + git(deployDir, 'remote', 'add', 'origin', remoteUrl); + + // Fetch gh-pages + fetchOrCreateGhPages(deployDir); + + // Apply content on first pass + applyContent(deployDir, publishDir, branchDir); + + // Push with retry handles cleanup, index regeneration, staging, commit, and push + await pushWithRetry(deployDir, publishDir, branchDir, message); +} + +deploy().catch(err => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh deleted file mode 100755 index aa8839e9388..00000000000 --- a/build/scripts/deploy-gh-pages.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright (c) Red Hat, Inc. -# This program and the accompanying materials are made -# available under the terms of the Eclipse Public License 2.0 -# which is available at https://www.eclipse.org/legal/epl-2.0/ -# -# SPDX-License-Identifier: EPL-2.0 -# -# Deploy files to the gh-pages branch with retry on push rejection. -# Replaces peaceiris/actions-gh-pages to handle concurrent builds. -# -# Usage: deploy-gh-pages.sh [--message ] -# -# Environment: GITHUB_TOKEN, GITHUB_REPOSITORY (set by GitHub Actions) - -set -euo pipefail - -PUBLISH_DIR="${1:?Usage: deploy-gh-pages.sh [--message ]}" -shift - -COMMIT_MSG="Deploy to GitHub Pages" -while [[ $# -gt 0 ]]; do - case "$1" in - --message) COMMIT_MSG="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; - esac -done - -PUBLISH_DIR="$(cd "$PUBLISH_DIR" && pwd)" - -: "${GITHUB_TOKEN:?GITHUB_TOKEN is required (set by GitHub Actions)}" -: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (set by GitHub Actions)}" - -# ── Diagnostics: log PUBLISH_DIR contents before deploying ── -echo "PUBLISH_DIR: $PUBLISH_DIR" -echo "Top-level entries in PUBLISH_DIR:" -find "$PUBLISH_DIR" -maxdepth 1 -not -path "$PUBLISH_DIR" -printf '%f\n' - -MAX_RETRIES=3 -DEPLOY_DIR="$(mktemp -d)" -trap 'rm -rf "$DEPLOY_DIR"' EXIT - -cd "$DEPLOY_DIR" -git init -q -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" -git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - -# ── Fetch gh-pages and prepare working tree ── -fetch_output=$(git fetch origin gh-pages --depth=1 2>&1) && fetch_ok=true || fetch_ok=false -if [[ "$fetch_ok" == "true" ]]; then - git checkout -B gh-pages FETCH_HEAD -elif echo "$fetch_output" | grep -qi "not found\|couldn't find\|no such remote ref"; then - echo "gh-pages branch does not exist, creating orphan" - git checkout --orphan gh-pages - git rm -rf . 2>/dev/null || true -else - echo "ERROR: Failed to fetch gh-pages: $fetch_output" >&2 - exit 1 -fi - -# ── Copy content and stage ── -cp -a "$PUBLISH_DIR"/. . - -# Force-add only the files from PUBLISH_DIR (bypasses .gitignore) -publish_entries=() -while IFS= read -r entry; do - publish_entries+=("$entry") -done < <(find "$PUBLISH_DIR" -maxdepth 1 -not -path "$PUBLISH_DIR" -printf '%f\n') - -echo "Staging ${#publish_entries[@]} entries from PUBLISH_DIR..." -git add --force -- "${publish_entries[@]}" - -if git diff --cached --quiet; then - echo "No changes to deploy" - exit 0 -fi - -echo "Staged files:" -git diff --cached --stat - -git commit -q -m "$COMMIT_MSG" - -# ── Push with pull-before-push retry ── -for attempt in $(seq 1 "$MAX_RETRIES"); do - if git push origin gh-pages; then - echo "Deployed successfully (attempt $attempt)" - exit 0 - fi - - echo "Push rejected (attempt $attempt/$MAX_RETRIES)" - if [[ $attempt -lt $MAX_RETRIES ]]; then - echo "Pulling remote changes before retrying..." - git pull --rebase origin gh-pages - fi -done - -echo "ERROR: Deploy failed after $MAX_RETRIES attempts" -exit 1 diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md new file mode 100644 index 00000000000..1009fd009cf --- /dev/null +++ b/docs/github-publication-workflow.md @@ -0,0 +1,212 @@ +# GitHub Publication Workflow Architecture + +## Overview + +The RHDH documentation project uses GitHub Actions to build AsciiDoc documentation and deploy HTML previews to GitHub Pages via the `gh-pages` branch. Two workflows handle this: + +- **`build-asciidoc.yml`** -- triggered on branch pushes, builds and deploys production documentation. +- **`pr.yml`** -- triggered on pull requests, builds preview HTML and posts a PR comment with a preview link and CQA checklist. + +Both workflows produce HTML output under `titles-generated//`, then push the result to the `gh-pages` branch using `deploy-gh-pages.js`. + +## Triggers and Branch Matrix + +| Workflow | Event | Branches | Build Script | +|---|---|---|---| +| `build-asciidoc.yml` | `push` | `main`, `release-1.**`, `rhdh-1.**`, `1.**.x` | `build-ccutil.sh` (all branches) | +| `pr.yml` | `pull_request_target` | `main`, `release-1.**`, `release-2.**` | release-1.9+/main: `build-orchestrator.js`; release-1.8: `build-ccutil.sh` (base branch scripts) | + +The `build-asciidoc.yml` workflow uses `build-ccutil.sh` on all branches for the build step. The `pr.yml` workflow detects whether `build-orchestrator.js` exists on the base branch and uses it when available (release-1.9+, main), falling back to `build-ccutil.sh` on older branches (release-1.8). The orchestrator wraps ccutil with parallel execution, lychee link validation, CQA assessment, and JSON reporting. + +## Security Model + +The `pr.yml` workflow uses `pull_request_target` instead of `pull_request` so it can access repository secrets (needed for `RHDH_BOT_TOKEN` to push to `gh-pages` and post PR comments). This event runs workflow code from the base branch, not the PR, which avoids exfiltration of secrets from untrusted PRs. + +### Two-checkout pattern + +To separate trusted code from untrusted content: + +1. **Trusted checkout** -- checks out `build/scripts` from the base branch (`sparse-checkout: build/scripts`) into `trusted-scripts/`. +2. **Content checkout** -- checks out the full PR head into `pr-content/`. +3. **Merge** -- the workflow replaces `pr-content/build/scripts` with `trusted-scripts/build/scripts` via `rsync`, then runs the build from `pr-content/`. + +Build scripts are always sourced from the base branch, never from the PR. This prevents a malicious PR from modifying build scripts to exfiltrate secrets. + +### Authorization gate + +The workflow enforces team-based authorization before running the build: + +1. `check-commit-author` -- uses a GitHub App token to check if the PR author is a member of the `rhdh` team in the `redhat-developer` organization. +2. `authorize` -- selects the `internal` or `external` environment: + - **Internal**: PR author is in the `rhdh` team, or the PR is from the same repository (not a fork). Runs immediately. + - **External**: fork PRs from non-team members. The `external` environment requires manual approval from the `rhdh-content` team before the build proceeds. +3. `adoc_build` -- depends on `authorize`, so it only runs after the gate passes. + +## Build Pipeline + +### build-orchestrator.js + +The orchestrator replaces the sequential `build-ccutil.sh` with parallel title builds, structured error reporting, and a JSON report. + +**Phases:** + +1. **Title discovery** -- scans `titles/` for directories containing `master.adoc`, excluding `rhdh-plugins-reference`. +2. **Parallel builds** -- runs `podman run ... ccutil compile` for each title, limited by a semaphore (`--jobs`, defaults to CPU count). Each title produces HTML under `titles-generated///`. +3. **Image copy** -- parses each generated `index.html` to find image references and copies them into the output directory. +4. **Branch index** -- generates `titles-generated/<branch>/index.html` listing all successfully built titles, with an optional release notes link. +5. **Lychee link validation** -- runs `lychee` against `titles-generated/` with cross-title link remapping (rewrites `docs.redhat.com` links to local file paths). Broken links are traced back to `.adoc` source files via `grep`. +6. **Preliminary report** -- writes `build-report.json` with lychee results and CQA status "pending". This allows CQA-14 to read lychee results without triggering a rebuild. +7. **CQA assessment** -- sets `CQA_RUNNING=1` in `process.env`, then runs `node build/scripts/cqa/index.js --all`. The env var propagates to CQA-14, which skips its internal orchestrator call and reads the preliminary report instead. +8. **Final report** -- overwrites `build-report.json` with completed CQA results. + +**Error classification:** the orchestrator loads `build/scripts/error-patterns.json`, which maps regex patterns to structured error messages with `cause` and `fix` fields. These appear in the JSON report and PR comment. + +**CQA-14 recursion guard:** CQA-14 (lychee link validation check) can trigger the orchestrator internally. To prevent infinite recursion, the orchestrator sets `CQA_RUNNING=1` when invoking CQA, so CQA-14 reads existing lychee results from the report instead of triggering a full rebuild. + +**CLI usage:** + +```bash +node build/scripts/build-orchestrator.js -b <branch> +node build/scripts/build-orchestrator.js -b pr-123 --verbose +node build/scripts/build-orchestrator.js -b main --jobs 4 +``` + +The `-b` flag determines the output directory name under `titles-generated/`. The orchestrator exits with code 1 if any title build, lychee, or CQA fails. + +## Deploy Pipeline + +### deploy-gh-pages.js + +Handles deployment of built content to the `gh-pages` branch, including cleanup and index regeneration in a single commit. + +**Sequence:** + +1. Detect the branch directory from `--publish-dir` (single top-level directory, e.g., `main/`, `pr-123/`). +2. Create a temporary git repo with `github-actions[bot]` identity. +3. Fetch `gh-pages` (shallow, depth=1) or create an orphan if it does not exist. +4. Copy `--publish-dir` contents into the working tree. +5. For branch deploys: run cleanup (see Cleanup section below). +6. Regenerate indexes from current directories on `gh-pages` (see below). +7. Stage all changes (content + cleanup deletions + indexes), commit, and push. + +**Index regeneration:** rebuilds HTML indexes from directories present on `gh-pages`: +- `index.html` -- lists all non-`pr-*` directories with optional release notes links (for `release-1.9+` and `main`). +- `pulls.html` -- lists all `pr-*` directories. + +Branch deploys regenerate both indexes. PR deploys regenerate `pulls.html` only. + +**Retry logic:** on push rejection, the script attempts `git pull --rebase`. If rebase succeeds, it pushes immediately. If rebase conflicts, it aborts, re-fetches `gh-pages`, re-applies content and cleanup, and retries. Maximum 3 attempts. + +**Invocation:** + +```bash +# Branch deploy +node build/scripts/deploy-gh-pages.js --publish-dir ./titles-generated --message "Deploy main" + +# PR deploy (from pr.yml, using trusted scripts) +node trusted-scripts/build/scripts/deploy-gh-pages.js --publish-dir ./pr-content/titles-generated --message "Deploy PR 123 preview" +``` + +### Branch vs PR deploys + +- **Branch deploys** (`build-asciidoc.yml`): deploy content + cleanup stale PRs/branches + regenerate both `index.html` and `pulls.html` → single commit. +- **PR deploys** (`pr.yml`): deploy content under `pr-<N>/` + regenerate `pulls.html` only → single commit. No cleanup runs. + +## gh-pages Branch Structure + +``` +gh-pages/ +|-- index.html # Links to branch builds + release notes +|-- pulls.html # Links to PR preview builds +|-- main/ # Main branch build +| |-- index.html # Per-branch title listing +| +-- <title>/ # Individual title HTML +| +-- index.html +|-- release-1.9/ # Release branch build +|-- release-1.8/ # Legacy release branch ++-- pr-123/ # PR preview build + |-- index.html + +-- <title>/ +``` + +**Preview URL pattern:** + +``` +https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/<branch-or-pr>/ +``` + +For PR previews: + +``` +https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/pr-<N>/ +``` + +## PR Preview Lifecycle + +1. PR opened, synchronized, reopened, or marked ready for review -- `pr.yml` triggers. +2. Authorization gate checks team membership. Fork PRs from non-team members require manual approval via the `external` environment. +3. Trusted build scripts are checked out from the base branch. PR content is checked out separately. +4. Build scripts from the base replace `pr-content/build/scripts`. The orchestrator (or `build-ccutil.sh` on older branches) runs with `-b pr-<N>`. +5. If HTML was successfully generated (checked via `build-report.json`), `deploy-gh-pages.js` pushes the output to `gh-pages` under `pr-<N>/`. +6. A consolidated PR comment is posted (or updated) with: + - Build status (passed/failed) with title counts and duration. + - Preview link (marked stale if title build failed). + - Build error details with classified causes and fixes. + - CQA checklist with pass/fail counts (when available). + - Link to full CI logs. +7. Old standalone CQA comments (from the previous two-comment format) are cleaned up. +8. When the PR is merged or closed, the next branch deploy cleans up the `pr-<N>/` directory from `gh-pages`. + +**Concurrency:** the workflow uses `concurrency` groups keyed on the PR number. If a new push arrives while a build is in progress, the in-progress run is cancelled. + +## Cleanup + +Cleanup is integrated into `deploy-gh-pages.js` and runs during branch deploys only, not during PR deploys. It executes before index regeneration so indexes reflect the cleaned-up state. Cleanup, content deployment, and index regeneration are committed together in a single commit. + +### PR cleanup + +1. List `pr-*` directories on `gh-pages`. +2. For each, query the GitHub API (`GET /repos/{owner}/{repo}/pulls/{number}`). +3. If the PR is merged or closed, remove the `pr-<N>/` directory. + +### Branch cleanup + +1. List non-`pr-*` directories on `gh-pages`. +2. For each, run `git ls-remote --heads origin <branch>`. +3. If the remote branch no longer exists, remove the directory. This cleans up directories for deleted branches (e.g., `release-1.9-post-cqa`). + +## Local Development + +### Build all titles + +```bash +node build/scripts/build-orchestrator.js -b main +``` + +Requires Podman. Builds all titles in parallel, runs lychee link validation, runs CQA, and writes `build-report.json`. + +### Run CQA standalone + +```bash +# All checks on a single title +node build/scripts/cqa/index.js titles/<title>/master.adoc + +# Auto-fix issues +node build/scripts/cqa/index.js --fix titles/<title>/master.adoc + +# Run a specific check +node build/scripts/cqa/index.js --check NN titles/<title>/master.adoc + +# All checks on all titles +node build/scripts/cqa/index.js --all +``` + +CQA-14 (lychee link validation) in standalone mode runs the orchestrator internally. It sets `CQA_RUNNING=1` to prevent recursion -- the orchestrator skips CQA when this variable is set, so CQA-14 reads the lychee results from the existing `build-report.json` instead of triggering another full build. + +### Legacy build + +```bash +build/scripts/build-ccutil.sh -b <branch> +``` + +Used on `release-1.8` and as a fallback on branches where `build-orchestrator.js` does not exist. Runs title builds sequentially without lychee or CQA. From 36d11c887223fe27fdc11acd4158e8453766017d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 14:49:02 +0200 Subject: [PATCH 02/18] chore: remove redundant CQA directory copy in pr.yml The rsync on line 148 already copies the entire build/scripts directory including cqa/. The subsequent rm + cp of the same cqa/ directory was a leftover from before rsync was used. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/pr.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c32a16dd190..69c1d9426d8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -150,10 +150,6 @@ jobs: for f in trusted-scripts/.lycheeignore trusted-scripts/lychee.toml; do if [[ -f $f ]]; then rsync -az $f pr-content/; fi done - if [[ -d "trusted-scripts/build/scripts/cqa" ]]; then - rm -rf pr-content/build/scripts/cqa - cp -r trusted-scripts/build/scripts/cqa pr-content/build/scripts/cqa - fi touch pr-content/.lycheecache cd pr-content # Add base branch as remote so CQA checks can diff PR content against it From 71c6f6e20f6165ef122ff69da5f4077772f6b13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 14:50:06 +0200 Subject: [PATCH 03/18] chore: remove unused build/scripts/build.sh Legacy utility script with no references from any workflow, script, or documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- build/scripts/build.sh | 80 ------------------------------------------ 1 file changed, 80 deletions(-) delete mode 100755 build/scripts/build.sh diff --git a/build/scripts/build.sh b/build/scripts/build.sh deleted file mode 100755 index d0315b21ddd..00000000000 --- a/build/scripts/build.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright (c) Red Hat, Inc. -# This program and the accompanying materials are made -# available under the terms of the Eclipse Public License 2.0 -# which is available at https://www.eclipse.org/legal/epl-2.0/ -# -# SPDX-License-Identifier: EPL-2.0 -# -# Utility script build html previews with referenced images -# Requires: asciidoctor - see https://docs.asciidoctor.org/asciidoctor/latest/install/linux-packaging/ -# input: titles/ -# output: titles-generated/ and titles-generated/$BRANCH/ - -# grep regex for title folders to exclude from processing below -EXCLUDED_TITLES="rhdh-plugins-reference" -BRANCH="main" - -while [[ "$#" -gt 0 ]]; do - case $1 in - '-b') BRANCH="$2"; shift 1;; - esac - shift 1 -done - -rm -fr titles-generated/; -mkdir -p titles-generated/"${BRANCH}"; -echo "<html><head><title>Red Hat Developer Hub Documentation Preview - ${BRANCH}
      " > titles-generated/"${BRANCH}"/index.html; -# exclude the rhdh-plugins-reference as it's embedded in the admin guide -# shellcheck disable=SC2044,SC2013 -set -e -for t in $(find titles -name master.adoc | sort -uV | grep -E -v "${EXCLUDED_TITLES}"); do - d=${t%/*}; d=${d/titles/titles-generated\/${BRANCH}}; - CMD="asciidoctor \ - --backend=html5 \ - --destination-dir $d \ - --failure-level ERROR \ - --section-numbers \ - --trace \ - --warnings \ - -a chapter-signifier=Chapter \ - -a sectnumslevels=5 \ - -a source-highlighter=coderay \ - -a stylesdir=$(pwd)/.asciidoctor \ - -a stylesheet=docs.css \ - -a toc=left \ - -a toclevels=5 \ - -o index.html \ - $t"; - echo -e -n "\nBuilding $t into $d ...\n "; - echo "${CMD}" | sed -r -e "s/\ +/ \\\\\n /g" - $CMD - # shellcheck disable=SC2013 - for im in $(grep images/ "$d/index.html" | grep -E -v 'mask-image|background|fa-icons|jupumbra' | sed -r -e "s#.+(images/[^\"]+)\".+#\1#"); do - # echo " Copy $im ..."; - IMDIR="$d/${im%/*}/" - mkdir -p "${IMDIR}"; rsync -q "$im" "${IMDIR}"; - done - # shellcheck disable=SC2044 - for f in $(find "$d/" -type f); do echo " $f"; done - echo "
    • ${d/titles-generated\/${BRANCH}\//}
    • " >> titles-generated/"${BRANCH}"/index.html; -done -echo "
    " >> titles-generated/"${BRANCH}"/index.html - -# shellcheck disable=SC2143 -if [[ $BRANCH == "pr-"* ]]; then - # fetch the existing https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/index.html to add prs and branches - curl -sSL https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/pulls.html -o titles-generated/pulls.html - if [[ -z $(grep "./${BRANCH}/index.html" titles-generated/pulls.html) ]]; then - echo "Building root index for $BRANCH in titles-generated/pulls.html ..."; - echo "
  • ${BRANCH}
  • " >> titles-generated/pulls.html - fi -else - # fetch the existing https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/index.html to add prs and branches - curl -sSL https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/index.html -o titles-generated/index.html - if [[ -z $(grep "./${BRANCH}/index.html" titles-generated/index.html) ]]; then - echo "Building root index for $BRANCH in titles-generated/index.html ..."; - echo "
  • ${BRANCH}
  • " >> titles-generated/index.html - fi -fi From facbfcc8728d4b22b5bce31774cc03c9da6263cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:51:06 +0200 Subject: [PATCH 04/18] chore: remove unused build-cqa.sh and lint-scripts.sh build-cqa.sh: thin wrapper for node cqa/index.js --all, never referenced by any workflow or script. lint-scripts.sh: local shellcheck runner superseded by the shellcheck.yml workflow using reviewdog. Co-Authored-By: Claude Opus 4.6 --- build/scripts/build-cqa.sh | 15 --------------- build/scripts/lint-scripts.sh | 35 ----------------------------------- 2 files changed, 50 deletions(-) delete mode 100755 build/scripts/build-cqa.sh delete mode 100755 build/scripts/lint-scripts.sh diff --git a/build/scripts/build-cqa.sh b/build/scripts/build-cqa.sh deleted file mode 100755 index e1b101cd0f8..00000000000 --- a/build/scripts/build-cqa.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright (c) Red Hat, Inc. -# This program and the accompanying materials are made -# available under the terms of the Eclipse Public License 2.0 -# which is available at https://www.eclipse.org/legal/epl-2.0/ -# -# SPDX-License-Identifier: EPL-2.0 -# -# Requires: Node.js - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -pushd "${SCRIPT_DIR}" >/dev/null || exit -node "cqa/index.js" --all # "$@" -popd >/dev/null || exit diff --git a/build/scripts/lint-scripts.sh b/build/scripts/lint-scripts.sh deleted file mode 100755 index 62e25b91257..00000000000 --- a/build/scripts/lint-scripts.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright (c) Red Hat, Inc. -# This program and the accompanying materials are made -# available under the terms of the Eclipse Public License 2.0 -# which is available at https://www.eclipse.org/legal/epl-2.0/ -# -# SPDX-License-Identifier: EPL-2.0 -# -# lint-scripts.sh — Run shellcheck on CQA scripts -# -# Usage: ./build/scripts/lint-scripts.sh - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "## Lint: CQA scripts" -echo "" - -errors=0 -for script in "$SCRIPT_DIR"/*.sh; do - if ! shellcheck -S warning -e SC2034,SC2329,SC1091 "$script" 2>/dev/null; then - errors=$((errors + 1)) - fi -done - -echo "" -if [[ $errors -eq 0 ]]; then - echo "All scripts pass shellcheck." -else - echo "$errors script(s) have shellcheck warnings." -fi - -[[ $errors -gt 0 ]] && exit 1 || exit 0 From 485abaa4085ca8ab36f9f49212537b34d9fb9e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:54:08 +0200 Subject: [PATCH 05/18] docs: update CLAUDE.md workflow description for integrated cleanup Cleanup of merged PRs and deleted branches is now part of the deploy step (deploy-gh-pages.js), not a separate workflow step. Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6bfe1eaf78d..179f1036d5c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -32,7 +32,7 @@ When creating PRs, follow `.github/pull_request_template.md`: | Workflow | Trigger | Purpose | |---|---|---| -| `build-asciidoc.yml` | Push to main/release | Builds AsciiDoc docs and deploys to GitHub Pages. Cleans up merged PR preview branches. | +| `build-asciidoc.yml` | Push to main/release | Builds AsciiDoc docs and deploys to GitHub Pages (deploy includes cleanup of merged/closed PRs and deleted branches). | | `pr.yml` | PR | Builds HTML preview, runs CQA checks, deploys to `gh-pages`, posts preview URL and CQA checklist as PR comments. Build scripts sourced from base branch. | | `style-guide.yml` | PR | Runs Vale linter on `assemblies/` for style guide compliance. | | `shellcheck.yml` | PR (`*.sh`) | Runs shellcheck on changed shell scripts via reviewdog. | From a54e5a16cc39e45694b9bd5bf1fc9548f0b91268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:55:40 +0200 Subject: [PATCH 06/18] docs: add README for build/scripts Quick reference for each script, CQA usage, and workflow mappings. Co-Authored-By: Claude Opus 4.6 --- build/scripts/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 build/scripts/README.md diff --git a/build/scripts/README.md b/build/scripts/README.md new file mode 100644 index 00000000000..63d4dc13f97 --- /dev/null +++ b/build/scripts/README.md @@ -0,0 +1,35 @@ +# Build Scripts + +Build, deploy, and content quality tooling for the RHDH documentation project. + +## Scripts + +| Script | Purpose | +|---|---| +| `build-ccutil.sh` | Wrapper that delegates to `build-orchestrator.js`. Used by `build-asciidoc.yml` and as a fallback in `pr.yml` on older branches. | +| `build-orchestrator.js` | Parallel documentation build orchestrator. Runs ccutil title builds, lychee link validation, and CQA assessment. Produces `build-report.json`. | +| `deploy-gh-pages.js` | Deploys build output to the `gh-pages` branch. Handles cleanup of stale PR/branch directories, index regeneration with release notes links, and retry with rebase on push conflicts. | +| `error-patterns.json` | Regex patterns for classifying ccutil build errors into structured messages with cause and fix fields. | +| `update-cqa-resources.sh` | Fetches upstream Red Hat style guide resources into `.claude/resources/`. | + +## CQA (`cqa/`) + +Content Quality Assessment framework with 19 checks (CQA-00a through CQA-17). + +```bash +node build/scripts/cqa/index.js titles//master.adoc # report +node build/scripts/cqa/index.js --fix titles/<title>/master.adoc # auto-fix +node build/scripts/cqa/index.js --check 14 titles/<title>/master.adoc # single check +node build/scripts/cqa/index.js --all # all titles +``` + +See `.claude/plugins/project-cqa/resources/cqa-spec.md` for the full specification. + +## Workflows + +These scripts are called by GitHub Actions workflows in `.github/workflows/`: + +- **`build-asciidoc.yml`** (push to main/release) -- `build-ccutil.sh` + `deploy-gh-pages.js` +- **`pr.yml`** (pull requests) -- `build-orchestrator.js` (or `build-ccutil.sh` on older branches) + `deploy-gh-pages.js` + +See `docs/github-publication-workflow.md` for the full architecture documentation. From 3383cd85196f1c17438c29bc6ab227fb94d92319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 14:56:15 +0200 Subject: [PATCH 07/18] docs: update README.adoc for removed build.sh and new deploy script Remove reference to deleted build/scripts/build.sh asciidoctor alternative. Update deploy script reference from .sh to .js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.adoc | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.adoc b/README.adoc index 80e9ff00036..80637843db3 100644 --- a/README.adoc +++ b/README.adoc @@ -56,17 +56,6 @@ The `ccutil` CLI tool reproduces the behavior of the production publication chai $ build/scripts/build-ccutil.sh ---- -[NOTE] -==== -* To build with `asciidoctor` rather than `ccutil`, run the following script. -The output will look differently and you might miss issues with content that cannot build in production. -+ -[source,terminal] ----- -$ build/scripts/build.sh ----- -==== - ## Checking for broken links You can run `lychee` locally to check for broken links in your PR. @@ -101,7 +90,7 @@ PRs have a link to the generated HTML attached as a comment. The publication workflow has two stages: -. The link:.github/workflows/pr.yml[PR workflow] and link:.github/workflows/build-asciidoc.yml[GitHub Pages workflow] build HTML from AsciiDoc sources and push the output to the `gh-pages` branch using `build/scripts/deploy-gh-pages.sh`, which handles concurrent pushes with automatic retry. +. The link:.github/workflows/pr.yml[PR workflow] and link:.github/workflows/build-asciidoc.yml[GitHub Pages workflow] build HTML from AsciiDoc sources and push the output to the `gh-pages` branch using `build/scripts/deploy-gh-pages.js`, which handles concurrent pushes with automatic retry, cleanup of stale PR/branch directories, and index regeneration. . The link:https://github.com/redhat-developer/red-hat-developers-documentation-rhdh/actions/workflows/pages/pages-build-deployment[GitHub Pages build and deployment] workflow, managed by GitHub, detects pushes to the `gh-pages` branch and publishes the content to GitHub Pages. ## Reviews From 6b94dcf7df338ddfc6bf17e71480559e28260bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 14:58:34 +0200 Subject: [PATCH 08/18] docs: update README.adoc for current build tooling - Fix prerequisites: Node.js + Podman (not asciidoctor) - Note that build includes lychee and CQA automatically - Fix lychee section: clarify manual vs automatic, remove false claim that orchestrator downloads lychee - Fix [code,terminal] to [source,terminal] - Fix missing link: prefix on RHDHPAI URL - Add link to architecture documentation - Remove redundant ccutil preference note Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.adoc | 69 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/README.adoc b/README.adoc index 80637843db3..0c50b16a55e 100644 --- a/README.adoc +++ b/README.adoc @@ -10,9 +10,9 @@ Before submitting a pull request (PR), create a Jira issue associated with your There are currently three Jira projects related to RHDH: -* link:https://issues.redhat.com/projects/RHDHBUGS[RHDHBUGS] - For issues related to existing content. -* link:https://issues.redhat.com/projects/RHIDP[RHIDP] - For new features related to the core platform, including security mechanisms. -* https://issues.redhat.com/projects/RHDHPAI[RHDHPAI] - For new features related to plugins and AI. +* link:https://redhat.atlassian.net/jira/software/c/projects/RHDHBUGS/issues[RHDHBUGS] - Red Hat Developer Hub Bugs. +* link:https://redhat.atlassian.net/jira/software/c/projects/RHIDP/issues[RHIDP] - Red Hat Internal Developer Platform. +* link:https://redhat.atlassian.net/jira/software/c/projects/RHDHPLAN/issues[RHDHPLAN] - RH Developer Hub Planning. Choose a Jira project that matches your contribution and create a new issue: @@ -23,9 +23,8 @@ Choose a Jira project that matches your contribution and create a new issue: [IMPORTANT] ==== -RHDH release notes are single-sourced from the *Release Notes Text* field in the Jira Epic. -If you want to make changes to release notes, you must make them in Jira. -Check the link:https://docs.google.com/document/d/1X2CrrU9K3ZqbxN7DXmU6P1mY7inLD32qs3rpOUHz6Vw/edit?tab=t.0#heading=h.yqxd252ryhua[RHDH Release Notes process document] for more information. +RHDH release notes are single-sourced from Jira. +See the link:https://docs.google.com/document/d/1X2CrrU9K3ZqbxN7DXmU6P1mY7inLD32qs3rpOUHz6Vw/edit?tab=t.0#heading=h.yqxd252ryhua[RHDH Release Notes process document] and the link:https://gitlab.cee.redhat.com/red-hat-developers-documentation/red-hat-developer-hub-release-notes[release notes repository] (VPN required) for details. ==== ## Style and formatting @@ -46,36 +45,64 @@ to modularize your content. ## Building locally -. Install `asciidoctor` - see https://docs.asciidoctor.org/asciidoctor/latest/install/linux-packaging/ -. Install Podman - see https://podman.io. -. Run the following command to generate html with images in titles-generated/ folders by using `ccutil` in a container. -The `ccutil` CLI tool reproduces the behavior of the production publication chain (Pantheon), including its limitations, and is therefore preferred over using a more recent `asciidoctor` CLI version. +.Prerequisites + +* link:https://nodejs.org[Node.js] +* link:https://podman.io[Podman] +* link:https://github.com/errata-ai/vale[Vale] +* link:https://github.com/lycheeverse/lychee[Lychee] (downloaded automatically by the build script if not installed) +* Optional: A `GITHUB_TOKEN` environment variable to avoid GitHub API rate limiting during lychee link validation. Use link:https://cli.github.com[GitHub CLI] (`gh`) to get and refresh the token: `export GITHUB_TOKEN=$(gh auth token)` + +.Procedure + +. Sync the Vale style rules: ++ +[source,terminal] +---- +$ vale sync +---- + +. Build HTML with images in `titles-generated/` using `ccutil` in a container, then run lychee link validation and CQA content quality checks: + [source,terminal] ---- -$ build/scripts/build-ccutil.sh +$ ./build/scripts/build-ccutil.sh ---- ++ +The `ccutil` CLI tool reproduces the behavior of the production publication chain (Pantheon), including its limitations, and is therefore preferred over `asciidoctor`. + +Results are written to `build-report.json`. ## Checking for broken links -You can run `lychee` locally to check for broken links in your PR. +The build command above includes lychee link validation automatically. +To run lychee separately on already-built HTML: -. Build the documentation first (see above). -. Run lychee on the generated HTML: +.Prerequisites + +* link:https://github.com/lycheeverse/lychee[Lychee] +* Optional: A `GITHUB_TOKEN` environment variable to avoid GitHub API rate limiting. Use link:https://cli.github.com[GitHub CLI] (`gh`) to get and refresh the token: `export GITHUB_TOKEN=$(gh auth token)` + +.Procedure + +. Run lychee on the built HTML: + [source,terminal] ---- -$ GITHUB_TOKEN=$(gh auth token) lychee titles-generated/ +$ lychee titles-generated/ ---- -+ -Install lychee from https://github.com/lycheeverse/lychee or the build orchestrator downloads it automatically. -You can run `linkchecker` to check for broken links in the published RHDH docs. +To check for broken links in the published RHDH docs, use `linkchecker`: + +.Prerequisites + +* link:https://github.com/linkchecker/linkchecker[linkchecker] + +.Procedure -. Install `linkchecker` from https://github.com/linkchecker/linkchecker. . Run the following command to create a .txt file with a list of detected broken links: + -[code,terminal] +[source,terminal] ---- $ linkchecker --check-extern --output failures --file-output failures/broken-links.txt https://docs.redhat.com/en/documentation/red_hat_developer_hub/<version> ---- @@ -93,6 +120,8 @@ The publication workflow has two stages: . The link:.github/workflows/pr.yml[PR workflow] and link:.github/workflows/build-asciidoc.yml[GitHub Pages workflow] build HTML from AsciiDoc sources and push the output to the `gh-pages` branch using `build/scripts/deploy-gh-pages.js`, which handles concurrent pushes with automatic retry, cleanup of stale PR/branch directories, and index regeneration. . The link:https://github.com/redhat-developer/red-hat-developers-documentation-rhdh/actions/workflows/pages/pages-build-deployment[GitHub Pages build and deployment] workflow, managed by GitHub, detects pushes to the `gh-pages` branch and publishes the content to GitHub Pages. +See link:docs/github-publication-workflow.md[GitHub Publication Workflow Architecture] for the full technical reference. + ## Reviews All PRs are reviewed for technical accuracy by an SME and writing quality by another tech writer. From 56c14588bbf406fcfe48e39dfb1dbfc1d0bf1425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 15:51:10 +0200 Subject: [PATCH 09/18] fix: resolve shellcheck and SonarCloud CI failures Shellcheck: filter out deleted .sh files before running shellcheck, preventing reviewdog parse error on empty input. SonarCloud: use http.extraHeader for git auth instead of embedding token in remote URL, avoiding security hotspot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/build-asciidoc.yml | 4 +- .github/workflows/shellcheck.yml | 11 +++++- build/scripts/README.md | 4 +- build/scripts/deploy-gh-pages.js | 57 ++++++++++++++++------------ docs/github-publication-workflow.md | 4 +- 5 files changed, 47 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-asciidoc.yml b/.github/workflows/build-asciidoc.yml index 23593694663..08dd988cc13 100644 --- a/.github/workflows/build-asciidoc.yml +++ b/.github/workflows/build-asciidoc.yml @@ -68,9 +68,9 @@ jobs: run: | echo "Building branch ${{ env.GIT_BRANCH }}" touch .lycheecache - build/scripts/build-ccutil.sh -b ${{ env.GIT_BRANCH }} + node build/scripts/build-orchestrator.js -b ${{ env.GIT_BRANCH }} - - name: Deploy to gh-pages + - name: Deploy to the gh-pages branch env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index dfe10e639b5..6686d48480e 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -89,9 +89,16 @@ jobs: - name: Get changed shell scripts id: changed-files run: | - # Get list of changed .sh files + # Get list of changed .sh files that still exist (exclude deletions) git fetch origin ${{ github.base_ref }} - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '\.sh$' || echo "") + ALL_CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '\.sh$' || echo "") + CHANGED_FILES="" + while IFS= read -r file; do + if [ -n "$file" ] && [ -f "$file" ]; then + CHANGED_FILES="${CHANGED_FILES:+$CHANGED_FILES + }$file" + fi + done <<< "$ALL_CHANGED" echo "changed_files<<EOF" >> $GITHUB_OUTPUT echo "$CHANGED_FILES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT diff --git a/build/scripts/README.md b/build/scripts/README.md index 63d4dc13f97..4bd7ad6d31d 100644 --- a/build/scripts/README.md +++ b/build/scripts/README.md @@ -6,7 +6,7 @@ Build, deploy, and content quality tooling for the RHDH documentation project. | Script | Purpose | |---|---| -| `build-ccutil.sh` | Wrapper that delegates to `build-orchestrator.js`. Used by `build-asciidoc.yml` and as a fallback in `pr.yml` on older branches. | +| `build-ccutil.sh` | Wrapper that delegates to `build-orchestrator.js`. Used as a fallback in `pr.yml` on older branches and for local builds. | | `build-orchestrator.js` | Parallel documentation build orchestrator. Runs ccutil title builds, lychee link validation, and CQA assessment. Produces `build-report.json`. | | `deploy-gh-pages.js` | Deploys build output to the `gh-pages` branch. Handles cleanup of stale PR/branch directories, index regeneration with release notes links, and retry with rebase on push conflicts. | | `error-patterns.json` | Regex patterns for classifying ccutil build errors into structured messages with cause and fix fields. | @@ -29,7 +29,7 @@ See `.claude/plugins/project-cqa/resources/cqa-spec.md` for the full specificati These scripts are called by GitHub Actions workflows in `.github/workflows/`: -- **`build-asciidoc.yml`** (push to main/release) -- `build-ccutil.sh` + `deploy-gh-pages.js` +- **`build-asciidoc.yml`** (push to main/release) -- `build-orchestrator.js` + `deploy-gh-pages.js` - **`pr.yml`** (pull requests) -- `build-orchestrator.js` (or `build-ccutil.sh` on older branches) + `deploy-gh-pages.js` See `docs/github-publication-workflow.md` for the full architecture documentation. diff --git a/build/scripts/deploy-gh-pages.js b/build/scripts/deploy-gh-pages.js index cc5e399a3ce..374e3213e60 100644 --- a/build/scripts/deploy-gh-pages.js +++ b/build/scripts/deploy-gh-pages.js @@ -31,7 +31,7 @@ const RELEASE_NOTES_BASE = 'https://red-hat-developers-documentation.pages.redha // ── Helpers ────────────────────────────────────────────────────────────────── function git(cwd, ...args) { - const result = execFileSync('git', args, { + const result = execFileSync('git', args, { // NOSONAR: git is resolved from PATH in a controlled CI environment cwd, stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000, @@ -71,7 +71,8 @@ function getPRState(owner, repo, prNumber) { } try { const json = JSON.parse(data); - resolve(json.state === 'closed' ? (json.merged ? 'merged' : 'closed') : 'open'); + const closedState = json.merged ? 'merged' : 'closed'; + resolve(json.state === 'closed' ? closedState : 'open'); } catch { resolve('unknown'); } }); res.on('error', () => resolve('unknown')); @@ -209,6 +210,26 @@ async function stageAndCommit(deployDir, publishDir, branchDir, message) { return true; } +function tryRebaseAndPush(deployDir, attempt) { + try { + git(deployDir, 'pull', '--rebase', 'origin', 'gh-pages'); + } catch { + console.log('Rebase conflict — resetting to remote'); + try { git(deployDir, 'rebase', '--abort'); } catch {} + fetchOrCreateGhPages(deployDir); + return false; + } + try { + git(deployDir, 'push', 'origin', 'gh-pages'); + console.log(`Deployed successfully (attempt ${attempt}, after rebase)`); + return true; + } catch { + console.log('Push failed after rebase, will rebuild'); + fetchOrCreateGhPages(deployDir); + return false; + } +} + async function pushWithRetry(deployDir, publishDir, branchDir, message) { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { if (attempt > 1) { @@ -224,25 +245,7 @@ async function pushWithRetry(deployDir, publishDir, branchDir, message) { return; } catch { console.log(`Push rejected (attempt ${attempt}/${MAX_RETRIES})`); - if (attempt < MAX_RETRIES) { - try { - git(deployDir, 'pull', '--rebase', 'origin', 'gh-pages'); - // Rebase succeeded — push immediately without rebuilding - try { - git(deployDir, 'push', 'origin', 'gh-pages'); - console.log(`Deployed successfully (attempt ${attempt}, after rebase)`); - return; - } catch { - console.log('Push failed after rebase, will rebuild'); - // Reset and rebuild on next iteration - fetchOrCreateGhPages(deployDir); - } - } catch { - console.log('Rebase conflict — resetting to remote'); - try { git(deployDir, 'rebase', '--abort'); } catch {} - fetchOrCreateGhPages(deployDir); - } - } + if (attempt < MAX_RETRIES && tryRebaseAndPush(deployDir, attempt)) return; } } throw new Error(`Deploy failed after ${MAX_RETRIES} attempts`); @@ -319,8 +322,10 @@ async function deploy() { git(deployDir, 'init', '-q'); git(deployDir, 'config', 'user.name', 'github-actions[bot]'); git(deployDir, 'config', 'user.email', 'github-actions[bot]@users.noreply.github.com'); - const remoteUrl = `https://x-access-token:${process.env.GITHUB_TOKEN}@github.com/${process.env.GITHUB_REPOSITORY}.git`; - git(deployDir, 'remote', 'add', 'origin', remoteUrl); + const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}.git`; + git(deployDir, 'remote', 'add', 'origin', repoUrl); + const credentials = Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'); + git(deployDir, 'config', `http.${repoUrl}.extraHeader`, `Authorization: Basic ${credentials}`); // Fetch gh-pages fetchOrCreateGhPages(deployDir); @@ -332,7 +337,9 @@ async function deploy() { await pushWithRetry(deployDir, publishDir, branchDir, message); } -deploy().catch(err => { +try { + await deploy(); +} catch (err) { console.error(err.message || err); process.exit(1); -}); +} diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md index 1009fd009cf..dc2242bad21 100644 --- a/docs/github-publication-workflow.md +++ b/docs/github-publication-workflow.md @@ -13,10 +13,10 @@ Both workflows produce HTML output under `titles-generated/<branch>/`, then push | Workflow | Event | Branches | Build Script | |---|---|---|---| -| `build-asciidoc.yml` | `push` | `main`, `release-1.**`, `rhdh-1.**`, `1.**.x` | `build-ccutil.sh` (all branches) | +| `build-asciidoc.yml` | `push` | `main`, `release-1.**`, `rhdh-1.**`, `1.**.x` | `build-orchestrator.js` | | `pr.yml` | `pull_request_target` | `main`, `release-1.**`, `release-2.**` | release-1.9+/main: `build-orchestrator.js`; release-1.8: `build-ccutil.sh` (base branch scripts) | -The `build-asciidoc.yml` workflow uses `build-ccutil.sh` on all branches for the build step. The `pr.yml` workflow detects whether `build-orchestrator.js` exists on the base branch and uses it when available (release-1.9+, main), falling back to `build-ccutil.sh` on older branches (release-1.8). The orchestrator wraps ccutil with parallel execution, lychee link validation, CQA assessment, and JSON reporting. +The `build-asciidoc.yml` workflow calls `build-orchestrator.js` directly. The `pr.yml` workflow detects whether `build-orchestrator.js` exists on the base branch and uses it when available (release-1.9+, main), falling back to `build-ccutil.sh` on older branches (release-1.8). The orchestrator wraps ccutil with parallel execution, lychee link validation, CQA assessment, and JSON reporting. ## Security Model From 839bfac1ac79c0edbd6ed2bf08b1e35f07cfa31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 16:20:20 +0200 Subject: [PATCH 10/18] fix: address PR review comments - Add --no-cqa and --no-lychee flags to build-orchestrator.js - Use --no-cqa in build-asciidoc.yml (CQA results not surfaced in branch builds) - Fix CQA-14 hardcoded '-b main': detect current branch from git - CQA-14 standalone now passes --no-cqa to avoid redundant CQA run - Clarify GITHUB_TOKEN is optional for local builds, required in CI - Update architecture docs for new flags Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/build-asciidoc.yml | 2 +- README.adoc | 2 +- build/scripts/README.md | 4 +-- build/scripts/build-orchestrator.js | 26 ++++++++++++++----- .../cqa/checks/cqa-14-no-broken-links.js | 11 +++++--- docs/github-publication-workflow.md | 7 ++--- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-asciidoc.yml b/.github/workflows/build-asciidoc.yml index 08dd988cc13..d67191fe292 100644 --- a/.github/workflows/build-asciidoc.yml +++ b/.github/workflows/build-asciidoc.yml @@ -68,7 +68,7 @@ jobs: run: | echo "Building branch ${{ env.GIT_BRANCH }}" touch .lycheecache - node build/scripts/build-orchestrator.js -b ${{ env.GIT_BRANCH }} + node build/scripts/build-orchestrator.js -b ${{ env.GIT_BRANCH }} --no-cqa - name: Deploy to the gh-pages branch env: diff --git a/README.adoc b/README.adoc index 0c50b16a55e..6b8ad20054f 100644 --- a/README.adoc +++ b/README.adoc @@ -51,7 +51,7 @@ to modularize your content. * link:https://podman.io[Podman] * link:https://github.com/errata-ai/vale[Vale] * link:https://github.com/lycheeverse/lychee[Lychee] (downloaded automatically by the build script if not installed) -* Optional: A `GITHUB_TOKEN` environment variable to avoid GitHub API rate limiting during lychee link validation. Use link:https://cli.github.com[GitHub CLI] (`gh`) to get and refresh the token: `export GITHUB_TOKEN=$(gh auth token)` +* Optional for local builds: A `GITHUB_TOKEN` environment variable to avoid GitHub API rate limiting during lychee link validation. Use link:https://cli.github.com[GitHub CLI] (`gh`) to get and refresh the token: `export GITHUB_TOKEN=$(gh auth token)`. This token is required in CI for deployment to `gh-pages`. .Procedure diff --git a/build/scripts/README.md b/build/scripts/README.md index 4bd7ad6d31d..eca2fe47090 100644 --- a/build/scripts/README.md +++ b/build/scripts/README.md @@ -7,7 +7,7 @@ Build, deploy, and content quality tooling for the RHDH documentation project. | Script | Purpose | |---|---| | `build-ccutil.sh` | Wrapper that delegates to `build-orchestrator.js`. Used as a fallback in `pr.yml` on older branches and for local builds. | -| `build-orchestrator.js` | Parallel documentation build orchestrator. Runs ccutil title builds, lychee link validation, and CQA assessment. Produces `build-report.json`. | +| `build-orchestrator.js` | Parallel documentation build orchestrator. Runs ccutil title builds, lychee link validation, and CQA assessment. Produces `build-report.json`. Supports `--no-cqa` and `--no-lychee` flags to skip phases. | | `deploy-gh-pages.js` | Deploys build output to the `gh-pages` branch. Handles cleanup of stale PR/branch directories, index regeneration with release notes links, and retry with rebase on push conflicts. | | `error-patterns.json` | Regex patterns for classifying ccutil build errors into structured messages with cause and fix fields. | | `update-cqa-resources.sh` | Fetches upstream Red Hat style guide resources into `.claude/resources/`. | @@ -29,7 +29,7 @@ See `.claude/plugins/project-cqa/resources/cqa-spec.md` for the full specificati These scripts are called by GitHub Actions workflows in `.github/workflows/`: -- **`build-asciidoc.yml`** (push to main/release) -- `build-orchestrator.js` + `deploy-gh-pages.js` +- **`build-asciidoc.yml`** (push to main/release) -- `build-orchestrator.js --no-cqa` + `deploy-gh-pages.js` - **`pr.yml`** (pull requests) -- `build-orchestrator.js` (or `build-ccutil.sh` on older branches) + `deploy-gh-pages.js` See `docs/github-publication-workflow.md` for the full architecture documentation. diff --git a/build/scripts/build-orchestrator.js b/build/scripts/build-orchestrator.js index d8a86a13a50..ce3a47d7b9b 100755 --- a/build/scripts/build-orchestrator.js +++ b/build/scripts/build-orchestrator.js @@ -9,6 +9,7 @@ * node build/scripts/build-orchestrator.js -b main * node build/scripts/build-orchestrator.js -b pr-123 --verbose * node build/scripts/build-orchestrator.js -b main --jobs 4 + * node build/scripts/build-orchestrator.js -b main --no-cqa --no-lychee */ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, renameSync, copyFileSync } from 'node:fs'; @@ -31,12 +32,14 @@ const SAFE_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' // ── Argument parsing ───────────────────────────────────────────────────────── function parseArgs(argv) { - const args = { branch: 'main', verbose: false, jobs: cpus().length }; + const args = { branch: 'main', verbose: false, jobs: cpus().length, lychee: true, cqa: true }; for (let i = 2; i < argv.length; i++) { switch (argv[i]) { case '-b': args.branch = argv[++i]; break; case '--verbose': args.verbose = true; break; case '--jobs': args.jobs = Number.parseInt(argv[++i], 10); break; + case '--no-lychee': args.lychee = false; break; + case '--no-cqa': args.cqa = false; break; } } return args; @@ -608,16 +611,25 @@ async function main() { generateBranchIndex(args.branch, buildResults, repoRoot); // Run lychee link validation - console.log('\nRunning link validation (lychee)...'); - const lycheeResult = await runLychee(repoRoot, args.branch, args.verbose); - if (lycheeResult.errors.length === 0) { - lycheeResult.errors = classifyErrors(lycheeResult.output, patterns); + const skippedResult = { status: 'skipped', duration: 0, output: '', stats: { total: 0, successful: 0, errors: 0, excludes: 0, timeouts: 0 }, errors: [] }; + let lycheeResult; + if (args.lychee) { + console.log('\nRunning link validation (lychee)...'); + lycheeResult = await runLychee(repoRoot, args.branch, args.verbose); + if (lycheeResult.errors.length === 0) { + lycheeResult.errors = classifyErrors(lycheeResult.output, patterns); + } + } else { + console.log('\nSkipping link validation (--no-lychee)'); + lycheeResult = { ...skippedResult }; } // Run CQA content quality assessment + const skippedCqa = { status: 'skipped', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } }; let cqaResult; - if (process.env.CQA_RUNNING) { - cqaResult = { status: 'skipped', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } }; + if (!args.cqa || process.env.CQA_RUNNING) { + if (!args.cqa) console.log('\nSkipping CQA (--no-cqa)'); + cqaResult = skippedCqa; } else { // Write preliminary report so CQA-14 can read lychee results without rebuilding const pendingCqa = { status: 'pending', duration: 0, output: '', stats: { total: 0, pass: 0, fail: 0 } }; diff --git a/build/scripts/cqa/checks/cqa-14-no-broken-links.js b/build/scripts/cqa/checks/cqa-14-no-broken-links.js index 0be187d9ae7..2fd5e7723af 100644 --- a/build/scripts/cqa/checks/cqa-14-no-broken-links.js +++ b/build/scripts/cqa/checks/cqa-14-no-broken-links.js @@ -89,16 +89,21 @@ function getLycheeIssues(root) { _lycheeIssuesCache = []; if (!process.env.CQA_RUNNING) { - // Standalone mode: build current state to get lychee results + // Standalone mode: build current state to get lychee results. + // Detect current branch for correct output directory naming and link remapping. + let branch = 'main'; try { - execFileSync('node', ['build/scripts/build-orchestrator.js', '-b', 'main'], { // NOSONAR — fixed args, no user input + branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + } catch { /* fall back to main */ } + try { + execFileSync('node', ['build/scripts/build-orchestrator.js', '-b', branch, '--no-cqa'], { // NOSONAR — fixed args, no user input cwd: root, stdio: 'pipe', timeout: 600000, env: { ...process.env, CQA_RUNNING: '1' }, }); } catch { - // Build may exit non-zero if lychee finds broken links — that's expected + // Orchestrator exits non-zero when lychee finds broken links; the report is still written } } // When CQA_RUNNING is set: preliminary report already exists with lychee results diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md index dc2242bad21..4c6e606af50 100644 --- a/docs/github-publication-workflow.md +++ b/docs/github-publication-workflow.md @@ -13,10 +13,10 @@ Both workflows produce HTML output under `titles-generated/<branch>/`, then push | Workflow | Event | Branches | Build Script | |---|---|---|---| -| `build-asciidoc.yml` | `push` | `main`, `release-1.**`, `rhdh-1.**`, `1.**.x` | `build-orchestrator.js` | +| `build-asciidoc.yml` | `push` | `main`, `release-1.**`, `rhdh-1.**`, `1.**.x` | `build-orchestrator.js --no-cqa` | | `pr.yml` | `pull_request_target` | `main`, `release-1.**`, `release-2.**` | release-1.9+/main: `build-orchestrator.js`; release-1.8: `build-ccutil.sh` (base branch scripts) | -The `build-asciidoc.yml` workflow calls `build-orchestrator.js` directly. The `pr.yml` workflow detects whether `build-orchestrator.js` exists on the base branch and uses it when available (release-1.9+, main), falling back to `build-ccutil.sh` on older branches (release-1.8). The orchestrator wraps ccutil with parallel execution, lychee link validation, CQA assessment, and JSON reporting. +The `build-asciidoc.yml` workflow calls `build-orchestrator.js --no-cqa` (CQA results aren't surfaced in branch builds, only in PR comments). The `pr.yml` workflow detects whether `build-orchestrator.js` exists on the base branch and uses it when available (release-1.9+, main), falling back to `build-ccutil.sh` on older branches (release-1.8). The orchestrator wraps ccutil with parallel execution, lychee link validation, CQA assessment, and JSON reporting. ## Security Model @@ -69,9 +69,10 @@ The orchestrator replaces the sequential `build-ccutil.sh` with parallel title b node build/scripts/build-orchestrator.js -b <branch> node build/scripts/build-orchestrator.js -b pr-123 --verbose node build/scripts/build-orchestrator.js -b main --jobs 4 +node build/scripts/build-orchestrator.js -b main --no-cqa --no-lychee ``` -The `-b` flag determines the output directory name under `titles-generated/`. The orchestrator exits with code 1 if any title build, lychee, or CQA fails. +The `-b` flag determines the output directory name under `titles-generated/`. `--no-cqa` and `--no-lychee` skip CQA and lychee respectively (used by `build-asciidoc.yml` where CQA results aren't surfaced). The orchestrator exits with code 1 if any enabled phase fails. ## Deploy Pipeline From aa4370c8e33e8c1e2ba9719f59fdf1b76775b5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 16:34:31 +0200 Subject: [PATCH 11/18] fix: suppress SonarCloud S4036 on git rev-parse in CQA-14 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- build/scripts/cqa/checks/cqa-14-no-broken-links.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/scripts/cqa/checks/cqa-14-no-broken-links.js b/build/scripts/cqa/checks/cqa-14-no-broken-links.js index 2fd5e7723af..c39a7071c8d 100644 --- a/build/scripts/cqa/checks/cqa-14-no-broken-links.js +++ b/build/scripts/cqa/checks/cqa-14-no-broken-links.js @@ -93,7 +93,7 @@ function getLycheeIssues(root) { // Detect current branch for correct output directory naming and link remapping. let branch = 'main'; try { - branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); // NOSONAR } catch { /* fall back to main */ } try { execFileSync('node', ['build/scripts/build-orchestrator.js', '-b', branch, '--no-cqa'], { // NOSONAR — fixed args, no user input From 238a3612e64bbed7b76a18339603e2b6919abb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 16:55:14 +0200 Subject: [PATCH 12/18] refactor: simplify deploy-gh-pages.js - Replace node:https callback API with fetch() (Node 18+) - Remove unused branchDir parameter from applyContent() - Extract cleanup from stageAndCommit into pushWithRetry (runs once, not per retry) - Deduplicate readdirSync call for diagnostics - Add flow overview and inline comments for non-obvious decisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- build/scripts/deploy-gh-pages.js | 118 +++++++++++++------------------ 1 file changed, 49 insertions(+), 69 deletions(-) diff --git a/build/scripts/deploy-gh-pages.js b/build/scripts/deploy-gh-pages.js index 374e3213e60..21782ba4387 100644 --- a/build/scripts/deploy-gh-pages.js +++ b/build/scripts/deploy-gh-pages.js @@ -8,8 +8,18 @@ * * SPDX-License-Identifier: EPL-2.0 * - * Replaces deploy-gh-pages.sh. Deploys content to the gh-pages branch - * with retry on push rejection, PR/branch cleanup, and index regeneration. + * Deploys build output (titles-generated/) to the gh-pages branch. + * + * Flow: + * 1. Create a temp git repo, fetch gh-pages (shallow) + * 2. Copy --publish-dir content into the working tree + * 3. For branch deploys: clean up stale PR and branch directories + * 4. Regenerate index.html (branch list) and pulls.html (PR list) + * 5. Commit everything (content + cleanup + indexes) and push + * 6. On push rejection: rebase and retry (max 3 attempts) + * + * Branch deploys clean up merged/closed PR dirs and deleted branch dirs. + * PR deploys only update content and pulls.html — no cleanup. * * Usage: * node deploy-gh-pages.js --publish-dir <dir> [--message <msg>] @@ -21,15 +31,10 @@ import { readdirSync, statSync, writeFileSync, existsSync, cpSync, rmSync, mkdte import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { get as httpsGet } from 'node:https'; - -// ── Constants ──────────────────────────────────────────────────────────────── const MAX_RETRIES = 3; const RELEASE_NOTES_BASE = 'https://red-hat-developers-documentation.pages.redhat.com/red-hat-developer-hub-release-notes'; -// ── Helpers ────────────────────────────────────────────────────────────────── - function git(cwd, ...args) { const result = execFileSync('git', args, { // NOSONAR: git is resolved from PATH in a controlled CI environment cwd, @@ -49,39 +54,27 @@ function noStagedChanges(cwd) { } } -function getPRState(owner, repo, prNumber) { - return new Promise((resolve, reject) => { - const options = { - hostname: 'api.github.com', - path: `/repos/${owner}/${repo}/pulls/${prNumber}`, +async function getPRState(owner, repo, prNumber) { + try { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { headers: { 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, 'User-Agent': 'deploy-gh-pages', 'Accept': 'application/vnd.github+json', }, - }; - httpsGet(options, (res) => { - let data = ''; - res.on('data', chunk => { data += chunk; }); - res.on('end', () => { - if (res.statusCode !== 200) { - console.log(`GitHub API returned ${res.statusCode} for PR ${prNumber}`); - resolve('unknown'); - return; - } - try { - const json = JSON.parse(data); - const closedState = json.merged ? 'merged' : 'closed'; - resolve(json.state === 'closed' ? closedState : 'open'); - } catch { resolve('unknown'); } - }); - res.on('error', () => resolve('unknown')); - }).on('error', () => resolve('unknown')); - }); + }); + if (!res.ok) { + console.log(`GitHub API returned ${res.status} for PR ${prNumber}`); + return 'unknown'; + } + const json = await res.json(); + if (json.state !== 'closed') return 'open'; + return json.merged ? 'merged' : 'closed'; + } catch { + return 'unknown'; + } } -// ── gh-pages branch setup ──────────────────────────────────────────────────── - function fetchOrCreateGhPages(deployDir) { try { git(deployDir, 'fetch', 'origin', 'gh-pages', '--depth=1'); @@ -97,20 +90,16 @@ function fetchOrCreateGhPages(deployDir) { } } -// ── Content application ────────────────────────────────────────────────────── - -function applyContent(deployDir, publishDir, branchDir) { +function applyContent(deployDir, publishDir) { cpSync(publishDir, deployDir, { recursive: true }); } -// ── Cleanup ────────────────────────────────────────────────────────────────── - async function cleanup(deployDir) { const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); // PR cleanup: remove directories for merged/closed PRs const prDirs = readdirSync(deployDir).filter(d => - d.startsWith('pr-') && !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory() + d.startsWith('pr-') && statSync(join(deployDir, d)).isDirectory() ); for (const dir of prDirs) { @@ -142,8 +131,6 @@ async function cleanup(deployDir) { } } -// ── Index generation ───────────────────────────────────────────────────────── - function getReleaseNotesUrl(branch) { if (branch === 'main') return `${RELEASE_NOTES_BASE}/main/index.html`; const match = branch.match(/^release-(\d+)\.(\d+)$/); @@ -180,19 +167,16 @@ function regenerateIndex(deployDir, branchDir) { writeIndex(deployDir, allDirs.filter(d => d.startsWith('pr-')), 'pulls.html', 'PR Previews', true); } -// ── Push with retry ────────────────────────────────────────────────────────── - -async function stageAndCommit(deployDir, publishDir, branchDir, message) { - if (!branchDir.startsWith('pr-')) { - await cleanup(deployDir); - } +function stageAndCommit(deployDir, publishDir, branchDir, message) { regenerateIndex(deployDir, branchDir); - const publishEntries = readdirSync(publishDir).filter(e => !e.startsWith('.')); - const toStage = [...publishEntries]; - if (existsSync(join(deployDir, 'index.html'))) toStage.push('index.html'); - if (existsSync(join(deployDir, 'pulls.html'))) toStage.push('pulls.html'); - git(deployDir, 'add', '--force', '--', ...new Set(toStage)); + // Force-add publish entries and index files (.gitignore may exclude them) + const forceEntries = new Set(readdirSync(publishDir).filter(e => !e.startsWith('.'))); + for (const f of ['index.html', 'pulls.html']) { + if (existsSync(join(deployDir, f))) forceEntries.add(f); + } + git(deployDir, 'add', '--force', '--', ...forceEntries); + // Stage deletions from cleanup git(deployDir, 'add', '-A'); if (noStagedChanges(deployDir)) { @@ -210,6 +194,9 @@ async function stageAndCommit(deployDir, publishDir, branchDir, message) { return true; } +// On push rejection (concurrent deploy from another branch/PR), try rebase first. +// If rebase conflicts (different branch touched same index files), reset and let +// the retry loop rebuild from a fresh fetch. function tryRebaseAndPush(deployDir, attempt) { try { git(deployDir, 'pull', '--rebase', 'origin', 'gh-pages'); @@ -231,12 +218,17 @@ function tryRebaseAndPush(deployDir, attempt) { } async function pushWithRetry(deployDir, publishDir, branchDir, message) { + // Cleanup runs once before retries so we don't make redundant API calls + if (!branchDir.startsWith('pr-')) { + await cleanup(deployDir); + } + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { if (attempt > 1) { - applyContent(deployDir, publishDir, branchDir); + applyContent(deployDir, publishDir); } - const hasChanges = await stageAndCommit(deployDir, publishDir, branchDir, message); + const hasChanges = stageAndCommit(deployDir, publishDir, branchDir, message); if (!hasChanges) return; try { @@ -251,10 +243,7 @@ async function pushWithRetry(deployDir, publishDir, branchDir, message) { throw new Error(`Deploy failed after ${MAX_RETRIES} attempts`); } -// ── Main ───────────────────────────────────────────────────────────────────── - async function deploy() { - // Parse args const args = process.argv.slice(2); let publishDir = null; let message = 'Deploy to GitHub Pages'; @@ -280,7 +269,6 @@ async function deploy() { publishDir = resolve(publishDir); - // Validate env if (!process.env.GITHUB_TOKEN) { console.error('GITHUB_TOKEN is required (set by GitHub Actions)'); process.exit(1); @@ -290,7 +278,6 @@ async function deploy() { process.exit(1); } - // Detect branchDir from publishDir const entries = readdirSync(publishDir).filter(e => !e.startsWith('.') && statSync(join(publishDir, e)).isDirectory() ); @@ -305,15 +292,11 @@ async function deploy() { const branchDir = entries[0]; - // Diagnostics console.log(`PUBLISH_DIR: ${publishDir}`); console.log('Top-level entries in PUBLISH_DIR:'); - readdirSync(publishDir) - .filter(e => !e.startsWith('.')) - .forEach(e => console.log(` ${e}`)); + entries.forEach(e => console.log(` ${e}`)); console.log(`Branch directory: ${branchDir}`); - // Create temp git repo const deployDir = mkdtempSync(join(tmpdir(), 'deploy-')); process.on('exit', () => { try { rmSync(deployDir, { recursive: true, force: true }); } catch {} @@ -322,18 +305,15 @@ async function deploy() { git(deployDir, 'init', '-q'); git(deployDir, 'config', 'user.name', 'github-actions[bot]'); git(deployDir, 'config', 'user.email', 'github-actions[bot]@users.noreply.github.com'); + // Auth via http.extraHeader keeps the token out of the remote URL (avoids leaking in logs) const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}.git`; git(deployDir, 'remote', 'add', 'origin', repoUrl); const credentials = Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'); git(deployDir, 'config', `http.${repoUrl}.extraHeader`, `Authorization: Basic ${credentials}`); - // Fetch gh-pages fetchOrCreateGhPages(deployDir); - // Apply content on first pass - applyContent(deployDir, publishDir, branchDir); - - // Push with retry handles cleanup, index regeneration, staging, commit, and push + applyContent(deployDir, publishDir); await pushWithRetry(deployDir, publishDir, branchDir, message); } From d728190209b95cb1758d633732cc1e6540978ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= <ffloreth@redhat.com> Date: Fri, 24 Apr 2026 17:13:41 +0200 Subject: [PATCH 13/18] refactor: rewrite deploy-gh-pages back to bash The Node.js rewrite was 325 lines for functionality that fits naturally in ~289 lines of bash. The deploy script's core job is git operations (fetch, copy, add, commit, push) which read more naturally in shell. Changes from the original 88-line script: - Rebase-based retry replaces sleep-based retry (instant vs 2+ minutes) - Cleanup of stale PR/branch dirs integrated (was separate workflow step) - Index regeneration integrated (was in build-orchestrator.js) - Token auth via http.extraHeader (was in remote URL, leaked in logs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/build-asciidoc.yml | 2 +- .github/workflows/pr.yml | 2 +- build/scripts/README.md | 6 +- build/scripts/deploy-gh-pages.js | 325 --------------------------- build/scripts/deploy-gh-pages.sh | 289 ++++++++++++++++++++++++ docs/github-publication-workflow.md | 12 +- 6 files changed, 300 insertions(+), 336 deletions(-) delete mode 100644 build/scripts/deploy-gh-pages.js create mode 100755 build/scripts/deploy-gh-pages.sh diff --git a/.github/workflows/build-asciidoc.yml b/.github/workflows/build-asciidoc.yml index d67191fe292..fceec6955ce 100644 --- a/.github/workflows/build-asciidoc.yml +++ b/.github/workflows/build-asciidoc.yml @@ -74,4 +74,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: node build/scripts/deploy-gh-pages.js --publish-dir ./titles-generated --message "Deploy ${{ env.GIT_BRANCH }}" + run: bash build/scripts/deploy-gh-pages.sh --publish-dir ./titles-generated --message "Deploy ${{ env.GIT_BRANCH }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 69c1d9426d8..9e59d00736a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -195,7 +195,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: node trusted-scripts/build/scripts/deploy-gh-pages.js --publish-dir ./pr-content/titles-generated --message "Deploy PR ${{ github.event.number }} preview" + run: bash trusted-scripts/build/scripts/deploy-gh-pages.sh --publish-dir ./pr-content/titles-generated --message "Deploy PR ${{ github.event.number }} preview" # Post one consolidated PR comment with build results, preview link, and CQA checklist. # Preview link is always shown; marked stale when title build failed (HTML not generated). diff --git a/build/scripts/README.md b/build/scripts/README.md index eca2fe47090..c4004364cb2 100644 --- a/build/scripts/README.md +++ b/build/scripts/README.md @@ -8,7 +8,7 @@ Build, deploy, and content quality tooling for the RHDH documentation project. |---|---| | `build-ccutil.sh` | Wrapper that delegates to `build-orchestrator.js`. Used as a fallback in `pr.yml` on older branches and for local builds. | | `build-orchestrator.js` | Parallel documentation build orchestrator. Runs ccutil title builds, lychee link validation, and CQA assessment. Produces `build-report.json`. Supports `--no-cqa` and `--no-lychee` flags to skip phases. | -| `deploy-gh-pages.js` | Deploys build output to the `gh-pages` branch. Handles cleanup of stale PR/branch directories, index regeneration with release notes links, and retry with rebase on push conflicts. | +| `deploy-gh-pages.sh` | Deploys build output to the `gh-pages` branch. Handles cleanup of stale PR/branch directories, index regeneration with release notes links, and retry with rebase on push conflicts. | | `error-patterns.json` | Regex patterns for classifying ccutil build errors into structured messages with cause and fix fields. | | `update-cqa-resources.sh` | Fetches upstream Red Hat style guide resources into `.claude/resources/`. | @@ -29,7 +29,7 @@ See `.claude/plugins/project-cqa/resources/cqa-spec.md` for the full specificati These scripts are called by GitHub Actions workflows in `.github/workflows/`: -- **`build-asciidoc.yml`** (push to main/release) -- `build-orchestrator.js --no-cqa` + `deploy-gh-pages.js` -- **`pr.yml`** (pull requests) -- `build-orchestrator.js` (or `build-ccutil.sh` on older branches) + `deploy-gh-pages.js` +- **`build-asciidoc.yml`** (push to main/release) -- `build-orchestrator.js --no-cqa` + `deploy-gh-pages.sh` +- **`pr.yml`** (pull requests) -- `build-orchestrator.js` (or `build-ccutil.sh` on older branches) + `deploy-gh-pages.sh` See `docs/github-publication-workflow.md` for the full architecture documentation. diff --git a/build/scripts/deploy-gh-pages.js b/build/scripts/deploy-gh-pages.js deleted file mode 100644 index 21782ba4387..00000000000 --- a/build/scripts/deploy-gh-pages.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * deploy-gh-pages.js — Deploy documentation builds to GitHub Pages - * - * Copyright (c) Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Deploys build output (titles-generated/) to the gh-pages branch. - * - * Flow: - * 1. Create a temp git repo, fetch gh-pages (shallow) - * 2. Copy --publish-dir content into the working tree - * 3. For branch deploys: clean up stale PR and branch directories - * 4. Regenerate index.html (branch list) and pulls.html (PR list) - * 5. Commit everything (content + cleanup + indexes) and push - * 6. On push rejection: rebase and retry (max 3 attempts) - * - * Branch deploys clean up merged/closed PR dirs and deleted branch dirs. - * PR deploys only update content and pulls.html — no cleanup. - * - * Usage: - * node deploy-gh-pages.js --publish-dir <dir> [--message <msg>] - * - * Environment: GITHUB_TOKEN, GITHUB_REPOSITORY (set by GitHub Actions) - */ - -import { readdirSync, statSync, writeFileSync, existsSync, cpSync, rmSync, mkdtempSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { execFileSync } from 'node:child_process'; - -const MAX_RETRIES = 3; -const RELEASE_NOTES_BASE = 'https://red-hat-developers-documentation.pages.redhat.com/red-hat-developer-hub-release-notes'; - -function git(cwd, ...args) { - const result = execFileSync('git', args, { // NOSONAR: git is resolved from PATH in a controlled CI environment - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000, - encoding: 'utf8', - }); - return (result || '').trim(); -} - -function noStagedChanges(cwd) { - try { - git(cwd, 'diff', '--cached', '--quiet'); - return true; - } catch { - return false; - } -} - -async function getPRState(owner, repo, prNumber) { - try { - const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { - headers: { - 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, - 'User-Agent': 'deploy-gh-pages', - 'Accept': 'application/vnd.github+json', - }, - }); - if (!res.ok) { - console.log(`GitHub API returned ${res.status} for PR ${prNumber}`); - return 'unknown'; - } - const json = await res.json(); - if (json.state !== 'closed') return 'open'; - return json.merged ? 'merged' : 'closed'; - } catch { - return 'unknown'; - } -} - -function fetchOrCreateGhPages(deployDir) { - try { - git(deployDir, 'fetch', 'origin', 'gh-pages', '--depth=1'); - git(deployDir, 'checkout', '-B', 'gh-pages', 'FETCH_HEAD'); - } catch (err) { - if (/not found|couldn't find|no such remote ref/i.test(err.message || err.stderr || '')) { - console.log('gh-pages branch does not exist, creating orphan'); - git(deployDir, 'checkout', '--orphan', 'gh-pages'); - try { git(deployDir, 'rm', '-rf', '.'); } catch {} - } else { - throw err; - } - } -} - -function applyContent(deployDir, publishDir) { - cpSync(publishDir, deployDir, { recursive: true }); -} - -async function cleanup(deployDir) { - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - - // PR cleanup: remove directories for merged/closed PRs - const prDirs = readdirSync(deployDir).filter(d => - d.startsWith('pr-') && statSync(join(deployDir, d)).isDirectory() - ); - - for (const dir of prDirs) { - const match = dir.match(/^pr-(\d+)$/); - if (!match) continue; - const prNumber = match[1]; - const state = await getPRState(owner, repo, prNumber); - if (state === 'merged' || state === 'closed') { - console.log(`Removing ${dir} (PR ${state})`); - rmSync(join(deployDir, dir), { recursive: true, force: true }); - } - } - - // Branch cleanup: remove directories for deleted remote branches - const branchDirs = readdirSync(deployDir).filter(d => - !d.startsWith('pr-') && !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory() - ); - - for (const dir of branchDirs) { - try { - const output = git(deployDir, 'ls-remote', '--heads', 'origin', dir); - if (!output) { - console.log(`Removing ${dir} (branch no longer exists on remote)`); - rmSync(join(deployDir, dir), { recursive: true, force: true }); - } - } catch { - // If ls-remote fails, keep the directory to be safe - } - } -} - -function getReleaseNotesUrl(branch) { - if (branch === 'main') return `${RELEASE_NOTES_BASE}/main/index.html`; - const match = branch.match(/^release-(\d+)\.(\d+)$/); - if (match && (Number(match[1]) > 1 || Number(match[2]) >= 9)) - return `${RELEASE_NOTES_BASE}/release-${match[1]}-${match[2]}/index.html`; - return null; -} - -function writeIndex(deployDir, dirs, filename, title, isPR) { - const entries = dirs.map(dir => { - let entry = `<li><a href=./${dir}/index.html>${dir}</a>`; - if (!isPR) { - const rnUrl = getReleaseNotesUrl(dir); - if (rnUrl) entry += ` | <a href="${rnUrl}">Release Notes</a>`; - } - return entry + '</li>'; - }); - - const html = `<html><head><title>RHDH Documentation - ${title}\n\n
      \n${entries.join('\n')}\n
    \n`; - writeFileSync(join(deployDir, filename), html); -} - -function regenerateIndex(deployDir, branchDir) { - const allDirs = readdirSync(deployDir) - .filter(d => !d.startsWith('.') && statSync(join(deployDir, d)).isDirectory()) - .sort(); - - const isPR = branchDir.startsWith('pr-'); - - // Branch deploys regenerate both indexes; PR deploys regenerate pulls.html only - if (!isPR) { - writeIndex(deployDir, allDirs.filter(d => !d.startsWith('pr-')), 'index.html', 'Documentation Branches', false); - } - writeIndex(deployDir, allDirs.filter(d => d.startsWith('pr-')), 'pulls.html', 'PR Previews', true); -} - -function stageAndCommit(deployDir, publishDir, branchDir, message) { - regenerateIndex(deployDir, branchDir); - - // Force-add publish entries and index files (.gitignore may exclude them) - const forceEntries = new Set(readdirSync(publishDir).filter(e => !e.startsWith('.'))); - for (const f of ['index.html', 'pulls.html']) { - if (existsSync(join(deployDir, f))) forceEntries.add(f); - } - git(deployDir, 'add', '--force', '--', ...forceEntries); - // Stage deletions from cleanup - git(deployDir, 'add', '-A'); - - if (noStagedChanges(deployDir)) { - console.log('No changes to deploy'); - return false; - } - - console.log('Staged files:'); - try { - const stat = git(deployDir, 'diff', '--cached', '--stat'); - if (stat) console.log(stat); - } catch {} - - git(deployDir, 'commit', '-q', '-m', message); - return true; -} - -// On push rejection (concurrent deploy from another branch/PR), try rebase first. -// If rebase conflicts (different branch touched same index files), reset and let -// the retry loop rebuild from a fresh fetch. -function tryRebaseAndPush(deployDir, attempt) { - try { - git(deployDir, 'pull', '--rebase', 'origin', 'gh-pages'); - } catch { - console.log('Rebase conflict — resetting to remote'); - try { git(deployDir, 'rebase', '--abort'); } catch {} - fetchOrCreateGhPages(deployDir); - return false; - } - try { - git(deployDir, 'push', 'origin', 'gh-pages'); - console.log(`Deployed successfully (attempt ${attempt}, after rebase)`); - return true; - } catch { - console.log('Push failed after rebase, will rebuild'); - fetchOrCreateGhPages(deployDir); - return false; - } -} - -async function pushWithRetry(deployDir, publishDir, branchDir, message) { - // Cleanup runs once before retries so we don't make redundant API calls - if (!branchDir.startsWith('pr-')) { - await cleanup(deployDir); - } - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - if (attempt > 1) { - applyContent(deployDir, publishDir); - } - - const hasChanges = stageAndCommit(deployDir, publishDir, branchDir, message); - if (!hasChanges) return; - - try { - git(deployDir, 'push', 'origin', 'gh-pages'); - console.log(`Deployed successfully (attempt ${attempt})`); - return; - } catch { - console.log(`Push rejected (attempt ${attempt}/${MAX_RETRIES})`); - if (attempt < MAX_RETRIES && tryRebaseAndPush(deployDir, attempt)) return; - } - } - throw new Error(`Deploy failed after ${MAX_RETRIES} attempts`); -} - -async function deploy() { - const args = process.argv.slice(2); - let publishDir = null; - let message = 'Deploy to GitHub Pages'; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--publish-dir': - publishDir = args[++i]; - break; - case '--message': - message = args[++i]; - break; - default: - console.error(`Unknown option: ${args[i]}`); - process.exit(1); - } - } - - if (!publishDir) { - console.error('Usage: node deploy-gh-pages.js --publish-dir [--message ]'); - process.exit(1); - } - - publishDir = resolve(publishDir); - - if (!process.env.GITHUB_TOKEN) { - console.error('GITHUB_TOKEN is required (set by GitHub Actions)'); - process.exit(1); - } - if (!process.env.GITHUB_REPOSITORY) { - console.error('GITHUB_REPOSITORY is required (set by GitHub Actions)'); - process.exit(1); - } - - const entries = readdirSync(publishDir).filter(e => - !e.startsWith('.') && statSync(join(publishDir, e)).isDirectory() - ); - - if (entries.length === 0) { - console.error('No top-level directory found in publish dir'); - process.exit(1); - } - if (entries.length > 1) { - console.log(`Warning: multiple directories in publish dir: ${entries.join(', ')}; using ${entries[0]}`); - } - - const branchDir = entries[0]; - - console.log(`PUBLISH_DIR: ${publishDir}`); - console.log('Top-level entries in PUBLISH_DIR:'); - entries.forEach(e => console.log(` ${e}`)); - console.log(`Branch directory: ${branchDir}`); - - const deployDir = mkdtempSync(join(tmpdir(), 'deploy-')); - process.on('exit', () => { - try { rmSync(deployDir, { recursive: true, force: true }); } catch {} - }); - - git(deployDir, 'init', '-q'); - git(deployDir, 'config', 'user.name', 'github-actions[bot]'); - git(deployDir, 'config', 'user.email', 'github-actions[bot]@users.noreply.github.com'); - // Auth via http.extraHeader keeps the token out of the remote URL (avoids leaking in logs) - const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}.git`; - git(deployDir, 'remote', 'add', 'origin', repoUrl); - const credentials = Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'); - git(deployDir, 'config', `http.${repoUrl}.extraHeader`, `Authorization: Basic ${credentials}`); - - fetchOrCreateGhPages(deployDir); - - applyContent(deployDir, publishDir); - await pushWithRetry(deployDir, publishDir, branchDir, message); -} - -try { - await deploy(); -} catch (err) { - console.error(err.message || err); - process.exit(1); -} diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh new file mode 100755 index 00000000000..b954d5fd52b --- /dev/null +++ b/build/scripts/deploy-gh-pages.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# +# Copyright (c) Red Hat, Inc. +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# +# Deploy build output (titles-generated/) to the gh-pages branch. +# +# Flow: +# 1. Create a temp git repo, fetch gh-pages (shallow) +# 2. Copy --publish-dir content into the working tree +# 3. For branch deploys: clean up stale PR and branch directories +# 4. Regenerate index.html (branch list) and pulls.html (PR list) +# 5. Commit everything (content + cleanup + indexes) and push +# 6. On push rejection: rebase and retry (max 3 attempts) +# +# Branch deploys clean up merged/closed PR dirs and deleted branch dirs. +# PR deploys only update content and pulls.html — no cleanup. +# +# Usage: deploy-gh-pages.sh --publish-dir [--message ] +# +# Environment: GITHUB_TOKEN, GITHUB_REPOSITORY (set by GitHub Actions) + +set -euo pipefail + +MAX_RETRIES=3 +RELEASE_NOTES_BASE="https://red-hat-developers-documentation.pages.redhat.com/red-hat-developer-hub-release-notes" + +# ── Parse arguments ────────────────────────────────────────────────────────── + +PUBLISH_DIR="" +COMMIT_MSG="Deploy to GitHub Pages" + +while [[ $# -gt 0 ]]; do + case "$1" in + --publish-dir) PUBLISH_DIR="$2"; shift 2 ;; + --message) COMMIT_MSG="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$PUBLISH_DIR" ]]; then + echo "Usage: deploy-gh-pages.sh --publish-dir [--message ]" >&2 + exit 1 +fi + +PUBLISH_DIR="$(cd "$PUBLISH_DIR" && pwd)" +: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +# Detect branch directory (first non-hidden top-level dir in publish dir) +BRANCH_DIR="" +for d in "$PUBLISH_DIR"/*/; do + [[ -d "$d" ]] || continue + name="$(basename "$d")" + [[ "$name" == .* ]] && continue + BRANCH_DIR="$name" + break +done + +if [[ -z "$BRANCH_DIR" ]]; then + echo "No top-level directory found in publish dir" >&2 + exit 1 +fi + +echo "PUBLISH_DIR: $PUBLISH_DIR" +echo "Branch directory: $BRANCH_DIR" + +# ── Set up temp deploy repo ────────────────────────────────────────────────── + +DEPLOY_DIR="$(mktemp -d)" +trap 'rm -rf "$DEPLOY_DIR"' EXIT + +git -C "$DEPLOY_DIR" init -q +git -C "$DEPLOY_DIR" config user.name "github-actions[bot]" +git -C "$DEPLOY_DIR" config user.email "github-actions[bot]@users.noreply.github.com" + +REPO_URL="https://github.com/${GITHUB_REPOSITORY}.git" +git -C "$DEPLOY_DIR" remote add origin "$REPO_URL" +# Auth via http.extraHeader keeps the token out of the remote URL (avoids leaking in logs) +CREDENTIALS="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)" +git -C "$DEPLOY_DIR" config "http.${REPO_URL}.extraHeader" "Authorization: Basic ${CREDENTIALS}" + +# ── Core functions ─────────────────────────────────────────────────────────── + +fetch_gh_pages() { + if git -C "$DEPLOY_DIR" fetch origin gh-pages --depth=1 2>/dev/null; then + git -C "$DEPLOY_DIR" checkout -B gh-pages FETCH_HEAD + else + echo "gh-pages branch does not exist, creating orphan" + git -C "$DEPLOY_DIR" checkout --orphan gh-pages + git -C "$DEPLOY_DIR" rm -rf . 2>/dev/null || true + fi +} + +apply_content() { + cp -a "$PUBLISH_DIR"/. "$DEPLOY_DIR"/ +} + +# ── Cleanup (branch deploys only) ──────────────────────────────────────────── + +get_pr_state() { + local pr_number="$1" + local owner="${GITHUB_REPOSITORY%%/*}" + local repo="${GITHUB_REPOSITORY##*/}" + local response status merged + + response="$(curl -sf \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${owner}/${repo}/pulls/${pr_number}" 2>/dev/null)" || { echo "unknown"; return; } + + status="$(printf '%s' "$response" | grep -o '"state": *"[^"]*"' | head -1 | grep -o '"[^"]*"$' | tr -d '"')" + merged="$(printf '%s' "$response" | grep -o '"merged": *[a-z]*' | head -1 | grep -o '[a-z]*$')" + + if [[ "$status" == "closed" ]]; then + [[ "$merged" == "true" ]] && echo "merged" || echo "closed" + else + echo "${status:-unknown}" + fi +} + +cleanup() { + # PR cleanup: remove directories for merged/closed PRs + for d in "$DEPLOY_DIR"/pr-*/; do + [[ -d "$d" ]] || continue + local dir_name pr_number state + dir_name="$(basename "$d")" + pr_number="${dir_name#pr-}" + [[ "$pr_number" =~ ^[0-9]+$ ]] || continue + + state="$(get_pr_state "$pr_number")" + if [[ "$state" == "merged" || "$state" == "closed" ]]; then + echo "Removing $dir_name (PR $state)" + rm -rf "$d" + fi + done + + # Branch cleanup: remove directories for deleted remote branches + for d in "$DEPLOY_DIR"/*/; do + [[ -d "$d" ]] || continue + local dir_name + dir_name="$(basename "$d")" + [[ "$dir_name" == pr-* || "$dir_name" == .* ]] && continue + + if ! git -C "$DEPLOY_DIR" ls-remote --heads origin "$dir_name" 2>/dev/null | grep -q .; then + echo "Removing $dir_name (branch no longer exists on remote)" + rm -rf "$d" + fi + done +} + +# ── Index generation ───────────────────────────────────────────────────────── + +release_notes_url() { + local branch="$1" + if [[ "$branch" == "main" ]]; then + echo "${RELEASE_NOTES_BASE}/main/index.html" + elif [[ "$branch" =~ ^release-([0-9]+)\.([0-9]+)$ ]]; then + local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}" + if (( major > 1 || minor >= 9 )); then + echo "${RELEASE_NOTES_BASE}/release-${major}-${minor}/index.html" + fi + fi +} + +regenerate_indexes() { + local branch_items="" pr_items="" + + for d in "$DEPLOY_DIR"/*/; do + [[ -d "$d" ]] || continue + local name + name="$(basename "$d")" + [[ "$name" == .* ]] && continue + + if [[ "$name" == pr-* ]]; then + pr_items+="
  • ${name}
  • "$'\n' + else + local entry="
  • ${name}" + local rn_url + rn_url="$(release_notes_url "$name")" + [[ -n "$rn_url" ]] && entry+=" | Release Notes" + branch_items+="${entry}
  • "$'\n' + fi + done + + # Branch deploys regenerate both; PR deploys regenerate pulls.html only + if [[ "$BRANCH_DIR" != pr-* ]]; then + cat > "$DEPLOY_DIR/index.html" <RHDH Documentation - Documentation Branches + +
      +${branch_items}
    + +EOF + fi + + cat > "$DEPLOY_DIR/pulls.html" <RHDH Documentation - PR Previews + +
      +${pr_items}
    + +EOF +} + +# ── Stage, commit, push ───────────────────────────────────────────────────── + +stage_and_commit() { + regenerate_indexes + + # Force-add publish entries and indexes (.gitignore may exclude them) + local to_stage=() + for e in "$PUBLISH_DIR"/*/; do + [[ -d "$e" ]] || continue + local name + name="$(basename "$e")" + [[ "$name" == .* ]] && continue + to_stage+=("$name") + done + [[ -f "$DEPLOY_DIR/index.html" ]] && to_stage+=("index.html") + [[ -f "$DEPLOY_DIR/pulls.html" ]] && to_stage+=("pulls.html") + + git -C "$DEPLOY_DIR" add --force -- "${to_stage[@]}" + git -C "$DEPLOY_DIR" add -A + + if git -C "$DEPLOY_DIR" diff --cached --quiet; then + echo "No changes to deploy" + return 1 + fi + + echo "Staged files:" + git -C "$DEPLOY_DIR" diff --cached --stat || true + git -C "$DEPLOY_DIR" commit -q -m "$COMMIT_MSG" +} + +# On push rejection (concurrent deploy), try rebase first. +# If rebase conflicts (e.g. both touched index.html), reset and rebuild. +try_rebase_and_push() { + local attempt="$1" + if git -C "$DEPLOY_DIR" pull --rebase origin gh-pages 2>/dev/null; then + if git -C "$DEPLOY_DIR" push origin gh-pages 2>/dev/null; then + echo "Deployed successfully (attempt $attempt, after rebase)" + return 0 + fi + echo "Push failed after rebase, will rebuild" + else + echo "Rebase conflict — resetting to remote" + git -C "$DEPLOY_DIR" rebase --abort 2>/dev/null || true + fi + fetch_gh_pages + return 1 +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +fetch_gh_pages +apply_content + +# Cleanup runs once before retries (avoids redundant API calls) +if [[ "$BRANCH_DIR" != pr-* ]]; then + cleanup +fi + +for attempt in $(seq 1 "$MAX_RETRIES"); do + if [[ "$attempt" -gt 1 ]]; then + apply_content + fi + + if ! stage_and_commit; then + exit 0 + fi + + if git -C "$DEPLOY_DIR" push origin gh-pages 2>/dev/null; then + echo "Deployed successfully (attempt $attempt)" + exit 0 + fi + + echo "Push rejected (attempt $attempt/$MAX_RETRIES)" + if [[ "$attempt" -lt "$MAX_RETRIES" ]]; then + try_rebase_and_push "$attempt" && exit 0 + fi +done + +echo "Deploy failed after $MAX_RETRIES attempts" >&2 +exit 1 diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md index 4c6e606af50..55dc0488c3f 100644 --- a/docs/github-publication-workflow.md +++ b/docs/github-publication-workflow.md @@ -7,7 +7,7 @@ The RHDH documentation project uses GitHub Actions to build AsciiDoc documentati - **`build-asciidoc.yml`** -- triggered on branch pushes, builds and deploys production documentation. - **`pr.yml`** -- triggered on pull requests, builds preview HTML and posts a PR comment with a preview link and CQA checklist. -Both workflows produce HTML output under `titles-generated//`, then push the result to the `gh-pages` branch using `deploy-gh-pages.js`. +Both workflows produce HTML output under `titles-generated//`, then push the result to the `gh-pages` branch using `deploy-gh-pages.sh`. ## Triggers and Branch Matrix @@ -76,7 +76,7 @@ The `-b` flag determines the output directory name under `titles-generated/`. `- ## Deploy Pipeline -### deploy-gh-pages.js +### deploy-gh-pages.sh Handles deployment of built content to the `gh-pages` branch, including cleanup and index regeneration in a single commit. @@ -102,10 +102,10 @@ Branch deploys regenerate both indexes. PR deploys regenerate `pulls.html` only. ```bash # Branch deploy -node build/scripts/deploy-gh-pages.js --publish-dir ./titles-generated --message "Deploy main" +bash build/scripts/deploy-gh-pages.sh --publish-dir ./titles-generated --message "Deploy main" # PR deploy (from pr.yml, using trusted scripts) -node trusted-scripts/build/scripts/deploy-gh-pages.js --publish-dir ./pr-content/titles-generated --message "Deploy PR 123 preview" +bash trusted-scripts/build/scripts/deploy-gh-pages.sh --publish-dir ./pr-content/titles-generated --message "Deploy PR 123 preview" ``` ### Branch vs PR deploys @@ -148,7 +148,7 @@ https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/pr-/ 2. Authorization gate checks team membership. Fork PRs from non-team members require manual approval via the `external` environment. 3. Trusted build scripts are checked out from the base branch. PR content is checked out separately. 4. Build scripts from the base replace `pr-content/build/scripts`. The orchestrator (or `build-ccutil.sh` on older branches) runs with `-b pr-`. -5. If HTML was successfully generated (checked via `build-report.json`), `deploy-gh-pages.js` pushes the output to `gh-pages` under `pr-/`. +5. If HTML was successfully generated (checked via `build-report.json`), `deploy-gh-pages.sh` pushes the output to `gh-pages` under `pr-/`. 6. A consolidated PR comment is posted (or updated) with: - Build status (passed/failed) with title counts and duration. - Preview link (marked stale if title build failed). @@ -162,7 +162,7 @@ https://redhat-developer.github.io/red-hat-developers-documentation-rhdh/pr-/ ## Cleanup -Cleanup is integrated into `deploy-gh-pages.js` and runs during branch deploys only, not during PR deploys. It executes before index regeneration so indexes reflect the cleaned-up state. Cleanup, content deployment, and index regeneration are committed together in a single commit. +Cleanup is integrated into `deploy-gh-pages.sh` and runs during branch deploys only, not during PR deploys. It executes before index regeneration so indexes reflect the cleaned-up state. Cleanup, content deployment, and index regeneration are committed together in a single commit. ### PR cleanup From 3ad2609fe82ca1d5a4b2d8c4a9d5de5d1563afbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:14:15 +0200 Subject: [PATCH 14/18] fix: address reviewer comments on deploy script - Add comment: orphan path is a bootstrap safety net - Add comment: branch cleanup catches all stale branches via ls-remote Co-Authored-By: Claude Opus 4.6 --- build/scripts/deploy-gh-pages.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh index b954d5fd52b..3ca5a6d4536 100755 --- a/build/scripts/deploy-gh-pages.sh +++ b/build/scripts/deploy-gh-pages.sh @@ -86,6 +86,7 @@ git -C "$DEPLOY_DIR" config "http.${REPO_URL}.extraHeader" "Authorization: Basic # ── Core functions ─────────────────────────────────────────────────────────── +# gh-pages exists in practice; orphan path is a bootstrap safety net fetch_gh_pages() { if git -C "$DEPLOY_DIR" fetch origin gh-pages --depth=1 2>/dev/null; then git -C "$DEPLOY_DIR" checkout -B gh-pages FETCH_HEAD @@ -139,7 +140,8 @@ cleanup() { fi done - # Branch cleanup: remove directories for deleted remote branches + # Branch cleanup: remove directories for deleted remote branches (rhdh-*, 1.*.x, etc.) + # No pattern list needed — we check if each branch still exists on the remote for d in "$DEPLOY_DIR"/*/; do [[ -d "$d" ]] || continue local dir_name From ba54154706f6c2f4f0fd7b1e988213705072a99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:19:17 +0200 Subject: [PATCH 15/18] fix: restore positional argument for publish_dir Keep the original CLI style: deploy-gh-pages.sh [--message ] Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-asciidoc.yml | 2 +- .github/workflows/pr.yml | 2 +- build/scripts/deploy-gh-pages.sh | 13 ++++--------- docs/github-publication-workflow.md | 8 ++++---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-asciidoc.yml b/.github/workflows/build-asciidoc.yml index fceec6955ce..48c81fa017e 100644 --- a/.github/workflows/build-asciidoc.yml +++ b/.github/workflows/build-asciidoc.yml @@ -74,4 +74,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: bash build/scripts/deploy-gh-pages.sh --publish-dir ./titles-generated --message "Deploy ${{ env.GIT_BRANCH }}" + run: bash build/scripts/deploy-gh-pages.sh ./titles-generated --message "Deploy ${{ env.GIT_BRANCH }}" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9e59d00736a..ad577c5dd4b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -195,7 +195,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.RHDH_BOT_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: bash trusted-scripts/build/scripts/deploy-gh-pages.sh --publish-dir ./pr-content/titles-generated --message "Deploy PR ${{ github.event.number }} preview" + run: bash trusted-scripts/build/scripts/deploy-gh-pages.sh ./pr-content/titles-generated --message "Deploy PR ${{ github.event.number }} preview" # Post one consolidated PR comment with build results, preview link, and CQA checklist. # Preview link is always shown; marked stale when title build failed (HTML not generated). diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh index 3ca5a6d4536..2037ada969f 100755 --- a/build/scripts/deploy-gh-pages.sh +++ b/build/scripts/deploy-gh-pages.sh @@ -20,7 +20,7 @@ # Branch deploys clean up merged/closed PR dirs and deleted branch dirs. # PR deploys only update content and pulls.html — no cleanup. # -# Usage: deploy-gh-pages.sh --publish-dir [--message ] +# Usage: deploy-gh-pages.sh [--message ] # # Environment: GITHUB_TOKEN, GITHUB_REPOSITORY (set by GitHub Actions) @@ -31,22 +31,17 @@ RELEASE_NOTES_BASE="https://red-hat-developers-documentation.pages.redhat.com/re # ── Parse arguments ────────────────────────────────────────────────────────── -PUBLISH_DIR="" -COMMIT_MSG="Deploy to GitHub Pages" +PUBLISH_DIR="${1:?Usage: deploy-gh-pages.sh [--message ]}" +shift +COMMIT_MSG="Deploy to GitHub Pages" while [[ $# -gt 0 ]]; do case "$1" in - --publish-dir) PUBLISH_DIR="$2"; shift 2 ;; --message) COMMIT_MSG="$2"; shift 2 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done -if [[ -z "$PUBLISH_DIR" ]]; then - echo "Usage: deploy-gh-pages.sh --publish-dir [--message ]" >&2 - exit 1 -fi - PUBLISH_DIR="$(cd "$PUBLISH_DIR" && pwd)" : "${GITHUB_TOKEN:?GITHUB_TOKEN is required}" : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md index 55dc0488c3f..8d1d7b7047d 100644 --- a/docs/github-publication-workflow.md +++ b/docs/github-publication-workflow.md @@ -82,10 +82,10 @@ Handles deployment of built content to the `gh-pages` branch, including cleanup **Sequence:** -1. Detect the branch directory from `--publish-dir` (single top-level directory, e.g., `main/`, `pr-123/`). +1. Detect the branch directory from `` (single top-level directory, e.g., `main/`, `pr-123/`). 2. Create a temporary git repo with `github-actions[bot]` identity. 3. Fetch `gh-pages` (shallow, depth=1) or create an orphan if it does not exist. -4. Copy `--publish-dir` contents into the working tree. +4. Copy `` contents into the working tree. 5. For branch deploys: run cleanup (see Cleanup section below). 6. Regenerate indexes from current directories on `gh-pages` (see below). 7. Stage all changes (content + cleanup deletions + indexes), commit, and push. @@ -102,10 +102,10 @@ Branch deploys regenerate both indexes. PR deploys regenerate `pulls.html` only. ```bash # Branch deploy -bash build/scripts/deploy-gh-pages.sh --publish-dir ./titles-generated --message "Deploy main" +bash build/scripts/deploy-gh-pages.sh ./titles-generated --message "Deploy main" # PR deploy (from pr.yml, using trusted scripts) -bash trusted-scripts/build/scripts/deploy-gh-pages.sh --publish-dir ./pr-content/titles-generated --message "Deploy PR 123 preview" +bash trusted-scripts/build/scripts/deploy-gh-pages.sh ./pr-content/titles-generated --message "Deploy PR 123 preview" ``` ### Branch vs PR deploys From d973368c4a0de860041bd7e2e642b852ac3e0aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:25:28 +0200 Subject: [PATCH 16/18] perf: cache git ls-remote in cleanup to avoid N+1 remote calls Branch cleanup now fetches all remote heads in a single call instead of one ls-remote per directory. Also adds cross-reference to orchestrator's getReleaseNotesLink() for maintainability. Co-Authored-By: Claude Opus 4.6 --- build/scripts/deploy-gh-pages.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh index 2037ada969f..fd51d70e871 100755 --- a/build/scripts/deploy-gh-pages.sh +++ b/build/scripts/deploy-gh-pages.sh @@ -135,15 +135,17 @@ cleanup() { fi done - # Branch cleanup: remove directories for deleted remote branches (rhdh-*, 1.*.x, etc.) - # No pattern list needed — we check if each branch still exists on the remote + # Branch cleanup: remove directories for deleted remote branches + local remote_branches + remote_branches="$(git -C "$DEPLOY_DIR" ls-remote --heads origin 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||')" + for d in "$DEPLOY_DIR"/*/; do [[ -d "$d" ]] || continue local dir_name dir_name="$(basename "$d")" [[ "$dir_name" == pr-* || "$dir_name" == .* ]] && continue - if ! git -C "$DEPLOY_DIR" ls-remote --heads origin "$dir_name" 2>/dev/null | grep -q .; then + if ! grep -qx "$dir_name" <<< "$remote_branches"; then echo "Removing $dir_name (branch no longer exists on remote)" rm -rf "$d" fi @@ -152,6 +154,7 @@ cleanup() { # ── Index generation ───────────────────────────────────────────────────────── +# See also: getReleaseNotesLink() in build-orchestrator.js (per-title links) release_notes_url() { local branch="$1" if [[ "$branch" == "main" ]]; then From 171f4a3c0a38056353261d76d86bf960753e97ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:26:02 +0200 Subject: [PATCH 17/18] fix: remove dead orphan branch fallback from fetch_gh_pages The gh-pages branch has existed for years; the orphan creation path was unreachable code. Co-Authored-By: Claude Opus 4.6 --- build/scripts/deploy-gh-pages.sh | 10 ++-------- docs/github-publication-workflow.md | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh index fd51d70e871..1e0f2b2bc8c 100755 --- a/build/scripts/deploy-gh-pages.sh +++ b/build/scripts/deploy-gh-pages.sh @@ -81,15 +81,9 @@ git -C "$DEPLOY_DIR" config "http.${REPO_URL}.extraHeader" "Authorization: Basic # ── Core functions ─────────────────────────────────────────────────────────── -# gh-pages exists in practice; orphan path is a bootstrap safety net fetch_gh_pages() { - if git -C "$DEPLOY_DIR" fetch origin gh-pages --depth=1 2>/dev/null; then - git -C "$DEPLOY_DIR" checkout -B gh-pages FETCH_HEAD - else - echo "gh-pages branch does not exist, creating orphan" - git -C "$DEPLOY_DIR" checkout --orphan gh-pages - git -C "$DEPLOY_DIR" rm -rf . 2>/dev/null || true - fi + git -C "$DEPLOY_DIR" fetch origin gh-pages --depth=1 + git -C "$DEPLOY_DIR" checkout -B gh-pages FETCH_HEAD } apply_content() { diff --git a/docs/github-publication-workflow.md b/docs/github-publication-workflow.md index 8d1d7b7047d..23d089fd7ec 100644 --- a/docs/github-publication-workflow.md +++ b/docs/github-publication-workflow.md @@ -84,7 +84,7 @@ Handles deployment of built content to the `gh-pages` branch, including cleanup 1. Detect the branch directory from `` (single top-level directory, e.g., `main/`, `pr-123/`). 2. Create a temporary git repo with `github-actions[bot]` identity. -3. Fetch `gh-pages` (shallow, depth=1) or create an orphan if it does not exist. +3. Fetch `gh-pages` (shallow, depth=1). 4. Copy `` contents into the working tree. 5. For branch deploys: run cleanup (see Cleanup section below). 6. Regenerate indexes from current directories on `gh-pages` (see below). From d6321488171518ca9cb2ae9b9d0c034f42501c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:30:50 +0200 Subject: [PATCH 18/18] fix: add explicit return statements to bash functions (S7682) SonarCloud S7682 flags implicit returns in bash functions as unclear intent. Adding explicit return 0 to all five flagged functions. Co-Authored-By: Claude Opus 4.6 --- build/scripts/deploy-gh-pages.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build/scripts/deploy-gh-pages.sh b/build/scripts/deploy-gh-pages.sh index 1e0f2b2bc8c..48f06f3a5bf 100755 --- a/build/scripts/deploy-gh-pages.sh +++ b/build/scripts/deploy-gh-pages.sh @@ -84,10 +84,12 @@ git -C "$DEPLOY_DIR" config "http.${REPO_URL}.extraHeader" "Authorization: Basic fetch_gh_pages() { git -C "$DEPLOY_DIR" fetch origin gh-pages --depth=1 git -C "$DEPLOY_DIR" checkout -B gh-pages FETCH_HEAD + return 0 } apply_content() { cp -a "$PUBLISH_DIR"/. "$DEPLOY_DIR"/ + return 0 } # ── Cleanup (branch deploys only) ──────────────────────────────────────────── @@ -144,6 +146,7 @@ cleanup() { rm -rf "$d" fi done + return 0 } # ── Index generation ───────────────────────────────────────────────────────── @@ -159,6 +162,7 @@ release_notes_url() { echo "${RELEASE_NOTES_BASE}/release-${major}-${minor}/index.html" fi fi + return 0 } regenerate_indexes() { @@ -199,6 +203,7 @@ EOF ${pr_items} EOF + return 0 } # ── Stage, commit, push ─────────────────────────────────────────────────────