Skip to content

[actions] Publish PR previews #38

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions .github/workflows/build.yml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
name: Deploy gh-pages
name: Publish main

on:
push:
branches:
- main

jobs:
deploy:
publish:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: ljharb/actions/node/install@main
name: 'nvm install lts/* && npm install'
with:
node-version: lts/*
- run: npm run build
- uses: JamesIves/github-pages-deploy-action@v4
- name: Publish to gh-pages
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: build
clean: true
clean-exclude: |
pr
110 changes: 110 additions & 0 deletions .github/workflows/publish-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Publish PR
run-name: ${{ github.event.workflow_run.display_title }}

on:
workflow_run:
workflows: ['Render PR']
types: [completed]

env:
# Must match ./render-pr.yml
ARTIFACT_NAME: ${{ vars.ARTIFACT_NAME || 'result' }}

jobs:
publish:
runs-on: ubuntu-latest
if: >
${{
!github.event.repository.fork
&& github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
}}
steps:
- uses: actions/checkout@v4
- uses: ljharb/actions/node/install@main
name: 'nvm install lts/* && npm install'
with:
node-version: lts/*
- name: Print event info
uses: actions/github-script@v7
with:
script: 'console.log(${{ toJson(github.event) }});'
- name: Download zipball
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const run_id = ${{ github.event.workflow_run.id }};
const name = process.env.ARTIFACT_NAME;
const listArtifactsQuery = { owner, repo, run_id, name };
const listArtifactsResponse = await github.rest.actions.listWorkflowRunArtifacts(listArtifactsQuery);
const { total_count, artifacts } = listArtifactsResponse.data;
if (total_count !== 1) {
const summary = artifacts?.map(({ name, size_in_bytes, url }) => ({ name, size_in_bytes, url }));
const detail = JSON.stringify(summary ?? []);
throw new RangeError(`Expected 1 ${name} artifact, got ${total_count} ${detail}`);
}
const artifact_id = artifacts[0].id;
console.log(`downloading artifact ${artifact_id}`);
const downloadResponse = await github.rest.actions.downloadArtifact({
owner,
repo,
artifact_id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{ github.workspace }}/result.zip', Buffer.from(downloadResponse.data));
- name: Provide result directory
run: rm -rf result && mkdir -p result
- run: unzip -o result.zip -d result
- run: ls result
- name: Extract PR data
run: |
cd result
awk -v ok=1 '
NR == 1 && match($0, /^[1-9][0-9]* [0-9a-fA-F]{7,}$/) {
print "PR=" $1;
print "COMMIT=" $2;
next;
}
{ ok = 0; }
END { exit !ok; }
' pr-data.txt >> "$GITHUB_ENV"
rm pr-data.txt
- name: Insert preview warning
run: |
tmp="$(mktemp -u XXXXXXXX.json)"
export REPO_URL="https://github.com/$GITHUB_REPOSITORY"
jq -n '
def repo_link($args): $args as [$path, $contents]
| (env.REPO_URL + ($path // "")) as $url
| "<a href=\"\($url | @html)\">\($contents // $url)</a>";
{
SUMMARY: "PR #\(env.PR)",
REPO_LINK: repo_link([]),
PR_LINK: repo_link(["/pull/" + env.PR, "PR #\(env.PR)"]),
COMMIT_LINK: ("commit " + repo_link(["/commit/" + env.COMMIT, "<code>\(env.COMMIT)</code>"])),
}
' > "$tmp"
find result -name '*.html' -exec \
node scripts/insert_warning.mjs scripts/pr_preview_warning.html "$tmp" '{}' '+'
- name: Publish to gh-pages
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: gh-pages
folder: result
target-folder: pr/${{ env.PR }}
- name: Determine gh-pages url
id: get-pages-url
run: |
gh_pages_url="https://$(printf '%s' "$GITHUB_REPOSITORY" \
| sed 's#/#.github.io/#; s#^tc39.github.io/#tc39.es/#')"
echo "url=$gh_pages_url" >> $GITHUB_OUTPUT
- name: Provide PR comment
uses: phulsechinmay/[email protected]
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_ID: ${{ env.PR }}
message: >
The rendered spec for this PR is available at
${{ steps.get-pages-url.outputs.url }}/pr/${{ env.PR }}.
39 changes: 39 additions & 0 deletions .github/workflows/render-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Render PR

on: [pull_request]

env:
# Must match ./publish-pr.yml
ARTIFACT_NAME: ${{ vars.ARTIFACT_NAME || 'result' }}

jobs:
render:
runs-on: ubuntu-latest
if: ${{ github.event.pull_request }}
steps:
- uses: actions/checkout@v4
- name: '[node LTS] npm install'
uses: ljharb/actions/node/install@main
with:
node-version: lts/*
- run: npm run build
- name: Save PR data
env:
PR: ${{ github.event.number }}
run: echo "$PR $(git rev-parse --verify HEAD)" > build/pr-data.txt
- uses: actions/upload-artifact@v4
id: upload
if: ${{ !github.event.repository.fork }}
with:
name: ${{ env.ARTIFACT_NAME }}
path: build/
- name: Echo artifact ID
run: echo 'Artifact ID is ${{ steps.upload.outputs.artifact-id }}'
- name: Verify artifact discoverability
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const run_id = ${{ github.run_id }};
const listArtifactsResponse = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, run_id });
console.log(`artifacts for run id ${run_id}`, listArtifactsResponse?.data);
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
"license": "MIT",
"devDependencies": {
"@tc39/ecma262-biblio": "^2.1.2816",
"ecmarkup": "^20.0.0"
"ecmarkup": "^20.0.0",
"jsdom": "^25.0.1",
"parse5-html-rewriting-stream": "^7.0.0",
"tmp": "^0.2.3"
},
"engines": {
"node": ">= 18"
Expand Down
141 changes: 141 additions & 0 deletions scripts/insert_warning.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import fs from 'fs';
import { readFile, rename } from 'fs/promises';
import pathlib from 'path';
import { pipeline } from 'stream/promises';
import { parseArgs } from 'util';
import { JSDOM } from 'jsdom';
import { RewritingStream } from 'parse5-html-rewriting-stream';
import tmp from 'tmp';

const { positionals: cliArgs } = parseArgs({
allowPositionals: true,
options: {},
});
if (cliArgs.length < 3) {
const self = pathlib.relative(process.cwd(), process.argv[1]);
console.error(`Usage: node ${self} <template.html> <data.json> <file.html>...

{{identifier}} substrings in template.html are replaced from data.json, then
the result is inserted into each file.html.`);
process.exit(64);
}

const main = async ([templateFile, dataFile, ...files]) => {
// Substitute data into the template.
const [
template,
data,
] = await Promise.all([
readFile(templateFile, 'utf8'),
readFile(dataFile, 'utf8').then(JSON.parse),
])
const formatErrors = [];
const placeholderPatt = /[{][{](?:([\p{ID_Start}$_][\p{ID_Continue}$]*)[}][}]|.*?(?:[}][}]|(?=[{][{])|$))/gsu;
const resolved = template.replaceAll(placeholderPatt, (m, name, i) => {
if (!name) {
const trunc = m.replace(/([^\n]{29}(?!$)|[^\n]{,29}(?=\n)).*/s, '$1…');
formatErrors.push(new SyntaxError(`bad placeholder at index ${i}: ${trunc}`));
} else if (!Object.hasOwn(data, name)) {
formatErrors.push(new ReferenceError(`no data for ${m}`));
}
return data[name];
});
if (formatErrors.length > 0) {
throw new AggregateError(formatErrors);
}

