Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
67603f7
fix (reference): add favicon
hey-august Jun 29, 2026
01c3e8e
ci(reference): trigger docs on v* release tags instead of the pilot b…
hey-august Jul 1, 2026
ca84ed5
chore(reference): address review nits — accurate docs_dir comment, dr…
hey-august Jul 1, 2026
b98608f
refactor(reference): publish a single unversioned site via mkdocs gh-…
hey-august Jul 2, 2026
7b6c198
Merge branch 'main' into august-20260714-promote-reference-to-production
hey-august Aug 17, 2026
d55e217
chore(reference): drop stale "(pilot)" from workflow header comment
hey-august Aug 17, 2026
e712ec7
Merge branch 'main' of github.com:signalwire/signalwire-python into a…
hey-august Aug 17, 2026
73cd62d
docs: escape bracketed docstring text that renders as broken links
hey-august Aug 18, 2026
f1fe4cc
build(reference): harden gen.sh flags, strict build, empty-page guard
hey-august Aug 18, 2026
d7ac075
ci(reference): publish docs only from stable, newer releases
hey-august Aug 18, 2026
b812226
ci(reference): add non-deploying PR build check
hey-august Aug 18, 2026
320f1f6
docs(reference): drop pilot framing, document the gated pipeline
hey-august Aug 18, 2026
288b738
refactor(reference): remove dead language-switcher CSS
hey-august Aug 18, 2026
0f0e8f8
ci(reference): publish via workflow_call, fail closed, restrict dispatch
hey-august Aug 18, 2026
d5b3303
ci(reference): scope the PR check and correct what it catches
hey-august Aug 18, 2026
6730acd
docs(reference): correct rollback, marker, and go-live documentation
hey-august Aug 18, 2026
256b36e
build(reference): correct the empty-page guard rationale, add --help
hey-august Aug 18, 2026
7d35542
fix(pom): the console script is pom-tool, not pom_tool
hey-august Aug 18, 2026
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
16 changes: 16 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,19 @@ jobs:
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**PyPI:** https://pypi.org/project/signalwire-sdk/${{ steps.tag_version.outputs.tag_version }}/" >> $GITHUB_STEP_SUMMARY

# Republish the API reference for the version that just shipped.
#
# Called here rather than triggered by `release: published` inside
# reference-docs.yml: the Create GitHub Release step above uses
# secrets.GITHUB_TOKEN, and GitHub deliberately does not raise
# workflow-triggering events for that token, so a release trigger would never
# fire on this path. `needs: publish-release` also gives the ordering we
# actually want, with the docs going out only after the PyPI upload succeeded.
reference-docs:
needs: publish-release
permissions:
contents: write # the called workflow force-pushes the built site to gh-pages
uses: ./.github/workflows/reference-docs.yml
with:
tag: ${{ github.ref_name }} # the `v*` tag that triggered this run
48 changes: 48 additions & 0 deletions .github/workflows/reference-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# API reference docs, PR check.
#
# Build-only counterpart to reference-docs.yml: no deploy, no write permission.
# Catches a crashing generator, a root `signalwire` package that will not import
# (gen.sh imports it to enumerate subpackages), and unresolved cross-references,
# on the PR rather than on release day against the live site.
#
# It does NOT catch a submodule whose runtime dependency is missing: mkdocstrings
# analyses statically through griffe, so such a module still renders a clean page.
# Nothing here lints workflow YAML either, so a mistake in reference-docs.yml
# still surfaces at release time. porting-sdk ships scripts/actionlint_gate.py,
# which run-ci.sh does not currently call; wiring it up would close that gap.
name: reference-check

on:
pull_request:
paths:
- "signalwire/**"
- "reference/**"
- "pyproject.toml"
- ".github/workflows/reference-check.yml"
# Changing the publish workflow at least re-runs this build. It is not
# validation OF that workflow; see the note above.
- ".github/workflows/reference-docs.yml"

permissions:
contents: read # never deploys

