From 1446151b3902832d09bec1f37350a16753c3fab2 Mon Sep 17 00:00:00 2001
From: mikkel <7003+mikkel@users.noreply.github.com>
Date: Thu, 22 Oct 2026 18:41:26 -0600
Subject: [PATCH 1/2] site: build patchling-py docs at /py/ and JS docs at /js/
into dist/ for patchling.app
---
.gitignore | 2 ++
docs/deploy.md | 39 +++++++++++++++++----
landing/index.html | 5 ++-
scripts/build-site.sh | 54 +++++++++++++++++++++++++++++
scripts/check-pages-settings.mjs | 4 +--
scripts/site/_redirects | 4 +++
scripts/site/mkdocs.js.yml | 27 +++++++++++++++
test/check_pages_settings.test.js | 4 +--
test/deploy_config.test.js | 33 ++++++++++++++++--
test/fixtures/pages/project-ok.json | 2 +-
10 files changed, 158 insertions(+), 16 deletions(-)
create mode 100755 scripts/build-site.sh
create mode 100644 scripts/site/_redirects
create mode 100644 scripts/site/mkdocs.js.yml
diff --git a/.gitignore b/.gitignore
index 07c5ad5..d2cc5d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ npm-debug.log*
*.log
.env
.env.*
+dist/
+.site-build/
diff --git a/docs/deploy.md b/docs/deploy.md
index 324c509..3538aba 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -3,7 +3,29 @@
patchling.app is a Cloudflare Pages project (`patchling`) connected to
`255BITS/patchling` through the Cloudflare Workers and Pages GitHub app.
There is no `wrangler.toml` and no deploy workflow in this repo: Cloudflare
-builds from Git on its side.
+builds from Git on its side, running `scripts/build-site.sh`.
+
+## What the build produces
+
+`bash scripts/build-site.sh` writes `dist/`:
+
+| Path | Source |
+| --- | --- |
+| `/` | `landing/` copied as-is (the demo page) |
+| `/py/` | `255BITS/patchling-py` `main` docs (`mkdocs.yml`, `site_url` set to `https://patchling.app/py/`) |
+| `/js/` | this repo's `README.md`, `CHANGELOG.md` and `docs/*.md`, rendered with `scripts/site/mkdocs.js.yml` |
+| `/docs/*` | 301 to `/py/*` (`scripts/site/_redirects`) |
+
+It makes a shallow public clone of patchling-py (no token) and installs
+`mkdocs-material==9.6.22` into a throwaway venv under `.site-build/`. That's
+the same version pinned by patchling-py's Deploy Documentation workflow.
+Nothing is added to `package.json`. The npm package still ships only `src/`.
+`PATCHLING_PY_REF` picks another patchling-py branch or tag.
+
+Each Pages build fetches patchling-py `main` fresh. patchling-py's "Trigger
+patchling.app deploy" workflow calls a Pages deploy hook when its docs or
+package change on `main`. That rebuilds this site with the new `/py/` docs,
+without a push here.
## Expected Pages settings
@@ -14,11 +36,14 @@ builds from Git on its side.
| Automatic production deployments | enabled |
| Preview deployments | `all` |
| PR preview comments | enabled |
-| Build command | *(none)* |
-| Build output directory | `landing` |
+| Build command | `bash scripts/build-site.sh` |
+| Build output directory | `dist` |
| Root directory | *(repo root)* |
| Custom domains (exact set) | `patchling.app`, `patchling.pages.dev` |
+Environment variable (production and preview): `PYTHON_VERSION` = `3.12`.
+The checker below does not read environment variables.
+
`scripts/check-pages-settings.mjs` holds these values as `EXPECTED`, and
`test/check_pages_settings.test.js` fails if this table and `EXPECTED`
disagree. "Preview deployments `all`" means every non-production branch gets
@@ -26,9 +51,9 @@ a preview, and "PR preview comments" (`pr_comments_enabled`) posts its URL
on each PR. The domain set is exact: a
missing or extra domain is drift.
-Because the build output is `landing/` with no build step, everything the
-page loads must live under `landing/`. `landing/lib/*.js` are byte-identical
-copies of `src/*.js`.
+Everything the landing page loads must live under `landing/`.
+`landing/lib/*.js` are byte-identical copies of `src/*.js`. `landing/` must
+not contain `py/`, `js/` or `docs/`, because the build owns those paths.
## What CI checks (from the repo alone)
@@ -70,7 +95,7 @@ invalid JSON), even when the project data would match. It never writes.
`npm test` runs it offline against `test/fixtures/pages/` with a negative case
for each check.
-Last verified 2026-10-13 via the API: production branch `main`,
+Last verified 2026-10-13 via the API, before the `dist/` build: production branch `main`,
automatic production deployments enabled, preview deployments `all`,
build output `landing`, no build command, domains `patchling.pages.dev` and
`patchling.app`, latest production deployment = `main` @ `58a3858` (#5).
diff --git a/landing/index.html b/landing/index.html
index 704b979..27f9186 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -352,6 +352,8 @@
Built with it
How it works
Any model
+ JS docs
+ Py docs
GitHub
@@ -697,6 +699,7 @@
JavaScript
@@ -710,7 +713,7 @@ Python
diff --git a/scripts/build-site.sh b/scripts/build-site.sh
new file mode 100755
index 0000000..cf6af60
--- /dev/null
+++ b/scripts/build-site.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Build the patchling.app Cloudflare Pages output into dist/:
+# dist/ landing/ as-is (the demo page)
+# dist/py/ patchling-py docs (mkdocs), fetched from 255BITS/patchling-py
+# dist/js/ this repo's README, CHANGELOG and docs/ rendered with mkdocs
+# dist/_redirects /docs/* -> /py/*
+# Build tooling (mkdocs-material in a throwaway venv) lives only here and in
+# .site-build/; nothing is added to package.json or the npm package.
+# Public clone, no token. Env:
+# PATCHLING_PY_REF branch/tag of patchling-py to build (default main)
+# MKDOCS_MATERIAL pip spec (default mkdocs-material==9.6.22, same pin as
+# patchling-py's Deploy Documentation workflow)
+# PYTHON interpreter (default python3)
+set -euo pipefail
+root=$(cd "$(dirname "$0")/.." && pwd)
+cd "$root"
+out="$root/dist"
+work="$root/.site-build"
+ref="${PATCHLING_PY_REF:-main}"
+py="${PYTHON:-python3}"
+
+rm -rf "$out" "$work"
+mkdir -p "$work"
+cp -R landing "$out"
+
+echo "== fetch 255BITS/patchling-py@$ref"
+git clone --quiet --depth 1 --branch "$ref" https://github.com/255BITS/patchling-py.git "$work/patchling-py"
+echo "patchling-py commit: $(git -C "$work/patchling-py" rev-parse HEAD)"
+
+echo "== mkdocs (${MKDOCS_MATERIAL:-mkdocs-material==9.6.22})"
+"$py" -m venv "$work/venv"
+"$work/venv/bin/python" -m pip install --quiet --disable-pip-version-check "${MKDOCS_MATERIAL:-mkdocs-material==9.6.22}"
+mkdocs="$work/venv/bin/mkdocs"
+
+echo "== /py/"
+# Same source and nav as gptdiff.255labs.xyz; only site_url changes.
+cat > "$work/patchling-py/mkdocs.patchling-app.yml" < "$js/docs/index.md"
+cp infographic.svg "$js/docs/"
+cp CHANGELOG.md docs/*.md "$js/docs/"
+cp scripts/site/mkdocs.js.yml "$js/mkdocs.yml"
+"$mkdocs" build --strict --quiet -f "$js/mkdocs.yml" -d "$out/js"
+
+cp scripts/site/_redirects "$out/_redirects"
+echo "== done: $(find "$out" -type f | wc -l) files in dist/"
diff --git a/scripts/check-pages-settings.mjs b/scripts/check-pages-settings.mjs
index e8dba81..6e4ae54 100755
--- a/scripts/check-pages-settings.mjs
+++ b/scripts/check-pages-settings.mjs
@@ -16,8 +16,8 @@ export const EXPECTED = Object.freeze({
productionDeploymentsEnabled: true,
previewDeploymentSetting: 'all',
prCommentsEnabled: true,
- buildCommand: '',
- destinationDir: 'landing',
+ buildCommand: 'bash scripts/build-site.sh',
+ destinationDir: 'dist',
rootDir: '',
domains: Object.freeze(['patchling.app', 'patchling.pages.dev']),
});
diff --git a/scripts/site/_redirects b/scripts/site/_redirects
new file mode 100644
index 0000000..c9dc223
--- /dev/null
+++ b/scripts/site/_redirects
@@ -0,0 +1,4 @@
+# Cloudflare Pages redirects (copied to dist/ by scripts/build-site.sh).
+/docs /py/ 301
+/docs/ /py/ 301
+/docs/* /py/:splat 301
diff --git a/scripts/site/mkdocs.js.yml b/scripts/site/mkdocs.js.yml
new file mode 100644
index 0000000..21051fe
--- /dev/null
+++ b/scripts/site/mkdocs.js.yml
@@ -0,0 +1,27 @@
+# Rendered by scripts/build-site.sh into dist/js/ (patchling.app/js/).
+site_name: patchling (JavaScript)
+site_url: https://patchling.app/js/
+repo_url: https://github.com/255BITS/patchling
+edit_uri: ""
+docs_dir: docs
+strict: true
+theme:
+ name: material
+ palette:
+ scheme: slate
+ primary: deep purple
+ accent: blue
+nav:
+ - README: index.md
+ - Changelog: CHANGELOG.md
+ - Deploying patchling.app: deploy.md
+extra:
+ social:
+ - name: Python docs
+ icon: fontawesome/brands/python
+ link: https://patchling.app/py/
+ - name: patchling.app
+ icon: fontawesome/solid/globe
+ link: https://patchling.app/
+plugins:
+ - search
diff --git a/test/check_pages_settings.test.js b/test/check_pages_settings.test.js
index 36dcf81..e2fa448 100644
--- a/test/check_pages_settings.test.js
+++ b/test/check_pages_settings.test.js
@@ -29,8 +29,8 @@ test('the saved live response passes every check', () => {
const negatives = {
'preview deployments none': [(p) => { p.source.config.preview_deployment_setting = 'none'; }, ['previewDeploymentSetting']],
'root_dir src': [(p) => { p.build_config.root_dir = 'src'; }, ['rootDir']],
- 'destination_dir changed': [(p) => { p.build_config.destination_dir = 'dist'; }, ['destinationDir']],
- 'build command added': [(p) => { p.build_config.build_command = 'npm run build'; }, ['buildCommand']],
+ 'destination_dir still landing': [(p) => { p.build_config.destination_dir = 'landing'; }, ['destinationDir']],
+ 'build command missing': [(p) => { p.build_config.build_command = ''; }, ['buildCommand']],
'missing patchling.pages.dev': [(p) => { p.domains = ['patchling.app']; }, ['domains']],
'missing patchling.app': [(p) => { p.domains = ['patchling.pages.dev']; }, ['domains']],
'extra domain': [(p) => { p.domains.push('www.patchling.app'); }, ['domains']],
diff --git a/test/deploy_config.test.js b/test/deploy_config.test.js
index 392d3c3..5362cec 100644
--- a/test/deploy_config.test.js
+++ b/test/deploy_config.test.js
@@ -1,5 +1,6 @@
/**
- * Cloudflare Pages serves landing/ as-is (no build step, see docs/deploy.md).
+ * Cloudflare Pages runs scripts/build-site.sh and serves dist/: landing/ at /,
+ * patchling-py docs at /py/, this repo's docs at /js/ (see docs/deploy.md).
* These are the deploy assumptions CI can verify from the repo alone.
*/
import { test } from 'node:test';
@@ -28,10 +29,10 @@ test('landing/index.html is the Pages entry point', () => {
assert.ok(existsSync(join(landing, 'index.html')));
});
-test('docs/deploy.md pins production branch main and output dir landing', () => {
+test('docs/deploy.md pins production branch main and output dir dist', () => {
const doc = readFileSync(join(root, 'docs', 'deploy.md'), 'utf8');
assert.match(doc, /Production branch \| `main`/);
- assert.match(doc, /Build output directory \| `landing`/);
+ assert.match(doc, /Build output directory \| `dist`/);
});
test('every relative import under landing/ resolves inside landing/', () => {
@@ -48,3 +49,29 @@ test('every relative import under landing/ resolves inside landing/', () => {
}
assert.deepEqual(problems, []);
});
+
+test('landing links the in-site docs, and /py/ and /js/ are left to the build', () => {
+ const html = readFileSync(join(landing, 'index.html'), 'utf8');
+ assert.match(html, /href="\/py\/"/);
+ assert.match(html, /href="\/js\/"/);
+ assert.doesNotMatch(html, /255bits\.github\.io\/patchling-py/);
+ for (const dir of ['py', 'js', 'docs']) {
+ assert.ok(!existsSync(join(landing, dir)), `landing/${dir} would collide with the build output`);
+ }
+});
+
+test('/docs/ redirects to /py/ and the build script wires everything up', () => {
+ const redirects = readFileSync(join(root, 'scripts', 'site', '_redirects'), 'utf8');
+ assert.match(redirects, /^\/docs\/\* \/py\/:splat 301$/m);
+ assert.match(redirects, /^\/docs \/py\/ 301$/m);
+ const sh = readFileSync(join(root, 'scripts', 'build-site.sh'), 'utf8');
+ assert.match(sh, /https:\/\/github\.com\/255BITS\/patchling-py\.git/);
+ assert.match(sh, /-d "\$out\/py"/);
+ assert.match(sh, /-d "\$out\/js"/);
+});
+
+test('site build tooling stays out of the npm package', () => {
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
+ assert.deepEqual(pkg.files, ['src']);
+ assert.equal(pkg.dependencies, undefined);
+});
diff --git a/test/fixtures/pages/project-ok.json b/test/fixtures/pages/project-ok.json
index 477ff2e..70ba9fa 100644
--- a/test/fixtures/pages/project-ok.json
+++ b/test/fixtures/pages/project-ok.json
@@ -17,7 +17,7 @@
"deployments_enabled": true
}
},
- "build_config": { "build_command": "", "destination_dir": "landing", "root_dir": "" },
+ "build_config": { "build_command": "bash scripts/build-site.sh", "destination_dir": "dist", "root_dir": "" },
"canonical_deployment": {
"environment": "production",
"created_on": "2026-10-13T16:50:51.866675Z",
From 806c0fab57f2d6ed4844a48b70eb73f9102a61eb Mon Sep 17 00:00:00 2001
From: mikkel <7003+mikkel@users.noreply.github.com>
Date: Sat, 24 Oct 2026 10:00:03 -0600
Subject: [PATCH 2/2] build-site: record patchling and patchling-py SHAs in
dist/build-info.json and the build log
---
scripts/build-site.sh | 15 ++++++++++++++-
test/deploy_config.test.js | 9 +++++++++
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/scripts/build-site.sh b/scripts/build-site.sh
index cf6af60..225b4a6 100755
--- a/scripts/build-site.sh
+++ b/scripts/build-site.sh
@@ -4,6 +4,7 @@
# dist/py/ patchling-py docs (mkdocs), fetched from 255BITS/patchling-py
# dist/js/ this repo's README, CHANGELOG and docs/ rendered with mkdocs
# dist/_redirects /docs/* -> /py/*
+# dist/build-info.json commit SHAs of both repos used for this build
# Build tooling (mkdocs-material in a throwaway venv) lives only here and in
# .site-build/; nothing is added to package.json or the npm package.
# Public clone, no token. Env:
@@ -25,7 +26,10 @@ cp -R landing "$out"
echo "== fetch 255BITS/patchling-py@$ref"
git clone --quiet --depth 1 --branch "$ref" https://github.com/255BITS/patchling-py.git "$work/patchling-py"
-echo "patchling-py commit: $(git -C "$work/patchling-py" rev-parse HEAD)"
+py_sha=$(git -C "$work/patchling-py" rev-parse HEAD)
+self_sha=$(git -C "$root" rev-parse HEAD 2>/dev/null || echo "${CF_PAGES_COMMIT_SHA:-unknown}")
+echo "patchling commit: $self_sha"
+echo "patchling-py commit: $py_sha (ref $ref)"
echo "== mkdocs (${MKDOCS_MATERIAL:-mkdocs-material==9.6.22})"
"$py" -m venv "$work/venv"
@@ -51,4 +55,13 @@ cp scripts/site/mkdocs.js.yml "$js/mkdocs.yml"
"$mkdocs" build --strict --quiet -f "$js/mkdocs.yml" -d "$out/js"
cp scripts/site/_redirects "$out/_redirects"
+# Record exactly what went into this build (served at /build-info.json).
+cat > "$out/build-info.json" <
assert.match(sh, /-d "\$out\/js"/);
});
+test('every site build records both repos\' commit SHAs', () => {
+ const sh = readFileSync(join(root, 'scripts', 'build-site.sh'), 'utf8');
+ assert.match(sh, /PATCHLING_PY_REF:-main/);
+ assert.match(sh, /\$out\/build-info\.json/);
+ assert.match(sh, /"patchling":.*"commit": "\$self_sha"/);
+ assert.match(sh, /"patchling_py":.*"commit": "\$py_sha"/);
+ assert.match(sh, /echo "patchling-py commit: \$py_sha/);
+});
+
test('site build tooling stays out of the npm package', () => {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
assert.deepEqual(pkg.files, ['src']);