From 635bb0fc5bf0a4002f51f461cac4ca226a8a0261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 15:46:11 +0200 Subject: [PATCH 01/10] [RHDHBUGS-3010]: Fix CQA-14 PR build destruction, rewrite deploy as Node.js 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 ------ 7 files changed, 370 insertions(+), 203 deletions(-) create mode 100644 build/scripts/deploy-gh-pages.js delete mode 100755 build/scripts/deploy-gh-pages.sh 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 From d1b1abcce46c01894332fd3a79475d2b45bd59d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:49:02 +0200 Subject: [PATCH 02/10] 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 --- .github/workflows/pr.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 89e93e4ae0d..a7dde3ee5a4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -148,10 +148,6 @@ jobs: for f in build/scripts/build-ccutil.sh build/scripts/build-orchestrator.js build/scripts/error-patterns.json lychee.toml .lycheeignore; do if [[ -f "trusted-scripts/$f" ]]; then cp "trusted-scripts/$f" "pr-content/$f"; 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 f3ed68eea3a684e7e147a9a93039b73efe3e63d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 14:50:06 +0200 Subject: [PATCH 03/10] 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 --- 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 "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 4ba0ad584141e159aefcb5a511dd68fcbd520a59 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/10] 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 67e28826486b57a657c9fa26e8dca4f3ba01db3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 15:51:10 +0200 Subject: [PATCH 05/10] 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 --- .github/workflows/build-asciidoc.yml | 4 +- .github/workflows/shellcheck.yml | 11 +++++- build/scripts/deploy-gh-pages.js | 57 ++++++++++++++++------------ 3 files changed, 43 insertions(+), 29 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<> $GITHUB_OUTPUT echo "$CHANGED_FILES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT 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); -}); +} From 580cf4d3da6a7199647aa3e31fa4e189c2a397dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 16:20:58 +0200 Subject: [PATCH 06/10] 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 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-asciidoc.yml | 2 +- build/scripts/build-orchestrator.js | 26 ++++++++++++++----- .../cqa/checks/cqa-14-no-broken-links.js | 11 +++++--- 3 files changed, 28 insertions(+), 11 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/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 From 10b315f54c8913d6f0a68c28ae4d80d113e0cc4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 16:34:46 +0200 Subject: [PATCH 07/10] fix: suppress SonarCloud S4036 on git rev-parse in CQA-14 Co-Authored-By: Claude Opus 4.6 --- 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 045abd6b54e5bdd57d4e1515eaa208e0e8830c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 16:55:24 +0200 Subject: [PATCH 08/10] 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 --- 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 [--message ] @@ -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 64551720edd7b8526f7833b5b12c492f2d5cf365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:14:43 +0200 Subject: [PATCH 09/10] 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 --- .github/workflows/build-asciidoc.yml | 2 +- .github/workflows/pr.yml | 14 +- build/scripts/deploy-gh-pages.js | 325 --------------------------- build/scripts/deploy-gh-pages.sh | 291 ++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 332 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 a7dde3ee5a4..9e59d00736a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -90,6 +90,7 @@ jobs: with: ref: ${{ github.event.pull_request.base.ref }} path: trusted-scripts + sparse-checkout: build/scripts - name: Checkout PR branch for content to build uses: actions/checkout@v4 @@ -104,7 +105,7 @@ jobs: # update sudo apt-get update -y || true # install - sudo apt-get -y -q install podman && podman --version + sudo apt-get -y -q install podman rsync && podman --version echo "GIT_BRANCH=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_ENV - name: Install lychee @@ -143,10 +144,11 @@ jobs: CQA_BASE_REF: base/${{ github.event.pull_request.base.ref }} run: | echo "Building PR ${{ github.event.pull_request.number }}" - # Copy trusted build scripts from base branch (not content) - # Guard each file — older branches may not have them - for f in build/scripts/build-ccutil.sh build/scripts/build-orchestrator.js build/scripts/error-patterns.json lychee.toml .lycheeignore; do - if [[ -f "trusted-scripts/$f" ]]; then cp "trusted-scripts/$f" "pr-content/$f"; fi + rm -rf pr-content/build/scripts + rsync -az trusted-scripts/build/scripts pr-content/build/ + # some files are new in main/1.10, so check if they exist before copying + for f in trusted-scripts/.lycheeignore trusted-scripts/lychee.toml; do + if [[ -f $f ]]; then rsync -az $f pr-content/; fi done touch pr-content/.lycheecache cd pr-content @@ -193,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/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 [--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'; - -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 = `
  • ${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); -} - -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..3ca5a6d4536 --- /dev/null +++ b/build/scripts/deploy-gh-pages.sh @@ -0,0 +1,291 @@ +#!/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 ─────────────────────────────────────────────────────────── + +# 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 +} + +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 (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 + 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 From 049b13800666b31029ef78d7f73e081ca34a3323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabrice=20Flore-Th=C3=A9bault?= Date: Fri, 24 Apr 2026 17:19:23 +0200 Subject: [PATCH 10/10] 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 ++++--------- 3 files changed, 6 insertions(+), 11 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}"