Skip to content

Commit 55f8ddf

Browse files
committed
ci: add preview
1 parent 05c9880 commit 55f8ddf

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

.github/workflows/preview.yaml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Preview
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- '**'
7+
# Lets a preview be built for a pull request that predates this workflow,
8+
# or rebuilt without pushing to it: run this workflow and pass the number.
9+
workflow_dispatch:
10+
inputs:
11+
pr:
12+
description: 'Pull request number to build a preview for'
13+
required: true
14+
type: string
15+
16+
permissions:
17+
contents: read
18+
pull-requests: write
19+
20+
concurrency:
21+
group: preview-${{ github.event.pull_request.number || inputs.pr }}
22+
cancel-in-progress: true
23+
24+
jobs:
25+
preview:
26+
runs-on: ubuntu-latest
27+
28+
steps:
29+
- uses: actions/checkout@v4
30+
with:
31+
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || github.ref }}
32+
33+
- name: Set up Node.js
34+
uses: actions/setup-node@v4
35+
with:
36+
node-version: '20'
37+
38+
- name: Install dependencies
39+
run: npm ci
40+
41+
- name: Build
42+
run: npm run build
43+
44+
- name: Upload to Swarm
45+
id: upload
46+
run: node scripts/upload-to-swarm.mjs build
47+
48+
- name: Write summary
49+
run: |
50+
{
51+
echo "### Preview"
52+
echo
53+
echo "${{ steps.upload.outputs.url }}"
54+
echo
55+
echo "Swarm reference: \`${{ steps.upload.outputs.reference }}\`"
56+
} >> "$GITHUB_STEP_SUMMARY"
57+
58+
# Fails on pull requests from forks, where GITHUB_TOKEN is read-only.
59+
# The preview URL is in the job summary either way.
60+
- name: Comment the preview link
61+
continue-on-error: true
62+
uses: actions/github-script@v7
63+
env:
64+
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
65+
PREVIEW_URL: ${{ steps.upload.outputs.url }}
66+
PREVIEW_REFERENCE: ${{ steps.upload.outputs.reference }}
67+
with:
68+
script: |
69+
const marker = '<!-- swarm-preview -->'
70+
const issue_number = Number(process.env.PR_NUMBER)
71+
const body = [
72+
marker,
73+
`**Preview:** ${process.env.PREVIEW_URL}`,
74+
'',
75+
`Built from ${context.sha.slice(0, 7)} and uploaded to Swarm as \`${process.env.PREVIEW_REFERENCE}\`.`,
76+
'Previews are unpaid uploads to a public gateway, so they are not guaranteed to stay retrievable.',
77+
].join('\n')
78+
79+
const comments = await github.paginate(github.rest.issues.listComments, {
80+
...context.repo,
81+
issue_number,
82+
})
83+
const existing = comments.find(comment => comment.body.includes(marker))
84+
85+
if (existing) {
86+
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body })
87+
} else {
88+
await github.rest.issues.createComment({ ...context.repo, issue_number, body })
89+
}

