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
4 changes: 4 additions & 0 deletions .github/audit/expected-github-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
},
"botCollaborator": { "login": "dormouse-bot", "forbiddenRoles": ["admin", "maintain"] },
"environments": {
"blyg-publish": {
"policies": [{ "type": "branch", "name": "main" }, { "type": "tag", "name": "v*" }],
"secrets": ["BLYG_API_TOKEN"]
},
"hosted-preview": {
"policies": [{ "type": "branch", "name": "main" }, { "type": "branch", "name": "refs/pull/*/merge" }],
"requiredReviewers": true,
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/blyg-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Publish Dormouse Blyg

on:
release:
types: [published]
push:
branches: [main]
paths: [CHANGELOG.md]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: blyg-publish
cancel-in-progress: false

jobs:
publish:
if: github.repository == 'diffplug/dormouse' && (github.ref == 'refs/heads/main' || (github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v')))
runs-on: ubuntu-latest
timeout-minutes: 15
environment: blyg-publish
steps:
# Release tags can predate this publisher. Always use reviewed main for
# publisher code and the canonical changelog, including later corrections.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
package-manager-cache: false
- run: node --test scripts/publish-blyg.test.mjs
- name: Publish and pin released changelog entries
run: node scripts/publish-blyg.mjs
env:
GH_TOKEN: ${{ github.token }}
BLYG_API_TOKEN: ${{ secrets.BLYG_API_TOKEN }}
7 changes: 7 additions & 0 deletions deploy/blyg/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.bundle/
.staging/
.wrangler/
.dev.vars*
backups/
*.secret

65 changes: 65 additions & 0 deletions deploy/blyg/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Dormouse Blyg

[Public changelog](https://blyg.dormouse.sh/) · [Studio](https://blyg.dormouse.sh/studio) · [RSS](https://blyg.dormouse.sh/feed.xml)

This directory deploys the official, unmodified Blygger Studio Worker to Cloudflare account `[email protected]`. `wrangler.jsonc` owns the domain and resource IDs. `release.json` pins the upstream archive and its SHA-256. The downloaded bundle, local credentials and backups are ignored; posts and private Studio state live in D1, and media live in R2.

The publication contract is in `docs/specs/deploy.md` → “Blyg changelog”; CI credential placement is in `docs/specs/security-ci.md` → “Automated Maintainer (tend)”. This installation receives Webmentions even when nobody has Studio open. Use Studio to read and moderate responses; public display starts disabled.

## Publishing

`.github/workflows/blyg-publish.yml` synchronizes on GitHub release publication, changes to `CHANGELOG.md` on `main`, or manual dispatch. It checks out reviewed `main` and needs no package installation. `scripts/publish-blyg.mjs` supports the initial historical import and later corrections through the same path. Entries without a stable, published GitHub release are skipped, including historical extension-only versions without a GitHub release. Old entries retain their release dates in the text; their Blyg publication timestamps are the import time because stock Studio has no backdating API.

Run these from the repository root:

```sh
node scripts/publish-blyg.mjs --plan
node scripts/publish-blyg.mjs --dry-run
node scripts/publish-blyg.mjs
```

`--plan` checks public GitHub release eligibility without Studio credentials. `--dry-run` reads Studio to compare entries but makes no writes or pins. The other two commands need `BLYG_API_TOKEN` in their environment. Never paste credentials into command arguments or Git. The renewal helper below passes its token to a local synchronization without saving it.

Make release corrections in `CHANGELOG.md`. Studio remains useful for responses, subscriptions, settings and independently authored posts. The publisher identifies its entries by their opening “Dormouse VERSION” heading and matching GitHub release link; retain both. Do not run local publication while the Actions publisher is running. After a failed or timed-out write, rerun the publisher: it reconciles existing drafts and versions before doing more work. A conflicting draft or deliberately withdrawn item stops the run for operator review.

## Renew publishing access

Stock Studio grants expire after 30 days; OAuth refresh does not extend that deadline. Renew before expiry or after a workflow reports HTTP 401:

```sh
python3 deploy/blyg/authorize.py --publish
```

The helper prompts for the Studio owner password with terminal echo disabled, logs in over HTTPS, creates a REST token with read/draft/publish permissions, and pipes it directly to GitHub's `blyg-publish` environment secret `BLYG_API_TOKEN`. It revokes the previous grants named “Dormouse release publisher” after saving the replacement, then synchronizes pending releases. It never saves the password, session cookie or token to disk. Requires Python 3, Node, and `gh` authenticated with permission to set this environment's secrets. Run between publisher jobs so rotating the token does not interrupt an active run.

Only for a new installation, `--initialize --publish` first sets the chosen owner password through Wrangler's stdin. It requires the separate random `COOKIE_SECRET` to already exist. Do not use `--initialize` during ordinary token renewal: it changes the owner password. No AI-provider credential is needed for release publication.

## Deploy and update Studio

Commands below start at the repository root. Run Wrangler from a temporary directory with an absolute config path, or under the repository's pinned Node runtime; ambient `npx` inside this monorepo may reject pnpm's `devEngines` setting.

```sh
node deploy/blyg/install-bundle.mjs
pnpm exec npx --yes [email protected] deploy --dry-run --config deploy/blyg/wrangler.jsonc
pnpm exec npx --yes [email protected] deploy --config deploy/blyg/wrangler.jsonc
```

`install-bundle.mjs` downloads the exact official GitHub archive, verifies its pinned hash, checks archive paths and file types, and extracts the bundle without running upstream hooks. It does not deploy. The upstream MIT license and installation guide are retained in `.bundle/LICENSE` and `.bundle/README.md`.

For an update, read every intervening [upstream changelog entry](https://github.com/blygger/blygger-studio/blob/main/CHANGELOG.md) and the target release instructions. Verify its published checksum, update `release.json`, and run the installer. Preserve this installation's configuration and secrets, incorporating only required config changes. Back up D1 and original R2 media before migrations; apply the official migrations with `d1 migrations apply DB --remote --config deploy/blyg/wrangler.jsonc`, then deploy. A Git revert does not reverse database migrations. Check the manifest's generator version, RSS, archive index, existing posts and pins, Studio login, and anonymous `/api/settings` returning 401. Commit the release pin and verified deployment record together.

## Backups

Git backs up configuration and publisher code, not posts, grants or media. D1 exports contain private data. Keep them outside Git with restricted permissions and store an encrypted copy off this computer. R2 originals need a separate backup; `media.r2_key` enumerates uploads in the current schema. The feed cache can be rebuilt.

```sh
mkdir -p deploy/blyg/backups
chmod 700 deploy/blyg/backups
(umask 077; pnpm exec npx --yes [email protected] d1 export DB --remote --config deploy/blyg/wrangler.jsonc --output "deploy/blyg/backups/$(date -u +%Y%m%dT%H%M%SZ).sql")
```

No scheduled data-backup job is configured. Changing `COOKIE_SECRET` invalidates Studio sessions and delegated grants. Changing `OWNER_PASSWORD` invalidates owner sessions; revoke delegated grants in Studio separately if required.

## Read from your personal Studio

Subscribe to `https://blyg.dormouse.sh/` in `blyg.nedshed.dev`. Once imported, quote a release or selected passage through Studio's normal transclusion controls. Dormouse's receiver collects responses targeting Dormouse entries; the personal receiver collects responses targeting personal posts.
89 changes: 89 additions & 0 deletions deploy/blyg/authorize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Renew Studio publishing access without writing credentials to disk or argv."""
import argparse
import datetime
import getpass
import http.cookiejar
import json
import os
from pathlib import Path
import subprocess
import tempfile
import urllib.error
import urllib.parse
import urllib.request

ORIGIN = "https://blyg.dormouse.sh"
NAME = "Dormouse release publisher"
ROOT = Path(__file__).resolve().parents[2]


class SameOriginRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlsplit(newurl)[:2] != urllib.parse.urlsplit(ORIGIN)[:2]:
raise RuntimeError("Refusing cross-origin Studio redirect")
return super().redirect_request(req, fp, code, msg, headers, newurl)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--initialize", action="store_true", help="Set a NEW installation's owner password in Cloudflare first")
parser.add_argument("--publish", action="store_true", help="Sync released changelog entries after renewing access")
args = parser.parse_args()
password = getpass.getpass("Choose the new Dormouse Studio owner password: " if args.initialize else "Dormouse Studio owner password: ")
if not password:
raise RuntimeError("Password must not be empty")
if args.initialize:
if len(password) < 16:
raise RuntimeError("Use an owner password with at least 16 characters")
if password != getpass.getpass("Confirm owner password: "):
raise RuntimeError("Passwords did not match")
release = json.loads((ROOT / "deploy/blyg/release.json").read_text())
# Run outside the monorepo: npm does not understand pnpm's devEngines onFail=download.
with tempfile.TemporaryDirectory(prefix="dormouse-blyg-auth-") as cwd:
subprocess.run(["npx", "--yes", f"wrangler@{release['wranglerVersion']}", "secret", "put", "OWNER_PASSWORD", "--config", str(ROOT / "deploy/blyg/wrangler.jsonc")], input=password, text=True, cwd=cwd, check=True)

jar = http.cookiejar.CookieJar()
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), SameOriginRedirect())
# Cloudflare Browser Integrity Check rejects urllib's generic default UA.
client.addheaders = [("User-Agent", "dormouse-blyg-publisher/1.0 (+https://dormouse.sh)")]
login = urllib.request.Request(ORIGIN + "/studio/login", data=urllib.parse.urlencode({"password": password}).encode(), headers={"Origin": ORIGIN})
with client.open(login, timeout=60) as response:
response.read()
password = None
if not list(jar):
raise RuntimeError("Studio did not establish an owner session")

def api(method, path, body=None):
request = urllib.request.Request(ORIGIN + "/api" + path, method=method, headers={"Origin": ORIGIN, "Content-Type": "application/json"}, data=None if body is None else json.dumps(body).encode())
with client.open(request, timeout=60) as response:
return json.load(response)

old = api("GET", "/authorizations")["items"]
grant = api("POST", "/authorizations", {"name": NAME, "resource": "api", "scope": ["owner:read", "owner:draft", "owner:publish"]})
token = grant["access_token"]
try:
subprocess.run(["gh", "secret", "set", "BLYG_API_TOKEN", "--repo", "diffplug/dormouse", "--env", "blyg-publish"], input=token, text=True, check=True)
except Exception:
api("DELETE", "/authorizations/" + urllib.parse.quote(grant["authorization"]["id"], safe=""))
raise
# Only revoke the grants this dedicated publisher previously owned, after
# GitHub has accepted the replacement. Other clients' grants are untouched.
for prior in old:
if prior["name"] == NAME:
api("DELETE", "/authorizations/" + urllib.parse.quote(prior["id"], safe=""))
expires = datetime.datetime.fromtimestamp(grant["authorization"]["expiresAt"], datetime.timezone.utc)
print(f"Publishing token saved to GitHub environment blyg-publish; expires {expires.isoformat()}.")
if args.publish:
subprocess.run(["node", "scripts/publish-blyg.mjs"], cwd=ROOT, env={**os.environ, "BLYG_API_TOKEN": token}, check=True)


if __name__ == "__main__":
try:
main()
except urllib.error.HTTPError as error:
# Never print auth response bodies or request headers.
path = urllib.parse.urlsplit(error.url).path
raise SystemExit(f"HTTP {error.code} at {path}. Check Studio access and try again.")
except (RuntimeError, subprocess.CalledProcessError) as error:
raise SystemExit(str(error))
17 changes: 17 additions & 0 deletions deploy/blyg/deployment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"deployedAt": "2026-10-08",
"studioVersion": "0.36.1",
"initialWorkerVersion": "a444cea0-f883-41a7-8b66-2b34bc8dd22c",
"publicUrl": "https://blyg.dormouse.sh/",
"verified": {
"publicPage": 200,
"manifest": 200,
"rss": 200,
"archiveIndex": 200,
"anonymousSettings": 401,
"webmentionAdvertised": true,
"ownerLogin": true,
"publishedReleases": 16,
"pinnedRevisions": 16
}
}
36 changes: 36 additions & 0 deletions deploy/blyg/install-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Download a checksum-pinned, unmodified upstream Worker; never run package hooks.
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

const root = fileURLToPath(new URL('.', import.meta.url));
const release = JSON.parse(await readFile(join(root, 'release.json'), 'utf8'));
if (!/^\d+\.\d+\.\d+$/.test(release.version) || !/^[a-f0-9]{64}$/.test(release.archiveSha256)) {
throw new Error('Invalid pinned release');
}
const name = `blygger-worker-${release.version}`;
const stage = join(root, '.staging', release.version);
await mkdir(stage, { recursive: true });
const response = await fetch(`https://github.com/blygger/blygger-studio/releases/download/v${release.version}/${name}.tar.gz`);
if (!response.ok) throw new Error(`Bundle download failed: HTTP ${response.status}`);
const archive = Buffer.from(await response.arrayBuffer());
if (createHash('sha256').update(archive).digest('hex') !== release.archiveSha256) {
throw new Error('Bundle checksum mismatch; nothing installed');
}
const path = join(stage, `${name}.tar.gz`);
await writeFile(path, archive);
const entries = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).trim().split('\n');
if (entries.some(entry => !entry.startsWith(`${name}/`) || entry.split('/').includes('..'))) {
throw new Error('Unsafe archive path');
}
const types = execFileSync('tar', ['-tvzf', path], { encoding: 'utf8' }).trim().split('\n');
if (types.some(entry => !['-', 'd'].includes(entry[0]))) throw new Error('Archive contains a link or special file');
await rm(join(stage, name), { recursive: true, force: true });
execFileSync('tar', ['-xzf', path, '-C', stage]);
await readFile(join(stage, name, 'worker.js'));
await rm(join(root, '.bundle'), { recursive: true, force: true });
await rename(join(stage, name), join(root, '.bundle'));
console.log(`Installed official Studio ${release.version} in deploy/blyg/.bundle (SHA-256 verified).`);
5 changes: 5 additions & 0 deletions deploy/blyg/release.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": "0.36.1",
"archiveSha256": "90b651ad40b6bc77615a29793165633b2d62c29571ed609696267664c0695f20",
"wranglerVersion": "4.141.0"
}
22 changes: 22 additions & 0 deletions deploy/blyg/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "blyg-dormouse-sh",
"account_id": "0a95e814ccf2b6a95d2dc3bea0a4a2b4",
"routes": [{ "pattern": "blyg.dormouse.sh", "custom_domain": true }],
"workers_dev": false,
"preview_urls": false,
"main": ".bundle/worker.js",
"no_bundle": true,
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"cache": { "enabled": false },
"exports": { "PublicHtml": { "type": "worker", "cache": { "enabled": true } } },
"vars": { "MOUNT": "" },
"triggers": { "crons": ["* * * * *", "*/15 * * * *", "0 0 * * *"] },
"d1_databases": [{
"binding": "DB",
"database_name": "blyg-dormouse-sh",
"database_id": "bd17595e-42e2-4dd9-82b8-ea2acbf94e78",
"migrations_dir": ".bundle/migrations"
}],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "blyg-dormouse-sh-media" }]
}
12 changes: 12 additions & 0 deletions docs/specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ The website changelog page imports `website/src/data/changelog.json`, but **`CHA

Source of truth: `create_release` in `scripts/sign-and-deploy.sh`; `website/scripts/generate-changelog.js`; lifecycle scripts in `website/package.json`.

### Blyg changelog

**Must host `https://blyg.dormouse.sh/` with the checksum-pinned, unmodified Blygger Studio Worker.** Its Cloudflare D1 database and R2 bucket hold content; Git records deployment configuration and the upstream release checksum. Operator setup, updates and token renewal are in `deploy/blyg/README.md`.

**Must publish only stable, public GitHub releases with a matching `CHANGELOG.md` entry**, oldest first when backfilling. `.github/workflows/blyg-publish.yml` runs on release publication, changelog changes on `main`, and manual dispatch; publisher code and changelog come from reviewed `main`. Versions without a public GitHub release remain unpublished. Studio records the Blyg publication time; the entry text retains the original release date.

**Must retain one Studio item per release, pin every published revision, and leave unchanged content at its existing revision.** A correction uses the same item. An interrupted run resumes its matching draft or completes the current pin; conflicting drafts, duplicate release identities and withdrawn items require operator resolution. **Never edit release text in Studio**; change `CHANGELOG.md` and synchronize it. Avoid simultaneous manual and automated publication; Actions serializes its runs.

**Must receive Webmentions independently of the publisher, with public response display off initially.** Studio owns verification and moderation. A publishing-token expiry stops synchronization without affecting the public site or receiver. Credential placement follows `docs/specs/security-ci.md` -> "Automated Maintainer (tend)".

Source of truth: `syncPublications` in `scripts/publish-blyg.mjs`; `.github/workflows/blyg-publish.yml`; `deploy/blyg/wrangler.jsonc`.

## Environment / secrets

| Secret | Where | Purpose |
Expand Down
Loading
Loading