Skip to content
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
69 changes: 64 additions & 5 deletions .github/workflows/release-embedded.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ name: Release Embedded Build

on:
release:
types: [created]
types: [published]
workflow_dispatch:
inputs:
cdn_release_tag:
description: 'Published release tag to upload to R2 (leave empty to test a build)'
required: false
type: string

permissions:
contents: write

jobs:
build-and-release:
if: github.event_name == 'release' || !inputs.cdn_release_tag
timeout-minutes: 15
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -35,16 +45,65 @@ jobs:
cd ..

- name: Upload release asset
uses: softprops/action-gh-release@v1
if: github.event_name == 'release'
with:
files: dist-embedded.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
count=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '[.assets[] | select(.name == "dist-embedded.zip")] | length')
if [ "$count" = 0 ]; then
gh release upload "$RELEASE_TAG" dist-embedded.zip
else
echo "Keeping the existing release archive for reproducible CDN publication."
fi

- name: Upload artifact (for workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: dist-embedded
path: dist-embedded.zip

publish-cdn:
needs: build-and-release
if: >-
always() && !cancelled() &&
((github.event_name == 'release' && needs.build-and-release.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.cdn_release_tag && needs.build-and-release.result == 'skipped'))
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: cdn-sqlide-${{ github.event.release.tag_name || inputs.cdn_release_tag }}
cancel-in-progress: false
permissions:
contents: read
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_R2_CDN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.cdn_release_tag }}
WRANGLER_SEND_METRICS: 'false'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }}
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm run test:cdn
- name: Install Wrangler
run: npm install --global wrangler@4.149.0
- name: Check Cloudflare configuration
run: |
test -n "$CLOUDFLARE_API_TOKEN" || { echo 'Missing CLOUDFLARE_R2_CDN Actions secret'; exit 1; }
test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo 'Missing CLOUDFLARE_ACCOUNT_ID Actions variable or secret'; exit 1; }
- name: Download and verify the published release
run: node scripts/fetch-cdn-release.mjs sqlide "$RELEASE_TAG"
- name: Allow browser access to runtime files
run: wrangler r2 bucket cors set cdn --file scripts/cdn-cors.json --force
- name: Publish immutable runtime files
run: node scripts/publish-cdn.mjs sqlide "$RELEASE_TAG" .cache/cdn-release
- name: Verify files through the public CDN
run: node scripts/verify-cdn.mjs sqlide "$RELEASE_TAG" .cache/cdn-release
18 changes: 18 additions & 0 deletions .github/workflows/test-cdn.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Validate CDN publishing
on:
pull_request:
push:
branches: [master]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
- run: npm ci
- run: npm run test:cdn
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*

node_modules
/.cache/cdn-release/
dist
dist-embedded
dist-ssr
Expand All @@ -28,4 +29,4 @@ dist-ssr
/upload_csg.cmd
/package-lock.json

/intern/
/intern/
48 changes: 48 additions & 0 deletions docs/cdn-releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Embedded releases on Cloudflare R2

Publishing a GitHub release builds and attaches `dist-embedded.zip`, then uploads
that attached ZIP to the R2 bucket `cdn` under `sqlide/<release-tag>/include/`.
The release workflow downloads the attached archive and verifies GitHub's SHA-256
digest, keeping the CDN identical to the downloadable release. It excludes HTML
examples and source maps. An existing attached ZIP is retained on reruns.