scripts/upload-to-swarm.mjs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Upload a built site to Swarm through a public gateway and print the URL it
2+
// can be previewed at. Used by .github/workflows/preview.yaml, but it works
3+
// the same way when run by hand:
4+
//
5+
// node scripts/upload-to-swarm.mjs build
6+
// node scripts/upload-to-swarm.mjs build --gateway https://bzz.limo
7+
//
8+
// The directory is packed into a tar and posted to the gateway's /bzz endpoint
9+
// as a collection. https://bzz.limo pays for uploads itself, so any 32-byte
10+
// postage batch id is accepted and we do not need a funded batch of our own.
11+
//
12+
// The gateway serves a collection both at /bzz/<reference>/ and — with the
13+
// reference re-encoded as a CIDv1, which fits in a 63 character DNS label —
14+
// at https://<cid>.bzz.limo/. Only the second one serves the site from the
15+
// root, so it is the URL we report: a Docusaurus build with baseUrl "/" works
16+
// there unchanged.
17+
18+
import { readFileSync, appendFileSync, existsSync, statSync, rmSync } from 'node:fs';
19+
import { execFileSync } from 'node:child_process';
20+
import { tmpdir } from 'node:os';
21+
import { join } from 'node:path';
22+
23+
const DEFAULT_GATEWAY = 'https://bzz.limo';
24+
// The gateway ignores which batch is named, but the header has to be a 32-byte hex id.
25+
const FAKE_BATCH_ID = '00'.repeat(32);
26+
const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000;
27+
const UPLOAD_ATTEMPTS = 3;
28+
29+
const args = process.argv.slice(2);
30+
const dir = args.find(arg => !arg.startsWith('--'));
31+
const gateway = (readOption('--gateway') ?? DEFAULT_GATEWAY).replace(/\/+$/, '');
32+
const indexDocument = readOption('--index') ?? 'index.html';
33+
const errorDocument = readOption('--error') ?? '404.html';
34+
35+
function readOption(name) {
36+
const index = args.indexOf(name);
37+
return index === -1 ? undefined : args[index + 1];
38+
}
39+
40+
if (!dir) {
41+
console.error('usage: node scripts/upload-to-swarm.mjs <directory> [--gateway url]');
42+
process.exit(1);
43+
}
44+
45+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
46+
console.error(`not a directory: ${dir}`);
47+
process.exit(1);
48+
}
49+
50+
/** Encode a Swarm reference as a CIDv1 (swarm-manifest codec, keccak-256 multihash). */
51+
function toCid(reference) {
52+
const bytes = Buffer.concat([Buffer.from([0x01, 0xfa, 0x1b, 0x20]), Buffer.from(reference, 'hex')]);
53+
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
54+
let bits = 0;
55+
let value = 0;
56+
let out = '';
57+
for (const byte of bytes) {
58+
value = (value << 8) | byte;
59+
bits += 8;
60+
while (bits >= 5) {
61+
out += alphabet[(value >>> (bits - 5)) & 31];
62+
bits -= 5;
63+
}
64+
}
65+
if (bits > 0) {
66+
out += alphabet[(value << (5 - bits)) & 31];
67+
}
68+
return `b${out}`;
69+
}
70+
71+
async function upload(tar) {
72+
for (let attempt = 1; attempt <= UPLOAD_ATTEMPTS; attempt++) {
73+
try {
74+
const response = await fetch(`${gateway}/bzz`, {
75+
method: 'POST',
76+
headers: {
77+
'content-type': 'application/x-tar',
78+
'swarm-postage-batch-id': FAKE_BATCH_ID,
79+
'swarm-collection': 'true',
80+
'swarm-index-document': indexDocument,
81+
'swarm-error-document': errorDocument,
82+
},
83+
body: tar,
84+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
85+
});
86+
const body = await response.text();
87+
if (!response.ok) {
88+
throw new Error(`gateway responded ${response.status}: ${body.trim()}`);
89+
}
90+
const { reference } = JSON.parse(body);
91+
if (!/^[0-9a-f]{64}$/.test(reference ?? '')) {
92+
throw new Error(`gateway returned an unexpected reference: ${body.trim()}`);
93+
}
94+
return reference;
95+
} catch (error) {
96+
console.error(`upload attempt ${attempt}/${UPLOAD_ATTEMPTS} failed: ${error.message}`);
97+
if (attempt === UPLOAD_ATTEMPTS) {
98+
process.exit(1);
99+
}
100+
}
101+
}
102+
}
103+
104+
const tarPath = join(tmpdir(), `swarm-upload-${process.pid}.tar`);
105+
execFileSync('tar', ['-C', dir, '-cf', tarPath, '.']);
106+
const tar = readFileSync(tarPath);
107+
rmSync(tarPath, { force: true });
108+
console.log(`uploading ${dir} (${(tar.length / 1024 / 1024).toFixed(1)} MB) to ${gateway}`);
109+
110+
const reference = await upload(tar);
111+
const cid = toCid(reference);
112+
const url = `https://${cid}.bzz.limo/`;
113+
114+
console.log(`reference: ${reference}`);
115+
console.log(`cid: ${cid}`);
116+
console.log(`url: ${url}`);
117+
118+
if (process.env.GITHUB_OUTPUT) {
119+
appendFileSync(process.env.GITHUB_OUTPUT, `reference=${reference}\ncid=${cid}\nurl=${url}\n`);
120+
}

0 commit comments

Comments
 (0)