concurrency:
group: reference-check-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install SDK (editable) + doc toolchain
run: |
python -m pip install --upgrade pip
pip install -r reference/requirements.txt
pip install -e .
# gen.sh without --no-build runs the strict `mkdocs build`.
- name: Generate + build (strict)
run: bash reference/gen.sh --no-install
227 changes: 192 additions & 35 deletions .github/workflows/reference-docs.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,190 @@
# API reference docs (pilot).
# API reference docs.
#
# Builds the SignalWire Python SDK API reference (MkDocs Material + mkdocstrings)
# and publishes it to this repo's OWN GitHub Pages via mike (versioned):
# and publishes a single, unversioned site to this repo's OWN GitHub Pages:
# https://signalwire.github.io/signalwire-python/
#
# PILOT triggers: workflow_dispatch + push to the pilot branch. This is NOT
# gated on releases yet.
# PRODUCTION (later): switch the trigger to `v*` tags so each release version
# is deployed and `latest` is re-aliased. See the commented block below.
# Publishing is deliberately narrow. `gh-deploy` force-pushes, but it does NOT
# discard history: mkdocs passes no_history=False, so ghp-import parents each
# deploy onto origin/gh-pages and --force forces only the push. A bad publish is
# therefore recoverable, by hand, with:
# git push --force origin gh-pages~1:gh-pages
# That recovery depends on somebody noticing, so the gate below exists to keep an
# older or invalid version from becoming the public site in the first place:
# * called by publish-release.yml only after the full CI gate set and the PyPI
# upload have succeeded, so a rejected tag can never become the public site.
# * stable semver tags only, and only when the tag is at least as new as the
# version the last publish recorded at the site root.
# * workflow_dispatch builds a preview by default. It publishes only when the
# `deploy` input is ticked, only from the default branch, and never from a
# fork.
# * anything the gate cannot positively determine fails CLOSED.
#
# There is deliberately NO `release: published` trigger. publish-release.yml
# creates the GitHub Release with `secrets.GITHUB_TOKEN`, and GitHub does not
# raise workflow-triggering events for actions taken with that token
# (workflow_dispatch and repository_dispatch excepted). A `release` trigger would
# therefore sit here looking correct and silently never fire on the normal tag
# path. The reusable `workflow_call` below is the wire that actually connects.
#
# MANUAL REPO SETTING REQUIRED to go live (one-time, in the GitHub UI):
# Settings -> Pages -> Build and deployment -> Source = "Deploy from a branch"
# -> Branch = `gh-pages` / `(root)`. mike pushes the built site there.
# Settings > Pages > Build and deployment > Source = "Deploy from a branch",
# Branch = `gh-pages` / `(root)`. `mkdocs gh-deploy` pushes the built site there.
name: reference-docs

on:
workflow_call:
inputs:
tag:
description: "Release tag being published (vX.Y.Z). Its presence is what marks a run as a release publish."
type: string
required: true
workflow_dispatch:
push:
branches:
- docs/api-reference-pilot

# --- PRODUCTION trigger (enable when promoting out of the pilot) ------------
# push:
# tags:
# - "v*"
inputs:
deploy:
description: "Publish to gh-pages (leave false for a build-only preview)"
type: boolean
default: false
version_marker:
description: "Recovery only: overwrite the site's version.txt with this (vX.Y.Z). Leave blank to preserve the current marker."
type: string
required: false
default: ""

# Applies to the workflow_dispatch path. On the workflow_call path the caller's
# job grants the token permissions, so publish-release.yml sets contents: write
# on the job that calls this workflow.
permissions:
contents: write # mike pushes the built site to the gh-pages branch
contents: write # gh-deploy pushes the built site to the gh-pages branch

jobs:
build-deploy:
runs-on: ubuntu-latest
# Job level, NOT workflow level. A called workflow's jobs run inside the
# CALLER's run, so a top-level `concurrency:` here would be ignored on the
# publish-release.yml path and two releases could race the force-push.
#
# Publishing runs share one serialized group; previews get a per-ref group of
# their own, so a newly queued preview can never displace a pending release
# publish (which would drop it as "cancelled" rather than failing red).
concurrency:
group: ${{ (inputs.tag != '' || inputs.deploy) && 'reference-docs-production' || format('reference-docs-preview-{0}', github.ref) }}
cancel-in-progress: false # never interrupt a push mid-flight
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # mike needs full history + the gh-pages branch
# Required, and do NOT "optimize" it away: this is what fetches
# origin/gh-pages. Without that ref present, the force-push lands a
# parentless orphan commit and the deploy history really is destroyed.
fetch-depth: 0
ref: ${{ inputs.tag || github.ref }}

# Runs BEFORE the toolchain install, so a run that cannot publish does not
# first pay several minutes of pip. It needs nothing but git.
#
# Single decision point for whether this run may force-push the public
# site, and the single place that decides what the version marker becomes.
# `inputs.tag` is only defined for workflow_call and `inputs.deploy` only
# for workflow_dispatch; the one belonging to the other trigger evaluates
# to an empty string, which is what separates the two paths below.
- name: Decide whether to publish
id: gate
env:
TAG: ${{ inputs.tag }}
DISPATCH_DEPLOY: ${{ inputs.deploy }}
DISPATCH_MARKER: ${{ inputs.version_marker }}
REF_NAME: ${{ github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
IS_UPSTREAM: ${{ github.repository == 'signalwire/signalwire-python' }}
run: |
set -euo pipefail
SEMVER='^v[0-9]+\.[0-9]+\.[0-9]+$'
publish=false
marker=""

# Probe gh-pages rather than trusting checkout's refspec, and keep the
# three states distinct. `ls-remote --exit-code` returns 2 for "no such
# ref" and something else for "could not ask", which is the difference
# between a legitimate bootstrap and a failure we must not read as one.
rc=0
git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1 || rc=$?
case "$rc" in
0) branch=present ;;
2) branch=absent ;;
*) branch=unknown ;;
esac

