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')
+            }

Reply via email to