This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new e847f67331 Improve error feedback from failing documentation builds
(#8363)
e847f67331 is described below
commit e847f673319a668390c771831473ecd5ee6664e3
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Sep 14 11:53:40 2026 +0200
Improve error feedback from failing documentation builds (#8363)
---
.github/scripts/antora-report.mjs | 126 ++++++++++++++++++++++++++++++++++
.github/workflows/pr_build_docs.yml | 54 ++++++---------
.github/workflows/pr_docs_comment.yml | 99 ++++++++++++++++++++++++++
3 files changed, 246 insertions(+), 33 deletions(-)
diff --git a/.github/scripts/antora-report.mjs
b/.github/scripts/antora-report.mjs
new file mode 100644
index 0000000000..058095b0ac
--- /dev/null
+++ b/.github/scripts/antora-report.mjs
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/*
+ * Turn Antora's JSON log into GitHub output: error annotations on the
+ * offending .adoc lines, a job summary and, for a pull request, the sticky
+ * comment that pr_docs_comment.yml posts on the PR.
+ *
+ * node antora-report.mjs <antora.log> <report dir>
+ *
+ * Reads GITHUB_WORKSPACE and GITHUB_STEP_SUMMARY, plus PR_NUMBER, HEAD_REPO,
+ * HEAD_SHA and RUN_URL for the comment. Without PR_NUMBER no comment is
written.
+ */
+import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from
'node:fs'
+import { join } from 'node:path'
+
+const [logFile, reportDir] = process.argv.slice(2)
+const root = join(process.env.GITHUB_WORKSPACE ?? '', 'hop') + '/'
+
+const rows = []
+for (const line of readFileSync(logFile, 'utf8').split('\n')) {
+ if (!line.startsWith('{')) continue
+ let e
+ try {
+ e = JSON.parse(line)
+ } catch {
+ continue
+ }
+ rows.push({
+ level: e.level,
+ file: e.file?.path?.replace(root, '') ?? '',
+ line: e.file?.line ?? '',
+ msg: e.msg,
+ })
+}
+const errors = rows.filter((r) => r.level === 'error' || r.level === 'fatal')
+const warnings = rows.filter((r) => r.level === 'warn')
+
+// Annotations: only errors. GitHub shows at most 10 per kind per step and the
+// docs still carry ~150 pre-existing warnings.
+for (const r of errors) {
+ const where = r.file ? `file=${r.file},${r.line ? `line=${r.line},` : ''}` :
''
+ console.log(`::error ${where}title=Antora::${r.msg}`)
+}
+
+const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ')
+// docs/hop-user-manual/modules/ROOT/pages/x/y.adoc -> user-manual: x/y.adoc
+const short = (file) =>
file.replace(/^docs\/hop-([a-z]+-manual)\/modules\/ROOT\/pages\//, '$1: ')
+const table = (list, name) =>
+ list.length
+ ? [
+ '| File | Line | Message |',
+ '|---|---|---|',
+ ...list.map((r) => `| ${name(r)} | ${r.line} | ${cell(r.msg)} |`),
+ ].join('\n')
+ : '_none_'
+
+const summary = [
+ '## Antora build',
+ `${errors.length} error(s), ${warnings.length} warning(s)`,
+ '',
+ '### Errors',
+ table(errors, (r) => cell(r.file)),
+ '',
+ '<details><summary>Warnings</summary>',
+ '',
+ table(warnings, (r) => cell(r.file)),
+ '',
+ '</details>',
+ '',
+].join('\n')
+if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
+
+const { PR_NUMBER, HEAD_REPO, HEAD_SHA, RUN_URL } = process.env
+if (!PR_NUMBER || !reportDir) process.exit(0)
+
+const linked = (r) =>
+ r.file
+ ?
`[${cell(short(r.file))}](https://github.com/${HEAD_REPO}/blob/${HEAD_SHA}/${r.file}${r.line
? `#L${r.line}` : ''})`
+ : ''
+// The warnings are mostly pre-existing and not what the PR is about: keep them
+// folded and capped so the comment stays readable.
+const MAX_WARNINGS = 50
+const shown = warnings.slice(0, MAX_WARNINGS)
+const body = [
+ '<!-- hop-docs-check -->',
+ errors.length
+ ? `### :x: Documentation build failed with ${errors.length} error(s)`
+ : '### :white_check_mark: Documentation build passed',
+ '',
+ `Antora build of the manuals at ${HEAD_SHA.slice(0, 7)}: ` +
+ `${errors.length} error(s), ${warnings.length} warning(s) — [run
log](${RUN_URL})`,
+ ...(errors.length ? ['', table(errors, linked)] : []),
+ ...(warnings.length
+ ? [
+ '',
+ `<details><summary>${warnings.length} warning(s)` +
+ `${shown.length < warnings.length ? `, first ${MAX_WARNINGS} shown`
: ''}</summary>`,
+ '',
+ table(shown, linked),
+ '',
+ '</details>',
+ ]
+ : []),
+ '',
+].join('\n')
+
+mkdirSync(reportDir, { recursive: true })
+writeFileSync(join(reportDir, 'comment.md'), body)
+writeFileSync(join(reportDir, 'pr-number'), `${PR_NUMBER}\n`)
+writeFileSync(join(reportDir, 'error-count'), `${errors.length}\n`)
diff --git a/.github/workflows/pr_build_docs.yml
b/.github/workflows/pr_build_docs.yml
index d7e52dc65d..14a71fdfe4 100644
--- a/.github/workflows/pr_build_docs.yml
+++ b/.github/workflows/pr_build_docs.yml
@@ -96,7 +96,10 @@ jobs:
# pull request that is the merge commit of the PR onto main. Warnings are
# logged so authors can see them; only errors (unresolved xrefs, missing
# includes, malformed tables, ...) fail the build.
+ # shell: bash is not the default: the default is `bash -e {0}` without
+ # pipefail, which would let the `tee` below swallow Antora's exit code.
- name: Build the manuals
+ shell: bash
working-directory: hop-website
run: |
node tools/sync-tokens.mjs
@@ -105,42 +108,27 @@ jobs:
npx antora --log-level=warn --log-failure-level=error
--log-format=json \
--to-dir "$GITHUB_WORKSPACE/site" antora-playbook-hop.yml 2>&1 |
tee antora.log
- # Turn the structured log into PR annotations on the offending .adoc
lines
- # and a per-file summary. Only errors are annotated: GitHub shows at most
- # 10 per kind and the docs still carry ~150 pre-existing warnings.
+ # Error annotations on the offending .adoc lines, a job summary and, for
a
+ # pull request, the body of the PR comment. The comment itself is posted
+ # by pr_docs_comment.yml: this job runs with the read-only token of a
+ # fork pull request, so it can only hand the report over as an artifact.
- name: Report problems
if: ${{ !cancelled() }}
working-directory: hop-website
- run: |
- node - <<'JS'
- const fs = require('node:fs')
- const root = process.env.GITHUB_WORKSPACE + '/hop/'
- const rows = []
- for (const line of fs.readFileSync('antora.log',
'utf8').split('\n')) {
- if (!line.startsWith('{')) continue
- let e
- try { e = JSON.parse(line) } catch { continue }
- const file = e.file && e.file.path ? e.file.path.replace(root, '')
: ''
- const at = e.file && e.file.line ? e.file.line : ''
- rows.push({ level: e.level, file, at, msg: e.msg })
- if (e.level === 'error' || e.level === 'fatal') {
- const where = file ? `file=${file},line=${at || 1},` : ''
- console.log(`::error ${where}title=Antora::${e.msg}`)
- }
- }
- const errors = rows.filter(r => r.level === 'error' || r.level ===
'fatal')
- const warnings = rows.filter(r => r.level === 'warn')
- const table = list => list.length
- ? ['| File | Line | Message |', '|---|---|---|',
- ...list.map(r => `| ${r.file} | ${r.at} |
${r.msg.replace(/\|/g, '\\|')} |`)].join('\n')
- : '_none_'
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, [
- '## Antora build',
- `${errors.length} error(s), ${warnings.length} warning(s)`,
- '', '### Errors', table(errors),
- '', '<details><summary>Warnings</summary>', '', table(warnings),
'', '</details>', ''
- ].join('\n'))
- JS
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository
}}/actions/runs/${{ github.run_id }}
+ run: node "$GITHUB_WORKSPACE/hop/.github/scripts/antora-report.mjs"
antora.log "$GITHUB_WORKSPACE/report"
+
+ - name: Hand the report to the comment workflow
+ if: ${{ !cancelled() && github.event_name == 'pull_request' }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: docs-report
+ path: report
+ retention-days: 1
- name: Upload the rendered manuals
if: ${{ !cancelled() }}
diff --git a/.github/workflows/pr_docs_comment.yml
b/.github/workflows/pr_docs_comment.yml
new file mode 100644
index 0000000000..b3ede39cb7
--- /dev/null
+++ b/.github/workflows/pr_docs_comment.yml
@@ -0,0 +1,99 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+---
+
+name: Hop PR Comment (Documentation)
+
+# Posts the Antora errors of "Hop PR Build (Documentation)" as one sticky
+# comment on the pull request. That build runs on `pull_request` with the
+# read-only token every fork PR gets, so it cannot comment itself; it uploads
+# the report as an artifact and this workflow, which runs in the base
+# repository with write access, picks it up. Nothing from the PR is checked
+# out or executed here: the artifact is data, and the PR number it names is
+# checked against the run's own head commit before anything is posted.
+on:
+ workflow_run:
+ workflows: [ 'Hop PR Build (Documentation)' ]
+ types: [ completed ]
+
+permissions:
+ contents: read
+
+jobs:
+ comment:
+ name: Comment on the pull request
+ if: github.event.workflow_run.event == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ pull-requests: write
+
+ steps:
+ - name: Post or update the comment
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const fs = require('node:fs')
+ const run = context.payload.workflow_run
+
+ const { data: { artifacts } } = await
github.rest.actions.listWorkflowRunArtifacts({
+ ...context.repo, run_id: run.id, name: 'docs-report'
+ })
+ if (!artifacts.length) {
+ core.info('No docs-report artifact on this run, nothing to post')
+ return
+ }
+ const { data: zip } = await github.rest.actions.downloadArtifact({
+ ...context.repo, artifact_id: artifacts[0].id, archive_format:
'zip'
+ })
+ fs.writeFileSync('docs-report.zip', Buffer.from(zip))
+ await exec.exec('unzip', ['-o', 'docs-report.zip', '-d',
'docs-report'])
+
+ const read = f => fs.readFileSync(`docs-report/${f}`,
'utf8').trim()
+ const prNumber = Number(read('pr-number'))
+ const errorCount = Number(read('error-count'))
+ const body = read('comment.md')
+ if (!Number.isInteger(prNumber) || !Number.isInteger(errorCount)) {
+ core.setFailed('docs-report artifact is malformed')
+ return
+ }
+
+ // The PR number comes from the artifact; make sure it is the PR
the
+ // build actually ran for, and that it has not moved on since.
+ const { data: pr } = await github.rest.pulls.get({
...context.repo, pull_number: prNumber })
+ if (pr.head.sha !== run.head_sha) {
+ core.info(`PR #${prNumber} is at ${pr.head.sha}, build was for
${run.head_sha}: skipping`)
+ return
+ }
+
+ const marker = '<!-- hop-docs-check -->'
+ const comments = await
github.paginate(github.rest.issues.listComments, {
+ ...context.repo, issue_number: prNumber, per_page: 100
+ })
+ const existing = comments.find(c => c.body &&
c.body.startsWith(marker))
+
+ if (existing) {
+ await github.rest.issues.updateComment({ ...context.repo,
comment_id: existing.id, body })
+ core.info(`Updated comment ${existing.html_url}`)
+ } else if (errorCount > 0) {
+ const { data: c } = await github.rest.issues.createComment({
...context.repo, issue_number: prNumber, body })
+ core.info(`Posted comment ${c.html_url}`)
+ } else {
+ // A clean build on a PR that never had a comment: stay quiet.
+ core.info('Build passed and there is no earlier comment, nothing
to post')
+ }