From 1660cb228f78fcd0a5100086191751fff655b06c Mon Sep 17 00:00:00 2001 From: Ryan Butler Date: Sat, 17 Oct 2026 17:15:31 -0500 Subject: [PATCH 1/4] ci(docs): deploy the docs site at release time, not on merge. Principle VIII. Every push to main that touched docs/user/** rebuilt and redeployed docs.aethersdr.com. Since 2026-10-17 every one of those deploys has failed: the PDF manual (25.3 MiB) is over Cloudflare Pages' 25 MiB per-file limit. - Drop the push-to-main trigger. Pull requests still run the link check, the generated-reference check and the PDF render check. - Deploy the site when an app release (v*, not a pre-release) is published, from its tag, after release-pdf has attached the signed PDF. - Stop uploading the PDF to Pages. A _redirects entry sends /AetherSDR-Manual.pdf (the URL the app and the site link to) to the signed PDF on the release. - workflow_dispatch deploys the latest app release, or the tag or branch given as `ref`. - release-pdf only runs for v* tags. It had been attaching the manual to flex-tailnet-shim and test pre-releases. Fixes #6556. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 248 +++++++++++++++++++++++-------------- docs/user/README.md | 20 ++- 2 files changed, 168 insertions(+), 100 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a558dbbe1c..051751855a 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 the tag or branch given as `ref` (main, for a +# docs-only fix between releases). 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: 'Tag or branch to deploy (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,119 @@ jobs: run: | gpgconf --kill gpg-agent || true rm -rf "$GNUPGHOME" + + # The site, from a published app release (after its PDF is attached, so the + # redirect never points at a missing file) or from a manual run. + deploy: + name: Deploy docs site + needs: release-pdf + if: >- + ${{ !cancelled() + && (needs.release-pdf.result == 'success' || needs.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: 20 + env: + # Mapped from the secret so steps can be gated on its presence: the + # `secrets` context is not available in step `if:` conditions. + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + GH_TOKEN: ${{ github.token }} + steps: + # A release deploys its own tag. A manual run deploys `ref`, or else the + # latest app release. The PDF link always names a release, because a + # run from main has no signed PDF of its own. + - name: Resolve what to deploy + id: target + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + if [ -n "$RELEASE_TAG" ]; then + ref="$RELEASE_TAG" + pdf_tag="$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 + pdf="AetherSDR-Manual-${pdf_tag}.pdf" + if ! gh release view "$pdf_tag" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name' | grep -qxF "$pdf"; then + echo "::error::Release ${pdf_tag} has no ${pdf}; the PDF redirect would be broken." + exit 1 + fi + echo "Deploying ${ref}; PDF manual from ${pdf_tag}" + { + echo "ref=${ref}" + echo "pdf_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${pdf_tag}/${pdf}" + } >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.target.outputs.ref }} + + - 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 + working-directory: docs/user + run: npm ci + + - name: Build (fails on any broken link or anchor) + working-directory: docs/user + 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 + working-directory: docs/user + env: + PDF_URL: ${{ steps.target.outputs.pdf_url }} + run: | + echo "/AetherSDR-Manual.pdf ${PDF_URL} 302" >> build/_redirects + cat build/_redirects + + - name: Check no file exceeds the Pages limit + working-directory: docs/user + 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: Check deploy configuration + if: ${{ env.CF_TOKEN == '' }} + run: | + echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping deploy." + echo "Add CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID repo secrets to enable it." + + # First run creates the project; later runs no-op (hence continue-on-error). + - name: Ensure Pages project exists + if: ${{ env.CF_TOKEN != '' }} + continue-on-error: true + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages project create aethersdr-docs --production-branch main + + - name: Deploy to Cloudflare Pages + if: ${{ env.CF_TOKEN != '' }} + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy docs/user/build --project-name=aethersdr-docs --branch=main --commit-dirty=true diff --git a/docs/user/README.md b/docs/user/README.md index 1062d5cce7..7c9164b523 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -40,7 +40,11 @@ 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 the tag or branch given as `ref` +(`main`, to publish a docs-only fix between releases). ## Writing pages @@ -65,8 +69,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 +138,11 @@ 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. ## Log Analyzer From 667e7940727b22b9958da025a98b6362e89570a4 Mon Sep 17 00:00:00 2001 From: Ryan Butler Date: Sun, 18 Oct 2026 15:42:18 -0500 Subject: [PATCH 2/4] ci(docs): build the deploy without secrets and refuse unreviewed refs. Principle XII. Addresses the review on #6558, and restores what the rebase onto #6571 dropped from the deploy job. - resolve-target (read-only token, no checkout) accepts only an empty ref, main, or a published non-pre-release v* release tag, and pins it to a commit SHA. Any other branch or tag is refused. - build-site builds that SHA with a read-only token, no secrets and npm ci --ignore-scripts, writes the PDF redirect, checks the 25 MiB limit and uploads the site. - deploy runs in the cloudflare-pages environment with no permissions and no checkout, and passes the token only to the pinned wrangler-action, with wrangler pinned to 4.147.0 again. A missing token or account ID fails the job instead of skipping it. - A release deploys after sign-release-pdf, the job that now attaches the PDF, not after release-pdf. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 156 ++++++++++++++++++++++++++++--------- docs/user/README.md | 7 +- 2 files changed, 125 insertions(+), 38 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 051751855a..6d196770a1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,9 +22,9 @@ name: Docs # _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 the tag or branch given as `ref` (main, for a -# docs-only fix between releases). Its PDF link always points at the latest -# app release. +# 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 @@ -40,7 +40,7 @@ on: workflow_dispatch: inputs: ref: - description: 'Tag or branch to deploy (empty: the latest app release)' + description: 'main or a published app release tag (empty: the latest app release)' required: false default: '' release: @@ -287,39 +287,58 @@ jobs: gpgconf --kill gpg-agent || true rm -rf "$GNUPGHOME" - # The site, from a published app release (after its PDF is attached, so the - # redirect never points at a missing file) or from a manual run. - deploy: - name: Deploy docs site - needs: release-pdf + # 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.release-pdf.result == 'success' || needs.release-pdf.result == 'skipped') + && (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: 20 - env: - # Mapped from the secret so steps can be gated on its presence: the - # `secrets` context is not available in step `if:` conditions. - CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - GH_TOKEN: ${{ github.token }} + timeout-minutes: 5 + permissions: + contents: read + outputs: + sha: ${{ steps.target.outputs.sha }} + pdf_url: ${{ steps.target.outputs.pdf_url }} steps: - # A release deploys its own tag. A manual run deploys `ref`, or else the - # latest app release. The PDF link always names a release, because a - # run from main has no signed PDF of its own. - - name: Resolve what to deploy + - 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 - ref="$RELEASE_TAG" pdf_tag="$RELEASE_TAG" + ref="$RELEASE_TAG" else pdf_tag=$(gh release list --repo "$GITHUB_REPOSITORY" \ --exclude-drafts --exclude-pre-releases --limit 50 \ @@ -330,21 +349,59 @@ jobs: 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" if ! gh release view "$pdf_tag" --repo "$GITHUB_REPOSITORY" \ --json assets --jq '.assets[].name' | grep -qxF "$pdf"; then echo "::error::Release ${pdf_tag} has no ${pdf}; the PDF redirect would be broken." exit 1 fi - echo "Deploying ${ref}; PDF manual from ${pdf_tag}" + + echo "Deploying ${ref} (${sha}); PDF manual from ${pdf_tag}" { - echo "ref=${ref}" + 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 + 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: ${{ steps.target.outputs.ref }} + ref: ${{ needs.resolve-target.outputs.sha }} + persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -353,24 +410,20 @@ jobs: cache-dependency-path: docs/user/package-lock.json - name: Install dependencies - working-directory: docs/user - run: npm ci + run: npm ci --ignore-scripts - name: Build (fails on any broken link or anchor) - working-directory: docs/user 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 - working-directory: docs/user env: - PDF_URL: ${{ steps.target.outputs.pdf_url }} + PDF_URL: ${{ needs.resolve-target.outputs.pdf_url }} run: | echo "/AetherSDR-Manual.pdf ${PDF_URL} 302" >> build/_redirects cat build/_redirects - name: Check no file exceeds the Pages limit - working-directory: docs/user run: | big=$(find build -type f -size +25M) if [ -n "$big" ]; then @@ -379,26 +432,57 @@ jobs: 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] + 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 - if: ${{ env.CF_TOKEN == '' }} + env: + HAS_CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} + HAS_CF_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID != '' }} run: | - echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping deploy." - echo "Add CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID repo secrets to enable it." + 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 - if: ${{ env.CF_TOKEN != '' }} 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: ${{ env.CF_TOKEN != '' }} uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy docs/user/build --project-name=aethersdr-docs --branch=main --commit-dirty=true + 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 7c9164b523..97137857c1 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -43,8 +43,11 @@ PDF on every pull request that touches `docs/user/`, `tools/docs/` or the 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 the tag or branch given as `ref` -(`main`, to publish a docs-only fix between releases). +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 From 7fa2989b15100c11b0a96f12f2aaaf6e68905868 Mon Sep 17 00:00:00 2001 From: jensenpat Date: Tue, 20 Oct 2026 10:44:24 -0700 Subject: [PATCH 3/4] Harden docs deployment and PDF redirects. Principle VIII. Avoid SIGPIPE false negatives in the release asset check. Explicit status conditions allow the manual deploy chain to run after release signing is skipped, while rejecting failed dependencies and cancellation. Replace any existing manual redirect instead of appending duplicates, preserving unrelated rules for builds with the Docusaurus redirect plugin. --- .github/workflows/docs.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6d196770a1..80ccc7c47c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -304,9 +304,10 @@ jobs: 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 == '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 @@ -374,8 +375,9 @@ jobs: fi pdf="AetherSDR-Manual-${pdf_tag}.pdf" - if ! gh release view "$pdf_tag" --repo "$GITHUB_REPOSITORY" \ - --json assets --jq '.assets[].name' | grep -qxF "$pdf"; then + 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 @@ -390,6 +392,7 @@ jobs: 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: @@ -420,7 +423,17 @@ jobs: env: PDF_URL: ${{ needs.resolve-target.outputs.pdf_url }} run: | - echo "/AetherSDR-Manual.pdf ${PDF_URL} 302" >> build/_redirects + 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 @@ -446,6 +459,7 @@ jobs: 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 From 5a5f45de8772aad47549c006662d23b41a013498 Mon Sep 17 00:00:00 2001 From: "Jeremy [KK7GWY]" Date: Sun, 18 Oct 2026 16:43:35 -0700 Subject: [PATCH 4/4] docs(user): serve the PDF manual from the stable release, not Pages. Principle VIII. The manual is now 25.5 MiB, over the 25 MiB a Cloudflare Pages file may be, so every main deploy since 2026-10-16 has failed on it and the site has not updated. The Docs build still renders the PDF, as a check, but outside the site's build/, so the deploy no longer carries it. A _redirects file written after the Docusaurus build sends /AetherSDR-Manual.pdf (302) to the copy attached to the release stable-version.json names, so the site's links and the app's Help menu link keep working. That copy is the stable manual; the site used to serve one typeset from main. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012U8wmr3AQGVrmQLp2zNBwa --- docs/user/README.md | 5 ++++- docs/user/docusaurus.config.js | 23 ++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/user/README.md b/docs/user/README.md index 97137857c1..a5028c618e 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -145,7 +145,10 @@ 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 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 282d289290..5f80ccf1cf 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',