Skip to content
Open
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
345 changes: 250 additions & 95 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
name: Docs

# Builds the user documentation site (docs/user, Docusaurus) and, on main,
# deploys it to Cloudflare Pages at https://docs.aethersdr.com. A manual run
# (workflow_dispatch) on main redeploys without a content change.
# Builds the user documentation site (docs/user, Docusaurus) and the
# printable PDF manual (tools/docs/build_pdf.py: Pandoc + Typst).
#
# It also typesets the printable PDF manual (tools/docs/build_pdf.py: Pandoc
# + Typst). On a pull request the PDF is a render check and a downloadable
# artifact; on main it is deployed with the site as
# https://docs.aethersdr.com/AetherSDR-Manual.pdf; and when a release is
# published it is attached to that release as AetherSDR-Manual-<tag>.pdf,
# with a detached signature (.pdf.asc) made with the release key.
# On a pull request both are checks: the build is the link check
# (docusaurus.config.js sets onBrokenLinks, onBrokenAnchors and
# onBrokenMarkdownLinks to 'throw', so a page that links to a missing page or
# heading fails this workflow), and the PDF is a render check and a
# downloadable artifact. Nothing is deployed.
#
# The build is the link check: docusaurus.config.js sets onBrokenLinks,
# onBrokenAnchors and onBrokenMarkdownLinks to 'throw', so a page that links
# to a missing page or heading fails this workflow.
# Publishing the site is a release-time job (#6556). When an app release (a
# v* tag, not a pre-release) is published, the PDF is attached to it as
# AetherSDR-Manual-<tag>.pdf with a detached signature (.pdf.asc) made with
# the release key, and then the site is built from the tag and deployed to
# Cloudflare Pages at https://docs.aethersdr.com. A merge to main deploys
# nothing, so Next (/next/) shows main as of the last deploy.
#
# The PDF is not uploaded to Pages, which refuses any file over 25 MiB (the
# manual passed that on 2026-10-17). The app and the site link to
# https://docs.aethersdr.com/AetherSDR-Manual.pdf; the deploy writes a
# _redirects entry sending that URL to the signed PDF on the release.
#
# A manual run (workflow_dispatch) redeploys the site: from the latest app
# release by default, or from `ref`, which must be `main` (for a docs-only
# fix between releases) or a published app release tag. Its PDF link always
# points at the latest app release.
#
# This is NOT a required status check, which is what makes the `paths:`
# filter safe here. A required check that a PR never triggers blocks that PR
Expand All @@ -26,21 +37,20 @@ on:
- 'docs/user/**'
- 'tools/docs/**'
- '.github/workflows/docs.yml'
push:
branches: [main]
paths:
- 'docs/user/**'
- 'tools/docs/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
inputs:
ref:
description: 'main or a published app release tag (empty: the latest app release)'
required: false
default: ''
release:
types: [published]

# Superseded PR runs are cancelled; every main run completes so a deploy is
# never cut off half-way (same expression as ci.yml / static-checks.yml).
# Superseded PR runs are cancelled; a deploy run always completes so it is
# never cut off half-way.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}${{ github.ref == 'refs/heads/main' && format('-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read
Expand All @@ -57,7 +67,7 @@ env:
jobs:
build:
name: Build docs site
if: ${{ github.event_name != 'release' }}
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
Expand Down Expand Up @@ -109,91 +119,23 @@ jobs:
bin/pandoc --version | head -1
bin/typst --version

# After the site build, which empties build/: the PDF lands in the
# site's root and is deployed with it on main.
- name: Build PDF manual
working-directory: .
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$PR_NUMBER" ]; then
version="PR #${PR_NUMBER} @ ${HEAD_SHA::9}"
else
version="${GITHUB_REF_NAME} @ ${HEAD_SHA::9}"
fi
python3 tools/docs/build_pdf.py --version "$version" \
--output docs/user/build/AetherSDR-Manual.pdf
python3 tools/docs/build_pdf.py --version "PR #${PR_NUMBER} @ ${HEAD_SHA::9}" \
--output "${RUNNER_TEMP}/AetherSDR-Manual.pdf"