Configure organization Actions secret `CLOUDFLARE_R2_CDN` with a Cloudflare API
token with **Account → Workers R2 Storage → Edit** (R2 **Admin Read & Write**)
for the account holding `cdn`. Bucket-scoped **Object Read & Write** permissions
support only the S3 API, so Wrangler needs the account permission; see
[Cloudflare's documentation](https://developers.cloudflare.com/r2/api/tokens/#permissions).
Set `CLOUDFLARE_ACCOUNT_ID` as an organization Actions variable or secret. Make
both available to this repository. Wrangler uses the API token directly; S3
credentials are unnecessary.

Uploads set correct MIME types and immutable year-long caching. They preserve
older versions, reject different content at an existing key and safely resume
interrupted uploads. `manifest.json` is written last. The public CDN is checked
for every file's checksum, MIME type, caching and CORS before the job succeeds.
The CORS policy permits public GET/HEAD requests from any origin.

To seed an existing release or retry it, use **Actions → Release Embedded Build →
Run workflow**, entering its published tag in `cdn_release_tag`, or:

```sh
gh workflow run release-embedded.yml --repo openpatch/sql-ide -f cdn_release_tag=v2.0.0-hyperbook.4
```

Leave that input empty to run the existing build/artifact validation without
publishing. Use this immutable base URL in Hyperbook:

```json
{
"elements": {
"sqlide": { "cdn": "https://cdn.openpatch.org/sqlide/v2.0.0-hyperbook.4/" }
}
}
```

The actual entry point is `include/sql-ide-embedded.js`; preserve the rest of
the `include/` directory for relative workers, chunks, fonts and WASM.
New releases publish themselves. Consumers adopt new versions by updating their
pinned tag. This process does not depend on the Hyperbook Markdown package.

Run `npm run test:cdn` to test extraction, checksums, immutable publication,
resumable uploads and browser-facing verification locally.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc && vite build && vite build --config vite.config-embedded.js",
"build-ide": "tsc && vite build",
"build-embedded": "tsc && vite build --config vite.config-embedded.js",
"test:cdn": "node --test scripts/publish-cdn.test.mjs scripts/fetch-cdn-release.test.mjs",
"preview": "npm run build && vite preview --port 4000 --host",
"build_tools": "tsc --project tsconfig_development-tools.json",
"deploy": "node src/development/ZipAndUpload.js"
Expand Down
13 changes: 13 additions & 0 deletions scripts/cdn-cors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"rules": [
{
"allowed": {
"origins": ["*"],
"methods": ["GET", "HEAD"],
"headers": ["*"]
},
"exposeHeaders": ["ETag", "Content-Length"],
"maxAgeSeconds": 86400
}
]
}
99 changes: 99 additions & 0 deletions scripts/fetch-cdn-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import JSZip from "jszip";
import { releasePrefix } from "./publish-cdn.mjs";

export async function extractRelease({ bytes, digest, directory, element }) {
if (
!/^sha256:[a-f0-9]{64}$/.test(digest) ||
`sha256:${createHash("sha256").update(bytes).digest("hex")}` !== digest
) {
throw new Error("GitHub release archive checksum mismatch");
}
const zip = await JSZip.loadAsync(bytes);
// Check every filename before extracting; JSZip exposes names it sanitizes.
const files = Object.values(zip.files).filter((entry) => !entry.dir);
for (const entry of files) {
const original = entry.unsafeOriginalName || entry.name;
if (
original !== entry.name ||
original.startsWith("/") ||
original.includes("\\") ||
original.includes("\0") ||
original
.split("/")
.some((part) => !part || part === "." || part === "..") ||
(entry.unixPermissions & 0o170000) === 0o120000
) {
throw new Error(`Unsafe release archive entry: ${original}`);
}
}
const basename = element === "onlineide" ? "online-ide" : "sql-ide";
for (const extension of ["js", "css"]) {
if (!zip.file(`${basename}-embedded.${extension}`))
throw new Error(`Missing embedded ${extension} entry point`);
}
await fs.rm(directory, { recursive: true, force: true });
for (const entry of files) {
if (/\.(map|html)$/i.test(entry.name)) continue;
const destination = path.join(directory, "include", entry.name);
await fs.mkdir(path.dirname(destination), { recursive: true });
await fs.writeFile(destination, await entry.async("nodebuffer"));
}
}

