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
14 changes: 9 additions & 5 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,15 @@ jobs:
# BASE_URL must match the versioned sub-path the build is served at, or its
# root-absolute assets 404. assemble files this build's artifact at the same
# version name (site/<default-branch>, site/<tag>, site/pr-<n>), so the two
# cannot drift. build-command comes through an env var (not interpolated into
# the script) and is run with `eval` so compound commands work.
# cannot drift. VERSION_NAME is the bare version token, exported for builds
# that need it directly — e.g. a Sphinx conf.py setting pydata-sphinx-theme's
# switcher `version_match` — without parsing it back out of BASE_URL.
# build-command comes through an env var (not interpolated into the script)
# and is run with `eval` so compound commands work.
- name: Build docs
env:
BASE_URL: /${{ github.event.repository.name }}/${{ steps.ver.outputs.version-name }}
VERSION_NAME: ${{ steps.ver.outputs.version-name }}
BUILD_COMMAND: ${{ inputs.build-command }}
run: |
set -euo pipefail
Expand Down Expand Up @@ -116,6 +120,6 @@ jobs:
path: ${{ runner.temp }}/docs.zip
compression-level: 0

# NOTE: the fork-PR "preview not published" hint now lives in publish-dispatch.yml
# (its `warn` job), which runs for fork PRs too — so this build job stays purely
# about building + uploading the docs artifact.
# NOTE: the fork-PR "preview not published" hint lives in publish.yml's `warn`
# job (reached via the publish-dispatch.yml shim), which runs for fork PRs too —
# so this build job stays purely about building + uploading the docs artifact.
62 changes: 44 additions & 18 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ jobs:
- name: Create staging dirs
run: mkdir -p "$RUNNER_TEMP/site" "$RUNNER_TEMP/gather"

# The live Pages base URL, from the Pages API — respects a custom domain
# (CNAME); falls back to the conventional github.io URL if the API call
# fails. Used by the _sources restore below and baked into switcher.json.
- name: Resolve the Pages URL
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
url=$(gh api "repos/$REPO/pages" -q .html_url 2>/dev/null || true)
if [ -z "$url" ]; then
owner=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]')
url="https://$owner.github.io/${REPO#*/}"
fi
echo "PAGES_URL=${url%/}" >> "$GITHUB_ENV"
echo "Pages URL: ${url%/}"

# Releases are permanent sources; the seed release (if present) stands in for the
# default branch until _sources/ is established — store it as $DEFAULT.zip so the
# priority-overwrite chain below can supersede it with a fresher source.
Expand All @@ -150,27 +166,39 @@ jobs:
|| echo "::warning::release $tag download failed — skipping"
done

# Branch CI artifacts overwrite the seed (higher priority). Default branch: latest
# successful push run. PRs: internal always; external forks only when the head SHA
# carries a preview-approved status. No $ARTIFACT_VERSION_NAME skip — the current
# build (staged next) overwrites anything gathered here with the same name.
# Branch CI artifacts overwrite the seed (higher priority). Found via the
# artifacts API by NAME (`docs` — the contract), never by workflow filename,
# so the consumer's entry workflow can be called anything. An artifact's
# workflow_run carries head_branch/head_sha/head_repository_id but no run
# conclusion — existence is the signal (docs.yml only uploads after a
# successful build). Default branch: newest non-expired docs artifact built
# from THIS repo's code on that branch (head_repository_id keeps a same-named
# fork branch out). PRs: keyed by head SHA — internal always; external forks
# only when the SHA carries a preview-approved status. No
# $ARTIFACT_VERSION_NAME skip — the current build (staged next) overwrites
# anything gathered here with the same name.
- name: Gather branch CI artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gather_run() {
local query="$1" dest="$2" label="$3"
local run; run=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?$query&status=success&per_page=1" \
-q '.workflow_runs[0].id // empty')
[ -n "$run" ] || return 0
repo_id=$(gh api "repos/$REPO" -q .id)
gather_artifact() {
local filter="$1" dest="$2" label="$3"
local id; id=$(gh api --paginate \
"repos/$REPO/actions/artifacts?name=docs&per_page=100" \
| jq -rs "[.[].artifacts[] | select((.expired | not) and ($filter))] | sort_by(.created_at) | last | .id // empty")
[ -n "$id" ] || return 0
# The API serves the artifact as a zip-of-the-artifact (docs.zip inside).
local tmp; tmp=$(mktemp -d)
gh run download "$run" --repo "$REPO" -n docs -D "$tmp" \
&& mv "$tmp/docs.zip" "$RUNNER_TEMP/gather/$dest.zip" \
{ gh api "repos/$REPO/actions/artifacts/$id/zip" > "$tmp/artifact.zip" \
&& unzip -q "$tmp/artifact.zip" docs.zip -d "$tmp" \
&& mv "$tmp/docs.zip" "$RUNNER_TEMP/gather/$dest.zip"; } \
|| echo "::warning::$label artifact unavailable — skipping"
rm -rf "$tmp"
}
gather_run "branch=$DEFAULT&event=push" "$DEFAULT" "default-branch CI"
gather_artifact ".workflow_run.head_branch == \"$DEFAULT\" and .workflow_run.head_repository_id == $repo_id" \
"$DEFAULT" "default-branch CI"
prs=$(gh pr list --repo "$REPO" --state open --limit 200 \
--json number,headRefOid,isCrossRepository -q '.[] | [.number, .headRefOid, .isCrossRepository] | @tsv')
while IFS=$'\t' read -r num sha cross; do
Expand All @@ -180,7 +208,7 @@ jobs:
-q 'any(.[]; .context=="preview-approved" and .state=="success")')
[ "$approved" = "true" ] || continue
fi
gather_run "head_sha=$sha" "pr-$num" "PR #$num"
gather_artifact ".workflow_run.head_sha == \"$sha\"" "pr-$num" "PR #$num"
done <<< "$prs"