// Parse the template into DOM nodes for appending to page head (metadata such
// as <style> elements) or prepending to page body (everything else).
const jsdomOpts = { contentType: 'text/html; charset=utf-8' };
const { document } = new JSDOM(resolved, jsdomOpts).window;
const headHTML = document.head.innerHTML;
const bodyHTML = document.body.innerHTML;

// Perform the insertions.
const work = files.map(async (file) => {
await null;
const { name: tmpName, fd, removeCallback } = tmp.fileSync({
tmpdir: pathlib.dirname(file),
prefix: pathlib.basename(file),
postfix: '.tmp',
detachDescriptor: true,
});
try {
// Make a pipeline: fileReader -> inserter -> finisher -> fileWriter
const fileReader = fs.createReadStream(file, 'utf8');
const fileWriter = fs.createWriteStream('', { fd, flush: true });

// Insert headHTML at the end of a possibly implied head, and bodyHTML at
// the beginning of a possibly implied body.
// https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhtml
let mode = 'before html'; // | 'before head' | 'in head' | 'after head' | '$DONE'
const stayInHead = new Set([
'base',
'basefont',
'bgsound',
'head',
'link',
'meta',
'noframes',
'noscript',
'script',
'style',
'template',
'title',
]);
const inserter = new RewritingStream();
const onEndTag = function (tag) {
if (tag.tagName === 'head') {
this.emitRaw(headHTML);
mode = 'after head';
}
this.emitEndTag(tag);
};
const onStartTag = function (tag) {
const echoTag = () => this.emitStartTag(tag);
if (mode === 'before html' && tag.tagName === 'html') {
mode = 'before head';
} else if (mode !== 'after head' && stayInHead.has(tag.tagName)) {
mode = 'in head';
} else {
if (mode !== 'after head') { this.emitRaw(headHTML); }
// Emit either `${bodyTag}${bodyHTML}` or `${bodyHTML}${otherTag}`.
const emits = [echoTag, () => this.emitRaw(bodyHTML)];
if (tag.tagName !== 'body') { emits.reverse(); }
for (const emit of emits) { emit(); }
mode = '$DONE';
this.off('endTag', onEndTag).off('startTag', onStartTag);
return;
}
echoTag();
};
inserter.on('endTag', onEndTag).on('startTag', onStartTag);

// Ensure headHTML/bodyHTML insertion before EOF.
const finisher = async function* (source) {
for await (const chunk of source) { yield chunk; }
if (mode === '$DONE') { return; }
if (mode !== 'after head') { yield headHTML; }
yield bodyHTML;
};

await pipeline(fileReader, inserter, finisher, fileWriter);

// Now that the temp file is complete, overwrite the source file.
await rename(tmpName, file);
} finally {
removeCallback();
}
});
const results = await Promise.allSettled(work);

const failures = results.filter((result) => result.status !== 'fulfilled');
if (failures.length > 0) {
throw new AggregateError(failures.map((r) => r.reason));
}
};

main(cliArgs).catch((err) => {
console.error(err);
process.exit(1);
});
Loading