export async function fetchRelease({
element,
version,
repository = process.env.GITHUB_REPOSITORY,
directory = ".cache/cdn-release",
fetchImpl = fetch,
}) {
releasePrefix(element, version);
if (!/^[\w.-]+\/[\w.-]+$/.test(repository))
throw new Error("Invalid GitHub repository");
const release = JSON.parse(
execFileSync(
"gh",
["api", `repos/${repository}/releases/tags/${version}`],
{
encoding: "utf8",
env: {
...process.env,
GH_TOKEN: process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
},
},
),
);
if (release.draft)
throw new Error("Cannot publish a draft release to the CDN");
const asset = release.assets.find(
(asset) => asset.name === "dist-embedded.zip",
);
if (!asset) throw new Error(`No dist-embedded.zip attached to ${version}`);
const response = await fetchImpl(asset.browser_download_url, {
signal: AbortSignal.timeout(300_000),
});
if (!response.ok)
throw new Error(`Cannot download release archive: HTTP ${response.status}`);
await extractRelease({
bytes: Buffer.from(await response.arrayBuffer()),
digest: asset.digest,
directory,
element,
});
console.log(
`Verified and extracted ${repository}@${version} into ${directory}`,
);
}

if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const [element, version] = process.argv.slice(2);
await fetchRelease({ element, version });
}
101 changes: 101 additions & 0 deletions scripts/fetch-cdn-release.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import JSZip from "jszip";
import { extractRelease } from "./fetch-cdn-release.mjs";

function archive(element = "onlineide") {
const zip = new JSZip();
const name = element === "onlineide" ? "online-ide" : "sql-ide";
zip.file(`${name}-embedded.js`, "export const ready = true;");
zip.file(`${name}-embedded.css`, "body {}");
zip.file("assets/worker.js", "self.onmessage = () => {};");
zip.file("assets/runtime.wasm", Buffer.from([0, 97, 115, 109]));
zip.file("assets/worker.js.map", "source map");
zip.file("embedded_java.html", "example");
return zip;
}

async function fixture(t, zip) {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "cdn-archive-"));
t.after(() => fs.rm(temporary, { recursive: true, force: true }));
const bytes = await zip.generateAsync({
type: "nodebuffer",
platform: "UNIX",
});
return {
bytes,
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
directory: path.join(temporary, "runtime"),
};
}

test("extracts both IDE entry points, workers and WASM under include, excluding examples and maps", async (t) => {
for (const element of ["onlineide", "sqlide"]) {
const f = await fixture(t, archive(element));
await extractRelease({ ...f, element });
assert.equal(
await fs.readFile(
path.join(f.directory, "include/assets/worker.js"),
"utf8",
),
"self.onmessage = () => {};",
);
assert.deepEqual(
await fs.readFile(path.join(f.directory, "include/assets/runtime.wasm")),
Buffer.from([0, 97, 115, 109]),
);
await assert.rejects(
fs.access(path.join(f.directory, "include/assets/worker.js.map")),
);
await assert.rejects(
fs.access(path.join(f.directory, "include/embedded_java.html")),
);
}
});

test("rejects a checksum mismatch before extraction", async (t) => {
const f = await fixture(t, archive());
await assert.rejects(
extractRelease({
...f,
digest: `sha256:${"0".repeat(64)}`,
element: "onlineide",
}),
/checksum/,
);
await assert.rejects(fs.access(f.directory));
});

test("rejects traversal, absolute paths and symlinks before extracting any files", async (t) => {
for (const name of ["../outside.js", "/outside.js", "assets\\outside.js"]) {
const zip = archive();
zip.file(name, "unsafe");
const f = await fixture(t, zip);
await assert.rejects(
extractRelease({ ...f, element: "onlineide" }),
/Unsafe/,
);
await assert.rejects(fs.access(f.directory));
}
const zip = archive();
zip.file("link", "../outside", { unixPermissions: 0o120777 });
const f = await fixture(t, zip);
await assert.rejects(
extractRelease({ ...f, element: "onlineide" }),
/Unsafe/,
);
});

test("rejects an archive without its embedded entry point", async (t) => {
const zip = archive();
zip.remove("online-ide-embedded.js");
const f = await fixture(t, zip);
await assert.rejects(
extractRelease({ ...f, element: "onlineide" }),
/Missing embedded js/,
);
});
Loading
Loading