# Highest priority: the current build overwrites anything gathered above.
Expand Down Expand Up @@ -209,11 +237,9 @@ jobs:
run: |
set -euo pipefail
[ -f "$RUNNER_TEMP/gather/$DEFAULT.zip" ] && exit 0
owner=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]')
pages_url="https://$owner.github.io/${REPO#*/}"
if curl -fsSL "$pages_url/$SOURCES_DIR/$DEFAULT.zip" \
if curl -fsSL "$PAGES_URL/$SOURCES_DIR/$DEFAULT.zip" \
-o "$RUNNER_TEMP/gather/$DEFAULT.zip" 2>/dev/null; then
echo "::notice::$DEFAULT restored from durable in-site copy ($pages_url/$SOURCES_DIR/$DEFAULT.zip)"
echo "::notice::$DEFAULT restored from durable in-site copy ($PAGES_URL/$SOURCES_DIR/$DEFAULT.zip)"
else
echo "::error::$DEFAULT: not provided by current build, releases, branch CI, or durable in-site copy — deploy aborted"
exit 1
Expand Down Expand Up @@ -245,7 +271,7 @@ jobs:
run: |
set -euo pipefail
stable_src=$(node "$GITHUB_WORKSPACE/.mvs/assemble/assemble.mjs" generate \
--site-dir "$RUNNER_TEMP/site" --repo "$REPO" --required "$DEFAULT")
--site-dir "$RUNNER_TEMP/site" --base-url "$PAGES_URL" --required "$DEFAULT")
[ -n "$stable_src" ] && ln -s "$stable_src" "$RUNNER_TEMP/site/stable"

- name: Upload Pages artifact
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Flag a/b/rc tags as prereleases (mirrors the version-discovery marker).
# Flag PEP 440-style prerelease tags — a/b/rc following a digit, e.g.
# 1.0a1 / 2.0rc1 (mirrors assemble.mjs's isPrerelease). Anchoring on the
# digit keeps tags that merely contain those letters out.
prerelease=""
case "$TAG" in *a*|*b*|*rc*) prerelease="--prerelease" ;; esac
case "$TAG" in *[0-9]a*|*[0-9]b*|*[0-9]rc*) prerelease="--prerelease" ;; esac

if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
# The Release already exists (e.g. published from the UI on a mutable repo).
Expand Down
20 changes: 13 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public action.

## Key design decisions

See [`docs/explanation/architecture.md`](docs/explanation/architecture.md) for the
See [`docs/explanations/architecture.md`](docs/explanations/architecture.md) for the
full rationale. In short:

### The default branch is self-durable in the site (`_sources/`)
Expand All @@ -47,7 +47,9 @@ persists the default branch's `docs.zip` (the one it arrived as — current buil
gathered run, or the fallback — copied verbatim) into the published site at
`_sources/<branch>.zip` (excluded from version discovery), and a deploy whose fresh
artifact is gone restores the branch from that durable in-site copy (fetched from
`PAGES_URL`, default `https://<owner>.github.io/<repo>`). Before the branch ever builds
`$PAGES_URL` — the live Pages URL resolved from the Pages API, so custom domains
work, falling back to `https://<owner>.github.io/<repo>`; the same URL roots the
`switcher.json` entries). Before the branch ever builds
docs (mid gh-pages migration) a final rung reads a **published seed release**
(`pages-default-seed`, created by `scripts/migrate.sh` from the old gh-pages `<default>/`
tree) and persists it to `_sources` on that deploy — so a repo can cut over to the
Expand Down Expand Up @@ -125,7 +127,8 @@ With no releases and no other branches, `assemble` produces a single-entry
rather than failing. The "preferred" version (the redirect target, flagged
`preferred: true` in switcher.json, rendered with a ★) is the newest deployed
non-prerelease tag, falling back to `main`/`master`. Prerelease detection mirrors
`release.yml` (an `a`/`b`/`rc` marker).
`release.yml` (an `a`/`b`/`rc` marker following a digit, PEP 440 style — so
`1.0a1`/`2.0rc1` are prereleases but `release-1.0` is not).

### `stable/` alias
When a non-prerelease release is deployed, the site serves a `stable/` symlink
Expand Down Expand Up @@ -172,13 +175,16 @@ Sub-workflows of `ci.yml`:
- `_test.yml` — `npm test`
- `docs.yml` — **reusable build, parameterised for cross-repo reuse.** Compute the
version name (`pr-<n>` / default-branch / tag) → run `build-command` (required
input) with `BASE_URL` set → pack `docs.zip` (bare `html/`, staged so
any `html-dir` works) → upload the `docs` artifact → warn on fork PRs. No deploy;
`contents: read` only. Installs uv unconditionally and relies on the runner's
input) with `BASE_URL` + `VERSION_NAME` set (the latter for builds that need the
bare token, e.g. a Sphinx conf.py setting pydata's switcher `version_match`) →
pack `docs.zip` (bare `html/`, staged so any `html-dir` works) → upload the `docs`
artifact. No deploy; `contents: read` only (the fork-PR hint lives in publish.yml's
`warn` job). Installs uv unconditionally and relies on the runner's
preinstalled Node, so `build-command` can be `make docs` / `npx … myst build` /
`tox -e docs` regardless of project. This repo passes `npm ci && npm run docs`. It
OWNS the build↔publish contract (version name, BASE_URL, docs.zip `html/` root,
`docs` artifact name) so consumers only choose a command.
`docs` artifact name — publish.yml gathers cross-run artifacts by that NAME via the
artifacts API, never by workflow filename) so consumers only choose a command.
- `release.yml` — **PUBLIC reusable, tag-only.** Downloads every artifact in the run
and attaches them to the tag's GitHub Release via `gh` — `gh release create` if no
Release exists yet (draft→upload→publish atomically, so immutable-safe), else
Expand Down
42 changes: 22 additions & 20 deletions assemble/assemble.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* switcher.json + index.html, and prints the stable-alias source dir on stdout.
* Exposed as one subcommand:
*
* node assemble.mjs generate --site-dir <dir> --repo <org/repo> [--required <csv>]
* node assemble.mjs generate --site-dir <dir> --base-url <url> [--required <csv>]
* → write switcher.json + index.html into <dir>; print the stable-alias
* source dir (the newest deployed release) on stdout, or nothing. Also
* exit-1s if a --required branch is absent from the site.
Expand Down Expand Up @@ -92,11 +92,13 @@ export function orderVersions(builds, tags) {
}

/**
* Is `tag` a prerelease? Mirrors `release.yml`'s test (an `a`, `b`, or `rc`
* marker in the name, PEP 440 style) so "stable" means the same thing repo-wide.
* Is `tag` a prerelease? Mirrors `release.yml`'s test (a PEP 440-style `a`,
* `b`, or `rc` marker *following a digit*, e.g. `1.0a1`/`2.0rc1`) so "stable"
* means the same thing repo-wide. Anchoring on the digit keeps tags that merely
* contain those letters (`release-1.0`, `beta-program`) out of the prerelease set.
*/
export function isPrerelease(tag) {
return /a|b|rc/i.test(tag);
return /\d(a|b|rc)/i.test(tag);
}

