Skip to content

Commit 2983ef4

Browse files
gibson042ljharb
authored andcommitted
[actions] Publish PR previews
Fixes #30
1 parent ab5b3d9 commit 2983ef4

File tree

7 files changed

+369
-21
lines changed

7 files changed

+369
-21
lines changed

.github/workflows/build.yml

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
name: Deploy gh-pages
1+
name: Publish main
22

33
on:
44
push:
55
branches:
66
- main
77

88
jobs:
9-
deploy:
9+
publish:
1010
runs-on: ubuntu-latest
11-
1211
steps:
1312
- uses: actions/checkout@v4
1413
- uses: ljharb/actions/node/install@main
1514
name: 'nvm install lts/* && npm install'
1615
with:
1716
node-version: lts/*
1817
- run: npm run build
19-
- uses: JamesIves/github-pages-deploy-action@v4
18+
- name: Publish to gh-pages
19+
uses: JamesIves/github-pages-deploy-action@v4
2020
with:
2121
branch: gh-pages
2222
folder: build
23-
clean: true
23+
clean-exclude: |
24+
pr

.github/workflows/publish-pr.yml

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
name: Publish PR
2+
run-name: ${{ github.event.workflow_run.display_title }}
3+
4+
on:
5+
workflow_run:
6+
workflows: ['Render PR']
7+
types: [completed]
8+
9+
env:
10+
# Must match ./render-pr.yml
11+
ARTIFACT_NAME: ${{ vars.ARTIFACT_NAME || 'result' }}
12+
13+
jobs:
14+
publish:
15+
runs-on: ubuntu-latest
16+
if: >
17+
${{
18+
!github.event.repository.fork
19+
&& github.event.workflow_run.event == 'pull_request'
20+
&& github.event.workflow_run.conclusion == 'success'
21+
}}
22+
steps:
23+
- uses: actions/checkout@v4
24+
- uses: ljharb/actions/node/install@main
25+
name: 'nvm install lts/* && npm install'
26+
with:
27+
node-version: lts/*
28+
- name: Print event info
29+
uses: actions/github-script@v7
30+
with:
31+
script: 'console.log(${{ toJson(github.event) }});'
32+
- name: Download zipball
33+
uses: actions/github-script@v7
34+
with:
35+
script: |
36+
const { owner, repo } = context.repo;
37+
const run_id = ${{ github.event.workflow_run.id }};
38+
const name = process.env.ARTIFACT_NAME;
39+
const listArtifactsQuery = { owner, repo, run_id, name };
40+
const listArtifactsResponse = await github.rest.actions.listWorkflowRunArtifacts(listArtifactsQuery);
41+
const { total_count, artifacts } = listArtifactsResponse.data;
42+
if (total_count !== 1) {
43+
const summary = artifacts?.map(({ name, size_in_bytes, url }) => ({ name, size_in_bytes, url }));
44+
const detail = JSON.stringify(summary ?? []);
45+
throw new RangeError(`Expected 1 ${name} artifact, got ${total_count} ${detail}`);
46+
}
47+
const artifact_id = artifacts[0].id;
48+
console.log(`downloading artifact ${artifact_id}`);
49+
const downloadResponse = await github.rest.actions.downloadArtifact({
50+
owner,
51+
repo,
52+
artifact_id,
53+
archive_format: 'zip',
54+
});
55+
const fs = require('fs');
56+
fs.writeFileSync('${{ github.workspace }}/result.zip', Buffer.from(downloadResponse.data));
57+
- name: Provide result directory
58+
run: rm -rf result && mkdir -p result
59+
- run: unzip -o result.zip -d result
60+
- run: ls result
61+
- name: Extract PR data
62+
run: |
63+
cd result
64+
awk -v ok=1 '
65+
NR == 1 && match($0, /^[1-9][0-9]* [0-9a-fA-F]{7,}$/) {
66+
print "PR=" $1;
67+
print "COMMIT=" $2;
68+
next;
69+
}
70+
{ ok = 0; }
71+
END { exit !ok; }
72+
' pr-data.txt >> "$GITHUB_ENV"
73+
rm pr-data.txt
74+
- name: Insert preview warning
75+
run: |
76+
tmp="$(mktemp -u XXXXXXXX.json)"
77+
export REPO_URL="https://github.com/$GITHUB_REPOSITORY"
78+
jq -n '
79+
def repo_link($args): $args as [$path, $contents]
80+
| (env.REPO_URL + ($path // "")) as $url
81+
| "<a href=\"\($url | @html)\">\($contents // $url)</a>";
82+
{
83+
SUMMARY: "PR #\(env.PR)",
84+
REPO_LINK: repo_link([]),
85+
PR_LINK: repo_link(["/pull/" + env.PR, "PR #\(env.PR)"]),
86+
COMMIT_LINK: ("commit " + repo_link(["/commit/" + env.COMMIT, "<code>\(env.COMMIT)</code>"])),
87+
}
88+
' > "$tmp"
89+
find result -name '*.html' -exec \
90+
node scripts/insert_warning.mjs scripts/pr_preview_warning.html "$tmp" '{}' '+'
91+
- name: Publish to gh-pages
92+
uses: JamesIves/github-pages-deploy-action@v4
93+
with:
94+
branch: gh-pages
95+
folder: result
96+
target-folder: pr/${{ env.PR }}
97+
- name: Determine gh-pages url
98+
id: get-pages-url
99+
run: |
100+
gh_pages_url="https://$(printf '%s' "$GITHUB_REPOSITORY" \
101+
| sed 's#/#.github.io/#; s#^tc39.github.io/#tc39.es/#')"
102+
echo "url=$gh_pages_url" >> $GITHUB_OUTPUT
103+
- name: Provide PR comment
104+
uses: phulsechinmay/[email protected]
105+
with:
106+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
107+
ISSUE_ID: ${{ env.PR }}
108+
message: >
109+
The rendered spec for this PR is available at
110+
${{ steps.get-pages-url.outputs.url }}/pr/${{ env.PR }}.

.github/workflows/render-pr.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Render PR
2+
3+
on: [pull_request]
4+
5+
env:
6+
# Must match ./publish-pr.yml
7+
ARTIFACT_NAME: ${{ vars.ARTIFACT_NAME || 'result' }}
8+
9+
jobs:
10+
render:
11+
runs-on: ubuntu-latest
12+
if: ${{ github.event.pull_request }}
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: '[node LTS] npm install'
16+
uses: ljharb/actions/node/install@main
17+
with:
18+
node-version: lts/*
19+
- run: npm run build
20+
- name: Save PR data
21+
env:
22+
PR: ${{ github.event.number }}
23+
run: echo "$PR $(git rev-parse --verify HEAD)" > build/pr-data.txt
24+
- uses: actions/upload-artifact@v4
25+
id: upload
26+
if: ${{ !github.event.repository.fork }}
27+
with:
28+
name: ${{ env.ARTIFACT_NAME }}
29+
path: build/
30+
- name: Echo artifact ID
31+
run: echo 'Artifact ID is ${{ steps.upload.outputs.artifact-id }}'
32+
- name: Verify artifact discoverability
33+
uses: actions/github-script@v7
34+
with:
35+
script: |
36+
const { owner, repo } = context.repo;
37+
const run_id = ${{ github.run_id }};
38+
const listArtifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, run_id });
39+
console.log(`artifacts for run id ${run_id}`, listArtifactsResponse?.data);

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
"license": "MIT",
1616
"devDependencies": {
1717
"@tc39/ecma262-biblio": "^2.1.2816",
18-
"ecmarkup": "^20.0.0"
18+
"ecmarkup": "^20.0.0",
19+
"jsdom": "^25.0.1",
20+
"parse5-html-rewriting-stream": "^7.0.0",
21+
"tmp": "^0.2.3"
1922
},
2023
"engines": {
2124
"node": ">= 18"

scripts/insert_warning.mjs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import fs from 'fs';
2+
import { readFile, rename } from 'fs/promises';
3+
import pathlib from 'path';
4+
import { pipeline } from 'stream/promises';
5+
import { parseArgs } from 'util';
6+
import { JSDOM } from 'jsdom';
7+
import { RewritingStream } from 'parse5-html-rewriting-stream';
8+
import tmp from 'tmp';
9+
10+
const { positionals: cliArgs } = parseArgs({
11+
allowPositionals: true,
12+
options: {},
13+
});
14+
if (cliArgs.length < 3) {
15+
const self = pathlib.relative(process.cwd(), process.argv[1]);
16+
console.error(`Usage: node ${self} <template.html> <data.json> <file.html>...
17+
18+
{{identifier}} substrings in template.html are replaced from data.json, then
19+
the result is inserted into each file.html.`);
20+
process.exit(64);
21+
}
22+
23+
const main = async ([templateFile, dataFile, ...files]) => {
24+
// Substitute data into the template.
25+
const [
26+
template,
27+
data,
28+
] = await Promise.all([
29+
readFile(templateFile, 'utf8'),
30+
readFile(dataFile, 'utf8').then(JSON.parse),
31+
])
32+
const formatErrors = [];
33+
const placeholderPatt = /[{][{](?:([\p{ID_Start}$_][\p{ID_Continue}$]*)[}][}]|.*?(?:[}][}]|(?=[{][{])|$))/gsu;
34+
const resolved = template.replaceAll(placeholderPatt, (m, name, i) => {
35+
if (!name) {
36+
const trunc = m.replace(/([^\n]{29}(?!$)|[^\n]{,29}(?=\n)).*/s, '$1…');
37+
formatErrors.push(new SyntaxError(`bad placeholder at index ${i}: ${trunc}`));
38+
} else if (!Object.hasOwn(data, name)) {
39+
formatErrors.push(new ReferenceError(`no data for ${m}`));
40+
}
41+
return data[name];
42+
});
43+
if (formatErrors.length > 0) {
44+
throw new AggregateError(formatErrors);
45+
}
46+
47+
// Parse the template into DOM nodes for appending to page head (metadata such
48+
// as <style> elements) or prepending to page body (everything else).
49+
const jsdomOpts = { contentType: 'text/html; charset=utf-8' };
50+
const { document } = new JSDOM(resolved, jsdomOpts).window;
51+
const headHTML = document.head.innerHTML;
52+
const bodyHTML = document.body.innerHTML;
53+
54+
// Perform the insertions.
55+
const work = files.map(async (file) => {
56+
await null;
57+
const { name: tmpName, fd, removeCallback } = tmp.fileSync({
58+
tmpdir: pathlib.dirname(file),
59+
prefix: pathlib.basename(file),
60+
postfix: '.tmp',
61+
detachDescriptor: true,
62+
});
63+
try {
64+
// Make a pipeline: fileReader -> inserter -> finisher -> fileWriter
65+
const fileReader = fs.createReadStream(file, 'utf8');
66+
const fileWriter = fs.createWriteStream('', { fd, flush: true });
67+
68+
// Insert headHTML at the end of a possibly implied head, and bodyHTML at
69+
// the beginning of a possibly implied body.
70+
// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhtml
71+
let mode = 'before html'; // | 'before head' | 'in head' | 'after head' | '$DONE'
72+
const stayInHead = new Set([
73+
'base',
74+
'basefont',
75+
'bgsound',
76+
'head',
77+
'link',
78+
'meta',
79+
'noframes',
80+
'noscript',
81+
'script',
82+
'style',
83+
'template',
84+
'title',
85+
]);
86+
const inserter = new RewritingStream();
87+
const onEndTag = function (tag) {
88+
if (tag.tagName === 'head') {
89+
this.emitRaw(headHTML);
90+
mode = 'after head';
91+
}
92+
this.emitEndTag(tag);
93+
};
94+
const onStartTag = function (tag) {
95+
const echoTag = () => this.emitStartTag(tag);
96+
if (mode === 'before html' && tag.tagName === 'html') {
97+
mode = 'before head';
98+
} else if (mode !== 'after head' && stayInHead.has(tag.tagName)) {
99+
mode = 'in head';
100+
} else {
101+
if (mode !== 'after head') { this.emitRaw(headHTML); }
102+
// Emit either `${bodyTag}${bodyHTML}` or `${bodyHTML}${otherTag}`.
103+
const emits = [echoTag, () => this.emitRaw(bodyHTML)];
104+
if (tag.tagName !== 'body') { emits.reverse(); }
105+
for (const emit of emits) { emit(); }
106+
mode = '$DONE';
107+
this.off('endTag', onEndTag).off('startTag', onStartTag);
108+
return;
109+
}
110+
echoTag();
111+
};
112+
inserter.on('endTag', onEndTag).on('startTag', onStartTag);
113+
114+
// Ensure headHTML/bodyHTML insertion before EOF.
115+
const finisher = async function* (source) {
116+
for await (const chunk of source) { yield chunk; }
117+
if (mode === '$DONE') { return; }
118+
if (mode !== 'after head') { yield headHTML; }
119+
yield bodyHTML;
120+
};
121+
122+
await pipeline(fileReader, inserter, finisher, fileWriter);
123+
124+
// Now that the temp file is complete, overwrite the source file.
125+
await rename(tmpName, file);
126+
} finally {
127+
removeCallback();
128+
}
129+
});
130+
const results = await Promise.allSettled(work);
131+
132+
const failures = results.filter((result) => result.status !== 'fulfilled');
133+
if (failures.length > 0) {
134+
throw new AggregateError(failures.map((r) => r.reason));
135+
}
136+
};
137+
138+
main(cliArgs).catch((err) => {
139+
console.error(err);
140+
process.exit(1);
141+
});

0 commit comments

Comments
 (0)