diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..57fa75c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,85 @@ +name: Docs + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened, closed] + +# All three jobs below deploy to the same Wisp site by pulling its current +# tree, editing one subpath, and redeploying the whole thing (see +# script/publish-docs.sh) — they must not run concurrently or they'll race +# and clobber each other's changes. +concurrency: + group: docs-wisp-deploy + cancel-in-progress: false + +permissions: + contents: read + pull-requests: write + +jobs: + publish: + name: Publish docs (master) + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Bogdanp/setup-racket@v1.5 + with: + version: stable + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: raco pkg install --batch --auto --link --name resyntax + - run: npm install -g wispctl@latest + - run: script/publish-docs.sh publish "" + env: + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} + + preview: + name: Publish PR preview + if: github.event_name == 'pull_request' && github.event.action != 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Bogdanp/setup-racket@v1.5 + with: + version: stable + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: raco pkg install --batch --auto --link --name resyntax + - run: npm install -g wispctl@latest + - run: script/publish-docs.sh publish "preview/${{ github.event.pull_request.number }}" + env: + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} + - name: Comment preview link on PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + MARKER="" + BODY="$MARKER + Docs preview: https://resyntax.notjack.space/preview/${PR_NUMBER}/" + existing_id="$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -n1)" + if [ -n "$existing_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" -f body="$BODY" + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BODY" + fi + + preview-cleanup: + name: Remove PR preview + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install -g wispctl@latest + - run: script/publish-docs.sh delete "preview/${{ github.event.pull_request.number }}" + env: + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} diff --git a/script/publish-docs.sh b/script/publish-docs.sh new file mode 100755 index 0000000..42f00b3 --- /dev/null +++ b/script/publish-docs.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Builds Resyntax's Scribble manual and publishes it to the resyntax-docs +# Wisp site (hosted under @claude.notjack.space), merging into whatever's +# already deployed there. +# +# wispctl deploy always replaces a site's entire tree with --path's +# contents, so a naive deploy of just the production docs (or just one PR's +# preview) would wipe out everything else on the site. This script pulls the +# current tree first and only touches the one subpath it's responsible for. +# +# Usage: +# publish-docs.sh publish # "" for the production docs at the +# # site root, "preview/802" for a PR +# publish-docs.sh delete # remove a subpath, e.g. after a PR closes +# +# Must be run from the repository root. Requires WISP_APP_PASSWORD in the +# environment and wispctl/raco on PATH. + +set -euo pipefail + +ACTION="${1:?usage: publish-docs.sh }" +SUBPATH="${2:-}" + +HANDLE="claude.notjack.space" +SITE="resyntax-docs" + +if [ -z "${WISP_APP_PASSWORD:-}" ]; then + echo "WISP_APP_PASSWORD must be set" >&2 + exit 1 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +SITE_DIR="$WORKDIR/site" +mkdir -p "$SITE_DIR" + +# Ignore failure: the site may not exist yet on the very first publish. +wispctl pull "$HANDLE" --site "$SITE" --path "$SITE_DIR" --password "$WISP_APP_PASSWORD" || true + +TARGET_DIR="$SITE_DIR${SUBPATH:+/$SUBPATH}" + +case "$ACTION" in + delete) + rm -rf "$TARGET_DIR" + ;; + publish) + BUILD_DIR="$WORKDIR/build" + raco scribble ++main-xref-in --redirect-main https://docs.racket-lang.org/ \ + --htmls --dest "$BUILD_DIR" main.scrbl + # Wisp serves directory URLs that lack a trailing slash by returning the + # index page directly instead of redirecting to the slash form, which + # breaks the page's relative links (the browser resolves them one + # directory too high). Scribble has no option for this, so inject a + # snippet that bounces slash-less directory URLs to the slash form. + python3 - "$BUILD_DIR/main/index.html" <<'EOF' +import sys +path = sys.argv[1] +snippet = ('') +with open(path, encoding="utf-8") as f: + html = f.read() +assert "" in html, "no tag found in " + path +with open(path, "w", encoding="utf-8") as f: + f.write(html.replace("", "" + snippet, 1)) +EOF + rm -rf "$TARGET_DIR" + mkdir -p "$TARGET_DIR" + cp -r "$BUILD_DIR/main/." "$TARGET_DIR/" + ;; + *) + echo "Unknown action: $ACTION (expected publish or delete)" >&2 + exit 1 + ;; +esac + +wispctl deploy "$HANDLE" --path "$SITE_DIR" --site "$SITE" --yes --password "$WISP_APP_PASSWORD"