/**
Expand Down Expand Up @@ -153,26 +155,26 @@ export function missingRequired(required = [], versions = []) {
return required.filter((branch) => !have.has(branch));
}

/** Build the pydata switcher array for `org/repo`, flagging the stable entry. */
export function switcherStruct(repository, versions, preferred) {
const [org, repoName] = repository.split("/");
/**
* Build the pydata switcher array rooted at `baseUrl` (the site's live Pages
* URL — publish.yml resolves it from the Pages API, so custom domains work),
* flagging the stable entry.
*/
export function switcherStruct(baseUrl, versions, preferred) {
const base = String(baseUrl).replace(/\/+$/, "");
return versions.map((version) => {
const entry = {
version,
url: `https://${org}.github.io/${repoName}/${version}/`,
url: `${base}/${version}/`,
};
if (version === preferred) entry.preferred = true;
return entry;
});
}

/** Serialise the switcher exactly as the Python tool did (2-space JSON). */
export function renderSwitcher(repository, versions, preferred) {
return JSON.stringify(
switcherStruct(repository, versions, preferred),
null,
2,
);
export function renderSwitcher(baseUrl, versions, preferred) {
return JSON.stringify(switcherStruct(baseUrl, versions, preferred), null, 2);
}

/** Root redirect to `target` (relative, so it is host- and repo-agnostic). */
Expand Down Expand Up @@ -202,7 +204,7 @@ function csv(value) {
}

/**
* `generate --site-dir --repo [--required <csv>]` — write switcher.json +
* `generate --site-dir --base-url [--required <csv>]` — write switcher.json +
* index.html and emit the stable-alias source. Runs after all gathering, so it
* also hard-fails (exit 1) if a `--required` branch is absent from the site.
*/
Expand All @@ -211,15 +213,15 @@ function cmdGenerate(rest) {
args: rest,
options: {
"site-dir": { type: "string" },
repo: { type: "string" },
"base-url": { type: "string" },
required: { type: "string" },
},
});
const siteDir = values["site-dir"];
const repo = values.repo;
if (!siteDir || !repo) {
const baseUrl = values["base-url"];
if (!siteDir || !baseUrl) {
throw new Error(
"usage: assemble.mjs generate --site-dir <dir> --repo <org/repo> [--required <csv>]",
"usage: assemble.mjs generate --site-dir <dir> --base-url <url> [--required <csv>]",
);
}

Expand Down Expand Up @@ -251,7 +253,7 @@ function cmdGenerate(rest) {

writeFileSync(
join(siteDir, "switcher.json"),
renderSwitcher(repo, versions, preferred),
renderSwitcher(baseUrl, versions, preferred),
"utf8",
);
if (redirectTarget) {
Expand Down
10 changes: 6 additions & 4 deletions docs/explanations/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ publishes it **directly to GitHub Pages** via `actions/upload-pages-artifact` +
`actions/deploy-pages`. There is **no `gh-pages` branch** — `deploy-pages` publishes
one artifact as the *entire* site, which is a whole-site-replace. The four source
kinds and their durability are in the
[reference](../reference/workflows.md#what-publishyml-gathers).
[reference](../reference/workflows.md#what-publish-yml-gathers).

Releases are permanent, so old versions never vanish. PR previews come from CI
artifacts and silently drop if the artifact expires and nothing rebuilds — fine for
Expand Down Expand Up @@ -68,7 +68,7 @@ ordering:

## The `docs.zip` / version-name contracts

Two contracts (described in the [reference](../reference/workflows.md#the-docszip--version-name-contracts))
Two contracts (described in the [reference](../reference/workflows.md#the-docs-zip-version-name-contracts))
keep build and reconstruction in sync. The design rationale in both is to eliminate
sanitisation:

Expand Down Expand Up @@ -242,8 +242,10 @@ The `stable` segment name is a fixed convention, hardcoded in the widget.
- **PR build not yet green / SHA moved:** an open PR whose current head SHA has no
successful CI run is skipped; its preview appears once the build passes.
- **Merged/closed PR:** drops from the gather (open-PRs only) on the next deploy.
- **Prereleases:** excluded from `preferred`/redirect (an `a`/`b`/`rc` marker, parity
with the release workflow), but still listed in the switcher if gathered.
- **Prereleases:** excluded from `preferred`/redirect (an `a`/`b`/`rc` marker
following a digit, PEP 440 style — parity with the release workflow; a tag that
merely contains those letters, like `release-1.0`, is not a prerelease), but still
listed in the switcher if gathered.
- **Concurrency:** `concurrency: { group: pages, cancel-in-progress: false }` makes
deploys last-writer-wins; reconstructing from durable sources keeps that mostly
self-healing.
Expand Down
Loading