- name: Upload PDF manual
if: ${{ github.event_name == 'pull_request' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: AetherSDR-Manual
path: docs/user/build/AetherSDR-Manual.pdf
path: ${{ runner.temp }}/AetherSDR-Manual.pdf
if-no-files-found: error
retention-days: 14

# The deploy job below takes the finished site, PDF included, from here.
- name: Upload site
if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: docs-site
path: docs/user/build
if-no-files-found: error
retention-days: 1

# Publishing is its own job: it checks out nothing and runs only the
# pinned wrangler-action, with an exact wrangler version (the action
# otherwise installs the newest 4.x), on the site the build job produced.
deploy:
name: Deploy docs site
needs: build
if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: cloudflare-pages
permissions: {}
steps:
- name: Check deploy configuration
id: config
env:
HAS_CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
run: |
echo "configured=$HAS_CF_TOKEN" >> "$GITHUB_OUTPUT"
if [ "$HAS_CF_TOKEN" != "true" ]; then
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping deploy."
echo "Add CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID to the cloudflare-pages environment to enable it."
fi

- name: Download site
if: ${{ steps.config.outputs.configured == 'true' }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
name: docs-site
path: site

# First run creates the project; later runs no-op (hence continue-on-error).
- name: Ensure Pages project exists
if: ${{ steps.config.outputs.configured == 'true' }}
continue-on-error: true
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4.147.0'
command: pages project create aethersdr-docs --production-branch main

- name: Deploy to Cloudflare Pages
if: ${{ steps.config.outputs.configured == 'true' }}
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4.147.0'
command: pages deploy site --project-name=aethersdr-docs --branch=main --commit-hash=${{ github.sha }} --commit-dirty=true

# A published release gets the manual for its tag. The tag's own code
# renders it here, with a read-only token and no secrets; sign-release-pdf
# below signs and attaches the finished file in a job that runs none of it.
Expand Down Expand Up @@ -344,3 +286,216 @@ jobs:
run: |
gpgconf --kill gpg-agent || true
rm -rf "$GNUPGHOME"

# Deploying the site is three jobs, so that no job both runs repository
# code and holds the Cloudflare token: resolve-target picks a reviewed
# commit, build-site builds it with no secrets, and deploy publishes the
# result under the cloudflare-pages environment without checking anything
# out.
#
# A release deploys its own tag, after sign-release-pdf has attached the
# PDF, so the redirect never points at a missing file. A manual run deploys
# the latest app release, or `ref`, which may only be `main` or a published
# app release tag: any other branch or tag is refused, so a manual run
# cannot publish unreviewed code. The PDF link always names a release,
# because a run from main has no signed PDF of its own.
resolve-target:
name: Resolve what to deploy
needs: sign-release-pdf
if: >-
${{ !cancelled()
&& (needs.sign-release-pdf.result == 'success' || needs.sign-release-pdf.result == 'skipped')
&& (github.event_name == 'workflow_dispatch'
|| (github.event_name == 'release'
&& startsWith(github.event.release.tag_name, 'v')
&& !github.event.release.prerelease)) }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
sha: ${{ steps.target.outputs.sha }}
pdf_url: ${{ steps.target.outputs.pdf_url }}
steps:
- name: Resolve the commit and the PDF
id: target
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
tag_re='^v[0-9]+(\.[0-9]+){1,3}$'

# A published, non-pre-release app release. Drafts are not visible
# to this read-only token, so they fail here too.
is_app_release() {
[[ "$1" =~ $tag_re ]] || return 1
[ "$(gh release view "$1" --repo "$GITHUB_REPOSITORY" \
--json isDraft,isPrerelease \
--jq '(.isDraft or .isPrerelease) | not' 2>/dev/null)" = "true" ]
}

if [ -n "$RELEASE_TAG" ]; then
pdf_tag="$RELEASE_TAG"
ref="$RELEASE_TAG"
else
pdf_tag=$(gh release list --repo "$GITHUB_REPOSITORY" \
--exclude-drafts --exclude-pre-releases --limit 50 \
--json tagName --jq '[.[] | select(.tagName | test("^v[0-9]"))][0].tagName')
if [ -z "$pdf_tag" ]; then
echo "::error::No published app release (v*) found."
exit 1
fi
ref="${INPUT_REF:-$pdf_tag}"
fi

if ! is_app_release "$pdf_tag"; then
echo "::error::${pdf_tag} is not a published app release."
exit 1
fi
if [ "$ref" != "main" ] && ! is_app_release "$ref"; then
echo "::error::Refusing to deploy '${ref}': ref must be empty, main, or a published app release tag (vX.Y.Z)."
exit 1
fi

# Pin the commit now, so the build cannot pick up a later push.
# The full ref keeps a tag named "main" from standing in for the
# branch.
if [ "$ref" = "main" ]; then
full_ref="refs/heads/main"
else
full_ref="refs/tags/${ref}"
fi
sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${full_ref}" --jq .sha)
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve ${ref} to a commit."
exit 1
fi

pdf="AetherSDR-Manual-${pdf_tag}.pdf"
has_pdf=$(gh release view "$pdf_tag" --repo "$GITHUB_REPOSITORY" \
--json assets --jq ".assets | any(.name == \"${pdf}\")")
if [ "$has_pdf" != "true" ]; then
echo "::error::Release ${pdf_tag} has no ${pdf}; the PDF redirect would be broken."
exit 1
fi

echo "Deploying ${ref} (${sha}); PDF manual from ${pdf_tag}"
{
echo "sha=${sha}"
echo "pdf_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${pdf_tag}/${pdf}"
} >> "$GITHUB_OUTPUT"

# Builds the resolved commit with a read-only token and no secrets.
build-site:
name: Build docs site for deploy
needs: resolve-target
if: ${{ !cancelled() && needs.resolve-target.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
defaults:
run:
working-directory: docs/user
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve-target.outputs.sha }}
persist-credentials: false

- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: docs/user/package-lock.json

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Build (fails on any broken link or anchor)
run: npm run build

# The PDF lives on the release, not on Pages (25 MiB per-file limit).
- name: Redirect the PDF manual to the release
env:
PDF_URL: ${{ needs.resolve-target.outputs.pdf_url }}
run: |
python3 - <<'PY'
import os
from pathlib import Path

redirects = Path('build/_redirects')
lines = redirects.read_text().splitlines() if redirects.exists() else []
lines = [line for line in lines
if not line.split() or line.split()[0] != '/AetherSDR-Manual.pdf']
lines.append(f"/AetherSDR-Manual.pdf {os.environ['PDF_URL']} 302")
redirects.write_text('\n'.join(lines) + '\n')
PY
cat build/_redirects

- name: Check no file exceeds the Pages limit
run: |
big=$(find build -type f -size +25M)
if [ -n "$big" ]; then
echo "::error::Cloudflare Pages refuses files over 25 MiB:"
echo "$big" | xargs ls -l
exit 1
fi

- name: Upload site
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: docs-site
path: docs/user/build
if-no-files-found: error
retention-days: 1

# Publishing is its own job: it checks out nothing and runs only the
# pinned wrangler-action, with an exact wrangler version (the action
# otherwise installs the newest 4.x), on the site build-site produced.
deploy:
name: Deploy docs site
needs: [resolve-target, build-site]
if: ${{ !cancelled() && needs.resolve-target.result == 'success' && needs.build-site.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: cloudflare-pages
permissions: {}
steps:
# The secrets are configured, so a missing one is a misconfiguration
# to fix, not a reason to skip the deploy.
- name: Check deploy configuration
env:
HAS_CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
HAS_CF_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }}
run: |
if [ "$HAS_CF_TOKEN" != "true" ] || [ "$HAS_CF_ACCOUNT" != "true" ]; then
echo "::error::CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set in the cloudflare-pages environment."
exit 1
fi

- name: Download site
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
name: docs-site
path: site

# First run creates the project; later runs no-op (hence continue-on-error).
- name: Ensure Pages project exists
continue-on-error: true
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4.147.0'
command: pages project create aethersdr-docs --production-branch main

- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4.147.0'
command: pages deploy site --project-name=aethersdr-docs --branch=main --commit-hash=${{ needs.resolve-target.outputs.sha }} --commit-dirty=true
Loading
Loading