88 *
99 * SPDX-License-Identifier: EPL-2.0
1010 *
11- * Replaces deploy-gh-pages.sh. Deploys content to the gh-pages branch
12- * with retry on push rejection, PR/branch cleanup, and index regeneration.
11+ * Deploys build output (titles-generated/) to the gh-pages branch.
12+ *
13+ * Flow:
14+ * 1. Create a temp git repo, fetch gh-pages (shallow)
15+ * 2. Copy --publish-dir content into the working tree
16+ * 3. For branch deploys: clean up stale PR and branch directories
17+ * 4. Regenerate index.html (branch list) and pulls.html (PR list)
18+ * 5. Commit everything (content + cleanup + indexes) and push
19+ * 6. On push rejection: rebase and retry (max 3 attempts)
20+ *
21+ * Branch deploys clean up merged/closed PR dirs and deleted branch dirs.
22+ * PR deploys only update content and pulls.html — no cleanup.
1323 *
1424 * Usage:
1525 * node deploy-gh-pages.js --publish-dir <dir> [--message <msg>]
@@ -21,15 +31,10 @@ import { readdirSync, statSync, writeFileSync, existsSync, cpSync, rmSync, mkdte
2131import { join , resolve } from 'node:path' ;
2232import { tmpdir } from 'node:os' ;
2333import { execFileSync } from 'node:child_process' ;
24- import { get as httpsGet } from 'node:https' ;
25-
26- // ── Constants ────────────────────────────────────────────────────────────────
2734
2835const MAX_RETRIES = 3 ;
2936const RELEASE_NOTES_BASE = 'https://red-hat-developers-documentation.pages.redhat.com/red-hat-developer-hub-release-notes' ;
3037
31- // ── Helpers ──────────────────────────────────────────────────────────────────
32-
3338function git ( cwd , ...args ) {
3439 const result = execFileSync ( 'git' , args , { // NOSONAR: git is resolved from PATH in a controlled CI environment
3540 cwd,
@@ -49,39 +54,27 @@ function noStagedChanges(cwd) {
4954 }
5055}
5156
52- function getPRState ( owner , repo , prNumber ) {
53- return new Promise ( ( resolve , reject ) => {
54- const options = {
55- hostname : 'api.github.com' ,
56- path : `/repos/${ owner } /${ repo } /pulls/${ prNumber } ` ,
57+ async function getPRState ( owner , repo , prNumber ) {
58+ try {
59+ const res = await fetch ( `https://api.github.com/repos/${ owner } /${ repo } /pulls/${ prNumber } ` , {
5760 headers : {
5861 'Authorization' : `Bearer ${ process . env . GITHUB_TOKEN } ` ,
5962 'User-Agent' : 'deploy-gh-pages' ,
6063 'Accept' : 'application/vnd.github+json' ,
6164 } ,
62- } ;
63- httpsGet ( options , ( res ) => {
64- let data = '' ;
65- res . on ( 'data' , chunk => { data += chunk ; } ) ;
66- res . on ( 'end' , ( ) => {
67- if ( res . statusCode !== 200 ) {
68- console . log ( `GitHub API returned ${ res . statusCode } for PR ${ prNumber } ` ) ;
69- resolve ( 'unknown' ) ;
70- return ;
71- }
72- try {
73- const json = JSON . parse ( data ) ;
74- const closedState = json . merged ? 'merged' : 'closed' ;
75- resolve ( json . state === 'closed' ? closedState : 'open' ) ;
76- } catch { resolve ( 'unknown' ) ; }
77- } ) ;
78- res . on ( 'error' , ( ) => resolve ( 'unknown' ) ) ;
79- } ) . on ( 'error' , ( ) => resolve ( 'unknown' ) ) ;
80- } ) ;
65+ } ) ;
66+ if ( ! res . ok ) {
67+ console . log ( `GitHub API returned ${ res . status } for PR ${ prNumber } ` ) ;
68+ return 'unknown' ;
69+ }
70+ const json = await res . json ( ) ;
71+ if ( json . state !== 'closed' ) return 'open' ;
72+ return json . merged ? 'merged' : 'closed' ;
73+ } catch {
74+ return 'unknown' ;
75+ }
8176}
8277
83- // ── gh-pages branch setup ────────────────────────────────────────────────────
84-
8578function fetchOrCreateGhPages ( deployDir ) {
8679 try {
8780 git ( deployDir , 'fetch' , 'origin' , 'gh-pages' , '--depth=1' ) ;
@@ -97,20 +90,16 @@ function fetchOrCreateGhPages(deployDir) {
9790 }
9891}
9992
100- // ── Content application ──────────────────────────────────────────────────────
101-
102- function applyContent ( deployDir , publishDir , branchDir ) {
93+ function applyContent ( deployDir , publishDir ) {
10394 cpSync ( publishDir , deployDir , { recursive : true } ) ;
10495}
10596
106- // ── Cleanup ──────────────────────────────────────────────────────────────────
107-
10897async function cleanup ( deployDir ) {
10998 const [ owner , repo ] = process . env . GITHUB_REPOSITORY . split ( '/' ) ;
11099
111100 // PR cleanup: remove directories for merged/closed PRs
112101 const prDirs = readdirSync ( deployDir ) . filter ( d =>
113- d . startsWith ( 'pr-' ) && ! d . startsWith ( '.' ) && statSync ( join ( deployDir , d ) ) . isDirectory ( )
102+ d . startsWith ( 'pr-' ) && statSync ( join ( deployDir , d ) ) . isDirectory ( )
114103 ) ;
115104
116105 for ( const dir of prDirs ) {
@@ -142,8 +131,6 @@ async function cleanup(deployDir) {
142131 }
143132}
144133
145- // ── Index generation ─────────────────────────────────────────────────────────
146-
147134function getReleaseNotesUrl ( branch ) {
148135 if ( branch === 'main' ) return `${ RELEASE_NOTES_BASE } /main/index.html` ;
149136 const match = branch . match ( / ^ r e l e a s e - ( \d + ) \. ( \d + ) $ / ) ;
@@ -180,19 +167,16 @@ function regenerateIndex(deployDir, branchDir) {
180167 writeIndex ( deployDir , allDirs . filter ( d => d . startsWith ( 'pr-' ) ) , 'pulls.html' , 'PR Previews' , true ) ;
181168}
182169
183- // ── Push with retry ──────────────────────────────────────────────────────────
184-
185- async function stageAndCommit ( deployDir , publishDir , branchDir , message ) {
186- if ( ! branchDir . startsWith ( 'pr-' ) ) {
187- await cleanup ( deployDir ) ;
188- }
170+ function stageAndCommit ( deployDir , publishDir , branchDir , message ) {
189171 regenerateIndex ( deployDir , branchDir ) ;
190172
191- const publishEntries = readdirSync ( publishDir ) . filter ( e => ! e . startsWith ( '.' ) ) ;
192- const toStage = [ ...publishEntries ] ;
193- if ( existsSync ( join ( deployDir , 'index.html' ) ) ) toStage . push ( 'index.html' ) ;
194- if ( existsSync ( join ( deployDir , 'pulls.html' ) ) ) toStage . push ( 'pulls.html' ) ;
195- git ( deployDir , 'add' , '--force' , '--' , ...new Set ( toStage ) ) ;
173+ // Force-add publish entries and index files (.gitignore may exclude them)
174+ const forceEntries = new Set ( readdirSync ( publishDir ) . filter ( e => ! e . startsWith ( '.' ) ) ) ;
175+ for ( const f of [ 'index.html' , 'pulls.html' ] ) {
176+ if ( existsSync ( join ( deployDir , f ) ) ) forceEntries . add ( f ) ;
177+ }
178+ git ( deployDir , 'add' , '--force' , '--' , ...forceEntries ) ;
179+ // Stage deletions from cleanup
196180 git ( deployDir , 'add' , '-A' ) ;
197181
198182 if ( noStagedChanges ( deployDir ) ) {
@@ -210,6 +194,9 @@ async function stageAndCommit(deployDir, publishDir, branchDir, message) {
210194 return true ;
211195}
212196
197+ // On push rejection (concurrent deploy from another branch/PR), try rebase first.
198+ // If rebase conflicts (different branch touched same index files), reset and let
199+ // the retry loop rebuild from a fresh fetch.
213200function tryRebaseAndPush ( deployDir , attempt ) {
214201 try {
215202 git ( deployDir , 'pull' , '--rebase' , 'origin' , 'gh-pages' ) ;
@@ -231,12 +218,17 @@ function tryRebaseAndPush(deployDir, attempt) {
231218}
232219
233220async function pushWithRetry ( deployDir , publishDir , branchDir , message ) {
221+ // Cleanup runs once before retries so we don't make redundant API calls
222+ if ( ! branchDir . startsWith ( 'pr-' ) ) {
223+ await cleanup ( deployDir ) ;
224+ }
225+
234226 for ( let attempt = 1 ; attempt <= MAX_RETRIES ; attempt ++ ) {
235227 if ( attempt > 1 ) {
236- applyContent ( deployDir , publishDir , branchDir ) ;
228+ applyContent ( deployDir , publishDir ) ;
237229 }
238230
239- const hasChanges = await stageAndCommit ( deployDir , publishDir , branchDir , message ) ;
231+ const hasChanges = stageAndCommit ( deployDir , publishDir , branchDir , message ) ;
240232 if ( ! hasChanges ) return ;
241233
242234 try {
@@ -251,10 +243,7 @@ async function pushWithRetry(deployDir, publishDir, branchDir, message) {
251243 throw new Error ( `Deploy failed after ${ MAX_RETRIES } attempts` ) ;
252244}
253245
254- // ── Main ─────────────────────────────────────────────────────────────────────
255-
256246async function deploy ( ) {
257- // Parse args
258247 const args = process . argv . slice ( 2 ) ;
259248 let publishDir = null ;
260249 let message = 'Deploy to GitHub Pages' ;
@@ -280,7 +269,6 @@ async function deploy() {
280269
281270 publishDir = resolve ( publishDir ) ;
282271
283- // Validate env
284272 if ( ! process . env . GITHUB_TOKEN ) {
285273 console . error ( 'GITHUB_TOKEN is required (set by GitHub Actions)' ) ;
286274 process . exit ( 1 ) ;
@@ -290,7 +278,6 @@ async function deploy() {
290278 process . exit ( 1 ) ;
291279 }
292280
293- // Detect branchDir from publishDir
294281 const entries = readdirSync ( publishDir ) . filter ( e =>
295282 ! e . startsWith ( '.' ) && statSync ( join ( publishDir , e ) ) . isDirectory ( )
296283 ) ;
@@ -305,15 +292,11 @@ async function deploy() {
305292
306293 const branchDir = entries [ 0 ] ;
307294
308- // Diagnostics
309295 console . log ( `PUBLISH_DIR: ${ publishDir } ` ) ;
310296 console . log ( 'Top-level entries in PUBLISH_DIR:' ) ;
311- readdirSync ( publishDir )
312- . filter ( e => ! e . startsWith ( '.' ) )
313- . forEach ( e => console . log ( ` ${ e } ` ) ) ;
297+ entries . forEach ( e => console . log ( ` ${ e } ` ) ) ;
314298 console . log ( `Branch directory: ${ branchDir } ` ) ;
315299
316- // Create temp git repo
317300 const deployDir = mkdtempSync ( join ( tmpdir ( ) , 'deploy-' ) ) ;
318301 process . on ( 'exit' , ( ) => {
319302 try { rmSync ( deployDir , { recursive : true , force : true } ) ; } catch { }
@@ -322,18 +305,15 @@ async function deploy() {
322305 git ( deployDir , 'init' , '-q' ) ;
323306 git ( deployDir , 'config' , 'user.name' , 'github-actions[bot]' ) ;
324307 git ( deployDir , 'config' , 'user.email' , 'github-actions[bot]@users.noreply.github.com' ) ;
308+ // Auth via http.extraHeader keeps the token out of the remote URL (avoids leaking in logs)
325309 const repoUrl = `https://github.com/${ process . env . GITHUB_REPOSITORY } .git` ;
326310 git ( deployDir , 'remote' , 'add' , 'origin' , repoUrl ) ;
327311 const credentials = Buffer . from ( 'x-access-token:' + process . env . GITHUB_TOKEN ) . toString ( 'base64' ) ;
328312 git ( deployDir , 'config' , `http.${ repoUrl } .extraHeader` , `Authorization: Basic ${ credentials } ` ) ;
329313
330- // Fetch gh-pages
331314 fetchOrCreateGhPages ( deployDir ) ;
332315
333- // Apply content on first pass
334- applyContent ( deployDir , publishDir , branchDir ) ;
335-
336- // Push with retry handles cleanup, index regeneration, staging, commit, and push
316+ applyContent ( deployDir , publishDir ) ;
337317 await pushWithRetry ( deployDir , publishDir , branchDir , message ) ;
338318}
339319
0 commit comments