Skip to content
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

[actions] Publish PR previews #38

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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'
- name: '[node LTS] npm install'
uses: ljharb/actions/node/install@main
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
114 changes: 114 additions & 0 deletions .github/workflows/publish-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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
- name: '[node LTS] npm install'
uses: ljharb/actions/node/install@main
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);
Comment on lines +40 to +41
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const listArtifactsResponse =
await github.rest.actions.listWorkflowRunArtifacts(listArtifactsQuery);
const listArtifactsResponse = await github.rest.actions.listWorkflowRunArtifacts(listArtifactsQuery);

const { total_count, artifacts } = listArtifactsResponse.data;
if (total_count !== 1) {
const summary = artifacts?.map(artifact => {
const { name, size_in_bytes, url } = artifact;
return { name, size_in_bytes, url };
});
Comment on lines +44 to +47
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe?

Suggested change
const summary = artifacts?.map(artifact => {
const { name, size_in_bytes, url } = artifact;
return { name, size_in_bytes, url };
});
const summary = artifacts?.map(({ name, size_in_bytes, url }) => ({ name, size_in_bytes, url }));

const detail = JSON.stringify(summary ?? []);
throw Error(`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 }}.
40 changes: 40 additions & 0 deletions .github/workflows/render-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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 });
Comment on lines +38 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const listArtifactsResponse =
await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, 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
125 changes: 125 additions & 0 deletions scripts/insert_warning.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import fs from 'node:fs';
import pathlib from 'node:path';
import { pipeline } from 'node:stream/promises';
import { parseArgs } from 'node: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 args => {
const [templateFile, dataFile, ...files] = args;

// Substitute data into the template.
const template = fs.readFileSync(templateFile, 'utf8');
const data = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
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(SyntaxError(`bad placeholder at index ${i}: ${trunc}`));
} else if (!Object.hasOwn(data, name)) {
formatErrors.push(ReferenceError(`no data for ${m}`));
Comment on lines +34 to +36
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
formatErrors.push(SyntaxError(`bad placeholder at index ${i}: ${trunc}`));
} else if (!Object.hasOwn(data, name)) {
formatErrors.push(ReferenceError(`no data for ${m}`));
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 AggregateError(formatErrors);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (formatErrors.length > 0) throw AggregateError(formatErrors);
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', 'link', 'meta', 'title'],
...['noscript', 'noframes', 'style', 'script', 'template'],
'head',
]);
Comment on lines +67 to +71
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const stayInHead = new Set([
...['base', 'basefont', 'bgsound', 'link', 'meta', 'title'],
...['noscript', 'noframes', 'style', 'script', 'template'],
'head',
]);
const stayInHead = new Set([].concat(
['base', 'basefont', 'bgsound', 'link', 'meta', 'title'],
['noscript', 'noframes', 'style', 'script', 'template'],
'head',
));

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.
fs.renameSync(tmpName, file);
} finally {
removeCallback();
}
});
const results = await Promise.allSettled(work);

const failures = results.filter(result => result.status !== 'fulfilled');
if (failures.length > 0) throw AggregateError(failures.map(r => r.reason));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (failures.length > 0) throw AggregateError(failures.map(r => r.reason));
if (failures.length > 0) throw new AggregateError(failures.map(r => r.reason));

};

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