Skip to content

Commit 02cc5c9

Browse files
ci: link preview comments to changed pages and stories (#1841)
1 parent e98b948 commit 02cc5c9

5 files changed

Lines changed: 403 additions & 18 deletions

File tree

.github/workflows/cd-docs.yml

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ on:
1010
types: [opened, synchronize, reopened]
1111
paths:
1212
- "docs/**"
13+
- "scripts/render-preview-comment.ts"
14+
- "scripts/lib/json.ts"
15+
- "scripts/lib/process.ts"
1316
- ".github/workflows/cd-docs.yml"
1417
workflow_dispatch:
1518

@@ -29,6 +32,8 @@ jobs:
2932
contents: read
3033
steps:
3134
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35+
with:
36+
fetch-depth: 0
3237
- uses: ./.github/actions/setup-toolchain
3338
with:
3439
install: "frozen"
@@ -44,11 +49,21 @@ jobs:
4449
- run: vp run --filter docs build
4550
env:
4651
DOCUSAURUS_BASE_URL: "/"
52+
- name: Prepare preview links
53+
run: >-
54+
node scripts/render-preview-comment.ts docs docs/.docusaurus
55+
https://ls1intum-hephaestus-docs-pr-${{ github.event.number }}.surge.sh
56+
${{ github.event.pull_request.base.sha }} docs-preview-comment.md
4757
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
4858
with:
4959
name: docs-build-preview
5060
path: docs/build
5161
retention-days: 1
62+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
63+
with:
64+
name: docs-preview-comment
65+
path: docs-preview-comment.md
66+
retention-days: 1
5267

5368
build-production:
5469
name: Build (production)
@@ -98,6 +113,10 @@ jobs:
98113
with:
99114
name: docs-build-preview
100115
path: docs-build
116+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
117+
with:
118+
name: docs-preview-comment
119+
path: preview-comment
101120
- uses: ./.github/actions/setup-toolchain
102121
with:
103122
install: "frozen"
@@ -108,14 +127,7 @@ jobs:
108127
- uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
109128
with:
110129
header: docs-preview
111-
message: |
112-
## 📚 Documentation Preview
113-
114-
This PR includes documentation changes. A preview has been deployed:
115-
116-
🔗 **[View Docs Preview](https://${{ env.PREVIEW_URL }})**
117-
118-
<sub>Preview for commit ${{ github.event.pull_request.head.sha }}. Updates automatically on new commits.</sub>
130+
path: preview-comment/docs-preview-comment.md
119131
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
120132
env:
121133
TARGET_URL: https://${{ env.PREVIEW_URL }}

.github/workflows/ci-quality-gates.yml

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -289,22 +289,21 @@ jobs:
289289
PREVIEW_URL: ls1intum-hephaestus-storybook-pr-${{ github.event.number }}.surge.sh
290290
SURGE_TOKEN: ${{ secrets.SURGE_TOKEN }}
291291
run: ./node_modules/.bin/surge ./webapp/storybook-static "$PREVIEW_URL" --token "$SURGE_TOKEN"
292-
- name: Add Storybook preview to the pull request
292+
- name: Prepare Storybook preview links
293+
if: steps.storybook_preview.outcome == 'success'
294+
run: >-
295+
node scripts/render-preview-comment.ts storybook webapp/storybook-static
296+
https://ls1intum-hephaestus-storybook-pr-${{ github.event.number }}.surge.sh
297+
${{ github.event.pull_request.base.sha }} "$RUNNER_TEMP/storybook-preview.md"
298+
- name: Publish Storybook preview links
293299
if: steps.storybook_preview.outcome == 'success'
294300
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
295301
with:
296302
header: storybook-preview
297-
message: |
298-
## 🧩 Storybook Preview
299-
300-
Review this pull request's components and interaction states:
301-
302-
🔗 **[View Storybook Preview](https://ls1intum-hephaestus-storybook-pr-${{ github.event.number }}.surge.sh)**
303-
304-
<sub>Preview for commit ${{ github.event.pull_request.head.sha }}. Updates automatically on new commits.</sub>
303+
path: ${{ runner.temp }}/storybook-preview.md
305304
- name: Create Storybook status check
306305
if: >-
307-
always() && startsWith(steps.chromatic.outputs.storybookUrl, 'http') &&
306+
success() && startsWith(steps.chromatic.outputs.storybookUrl, 'http') &&
308307
(github.event_name != 'pull_request' || steps.storybook_preview.outcome == 'success')
309308
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
310309
env:

scripts/ci-contract.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,24 @@ void describe("CI contract", () => {
455455
assert.match(storybook, /storybookBuildDir: storybook-static/);
456456
assert.match(storybook, /onlyChanged: true/);
457457
assert.match(storybook, /surge \.\/webapp\/storybook-static/);
458+
assert.match(storybook, /render-preview-comment\.ts storybook webapp\/storybook-static/);
459+
assert.match(storybook, /github\.event\.pull_request\.base\.sha/);
460+
assert.match(storybook, /path: \$\{\{ runner\.temp \}\}\/storybook-preview\.md/);
461+
assert.match(storybook, /name: Create Storybook status check\s+if: >-\s+success\(\)/);
462+
});
463+
464+
void test("publishes documentation links derived from the built pages", async () => {
465+
const docs = await readFile(".github/workflows/cd-docs.yml", "utf8");
466+
assert.ok(
467+
String(parseDocument(docs).getIn(["on", "pull_request", "paths"])).includes(
468+
"scripts/render-preview-comment.ts",
469+
),
470+
);
471+
const buildPreview = job(docs, "build-preview");
472+
assert.match(buildPreview, /fetch-depth: 0/);
473+
assert.match(buildPreview, /render-preview-comment\.ts docs docs\/\.docusaurus/);
474+
assert.match(buildPreview, /github\.event\.pull_request\.base\.sha/);
475+
assert.match(job(docs, "preview"), /path: preview-comment\/docs-preview-comment\.md/);
458476
});
459477

460478
void test("routes tooling-only changes away from server infrastructure", async () => {
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { dirname, resolve } from "node:path";
6+
import { test } from "node:test";
7+
8+
const script = resolve("scripts/render-preview-comment.ts");
9+
const environment = Object.fromEntries(
10+
Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")),
11+
);
12+
13+
function git(root: string, ...arguments_: string[]): string {
14+
return execFileSync("git", arguments_, { cwd: root, encoding: "utf8", env: environment }).trim();
15+
}
16+
17+
async function repository(changedFile: string): Promise<{ base: string; root: string }> {
18+
const root = await mkdtemp(resolve(tmpdir(), "preview-comment-"));
19+
git(root, "init", "--quiet");
20+
git(root, "config", "user.email", "test@example.invalid");
21+
git(root, "config", "user.name", "Test");
22+
await writeFile(resolve(root, "seed"), "seed");
23+
git(root, "add", ".");
24+
git(root, "commit", "--quiet", "-m", "seed");
25+
const base = git(root, "rev-parse", "HEAD");
26+
await mkdir(dirname(resolve(root, changedFile)), { recursive: true });
27+
await writeFile(resolve(root, changedFile), "changed");
28+
git(root, "add", ".");
29+
git(root, "commit", "--quiet", "-m", "change");
30+
return { base, root };
31+
}
32+
33+
async function render(
34+
root: string,
35+
base: string,
36+
kind: string,
37+
directory: string,
38+
): Promise<string> {
39+
execFileSync("node", [script, kind, directory, "https://preview.example/", base, "comment.md"], {
40+
cwd: root,
41+
stdio: "pipe",
42+
env: {
43+
...environment,
44+
GITHUB_SERVER_URL: "https://github.com",
45+
GITHUB_REPOSITORY: "example/project",
46+
GITHUB_RUN_ID: "123",
47+
GITHUB_SHA: "not-the-checked-out-commit",
48+
},
49+
});
50+
return readFile(resolve(root, "comment.md"), "utf8");
51+
}
52+
53+
void test("links only stories from changed files to their canvases", async () => {
54+
const { base, root } = await repository("webapp/src/Button.stories.tsx");
55+
await mkdir(resolve(root, "build"));
56+
await writeFile(
57+
resolve(root, "build/index.json"),
58+
JSON.stringify({
59+
entries: {
60+
"button--primary": {
61+
importPath: "./src/Button.stories.tsx",
62+
name: "Primary [default]",
63+
title: "UI/Button",
64+
type: "story",
65+
},
66+
"button--unsafe": {
67+
importPath: "./src/Button.stories.tsx",
68+
name: "Unsafe\n<img>",
69+
title: "UI/Button",
70+
type: "story",
71+
},
72+
"button--docs": {
73+
importPath: "./src/Button.stories.tsx",
74+
name: "Docs",
75+
title: "UI/Button",
76+
type: "docs",
77+
},
78+
"other--unchanged": {
79+
importPath: "./src/Other.stories.tsx",
80+
name: "Unchanged",
81+
title: "Other",
82+
type: "story",
83+
},
84+
},
85+
}),
86+
);
87+
const comment = await render(root, base, "storybook", "build");
88+
assert.match(comment, /Stories in changed files/);
89+
assert.ok(comment.includes("UI/Button — Primary \\[default\\]"));
90+
assert.match(comment, /\?path=\/story\/button--primary/);
91+
assert.doesNotMatch(comment, /button--docs/);
92+
assert.doesNotMatch(comment, /other--unchanged/);
93+
assert.ok(comment.includes("Unsafe &lt;img&gt;"));
94+
assert.doesNotMatch(comment, /Unsafe\n/);
95+
const sha = git(root, "rev-parse", "HEAD");
96+
assert.ok(
97+
comment.includes(
98+
`Built from [\`${sha.slice(0, 7)}\`](<https://github.com/example/project/commit/${sha}>)`,
99+
),
100+
);
101+
assert.ok(
102+
comment.includes("[Build logs](<https://github.com/example/project/actions/runs/123>)"),
103+
);
104+
assert.doesNotMatch(comment, /not-the-checked-out-commit/);
105+
});
106+
107+
void test("limits long story lists", async () => {
108+
const { base, root } = await repository("webapp/src/Button.stories.tsx");
109+
await mkdir(resolve(root, "build"));
110+
await writeFile(
111+
resolve(root, "build/index.json"),
112+
JSON.stringify({
113+
entries: Object.fromEntries(
114+
Array.from({ length: 28 }, (_, index) => [
115+
`button--${index}`,
116+
{
117+
importPath: "./src/Button.stories.tsx",
118+
name: `Example ${index}`,
119+
title: "Button",
120+
type: "story",
121+
},
122+
]),
123+
),
124+
}),
125+
);
126+
const comment = await render(root, base, "storybook", "build");
127+
assert.match(comment, /Stories in changed files \(25 of 28\)/);
128+
assert.match(comment, /3 more are available in the full preview/);
129+
});
130+
131+
void test("does not claim files are unchanged when they have no published stories", async () => {
132+
const { base, root } = await repository("webapp/src/Button.stories.tsx");
133+
await mkdir(resolve(root, "build"));
134+
await writeFile(resolve(root, "build/index.json"), JSON.stringify({ entries: {} }));
135+
const comment = await render(root, base, "storybook", "build");
136+
assert.match(
137+
comment,
138+
/Stories in changed files\n\nNo published stories found in changed files\./,
139+
);
140+
});
141+
142+
void test("renders safe changed Docusaurus routes once", async () => {
143+
const { base, root } = await repository("docs/user/getting-started.mdx");
144+
await mkdir(resolve(root, "metadata/nested"), { recursive: true });
145+
await writeFile(
146+
resolve(root, "metadata/nested/page.json"),
147+
JSON.stringify({
148+
permalink: "/user/start-(here)",
149+
source: "@site/user/getting-started.mdx",
150+
title: "Start [here]",
151+
}),
152+
);
153+
await writeFile(
154+
resolve(root, "metadata/nested/duplicate.json"),
155+
JSON.stringify({
156+
permalink: "/user/start-(here)",
157+
source: "@site/user/getting-started.mdx",
158+
title: "Start [here]",
159+
}),
160+
);
161+
await writeFile(
162+
resolve(root, "metadata/nested/unchanged.json"),
163+
JSON.stringify({
164+
permalink: "/user/other",
165+
source: "@site/user/other.mdx",
166+
title: "Unchanged",
167+
}),
168+
);
169+
await writeFile(
170+
resolve(root, "metadata/nested/external.json"),
171+
JSON.stringify({
172+
permalink: "https://attacker.example/phishing",
173+
source: "@site/user/getting-started.mdx",
174+
title: "External",
175+
}),
176+
);
177+
const comment = await render(root, base, "docs", "metadata");
178+
assert.match(comment, /Changed pages/);
179+
const link = "[Start \\[here\\]](<https://preview.example/user/start-(here)>)";
180+
assert.equal(comment.split(link).length - 1, 1);
181+
assert.doesNotMatch(comment, /attacker|External/);
182+
assert.doesNotMatch(comment, /Unchanged/);
183+
});
184+
185+
void test("rejects an invalid Storybook index instead of publishing an empty result", async () => {
186+
const { base, root } = await repository("webapp/src/Button.stories.tsx");
187+
await mkdir(resolve(root, "build"));
188+
for (const index of [
189+
{},
190+
{ entries: [] },
191+
{ entries: null },
192+
{ entries: { "button--primary": { type: "story" } } },
193+
]) {
194+
await writeFile(resolve(root, "build/index.json"), JSON.stringify(index));
195+
await assert.rejects(
196+
render(root, base, "storybook", "build"),
197+
/Storybook (index\.entries|entry button--primary\.importPath) must be/,
198+
);
199+
}
200+
});
201+
202+
void test("does not claim docs are unchanged when a changed page is unpublished", async () => {
203+
const { base, root } = await repository("docs/user/draft.mdx");
204+
await mkdir(resolve(root, "metadata"));
205+
const comment = await render(root, base, "docs", "metadata");
206+
assert.match(comment, /Changed pages\n\nNo published pages found in changed files\./);
207+
});

0 commit comments

Comments
 (0)