@inditextech/docouture-publish-gh-pages 0.1.0-SNAPSHOT.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +289 -0
- package/package.json +25 -0
package/index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// The GitHub Pages `docouture publish` driver. Called by
|
|
4
|
+
// @inditextech/docouture-cli's `publish` command as
|
|
5
|
+
// `require('@inditextech/docouture-publish-gh-pages')(dir, options)` — a plain
|
|
6
|
+
// function, not an Antora extension. Earlier design hooked Antora's own
|
|
7
|
+
// `sitePublished` pipeline event instead (a publish-target `antora.extensions`
|
|
8
|
+
// entry); this package is deliberately NOT that anymore; publishing is a CLI
|
|
9
|
+
// concern (`docouture publish gh-pages`), decoupled from `antora build` — a site
|
|
10
|
+
// can build without publishing, and re-publish an already-built `build/site`
|
|
11
|
+
// without rebuilding.
|
|
12
|
+
//
|
|
13
|
+
// `dir` is the already-built site directory — resolved by the CLI (reading
|
|
14
|
+
// `output.dir` out of the site's own antora-playbook.yml, defaulting to
|
|
15
|
+
// Antora's own `build/site`), not by this package. This package's only job
|
|
16
|
+
// is pushing that directory to a branch.
|
|
17
|
+
//
|
|
18
|
+
// THREE HARD-LEARNED FIXES BAKED IN HERE, all discovered from the same
|
|
19
|
+
// symptom: `docouture-publish.yml` reported success ("GitHub Pages publish
|
|
20
|
+
// complete") while the `gh-pages` branch was never actually created/updated.
|
|
21
|
+
//
|
|
22
|
+
// 1. DEFAULT GIT IDENTITY. `gh-pages`'s own commit step needs a
|
|
23
|
+
// `user.name`/`user.email` somewhere. A fresh GitHub Actions runner has
|
|
24
|
+
// none configured, and nothing here supplied one unless the caller
|
|
25
|
+
// passed `--user-name`/`--user-email` explicitly — so the very first
|
|
26
|
+
// `git commit` inside gh-pages's scratch clone failed with `fatal:
|
|
27
|
+
// empty ident name`. `DEFAULT_USER` below is the standard
|
|
28
|
+
// github-actions[bot] identity (same one other gh-pages-deploy actions
|
|
29
|
+
// use), applied whenever the caller doesn't supply their own.
|
|
30
|
+
//
|
|
31
|
+
// 2. THE SWALLOWED FAILURE. `gh-pages@6.3.0`'s own `publish()`
|
|
32
|
+
// (lib/index.js) ends its promise chain with
|
|
33
|
+
// `.then(() => done(), (error) => { ...; done(error) })` — the
|
|
34
|
+
// rejection handler never re-throws, so the returned promise RESOLVES
|
|
35
|
+
// even when a step failed (the identity failure above, a rejected
|
|
36
|
+
// push, or its own "no files matched" early-return, which doesn't even
|
|
37
|
+
// return a promise at all). With no callback passed, its internal
|
|
38
|
+
// default (`err => { if (err) log(err.message) }`, gated behind
|
|
39
|
+
// `util.debuglog` and therefore invisible without `NODE_DEBUG=gh-pages`)
|
|
40
|
+
// swallowed the real error entirely. `await ghpages.publish(dir,
|
|
41
|
+
// opts)` therefore never threw, no matter what actually happened.
|
|
42
|
+
// Fixed below by passing our OWN callback and settling a wrapping
|
|
43
|
+
// promise from it, so any failure gh-pages reports through the
|
|
44
|
+
// callback surfaces as a real rejection here.
|
|
45
|
+
//
|
|
46
|
+
// 3. THE DIRTY-BRANCH RISK ON FIRST PUBLISH. When the target branch
|
|
47
|
+
// doesn't exist on the remote yet, `gh-pages`'s own `Git.clone()`
|
|
48
|
+
// (lib/git.js) does `git clone --branch <branch> --single-branch`,
|
|
49
|
+
// which FAILS (no such branch), and falls back to a full clone of the
|
|
50
|
+
// remote's default branch instead — typically `main`. It then does
|
|
51
|
+
// `git checkout --orphan <branch>`, which detaches history but leaves
|
|
52
|
+
// the working tree exactly as it was — full of `main`'s files. The
|
|
53
|
+
// "Removing files" step gh-pages runs next is supposed to clear all of
|
|
54
|
+
// that out before copying in the new build, but its own
|
|
55
|
+
// `globby.sync(options.remove, { cwd })` call never passes `dot:
|
|
56
|
+
// true` — so any dotfile/dot-directory from `main` (`.github/`,
|
|
57
|
+
// `.gitignore`, ...) survives into the very first `gh-pages` commit.
|
|
58
|
+
// Fixed below by pre-creating the branch as a genuinely empty orphan
|
|
59
|
+
// commit ourselves, whenever it doesn't already exist remotely, BEFORE
|
|
60
|
+
// calling into `gh-pages` at all — its own `--branch --single-branch`
|
|
61
|
+
// clone then succeeds normally and the risky fallback path, and the
|
|
62
|
+
// dotfile-cleanup gap along with it, is never reached.
|
|
63
|
+
//
|
|
64
|
+
// A fourth, unrelated fix lives in `ghpagesOptions.nojekyll` below: GitHub
|
|
65
|
+
// Pages runs Jekyll by default, which excludes any `_`-prefixed directory
|
|
66
|
+
// from what it serves — exactly where Antora's default UI bundle output
|
|
67
|
+
// lives (`/_/css/...`, `/_/js/...`). Without a `.nojekyll` file at the
|
|
68
|
+
// branch root, every one of those assets 404s. `gh-pages` writes that file
|
|
69
|
+
// itself when `options.nojekyll` is set; nothing here was ever passing it.
|
|
70
|
+
|
|
71
|
+
const { execFile } = require('node:child_process')
|
|
72
|
+
const { promisify } = require('node:util')
|
|
73
|
+
const { mkdtemp, rm } = require('node:fs/promises')
|
|
74
|
+
const os = require('node:os')
|
|
75
|
+
const path = require('node:path')
|
|
76
|
+
|
|
77
|
+
const execFileAsync = promisify(execFile)
|
|
78
|
+
|
|
79
|
+
const DEFAULT_USER = {
|
|
80
|
+
name: 'github-actions[bot]',
|
|
81
|
+
email: '41898282+github-actions[bot]@users.noreply.github.com',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertSafeGitRemote(value, name = 'remote') {
|
|
85
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
86
|
+
throw new Error(`Invalid ${name}: expected a non-empty string`)
|
|
87
|
+
}
|
|
88
|
+
if (value.startsWith('-')) {
|
|
89
|
+
throw new Error(`Invalid ${name}: must not start with '-'`)
|
|
90
|
+
}
|
|
91
|
+
if (/[\r\n\t ]/.test(value)) {
|
|
92
|
+
throw new Error(`Invalid ${name}: must not contain whitespace or control characters`)
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
/^(?:https?:\/\/|ssh:\/\/|git:\/\/)/.test(value) || // URL forms
|
|
96
|
+
/^[A-Za-z0-9._-]+$/.test(value) || // remote name, e.g. origin
|
|
97
|
+
/^[^@\s]+@[^:\s]+:[^\s]+$/.test(value) // scp-like, e.g. git@github.com:org/repo.git
|
|
98
|
+
) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Invalid ${name}: unsupported remote format`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Real git plumbing used to pre-create an empty orphan branch. Exposed as
|
|
106
|
+
* its own object — rather than inlined — so tests can inject a fake in its
|
|
107
|
+
* place, the same seam-over-mock reasoning as the `ghpages` parameter below
|
|
108
|
+
* (a real `git` binary is not something a unit test should shell out to).
|
|
109
|
+
*/
|
|
110
|
+
async function assertSafeGitBranch(branch, fieldName = 'branch') {
|
|
111
|
+
if (typeof branch !== 'string' || branch.length === 0) {
|
|
112
|
+
throw new Error(`Invalid ${fieldName}: expected a non-empty string`)
|
|
113
|
+
}
|
|
114
|
+
if (branch.startsWith('-')) {
|
|
115
|
+
throw new Error(`Invalid ${fieldName}: must not start with '-'`)
|
|
116
|
+
}
|
|
117
|
+
// Whitelist guard, checked ahead of (and independently from) the
|
|
118
|
+
// `check-ref-format` shellout below. Static analysis (CodeQL's
|
|
119
|
+
// second-order command injection query) doesn't treat "validated by
|
|
120
|
+
// shelling out to `git check-ref-format`" as a sanitizer — it only
|
|
121
|
+
// recognises inline whitelist checks like this one, the same pattern
|
|
122
|
+
// `assertSafeGitRemote` above already uses for `remote`. Functionally
|
|
123
|
+
// this also closes the actual gap: it rejects any value containing
|
|
124
|
+
// characters (spaces, `=`, control chars, …) that could turn a
|
|
125
|
+
// `--upload-pack=<cmd>`-shaped branch name into an argument `git
|
|
126
|
+
// ls-remote`/`git push` would interpret as a flag rather than a ref.
|
|
127
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch)) {
|
|
128
|
+
throw new Error(`Invalid ${fieldName}: unsupported branch name format`)
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await execFileAsync('git', ['check-ref-format', '--branch', branch])
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new Error(`Invalid ${fieldName}: ${branch}`, { cause: err })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const defaultGit = {
|
|
138
|
+
/** @returns {Promise<boolean>} Whether `branch` already exists on `remote`. */
|
|
139
|
+
async branchExists(remote, branch) {
|
|
140
|
+
try {
|
|
141
|
+
await execFileAsync('git', ['ls-remote', '--exit-code', remote, branch])
|
|
142
|
+
return true
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err && err.code === 2) return false
|
|
145
|
+
throw err
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** Creates `branch` on `remote` as a single, empty, historyless commit. */
|
|
150
|
+
async createOrphanBranch(remote, branch, user) {
|
|
151
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'docouture-gh-pages-'))
|
|
152
|
+
try {
|
|
153
|
+
await execFileAsync('git', ['init', '--quiet', dir])
|
|
154
|
+
await execFileAsync('git', ['checkout', '--quiet', '--orphan', branch], { cwd: dir })
|
|
155
|
+
await execFileAsync('git', ['config', 'user.email', user.email], { cwd: dir })
|
|
156
|
+
await execFileAsync('git', ['config', 'user.name', user.name], { cwd: dir })
|
|
157
|
+
await execFileAsync('git', ['commit', '--quiet', '--allow-empty', '-m', 'Initial gh-pages branch'], {
|
|
158
|
+
cwd: dir,
|
|
159
|
+
})
|
|
160
|
+
await execFileAsync('git', ['push', '--quiet', remote, `HEAD:refs/heads/${branch}`], { cwd: dir })
|
|
161
|
+
} finally {
|
|
162
|
+
await rm(dir, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} dir - Absolute path to the already-built site.
|
|
169
|
+
* @param {Object} [options]
|
|
170
|
+
* @param {string} [options.branch] - Branch to publish to. Default `gh-pages`.
|
|
171
|
+
* @param {string} [options.remote] - Remote to push that branch to. Default `origin`.
|
|
172
|
+
* @param {string} [options.repo] - A full remote URL, overriding `remote`
|
|
173
|
+
* entirely. Defaults to
|
|
174
|
+
* `https://x-access-token:<token>@github.com/<GITHUB_REPOSITORY>.git` when
|
|
175
|
+
* a token is available and `GITHUB_REPOSITORY` is set (both true on every
|
|
176
|
+
* GitHub Actions run) — set this explicitly to publish somewhere else (a
|
|
177
|
+
* different repo, a non-GitHub remote, over SSH).
|
|
178
|
+
* @param {string} [options.token] - Falls back to `process.env.GITHUB_TOKEN`.
|
|
179
|
+
* Required unless `repo` already embeds its own credentials (e.g. an SSH
|
|
180
|
+
* URL) — see "Skipping" below.
|
|
181
|
+
* @param {string} [options.cname] - Written as a `CNAME` file, for a custom domain.
|
|
182
|
+
* @param {boolean} [options.dotfiles] - Publish dotfiles too. Default `false`.
|
|
183
|
+
* @param {boolean} [options.nojekyll] - Write a `.nojekyll` file at the branch
|
|
184
|
+
* root, so GitHub Pages serves `_`-prefixed paths (Antora's default UI
|
|
185
|
+
* bundle output dir) instead of stripping them via its default Jekyll
|
|
186
|
+
* processing. Default `true` — pass `false` explicitly to opt back into
|
|
187
|
+
* Jekyll processing.
|
|
188
|
+
* @param {string} [options.message] - The commit message. Default `Publish site`.
|
|
189
|
+
* @param {{name: string, email: string}} [options.user] - Commit author.
|
|
190
|
+
* Defaults to the `github-actions[bot]` identity when not supplied — see
|
|
191
|
+
* fix 1 in this file's header comment for why a default is needed at all.
|
|
192
|
+
* @param {boolean} [options.force] - Publish even when `GITHUB_ACTIONS` is
|
|
193
|
+
* not `'true'`. See "Skipping" below.
|
|
194
|
+
* @param {Object} [options.logger] - `{ warn, info }`. Defaults to `console`.
|
|
195
|
+
* @param {Object} [ghpages] - The `gh-pages` client to publish through.
|
|
196
|
+
* Defaults to the real `gh-pages` package; overridden in tests, since
|
|
197
|
+
* `vi.mock` cannot intercept a plain CommonJS `require()` of a dependency
|
|
198
|
+
* the way it does an ES module import — this parameter is the seam instead.
|
|
199
|
+
* @param {Object} [git] - The git plumbing client used to pre-create an
|
|
200
|
+
* empty orphan branch when it doesn't exist remotely yet (fix 3 above).
|
|
201
|
+
* Defaults to `defaultGit`; overridden in tests for the same reason as
|
|
202
|
+
* `ghpages`.
|
|
203
|
+
* @returns {Promise<boolean>} Whether the push actually happened.
|
|
204
|
+
*/
|
|
205
|
+
module.exports = async function publishGhPages(dir, options = {}, ghpages = require('gh-pages'), git = defaultGit) {
|
|
206
|
+
const logger = options.logger || console
|
|
207
|
+
|
|
208
|
+
// Two independent guards, both meant to make an accidental push
|
|
209
|
+
// impossible rather than merely unlikely:
|
|
210
|
+
//
|
|
211
|
+
// - No token, no push. Running this by hand with no `GITHUB_TOKEN` and
|
|
212
|
+
// no `options.token` can never reach the git operations below.
|
|
213
|
+
// - Not GitHub Actions, no push, UNLESS `options.force` says so
|
|
214
|
+
// explicitly. `GITHUB_ACTIONS=true` is set by every GitHub Actions job
|
|
215
|
+
// automatically — nothing to configure there — so the common case
|
|
216
|
+
// (docouture-publish.yml, in GitHub Actions) "just works". Any other CI
|
|
217
|
+
// vendor, or a deliberate publish from a local machine, has to opt in
|
|
218
|
+
// with `--force` — a choice made explicitly at the call site, never a
|
|
219
|
+
// side effect nobody asked for.
|
|
220
|
+
const token = options.token || process.env.GITHUB_TOKEN
|
|
221
|
+
if (!token) {
|
|
222
|
+
logger.warn('Skipping GitHub Pages publish: no token (set GITHUB_TOKEN or pass --token)')
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
if (process.env.GITHUB_ACTIONS !== 'true' && !options.force) {
|
|
226
|
+
logger.warn('Skipping GitHub Pages publish: not running in GitHub Actions (pass --force to publish anyway)')
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const branch = options.branch || 'gh-pages'
|
|
231
|
+
await assertSafeGitBranch(branch, 'branch')
|
|
232
|
+
const remote = options.remote || 'origin'
|
|
233
|
+
const repo =
|
|
234
|
+
options.repo ||
|
|
235
|
+
(process.env.GITHUB_REPOSITORY
|
|
236
|
+
? `https://x-access-token:${token}@github.com/${process.env.GITHUB_REPOSITORY}.git`
|
|
237
|
+
: undefined)
|
|
238
|
+
const user = options.user || DEFAULT_USER
|
|
239
|
+
|
|
240
|
+
// Fix 3: pre-create the branch as an empty orphan commit when it doesn't
|
|
241
|
+
// exist remotely yet, so gh-pages's own clone never falls back to `main`.
|
|
242
|
+
// `repo || remote` mirrors gh-pages's own `getRepo()` fallback (an
|
|
243
|
+
// explicit repo URL, or else whatever the named remote resolves to
|
|
244
|
+
// locally) — either is a valid target for `git ls-remote`/`git push`.
|
|
245
|
+
const remoteTarget = repo || remote
|
|
246
|
+
assertSafeGitRemote(remoteTarget, 'repo/remote')
|
|
247
|
+
const branchAlreadyExists = await git.branchExists(remoteTarget, branch)
|
|
248
|
+
if (!branchAlreadyExists) {
|
|
249
|
+
logger.info(`Branch '${branch}' does not exist on '${remoteTarget}' yet; creating it as an empty orphan branch`)
|
|
250
|
+
await git.createOrphanBranch(remoteTarget, branch, user)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const ghpagesOptions = {
|
|
254
|
+
branch,
|
|
255
|
+
remote,
|
|
256
|
+
repo,
|
|
257
|
+
dotfiles: options.dotfiles || false,
|
|
258
|
+
nojekyll: options.nojekyll !== false,
|
|
259
|
+
message: options.message || 'Publish site',
|
|
260
|
+
user,
|
|
261
|
+
cname: options.cname,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
logger.info(`Publishing ${dir} to branch '${ghpagesOptions.branch}'`)
|
|
265
|
+
|
|
266
|
+
// Fix 2: never trust ghpages.publish()'s own return value alone — pass an
|
|
267
|
+
// explicit callback and settle our own promise from it. In the normal
|
|
268
|
+
// (non-buggy) case gh-pages both invokes this callback AND resolves its
|
|
269
|
+
// own returned promise for the same outcome, so `settled` guards against
|
|
270
|
+
// double-resolution; in the buggy cases described above (a resolved
|
|
271
|
+
// promise on failure, or no promise at all), the callback is the only
|
|
272
|
+
// place the real error ever surfaces.
|
|
273
|
+
await new Promise((resolve, reject) => {
|
|
274
|
+
let settled = false
|
|
275
|
+
const finish = (err) => {
|
|
276
|
+
if (settled) return
|
|
277
|
+
settled = true
|
|
278
|
+
if (err) reject(err instanceof Error ? err : new Error(String(err)))
|
|
279
|
+
else resolve()
|
|
280
|
+
}
|
|
281
|
+
const maybePromise = ghpages.publish(dir, ghpagesOptions, finish)
|
|
282
|
+
if (maybePromise && typeof maybePromise.then === 'function') {
|
|
283
|
+
maybePromise.then(() => finish(), finish)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
logger.info('GitHub Pages publish complete')
|
|
288
|
+
return true
|
|
289
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inditextech/docouture-publish-gh-pages",
|
|
3
|
+
"version": "0.1.0-SNAPSHOT.40.1",
|
|
4
|
+
"description": "docouture publish driver: pushes a built site to a GitHub Pages branch. Invoked by `docouture publish gh-pages`, not an Antora extension.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/InditexTech/test-antoradocs.git",
|
|
8
|
+
"directory": "code/packages/publish-gh-pages"
|
|
9
|
+
},
|
|
10
|
+
"license": "MPL-2.0",
|
|
11
|
+
"main": "index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"gh-pages": "~6.3.0"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"lint": "eslint .",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
}
|
|
25
|
+
}
|