Skip to content

Commit b0a1d21

Browse files
authored
chore: improve link validation CI reporting (#29679)
1 parent a31cfda commit b0a1d21

6 files changed

Lines changed: 551 additions & 2944 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jobs:
9797
needs: pre-build
9898
runs-on: ubuntu-latest
9999
outputs:
100-
link_check_failed: ${{ steps.check_link_result.outputs.failed }}
100+
link_validation_failed: ${{ steps.build_step.outputs.link_validation_failed }}
101101
permissions:
102102
contents: read
103103
pull-requests: write
@@ -134,53 +134,26 @@ jobs:
134134
restore-keys: |
135135
astro-assets-
136136
137-
# The starlight-links-validator plugin runs in astro:build:done, which fires
138-
# AFTER all pages have been written to dist/. If link validation fails, the
139-
# build exits non-zero but dist/ is complete. We use continue-on-error so the
140-
# job succeeds (allowing deploy + post-build to run), then check the outcome below.
141-
# We capture build output with tee so we can grep it to distinguish link-check
142-
# failures from real build failures.
143137
- name: Build
144138
id: build_step
145-
continue-on-error: true
146-
shell: bash
147139
env:
148140
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
149141
RUN_LINK_CHECK: true
150-
run: |
151-
set -o pipefail
152-
npm run build 2>&1 | tee /tmp/build-output.log
153-
154-
# Distinguish between "link check failed" and "real build failure" by grepping
155-
# the captured build output for the link validator's specific error message.
156-
# The link validator runs in astro:build:done (after all pages are written to
157-
# dist/), so its error string can only appear when the build itself completed.
158-
# Any other failure — including partial builds with a non-empty dist/ — will
159-
# not contain that string and will correctly fail here.
160-
- name: Check build result
161-
id: check_link_result
162-
shell: bash
163-
run: |
164-
if [ "${{ steps.build_step.outcome }}" = "success" ]; then
165-
echo "failed=false" >> "$GITHUB_OUTPUT"
166-
exit 0
167-
fi
142+
run: npm run build
168143

169-
# Build failed. Was it only the link validator?
170-
if grep -q "Links validation failed" /tmp/build-output.log; then
171-
echo "failed=true" >> "$GITHUB_OUTPUT"
172-
echo "::warning::Build succeeded but link validation failed. Preview will still be deployed."
173-
else
174-
echo "::error::Build failed for a reason other than link validation. Check the Build step logs."
175-
exit 1
176-
fi
177-
178-
- name: Upload artifact
144+
- name: Upload build artifact
179145
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
180146
with:
181147
name: dist
182148
path: dist
183149

150+
- name: Upload link validation report
151+
if: steps.build_step.outputs.link_validation_failed == 'true'
152+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
153+
with:
154+
name: link-validation-report
155+
path: .starlight-links-validator/errors.json
156+
184157
post-build:
185158
name: Post Build
186159
needs: build
@@ -221,11 +194,17 @@ jobs:
221194
- name: Tests (Workers)
222195
run: npm run test:postbuild
223196

197+
- name: Download link validation report
198+
if: needs.build.outputs.link_validation_failed == 'true'
199+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
200+
with:
201+
name: link-validation-report
202+
path: .starlight-links-validator
203+
224204
- name: Link validation
225-
if: needs.build.outputs.link_check_failed == 'true'
226-
run: |
227-
echo "::error::starlight-links-validator found broken internal links during the build. See the Build job logs for details."
228-
exit 1
205+
env:
206+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
207+
run: npx tsx bin/post-link-validation-comment/index.ts
229208

230209
notify:
231210
name: Notify

astro.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,12 @@ export default defineConfig({
135135
...(RUN_LINK_CHECK
136136
? [
137137
starlightLinksValidator({
138+
failOnError: false,
138139
errorOnInvalidHashes: false,
139140
errorOnLocalLinks: false,
141+
reporters: {
142+
json: true,
143+
},
140144
exclude: [
141145
"/api/",
142146
"/api/**",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const GITHUB_ACTIONS_BOT_ID = 41898282;
2+
export const COMMENT_MARKER = "**Broken Links**";
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
3+
import * as core from "@actions/core";
4+
import * as github from "@actions/github";
5+
6+
import { COMMENT_MARKER, GITHUB_ACTIONS_BOT_ID } from "./constants";
7+
8+
interface LinkValidationError {
9+
docsPath: string;
10+
link: string;
11+
position: { line: number; column: number } | null;
12+
message: string;
13+
documentationUrl: string;
14+
}
15+
16+
interface LinkValidationReport {
17+
errorCount: number;
18+
errorFileCount: number;
19+
errors: LinkValidationError[];
20+
}
21+
22+
async function findExistingComment(
23+
octokit: ReturnType<typeof github.getOctokit>,
24+
owner: string,
25+
repo: string,
26+
pullRequestNumber: number,
27+
) {
28+
const { data: comments } = await octokit.rest.issues.listComments({
29+
owner,
30+
repo,
31+
issue_number: pullRequestNumber,
32+
per_page: 100,
33+
});
34+
35+
return comments.find(
36+
(c) =>
37+
c.user?.id === GITHUB_ACTIONS_BOT_ID && c.body?.includes(COMMENT_MARKER),
38+
);
39+
}
40+
41+
async function run(): Promise<void> {
42+
try {
43+
if (!process.env.GITHUB_TOKEN) {
44+
core.setFailed("Could not find GITHUB_TOKEN in env");
45+
process.exit();
46+
}
47+
48+
const octokit = github.getOctokit(process.env.GITHUB_TOKEN);
49+
const { owner, repo } = github.context.repo;
50+
const payload = github.context.payload;
51+
const pullRequestNumber = payload.pull_request?.number;
52+
53+
if (!pullRequestNumber) {
54+
core.setFailed("Could not find pull request number");
55+
process.exit();
56+
return;
57+
}
58+
59+
const reportPath =
60+
process.env.REPORT_PATH ?? ".starlight-links-validator/errors.json";
61+
62+
// No report means no broken links — clean up any existing comment
63+
if (!existsSync(reportPath)) {
64+
const existing = await findExistingComment(
65+
octokit,
66+
owner,
67+
repo,
68+
pullRequestNumber,
69+
);
70+
71+
if (existing) {
72+
core.info(
73+
`No broken links found. Removing existing comment ${existing.id}`,
74+
);
75+
await octokit.rest.issues.deleteComment({
76+
owner,
77+
repo,
78+
comment_id: existing.id,
79+
});
80+
} else {
81+
core.info("No broken links found.");
82+
}
83+
84+
return;
85+
}
86+
87+
let report: LinkValidationReport;
88+
try {
89+
report = JSON.parse(readFileSync(reportPath, "utf8"));
90+
} catch {
91+
core.setFailed(`Could not read report at ${reportPath}`);
92+
process.exit();
93+
return;
94+
}
95+
96+
// Build the comment body
97+
const rows = report.errors.map((error) => {
98+
const position = error.position
99+
? `\`${error.position.line}:${error.position.column}\``
100+
: "-";
101+
const link = decodeURIComponent(error.link);
102+
return `| \`${error.docsPath}\` | \`${link}\` | ${position} | ${error.message} |`;
103+
});
104+
105+
const comment = [
106+
`## ${COMMENT_MARKER}`,
107+
"",
108+
`Found **${report.errorCount}** broken link(s) across **${report.errorFileCount}** file(s).`,
109+
"",
110+
"| File | Link | Position | Error |",
111+
"| --- | --- | :---: | --- |",
112+
...rows,
113+
].join("\n");
114+
115+
// Find existing comment
116+
const existingComment = await findExistingComment(
117+
octokit,
118+
owner,
119+
repo,
120+
pullRequestNumber,
121+
);
122+
123+
if (existingComment) {
124+
core.info(`Updating existing comment ${existingComment.id}`);
125+
await octokit.rest.issues.updateComment({
126+
owner,
127+
repo,
128+
comment_id: existingComment.id,
129+
body: comment,
130+
});
131+
} else {
132+
core.info("Creating new comment");
133+
await octokit.rest.issues.createComment({
134+
owner,
135+
repo,
136+
issue_number: pullRequestNumber,
137+
body: comment,
138+
});
139+
}
140+
141+
core.setFailed(
142+
`Found ${report.errorCount} broken link(s) across ${report.errorFileCount} file(s).`,
143+
);
144+
} catch (error) {
145+
if (error instanceof Error) {
146+
core.setFailed(error.message);
147+
}
148+
process.exit();
149+
}
150+
}
151+
152+
run();

0 commit comments

Comments
 (0)