@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.
Files changed (2) hide show
  1. package/index.js +289 -0
  2. 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
+ }