prev=""
if [ "$branch" = "present" ]; then
# No `|| true` here: an unreadable gh-pages must stop the run rather
# than fall through as "no marker, go ahead".
git fetch --no-tags origin +refs/heads/gh-pages:refs/remotes/origin/gh-pages
if git cat-file -e refs/remotes/origin/gh-pages:version.txt 2>/dev/null; then
prev="$(git show refs/remotes/origin/gh-pages:version.txt | tr -d '[:space:]')"
fi
fi
echo "gh-pages=$branch marker-on-site='${prev}'"

if [ "$IS_UPSTREAM" != "true" ]; then
echo "::notice::fork build; not publishing"
elif [ "$branch" = "unknown" ]; then
echo "::error::could not determine the state of gh-pages; refusing to publish"
elif [ -n "$TAG" ]; then
# Release publish, called from publish-release.yml after its gates.
# publish-release.yml triggers on the `v*` glob, so an rc/beta tag
# does reach here; the shape check is what stops it overwriting the
# stable site. Actions expressions have no regex, hence a step.
if [[ ! "$TAG" =~ $SEMVER ]]; then
echo "::warning::$TAG is not stable semver; not republishing"
elif [ "$branch" = "absent" ]; then
echo "::notice::gh-pages does not exist yet; treating $TAG as the first publish"
publish=true
marker="$TAG"
elif [ -z "$prev" ]; then
# Every publish stamps a marker, so a site without one has been
# hand-edited or predates this workflow. Fail closed and make the
# operator repair it deliberately (dispatch with version_marker).
echo "::error::gh-pages carries no readable version.txt; refusing to publish $TAG. Repair with a manual dispatch using the version_marker input."
elif [ "$(printf '%s\n%s\n' "${prev#v}" "${TAG#v}" | sort -V | tail -1)" = "${TAG#v}" ]; then
# Equal versions pass, so re-running a release can republish it.
publish=true
marker="$TAG"
else
echo "::warning::live site is $prev, which is newer than $TAG; not republishing"
fi
else
# Manual dispatch.
if [ "${DISPATCH_DEPLOY:-false}" != "true" ]; then
echo "::notice::preview build; the deploy input was not ticked"
elif [ -z "$DEFAULT_BRANCH" ] || [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then
# Otherwise any branch in the dropdown could publish itself as the
# official public API reference, running its own code under a
# contents: write token.
echo "::error::refusing to publish from '$REF_NAME'; dispatch a publish from the default branch ('${DEFAULT_BRANCH:-unknown}')"
else
publish=true
# gh-deploy replaces the whole site tree (ghp-import emits
# deleteall), so NOT re-stamping here would erase the marker and
# silently disarm the monotonicity guard for the next release.
if [ -n "$DISPATCH_MARKER" ]; then
if [[ ! "$DISPATCH_MARKER" =~ $SEMVER ]]; then
echo "::error::version_marker '$DISPATCH_MARKER' is not vX.Y.Z"
exit 1
fi
marker="$DISPATCH_MARKER"
elif [ -n "$prev" ]; then
marker="$prev"
else
# Bootstrap floor, so the site always carries a readable marker
# and "missing" stays meaningful as an anomaly.
marker="v0.0.0"
fi
fi
fi

echo "publish=$publish" >> "$GITHUB_OUTPUT"
echo "marker=$marker" >> "$GITHUB_OUTPUT"
echo "publish=$publish marker=$marker"

- uses: actions/setup-python@v6
with:
Expand All @@ -46,24 +196,31 @@ jobs:
pip install -r reference/requirements.txt
pip install -e .

- name: Configure git for mike
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Generate API pages
run: bash reference/gen.sh --no-build
run: bash reference/gen.sh --no-build --no-install

- name: Resolve version
id: ver
run: |
# Pilot: use the package version. Production: derive from the v* tag,
# e.g. VERSION="${GITHUB_REF_NAME#v}".
VERSION=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Must run after gen.sh, which does `rm -rf _docs`. The value goes through
# `env:` rather than inline `${{ }}`: git permits shell metacharacters in
# tag names, and this job holds contents: write.
- name: Stamp the version marker
if: steps.gate.outputs.marker != ''
env:
MARKER: ${{ steps.gate.outputs.marker }}
run: printf '%s\n' "$MARKER" > reference/_docs/version.txt

- name: Deploy with mike (versioned) to gh-pages
# Validates preview runs, and fails a publish before it reaches gh-deploy.
# gh-deploy builds again internally (its --strict is honoured there too),
# so publishing runs do build twice; that is deliberate insurance, not an
# oversight.
- name: Build (strict), the gate before any push
run: python3 -m mkdocs build --strict --config-file reference/mkdocs.yml

- name: Configure git
if: steps.gate.outputs.publish == 'true'
run: |
mike deploy --config-file reference/mkdocs.yml --push --update-aliases \
"${{ steps.ver.outputs.version }}" latest
mike set-default --config-file reference/mkdocs.yml --push latest
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Deploy to gh-pages
if: steps.gate.outputs.publish == 'true'
run: python3 -m mkdocs gh-deploy --strict --config-file reference/mkdocs.yml --force
Loading
Loading