Skip to content

ci(docs): deploy the docs site at release time, not on merge. Principle VIII. - #6558

Open
NF0T wants to merge 2 commits into
aethersdr:mainfrom
NF0T:ci/docs-release-time-deploy
Open

NF0T wants to merge 2 commits into
aethersdr:mainfrom
NF0T:ci/docs-release-time-deploy

Conversation

@NF0T

@NF0T NF0T commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #6556.

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, because the PDF manual (25.3 MiB) is over Cloudflare Pages' 25 MiB per-file limit. As agreed in Discord, this PR makes publishing the docs a release-time job:

  • No deploy on merge. The push: main trigger is gone. The pull-request job is unchanged apart from where the PDF is written: link check, generated-reference check, Log Analyzer rules, PDF render check and PDF artifact.

  • Deploy on release. When an app release (v* tag, not a pre-release) is published, the site is built from the tag and deployed. This runs after sign-release-pdf, so the signed PDF is already on the release before the site links to it.

  • The PDF is no longer uploaded to Pages. The deploy writes a _redirects entry that sends /AetherSDR-Manual.pdf to AetherSDR-Manual-<tag>.pdf on the release. The app (DocsLinks::manualPdf()) and the site's navbar, footer and pages all link to that URL, so none of them change. The deploy fails early if the release has no such asset, and fails if any file in the site build is over 25 MiB.

  • Manual redeploy. workflow_dispatch deploys the latest app release by default. Its ref input takes main, so a maintainer can publish a docs-only fix between releases, or a published app release tag. Any other branch or tag is refused. The PDF link always points at the latest app release.

  • Credentials kept apart from repository code (ci: run signing, Store and Pages deploys in environment-gated jobs. Principle XII. #6571's pattern). The deploy is three jobs:

    • resolve-target: read-only token, no checkout. Validates ref and pins it to a commit SHA.
    • build-site: read-only token, no secrets, npm ci --ignore-scripts. Builds that SHA.
    • deploy: environment cloudflare-pages, permissions: {}, no checkout. Runs only the pinned wrangler-action (wrangler 4.147.0) on the built artifact.

    A missing Cloudflare token or account ID fails the deploy instead of skipping it.

docs/user/README.md is updated to describe the new deploy.

Repository setting needed (see #6556): the cloudflare-pages environment allows only the main branch. A release-triggered deploy runs on refs/tags/v…, so the environment needs a tag v* rule, as release has, before a release can deploy. Manual runs from main work today.

Open for a maintainer decision (see #6556): between releases, Next (/next/) shows main as of the last deploy rather than following it. If Next should stay live, a nightly scheduled deploy can be added. Push changes to this branch or force-push it if you want a different approach.

Constitution principle honored

Principle VIII: the published docs describe the software users can install, and the PDF users download is the signed release asset. Principle XII: the job that holds the Cloudflare token runs no repository code, and a manual deploy can publish only reviewed code.

Test plan

  • actionlint 1.7.7 is clean on docs.yml.
  • I ran the resolve-target script locally against the real releases in aethersdr/AetherSDR:
    • Deployed as expected: empty ref deploys v26.10.3. main deploys main with the PDF from v26.10.3. v26.10.2 deploys that tag. A release event for v26.10.3 deploys it.
    • Refused: a pre-release (lynovation-test-2026-10-16), a release that isn't the app (flex-tailnet-shim-v0.4.1), a branch, a tag with no release (v26.5.2), v99.0.0, refs/heads/main, main; curl evil and v26.10.2/../main.
    • Failed with "the PDF redirect would be broken": a release without the manual (v26.9.4).
  • The redirect target https://github.com/aethersdr/AetherSDR/releases/download/v26.10.3/AetherSDR-Manual-v26.10.3.pdf resolves (HTTP 302 to the asset).
  • This PR's own Docs run exercises the pull-request job.
  • Deploy path: after merge, a manual Run workflow on main with ref empty should deploy v26.10.3 and fix the live site. I can't run it from a fork (no secrets, no releases). After that run, https://docs.aethersdr.com/AetherSDR-Manual.pdf should return a 302 to the release asset.

No app code changed, so there is no local CMake build or radio test.

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls (n/a)
  • Documentation updated (docs/user/README.md)
  • No CHANGELOG entry (release-prep only)

73,
Ryan NF0T


👨🏼‍💻 Co-authored by Claude Opus 5.5

🤖 Generated with Claude Code

@NF0T
NF0T requested review from a team as code owners October 17, 2026 22:17
@NF0T
NF0T force-pushed the ci/docs-release-time-deploy branch from a7297e7 to 6f50d36 Compare October 18, 2026 14:37

@jensenpat jensenpat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes: the release-triggered path is sound, but the new manual deployment path creates an untrusted-checkout secret-exfiltration and review-bypass primitive.

Scope checked

File Area checked Verdict
.github/workflows/docs.yml Manual ref selection, checkout ordering, npm execution, and Cloudflare credential scope Blocking changes required
docs/user/README.md Description of release/manual deployment Consistent with the workflow

Blockers

  1. .github/workflows/docs.yml:239 — Do not expose CLOUDFLARE_API_TOKEN at job scope.

    CF_TOKEN is present for every step in deploy, including actions/checkout, npm ci, and npm run build. Because those npm commands execute scripts from the selected checkout, a poisoned package.json or dependency lifecycle script can read and exfiltrate the Cloudflare token before the pinned Wrangler action runs.

    Remove the job-level CF_TOKEN mapping and pass CLOUDFLARE_API_TOKEN only to the pinned Wrangler steps. Since this repository already has the secret configured, a missing credential should fail the deployment rather than make it silently skip. If optional deployment must remain supported, gate it with a non-secret repository variable or a small credential-presence step whose environment is scoped to that step.

  2. .github/workflows/docs.yml:266 — Restrict the manual ref input before checkout.

    ref="${INPUT_REF:-$pdf_tag}" accepts any branch or tag. A repository writer can push an unreviewed branch, dispatch this workflow with that branch as ref, and cause its npm scripts to run in a secret-bearing job. This bypasses CODEOWNERS review and converts repository write access into a reusable Cloudflare credential outside GitHub. If the token has account-scoped Pages Edit, compromise can reach other Pages projects in the same account and persists until the token is revoked.

    Accept only an empty input, the exact main branch, or a published non-prerelease v* release tag; resolve the accepted value to an immutable commit before checkout. Reject every other branch/tag. A protected docs-production GitHub environment with required review would add a second boundary around manual deployments.

Attack class

This is the standard untrusted checkout before secret-bearing build steps attack: attacker-controlled repository content runs while deployment credentials are in the process environment. It is both CI secret exfiltration and a review-boundary bypass. Masking prevents accidental log display; it does not stop code from sending the secret elsewhere.

I verified these findings against head 6f50d36c129a9ba4f5b33ac82ed0bda695d46ff6. The two Cloudflare secrets already exist as repository Actions secrets, and no protected docs deployment environment currently gates this job.

…le 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 aethersdr#6556.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NF0T
NF0T force-pushed the ci/docs-release-time-deploy branch from 6f50d36 to 5b10460 Compare October 18, 2026 20:14
…. Principle XII.

Addresses the review on aethersdr#6558, and restores what the rebase onto aethersdr#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 <noreply@anthropic.com>
@NF0T

NF0T commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

@jensenpat thanks. Both blockers are fixed in e7bb8d6, and so is a regression I found while rebasing.

What the rebase did. This branch was rebased onto #6571, which merged 10 minutes before the push. Resolving the conflict kept #6571's build, release-pdf and sign-release-pdf jobs, but put back this PR's old single deploy job in place of #6571's hardened one. So 5b1046076 still had both of your findings. On top of that, it had dropped environment: cloudflare-pages, permissions: {}, the wranglerVersion pin and --ignore-scripts. It also waited on release-pdf, although since #6571 the PDF is attached by sign-release-pdf. And because the Cloudflare secrets now live only in the cloudflare-pages environment, its deploy would always have skipped.

The deploy is now three jobs, following #6571. No job both runs repository code and holds the token.

Job Token / secrets Runs repo code
resolve-target contents: read, none no (no checkout)
build-site contents: read, none, persist-credentials: false yes: npm ci --ignore-scripts, npm run build
deploy permissions: {}, environment cloudflare-pages no (no checkout, artifact only)

Blocker 1 (token at job scope). CF_TOKEN is gone. The token reaches only the two pinned wrangler-action steps, through apiToken:. If the token or account ID is missing, the job now fails instead of skipping. The check is a step that sees only secrets.X != '' booleans.

Blocker 2 (unrestricted ref). resolve-target accepts an empty ref, exactly main, or a tag matching ^v[0-9]+(\.[0-9]+){1,3}$ that is a published release that is neither a draft nor a pre-release. It refuses everything else. It resolves the accepted value through refs/heads/main or refs/tags/<tag>, so a tag named main can't stand in for the branch. The resulting 40-hex SHA is what build-site checks out, and what --commit-hash records.

I ran the resolve step locally against this repository's real releases:

RELEASE_TAG / ref Result
— / empty deploys v26.10.3 86e90afce
— / main deploys main cedd50f4e, PDF from v26.10.3
— / v26.10.2 deploys f753dbfc4, PDF from v26.10.3
v26.10.3 / — deploys 86e90afce
— / lynovation-test-2026-10-16 (pre-release) refused
— / flex-tailnet-shim-v0.4.1 (non-app release) refused
— / ci/docs-release-time-deploy (branch) refused
— / v26.5.2 (tag, no release) refused
— / v99.0.0, refs/heads/main, main; curl evil, v26.10.2/../main refused
v26.9.4 / — (release without the manual) fails: no PDF, redirect would break

actionlint 1.7.7 is clean.

One environment setting is needed before this can deploy on a release. @ten9876, the cloudflare-pages environment's deployment policy currently allows only the main branch. A release-triggered run has github.ref = refs/tags/v…, so GitHub will refuse the deploy job until a tag v* rule is added, the way the release environment already has one. Manual runs from main are unaffected. As Pat suggested, required reviewers on cloudflare-pages would add a second gate around manual deploys. Both are repository settings, so they're outside this PR. I've also noted this on #6556.

73,
Ryan NF0T


👨🏼‍💻 Co-authored by Claude Opus 5.5

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(docs): deploy docs.aethersdr.com at release time, not on every merge to main

2 participants