diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a558dbbe1..80ccc7c47 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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-.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-.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 @@ -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 @@ -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: @@ -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. @@ -344,3 +286,217 @@ 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() + && ((github.event_name == 'workflow_dispatch' + && needs.sign-release-pdf.result == 'skipped') + || (github.event_name == 'release' + && needs.sign-release-pdf.result == 'success' + && 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 diff --git a/docs/user/README.md b/docs/user/README.md index 1062d5cce..a5028c618 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -40,7 +40,14 @@ work in `.md` files. The **Docs** workflow (`.github/workflows/docs.yml`) builds the site and the PDF on every pull request that touches `docs/user/`, `tools/docs/` or the -workflow itself, and deploys both to Cloudflare Pages on a push to `main`. +workflow itself. A merge to `main` deploys nothing: the site is deployed to +Cloudflare Pages when an app release is published, from the release's tag. +A maintainer can redeploy by hand with **Run workflow** on the Docs workflow, +from the latest release by default, or from `ref`: `main`, to publish a +docs-only fix between releases, or a published app release tag. Any other +branch or tag is refused. The deploy runs in the `cloudflare-pages` +environment, which holds `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`; +it fails if either is missing. ## Writing pages @@ -65,8 +72,11 @@ at Stable for every page the snapshot now has: python3 tools/docs/snapshot_stable.py 26.10.2 ``` -The PDF manual is built from `docs/`: Next on `main`, the tag's docs on a -release. +The PDF manual is built from `docs/`: Next on a pull request, the tag's docs +on a release. + +Between releases the site does not follow `main`: Next shows `main` as of the +last deploy. ## Generated reference pages @@ -131,8 +141,14 @@ font in `tools/docs/pdf/fonts/`). It needs `node`, `pandoc` 3.6 or newer and python3 tools/docs/build_pdf.py --output AetherSDR-Manual.pdf ``` -CI deploys it with the site as `/AetherSDR-Manual.pdf`, and attaches it to -every release with a detached signature made with the release key. +CI attaches it to every release, as `AetherSDR-Manual-.pdf`, with a +detached signature made with the release key. It is not deployed with the site, +because Cloudflare Pages refuses any file over 25 MiB. Instead the deploy writes +a `_redirects` entry that sends `/AetherSDR-Manual.pdf`, the address the app and +the site link to, to the PDF on the release. The site build also generates this +redirect from `stable-version.json` for standalone builds. The deploy replaces +that default with its verified release asset, keeping exactly one manual rule +and preserving unrelated redirects. ## Log Analyzer diff --git a/docs/user/docusaurus.config.js b/docs/user/docusaurus.config.js index 282d28929..5f80ccf1c 100644 --- a/docs/user/docusaurus.config.js +++ b/docs/user/docusaurus.config.js @@ -4,6 +4,8 @@ import {themes as prismThemes} from 'prism-react-renderer'; import {createRequire} from 'node:module'; +import {writeFile} from 'node:fs/promises'; +import path from 'node:path'; // The release the Stable docs describe. Written by // tools/docs/snapshot_stable.py with the snapshot in versioned_docs/. @@ -46,6 +48,21 @@ const config = { locales: ['en'], }, + plugins: [ + // The printable manual is not deployed with the site: it is larger than + // the 25 MiB a Cloudflare Pages file may be. /AetherSDR-Manual.pdf, the + // link in the site and in the app's Help menu, is a redirect to the copy + // attached to the stable release (docs.yml makes and signs it). + () => ({ + name: 'manual-pdf-redirect', + async postBuild({outDir}) { + const tag = `v${stable.label}`; + const pdf = `https://github.com/aethersdr/AetherSDR/releases/download/${tag}/AetherSDR-Manual-${tag}.pdf`; + await writeFile(path.join(outDir, '_redirects'), `/AetherSDR-Manual.pdf ${pdf} 302\n`); + }, + }), + ], + presets: [ [ 'classic', @@ -125,9 +142,9 @@ const config = { }, {to: '/log-analyzer', label: 'Log Analyzer', position: 'left'}, { - // Built by tools/docs/build_pdf.py and deployed with the site - // (docs.yml). pathname:// keeps the link checker off it: it is a - // file, not a page. + // Redirected to the stable release's PDF (the plugin above). + // pathname:// keeps the link checker off it: it is a file, not a + // page. href: 'pathname:///AetherSDR-Manual.pdf', label: 'PDF manual', position: 'left',