From 6851e7ed82aca133628ee5b712ae06e96ad5f58c Mon Sep 17 00:00:00 2001 From: sandwich Date: Sat, 5 Sep 2026 14:16:49 +0200 Subject: [PATCH] Prevent stale CDN files after successful docs deployments Enable a full Pull Zone purge in Deploy Docs only after every storage upload and deployment-manifest write succeeds. Validate the separate account key and zone ID before uploads, and preserve upload-only CLI use. Guide operators through the new settings and document environment scope, recovery, and browser-cache limits alongside regression coverage. Constraint: Bunny's CDN management API requires an account API key distinct from the storage-zone password. Rejected: Add a separate purge entrypoint | the existing uploader preserves ordering and current CI path coverage. Confidence: high Scope-risk: moderate Directive: Keep purge after all upload/manifest writes; never pass the account key in curl arguments or logs. Tested: Purge regressions, 14 setup cases, full script suite, docs/workflow contracts, syntax, actionlint, ShellCheck. Tested: Real-curl loopback success, transient retry, redirect rejection, and hidden authentication errors. Tested: Workspace lint/typechecks, production web builds, and combined static-site verification. Not-tested: Live Bunny purge or a workflow run with operator-supplied new settings; no credentials were changed. Related: https://github.com/sandwichfarm/loopwire/issues/43 --- .github/workflows/deploy-docs.yml | 14 +- .../260905-jjc-PLAN.md | 20 ++ .../260905-jjc-SUMMARY.md | 55 +++++ .../docs/developer/github-actions-setup.md | 35 +++- apps/docs/docs/developer/release.md | 35 +++- apps/docs/docs/release-notes/unreleased.md | 7 + scripts/deploy-docs-bunny.sh | 68 ++++++- scripts/setup-github-actions.mjs | 24 ++- scripts/test-docs-cache-purge.mjs | 115 +++++++++++ scripts/test-setup-github-actions.mjs | 188 +++++++++++++++--- scripts/verify-docs.sh | 10 +- scripts/verify-github-workflows.sh | 5 + scripts/verify-scripts.sh | 2 + 13 files changed, 523 insertions(+), 55 deletions(-) create mode 100644 .planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-PLAN.md create mode 100644 .planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-SUMMARY.md create mode 100644 scripts/test-docs-cache-purge.mjs diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index cf6dc7c..3207c07 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 environment: docs-production - if: > + if: >- ${{ github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || @@ -99,6 +99,8 @@ jobs: env: BUNNY_STORAGE_ZONE: ${{ vars.BUNNY_STORAGE_ZONE || secrets.BUNNY_STORAGE_ZONE }} BUNNY_ACCESS_KEY: ${{ secrets.BUNNY_ACCESS_KEY }} + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID || secrets.BUNNY_PULL_ZONE_ID }} BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT || secrets.BUNNY_STORAGE_ENDPOINT }} BUNNY_REMOTE_PREFIX: ${{ vars.BUNNY_REMOTE_PREFIX || secrets.BUNNY_REMOTE_PREFIX }} BUNNY_PULL_ZONE_HOSTNAME: ${{ vars.BUNNY_PULL_ZONE_HOSTNAME || secrets.BUNNY_PULL_ZONE_HOSTNAME }} @@ -107,10 +109,12 @@ jobs: missing=() [ -n "${BUNNY_STORAGE_ZONE:-}" ] || missing+=(BUNNY_STORAGE_ZONE) [ -n "${BUNNY_ACCESS_KEY:-}" ] || missing+=(BUNNY_ACCESS_KEY) + [ -n "${BUNNY_API_KEY:-}" ] || missing+=(BUNNY_API_KEY) + [ -n "${BUNNY_PULL_ZONE_ID:-}" ] || missing+=(BUNNY_PULL_ZONE_ID) if [ "${#missing[@]}" -gt 0 ]; then echo "::error::Bunny.net deployment configuration is missing: ${missing[*]}" echo "::error::Run the guarded cross-platform setup: pnpm setup:github -- --repo ${GITHUB_REPOSITORY} --scope deploy" - echo "::error::The prompts explain where every Bunny value is found and keep the access key hidden." + echo "::error::The prompts explain where every Bunny value is found and keep credentials hidden." exit 1 fi if [ -n "${BUNNY_PULL_ZONE_HOSTNAME:-}" ]; then @@ -119,14 +123,16 @@ jobs: echo "hostname=false" >>"$GITHUB_OUTPUT" fi - - name: Upload files to Bunny.net storage + - name: Upload files and purge Bunny.net CDN cache env: BUNNY_STORAGE_ZONE: ${{ vars.BUNNY_STORAGE_ZONE || secrets.BUNNY_STORAGE_ZONE }} BUNNY_ACCESS_KEY: ${{ secrets.BUNNY_ACCESS_KEY }} + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} + BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID || secrets.BUNNY_PULL_ZONE_ID }} BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT || secrets.BUNNY_STORAGE_ENDPOINT }} BUNNY_REMOTE_PREFIX: ${{ vars.BUNNY_REMOTE_PREFIX || secrets.BUNNY_REMOTE_PREFIX }} LOOPWIRE_DOCS_DEPLOYMENT_MANIFEST: dist/docs-deployment/deployment-manifest.json - run: bash scripts/deploy-docs-bunny.sh --dist dist/site + run: bash scripts/deploy-docs-bunny.sh --dist dist/site --purge-cache - name: Verify docs deployment manifest env: diff --git a/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-PLAN.md b/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-PLAN.md new file mode 100644 index 0000000..2f61a96 --- /dev/null +++ b/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-PLAN.md @@ -0,0 +1,20 @@ +--- +status: verified +--- + +# Purge the docs CDN after deployment + +Tracking: https://github.com/sandwichfarm/loopwire/issues/43 + +1. Extend the existing uploader with an explicit --purge-cache option, enabled by Deploy Docs. Require a separate + Bunny account API key and numeric Pull Zone ID before uploads. Preserve storage-only and dry-run CLI behavior. +2. After all uploads and manifest generation succeed, POST to the fixed Bunny purge endpoint using a private stdin + header. Keep credentials out of arguments/logs, bound network waits/retries, and reject errors without fake success. +3. Update guided GitHub setup and documentation for BUNNY_API_KEY secret and BUNNY_PULL_ZONE_ID variable. The legacy + upload-only rehearsal helper remains separate; no actual credentials are requested through chat or changed here. +4. Add regression tests for ordering, dry-run/no-purge, failed uploads/manifests, malformed/missing config, HTTP and + network failures, and secret handling. Run relevant script/workflow/setup/docs checks and open a focused PR. + +This branch starts from current master independently of pending CI-filter PR #42. Reuse deploy-docs-bunny.sh so #42's +existing deployment path filters cover the feature. No app/backend/package/dependency changes or actual cache purge. +The live API call remains dependent on the operator supplying the account key and zone ID. diff --git a/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-SUMMARY.md b/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-SUMMARY.md new file mode 100644 index 0000000..16a323d --- /dev/null +++ b/.planning/quick/260905-jjc-bunny-cache-purge/260905-jjc-SUMMARY.md @@ -0,0 +1,55 @@ +--- +status: verified +issue: 43 +--- + +# Purge the docs CDN after deployment + +Tracking: https://github.com/sandwichfarm/loopwire/issues/43 + +## Result + +The existing uploader accepts `--purge-cache`; Deploy Docs always enables it. Purge configuration is validated before +uploads. After all files and the local deployment manifest are written successfully, the uploader requests a full +CDN Pull Zone purge. It accepts only HTTP 2xx and hides response bodies and raw transport errors. The account key is +passed through stdin, curl config files and redirects are disabled, and connection/request/retry waits are bounded. + +The guarded setup helper requires `BUNNY_API_KEY` and `BUNNY_PULL_ZONE_ID` in deploy and final scopes, with hidden +prompts and exact stdin transport. The signed-int64 ID check preserves leading zeros without integer overflow. +Existing storage-only CLI behavior, storage-password handling, and legacy Unix setup/config templates are unchanged. +No dependencies or audio/backend/UI behavior changed. Existing upload and setup machinery was reused. + +Docs explain the new settings, environment precedence, repository-only setup checks, error recovery, full-zone purge +scope, and the browser-cache limitation. The docs contract no longer requires obsolete skip-on-missing-secret prose. + +## Validation + +- Existing setup baseline: 11 transport cases passed before edits. New required-name assertions first failed because + `BUNNY_PULL_ZONE_ID` was missing; the expanded 14-case suite now passes. +- `node scripts/test-docs-cache-purge.mjs`: first failed on unknown `--purge-cache`, then passed. Covers upload/manifest + ordering, dry run, legacy upload-only use, required config, control characters, int64 bounds and leading zeros, + prefix-independent full-zone purge, response errors, bounded curl flags, and account-key secrecy. +- Independent real-curl loopback checks passed for successful upload/purge, a 503-to-204 retry preserving the private + header, rejected redirects, and hidden authentication-error bodies. Only local fixture servers were contacted. +- `node scripts/test-setup-github-actions.mjs`: all 14 cases passed, including both configuration scopes, + stdin-only secret writes, control rejection, exact bytes, dry run, readback failure, and idempotent repeat setup. +- `bash scripts/verify-scripts.sh`: passed the full script regression suite. +- `bash scripts/verify-github-workflows.sh` and `bash scripts/verify-docs.sh`: passed. +- Node syntax checks, Bash syntax checks, actionlint on Deploy Docs, and ShellCheck on the uploader passed. + ShellCheck ran from an existing offline container image because no host binary was installed. +- `pnpm lint`: workspace typechecks and Svelte checks passed with no errors or warnings. +- `pnpm build:web && pnpm verify:site`: production Astro/VitePress builds and combined site checks passed. +- `git diff --check` and the 150-character limit for added lines passed. + +## Operator configuration and limits + +In repository Settings → Environments → docs-production, add `BUNNY_API_KEY` as an environment secret and +`BUNNY_PULL_ZONE_ID` as an environment variable. Obtain the account key from +https://dash.bunny.net/account/api-key and the numeric CDN ID from the Pull Zone dashboard. Keep the existing +`BUNNY_ACCESS_KEY` storage password. The guided helper writes/checks repository settings; environment values override +matching repository settings and are not inspected by that helper. + +No production credentials were read or changed, no Bunny API request was made, and no deployment was triggered. +Production purge acceptance remains an operator validation after setting the new values. Purging the full zone +does not clear browser caches or prove immediate refresh at every edge. Native app/audio tests were not needed +for this deployment-only change. diff --git a/apps/docs/docs/developer/github-actions-setup.md b/apps/docs/docs/developer/github-actions-setup.md index dfade9c..16d16c3 100644 --- a/apps/docs/docs/developer/github-actions-setup.md +++ b/apps/docs/docs/developer/github-actions-setup.md @@ -51,11 +51,36 @@ repository, variable, and secret preflight. Review the displayed names and type | `BUNNY_PULL_ZONE_HOSTNAME` | Actions variable | Final; optional for deploy smoke | Bunny dashboard → CDN → Pull Zones → select the Loopwire zone → Hostnames. Copy only the hostname, without a scheme or path. Skipping it in deploy scope leaves existing configuration unchanged. | | `BUNNY_REMOTE_PREFIX` | Actions variable | Optional | Choose a relative storage subdirectory only when the site should not deploy at the storage-zone root. Skipping it leaves existing configuration unchanged; an unset value means the root. | | `BUNNY_ACCESS_KEY` | Actions secret | Deploy and final | Bunny dashboard → Storage → select the Loopwire zone → FTP & API Access → Password. Use the storage-zone password, not the account API key. | +| `BUNNY_PULL_ZONE_ID` | Actions variable | Deploy and final | Numeric CDN Pull Zone ID; see below. | +| `BUNNY_API_KEY` | Actions secret | Deploy and final | Bunny account API key for CDN purges; see below. | | `LOOPWIRE_RELEASE_PRIVATE_KEY` | Actions secret | Final | Generate the local PEM with `pnpm release:prepare-key`. At the prompt, enter its file path; do not paste it into a shell argument. | Public configuration uses the GitHub `vars` context. Credentials and signing material use the `secrets` context. During migration, workflows still fall back to older repository secrets when the matching Actions variable is absent. +### CDN purge configuration + +Get `BUNNY_API_KEY` from the [Bunny account API Keys page](https://dash.bunny.net/account/api-key). +This is the account API key used by Bunny's [CDN purge API](https://bunny.net/docs/cdn/purge-cache). +Keep `BUNNY_ACCESS_KEY` as the separate storage-zone password; it cannot authorize the CDN management request. + +Find `BUNNY_PULL_ZONE_ID` under Bunny dashboard → CDN → Pull Zones → select the zone serving the site. +Copy the numeric Pull Zone ID, not the Storage Zone ID or hostname. The helper accepts decimal digits representing +an integer from 1 through 9223372036854775807 and preserves entered digits. API keys must be a single header line +without ASCII control characters, including tabs, carriage returns, and newlines. + +The guided helper writes **repository-level** Actions variables and secrets; its `--check` checks that scope only. +The `Deploy Docs` job runs in the **docs-production** environment. To configure just the two new settings there, open +repository Settings → Environments → docs-production. Add `BUNNY_API_KEY` under **Environment secrets** and +`BUNNY_PULL_ZONE_ID` under **Environment variables**. Existing storage settings remain in their current scope. +Environment settings take precedence over matching repository settings, so update stale environment values there +instead of relying on a repository-level setup run to replace them. + +Production deployment requests a full Pull Zone purge after all uploads and manifest generation succeed. +This includes every path in that CDN zone, even when `BUNNY_REMOTE_PREFIX` selects a storage subdirectory. +Acceptance by the purge API does not invalidate copies already cached in browsers or prove immediate refresh on +every CDN edge. Browser caches continue to follow the site's HTTP cache headers. + ## AUR publication environment The manually dispatched `Publish AUR` workflow uses a separate GitHub environment named `aur`. Configure required @@ -109,10 +134,12 @@ pnpm setup:github -- --repo OWNER/REPO --scope final --check The check does not and cannot read secret values. It verifies required variables through GitHub's variable API and required secrets through the names-only secret list. -The production `Deploy Docs` workflow fails before upload when `BUNNY_STORAGE_ZONE` or `BUNNY_ACCESS_KEY` is absent. -This is intentional: a green workflow run means the static site was uploaded, not merely built. Configure -`BUNNY_PULL_ZONE_HOSTNAME` as well to make the workflow probe the public HTTPS site after upload; without it, the live -HTTP verification step is skipped. +The production `Deploy Docs` workflow fails before upload when `BUNNY_STORAGE_ZONE`, `BUNNY_ACCESS_KEY`, +`BUNNY_API_KEY`, or `BUNNY_PULL_ZONE_ID` is absent. Malformed purge settings also fail before any upload. +A successful deployment requires every upload and acceptance of the CDN purge request. Configure +`BUNNY_PULL_ZONE_HOSTNAME` as well to make the workflow probe the public HTTPS site afterward; without it, the live +HTTP verification step is skipped. A failed purge leaves uploaded storage objects in place; correct the API key, +zone ID, or connectivity problem and rerun the deployment. ## Recover from a failed write diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md index f6ac2fc..5baf697 100644 --- a/apps/docs/docs/developer/release.md +++ b/apps/docs/docs/developer/release.md @@ -822,16 +822,22 @@ The docs deployment workflow builds the Astro homepage plus the VitePress docs t artifact, and deploys to Bunny.net only on explicit workflow dispatch, `main`, `master`, or `v*` tags. The deploy job is assigned to the `docs-production` GitHub environment so repository protection rules can require manual review or protected branches. -If Bunny.net secrets are missing, the deploy job emits a notice and skips upload instead of failing unrelated CI. The -notice prints the safe local recovery sequence: create `/secure/loopwire-release-secrets.env` with -`--write-env-template`, fill it locally, then run the same helper with the local env file: +The deploy job fails before upload when `BUNNY_STORAGE_ZONE`, `BUNNY_ACCESS_KEY`, `BUNNY_API_KEY`, or +`BUNNY_PULL_ZONE_ID` is missing. Use the guarded repository-level setup command: ```bash -bash scripts/setup-github-secrets.sh --repo --scope deploy --env-file /secure/loopwire-release-secrets.env +pnpm setup:github -- --repo OWNER/REPO --scope deploy ``` -For final release proof, include `BUNNY_PULL_ZONE_HOSTNAME` in that env file so the live-docs smoke can run against the -Bunny pull-zone URL after upload. +Alternatively, add `BUNNY_API_KEY` as an environment secret and `BUNNY_PULL_ZONE_ID` as an environment variable in +Settings → Environments → docs-production. The API key comes from the +[Bunny account API Keys page](https://dash.bunny.net/account/api-key); the ID is the numeric CDN Pull Zone ID, +not the Storage Zone ID. The guided helper's `--check` reads repository-level settings only, and environment values +override matching repository values. See [GitHub Actions setup](./github-actions-setup.md#cdn-purge-configuration). + +The legacy `scripts/setup-github-secrets.sh` and `.env.example` still describe storage-upload and release-signing +configuration only. They do not configure or check the new CDN purge settings; add those separately or use the +guided helper. For final release proof, configure `BUNNY_PULL_ZONE_HOSTNAME` so live smoke can probe the public site. Deployment uses `scripts/deploy-docs-bunny.sh`, which uploads raw files with Bunny Edge Storage's `PUT` endpoint and the storage-zone password in the `AccessKey` header. The script defaults to `https://storage.bunnycdn.com`, and @@ -840,7 +846,19 @@ zone is not in Bunny's default region. `BUNNY_REMOTE_PREFIX` can deploy the site which is useful when one zone serves multiple preview or product directories. The helper rejects unsafe `.` or `..` remote-prefix segments before upload planning. -Preview the upload plan without contacting Bunny.net: +The workflow passes `--purge-cache` to this uploader. After every file upload and deployment-manifest write succeeds, +the helper sends `POST https://api.bunny.net/pullzone/{id}/purgeCache` with the account API key in a private stdin +header. It disables curl configuration-file loading, follows no redirects, discards the response body, and bounds +connection time, request time, and retries. Only HTTP 2xx is accepted; authentication, HTTP, or transport failures +fail the deployment without printing the account key. Failed uploads or manifest writes never trigger a purge. + +This purges the **entire CDN Pull Zone**, including other prefixes served by that zone. An accepted request does +not clear browser caches or prove immediate refresh at every edge. If purge fails after upload, the new storage +objects remain in place; correct the error and rerun deployment. The local CLI remains upload-only unless +`--purge-cache` is supplied. See Bunny's [purge API documentation](https://bunny.net/docs/cdn/purge-cache). + +Preview the upload and purge plan without contacting Bunny.net or supplying either credential; use the real CDN +Pull Zone ID in `BUNNY_PULL_ZONE_ID` so the target can be validated: ```bash pnpm build:web @@ -850,6 +868,7 @@ bash scripts/deploy-docs-bunny.sh \ --storage-endpoint https://ny.storage.bunnycdn.com \ --remote-prefix loopwire \ --deployment-manifest dist/docs-deployment/deployment-manifest.json \ + --purge-cache \ --dry-run ``` @@ -864,7 +883,7 @@ inventory, rejects checksum drift, checks the remote-prefix mapping, rejects sou secret-like manifest keys. When `BUNNY_PULL_ZONE_HOSTNAME` is configured, the deploy workflow also runs `scripts/verify-docs-live.sh --hostname "$BUNNY_PULL_ZONE_HOSTNAME" --remote-prefix "$BUNNY_REMOTE_PREFIX"` after -upload. The smoke uses the same pull-zone prefix used for upload. It fetches the deployed homepage, `/docs/`, the +upload and purge acceptance. The smoke uses the same pull-zone prefix used for upload. It fetches the deployed homepage, `/docs/`, the basic-usage guide, and `/install.sh`, then checks the installer parses as shell and matches the local public installer. Final release proof must be tied to the same deployment run. Pass the deploy-docs workflow run id that uploaded diff --git a/apps/docs/docs/release-notes/unreleased.md b/apps/docs/docs/release-notes/unreleased.md index 3e1a9c2..24fce1a 100644 --- a/apps/docs/docs/release-notes/unreleased.md +++ b/apps/docs/docs/release-notes/unreleased.md @@ -33,6 +33,13 @@ These notes describe source-tree progress. They are not a public release announc - A third `loopwire-git` AUR recipe follows the protected default branch, derives a monotonic VCS package version, and remains explicitly separate from the stable source and binary package bases. +## Docs deployment + +- Docs deployment now requests a full Bunny CDN Pull Zone purge after all uploads and manifest generation succeed. + Configure the separate `BUNNY_API_KEY` account secret and numeric `BUNNY_PULL_ZONE_ID` variable; the guided setup + checks both. Upload, manifest, and purge failures fail the deployment, while dry-run makes no requests. + Purges invalidate the CDN zone's paths; copies already cached in browsers continue to follow HTTP cache headers. + ## Desktop UI Rebuild - GitHub Actions setup now has a cross-platform guided command that separates public variables from secrets, explains diff --git a/scripts/deploy-docs-bunny.sh b/scripts/deploy-docs-bunny.sh index 110903d..9dec393 100755 --- a/scripts/deploy-docs-bunny.sh +++ b/scripts/deploy-docs-bunny.sh @@ -4,10 +4,13 @@ set -euo pipefail dist_dir="${LOOPWIRE_SITE_DIST:-${LOOPWIRE_DOCS_DIST:-dist/site}}" storage_zone="${BUNNY_STORAGE_ZONE:-}" access_key="${BUNNY_ACCESS_KEY:-}" +api_key="${BUNNY_API_KEY:-}" +pull_zone_id="${BUNNY_PULL_ZONE_ID:-}" storage_endpoint="${BUNNY_STORAGE_ENDPOINT:-https://storage.bunnycdn.com}" remote_prefix="${BUNNY_REMOTE_PREFIX:-}" deployment_manifest="${LOOPWIRE_DOCS_DEPLOYMENT_MANIFEST:-}" dry_run="false" +purge_cache="false" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/.." && pwd)" @@ -18,20 +21,23 @@ Deploy the built Loopwire static site directory to Bunny.net Edge Storage. Usage: deploy-docs-bunny.sh [--dist DIR] [--storage-zone ZONE] [--access-key KEY] [--storage-endpoint URL] [--remote-prefix PATH] - [--deployment-manifest FILE] [--dry-run] + [--deployment-manifest FILE] [--purge-cache] [--dry-run] Environment: LOOPWIRE_SITE_DIST Built combined site directory, default dist/site LOOPWIRE_DOCS_DIST Legacy fallback for the built site directory BUNNY_STORAGE_ZONE Bunny Edge Storage zone name BUNNY_ACCESS_KEY Storage zone password from Bunny's FTP & API Access panel + BUNNY_API_KEY Account API key for --purge-cache, distinct from the storage password + BUNNY_PULL_ZONE_ID Numeric CDN Pull Zone ID for --purge-cache, not the Storage Zone ID BUNNY_STORAGE_ENDPOINT Regional storage endpoint, default https://storage.bunnycdn.com BUNNY_REMOTE_PREFIX Optional remote path prefix inside the storage zone LOOPWIRE_DOCS_DEPLOYMENT_MANIFEST Optional JSON manifest describing uploaded files without secrets -Dry-run mode validates the local build output, prints upload targets, and can write the deployment manifest without -contacting Bunny.net. +With --purge-cache, purge the entire CDN Pull Zone after every upload and manifest write succeeds. Existing browser +caches are unaffected. Dry-run validates the local build output and Pull Zone ID, prints planned uploads/purge, and +can write the deployment manifest without contacting Bunny.net or requiring credentials. USAGE } @@ -70,6 +76,10 @@ while [ "$#" -gt 0 ]; do dry_run="true" shift ;; + --purge-cache) + purge_cache="true" + shift + ;; -h | --help) usage exit 0 @@ -91,6 +101,50 @@ reject_unsafe_value() { esac } +validate_purge_configuration() { + local LC_ALL=C + local significant_id="${pull_zone_id#"${pull_zone_id%%[!0]*}"}" + + # Equal-length decimal strings compare exactly without shell integer overflow. + # shellcheck disable=SC2071 + if [[ ! "$pull_zone_id" =~ ^[0-9]+$ || -z "$significant_id" || ${#significant_id} -gt 19 || + ( ${#significant_id} -eq 19 && "$significant_id" > 9223372036854775807 ) ]]; then + fail "BUNNY_PULL_ZONE_ID must be a positive integer no greater than 9223372036854775807" + fi + if [ "$dry_run" != "true" ]; then + [ -n "$api_key" ] || fail "BUNNY_API_KEY is required for --purge-cache outside dry-run mode" + if [[ "$api_key" =~ [[:cntrl:]] ]]; then + fail "BUNNY_API_KEY must not contain control characters" + fi + fi +} + +purge_pull_zone_cache() { + local status + if [ "$dry_run" = "true" ]; then + printf 'would purge the entire Bunny CDN Pull Zone %s\n' "$pull_zone_id" + return + fi + + if ! status="$(printf 'AccessKey: %s\n' "$api_key" | curl --disable --silent --request POST \ + --header @- --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 2 --retry-max-time 60 \ + --output /dev/null --write-out '%{http_code}' \ + "https://api.bunny.net/pullzone/${pull_zone_id}/purgeCache" 2>/dev/null)"; then + fail "Bunny CDN cache purge request failed; uploaded files remain in storage. Check connectivity and retry deployment." + fi + case "$status" in + 2[0-9][0-9]) + printf 'Bunny CDN cache purge accepted for Pull Zone %s.\n' "$pull_zone_id" + ;; + [0-9][0-9][0-9]) + fail "Bunny CDN cache purge failed (HTTP ${status}); check the account API key and Pull Zone ID, then retry deployment." + ;; + *) + fail "Bunny CDN cache purge returned an invalid HTTP status; retry deployment." + ;; + esac +} + normalize_endpoint() { endpoint="$1" reject_unsafe_value "$endpoint" "storage endpoint" @@ -220,6 +274,10 @@ case "$storage_zone" in ;; esac +if [ "$purge_cache" = "true" ]; then + validate_purge_configuration +fi + if [ "$dry_run" != "true" ]; then [ -n "$access_key" ] || fail "BUNNY_ACCESS_KEY or --access-key is required outside dry-run mode" command -v curl >/dev/null 2>&1 || fail "curl is required" @@ -264,6 +322,10 @@ if [ -n "$deployment_manifest" ]; then write_deployment_manifest "$deployment_manifest" "$uploads_tsv" fi +if [ "$purge_cache" = "true" ]; then + purge_pull_zone_cache +fi + if [ "$dry_run" = "true" ]; then echo "Dry run complete; ${file_count} site file(s) would be uploaded to ${storage_endpoint}/${storage_zone}." else diff --git a/scripts/setup-github-actions.mjs b/scripts/setup-github-actions.mjs index 6bc3c03..0735871 100644 --- a/scripts/setup-github-actions.mjs +++ b/scripts/setup-github-actions.mjs @@ -26,6 +26,13 @@ const variableSpecs = [ "Bunny dashboard -> Storage -> select the zone -> FTP & API Access. Use the API hostname with https://. Press Enter for Bunny's global endpoint.", validate: validateStorageEndpoint }, + { + name: "BUNNY_PULL_ZONE_ID", + required: true, + source: + "Bunny dashboard -> CDN -> Pull Zones -> select the Loopwire zone. Copy its numeric Pull Zone ID, not the Storage Zone ID.", + validate: validatePullZoneId + }, { name: "BUNNY_PULL_ZONE_HOSTNAME", requiredForFinal: true, @@ -48,6 +55,13 @@ const secretSpecs = [ source: "Bunny dashboard -> Storage -> select the Loopwire zone -> FTP & API Access -> Password. This is the storage-zone password, not the account API key.", validate: validateSecretLine + }, + { + name: "BUNNY_API_KEY", + required: true, + source: + "Bunny account API key: https://dash.bunny.net/account/api-key. This authorizes CDN cache purges; it is not the storage-zone password.", + validate: validateSingleLine } ]; @@ -329,13 +343,13 @@ function checkConfiguration(runner, scope) { } function requiredVariableNames(scope) { - const names = ["BUNNY_STORAGE_ZONE", "BUNNY_STORAGE_ENDPOINT"]; + const names = ["BUNNY_STORAGE_ZONE", "BUNNY_STORAGE_ENDPOINT", "BUNNY_PULL_ZONE_ID"]; if (scope === "final") names.push("BUNNY_PULL_ZONE_HOSTNAME"); return names; } function requiredSecretNames(scope) { - const names = ["BUNNY_ACCESS_KEY"]; + const names = ["BUNNY_ACCESS_KEY", "BUNNY_API_KEY"]; if (scope === "final") names.push("LOOPWIRE_RELEASE_PRIVATE_KEY"); return names; } @@ -558,6 +572,12 @@ function validateHostname(value, name) { } } +function validatePullZoneId(value, name) { + if (!/^[0-9]+$/.test(value) || BigInt(value) < 1n || BigInt(value) > 9223372036854775807n) { + throw new Error(`${name} must be a positive integer no greater than 9223372036854775807`); + } +} + function validateRemotePrefix(value, name) { validateSingleLine(value, name); rejectSurroundingWhitespace(value, name); diff --git a/scripts/test-docs-cache-purge.mjs b/scripts/test-docs-cache-purge.mjs new file mode 100644 index 0000000..7f797b2 --- /dev/null +++ b/scripts/test-docs-cache-purge.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const work = mkdtempSync(join(tmpdir(), "loopwire-cache-purge-")); +const deploy = resolve("scripts/deploy-docs-bunny.sh"); +const dist = join(work, "site"); +const trace = join(work, "requests.jsonl"); +const apiKey = 'account-key-"$()&-must-not-leak'; +mkdirSync(join(work, "bin")); +mkdirSync(join(dist, "docs/guide"), { recursive: true }); +for (const file of ["index.html", "docs/index.html", "docs/guide/basic-usage.html"]) writeFileSync(join(dist, file), "site"); +writeFileSync(join(dist, "install.sh"), "#!/bin/sh\necho installer\n"); +writeFileSync(join(work, "bin/curl"), `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +const isPurge = args.some(arg => arg.endsWith("/purgeCache")); +const header = isPurge ? fs.readFileSync(0, "utf8") : ""; +if (isPurge && process.env.PURGE_TEST_EXPECT_MANIFEST && !fs.existsSync(process.env.PURGE_TEST_EXPECT_MANIFEST)) process.exit(1); +fs.appendFileSync(process.env.PURGE_TEST_TRACE, JSON.stringify({args, header, isPurge}) + "\\n"); +if (!isPurge && process.env.PURGE_TEST_UPLOAD_FAIL === "true") process.exit(22); +if (isPurge) { + if (process.env.PURGE_TEST_NETWORK_FAIL === "true") { + process.stderr.write(process.env.BUNNY_API_KEY); + process.exit(7); + } + process.stdout.write(process.env.PURGE_TEST_STATUS || "204"); +} +`, { mode: 0o755 }); + +function run(args = [], env = {}) { + writeFileSync(trace, ""); + const environment = { + ...process.env, PATH: `${join(work, "bin")}:${process.env.PATH}`, PURGE_TEST_TRACE: trace, + BUNNY_STORAGE_ZONE: "test-zone", BUNNY_ACCESS_KEY: "storage-password", BUNNY_API_KEY: apiKey, BUNNY_PULL_ZONE_ID: "12345", + ...env + }; + const result = spawnSync("bash", [deploy, "--dist", dist, ...args], { env: environment, encoding: "utf8" }); + result.requests = readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); + assert.ok(!`${result.stdout}${result.stderr}`.includes(apiKey), "account key leaked into output"); + if (environment.BUNNY_API_KEY) { + assert.ok(!`${result.stdout}${result.stderr}`.includes(environment.BUNNY_API_KEY), "entered account key leaked into output"); + } + return result; +} + +try { + const manifest = join(work, "deployment-manifest.json"); + const success = run(["--purge-cache", "--deployment-manifest", manifest], { PURGE_TEST_EXPECT_MANIFEST: manifest }); + assert.equal(success.status, 0, success.stderr); + assert.equal(success.requests.length, 5, "four uploads followed by one purge"); + assert.ok(success.requests.slice(0, -1).every(request => !request.isPurge)); + const purge = success.requests.at(-1); + assert.ok(purge.isPurge); + assert.ok(purge.args.includes("https://api.bunny.net/pullzone/12345/purgeCache")); + assert.ok(purge.args.includes("POST")); + assert.equal(purge.args[0], "--disable", "ignore curlrc so it cannot enable logging or redirects"); + assert.ok(!purge.args.some(arg => ["-L", "--location", "--location-trusted"].includes(arg)), "purge must not follow redirects"); + assert.ok(!purge.args.some(arg => ["-d", "--data", "--data-raw", "--data-binary", "--form"].includes(arg)), "purge needs no body"); + assert.equal(purge.args[purge.args.indexOf("--output") + 1], "/dev/null", "discard purge response bodies"); + assert.ok(purge.args.includes("@-")); + assert.ok(!purge.args.some(arg => arg.includes(apiKey)), "account key must not appear in curl argv"); + assert.equal(purge.header, `AccessKey: ${apiKey}\n`); + assert.ok(purge.args.includes("--max-time") && purge.args.includes("--retry"), "purge waits and retries must be bounded"); + assert.ok(purge.args.includes("--connect-timeout") && purge.args.includes("--retry-max-time")); + assert.match(success.stdout, /purge accepted/i); + assert.ok(!readFileSync(manifest, "utf8").includes(apiKey), "account key must not appear in deployment manifest"); + + const legacy = run([], { BUNNY_API_KEY: "", BUNNY_PULL_ZONE_ID: "" }); + assert.equal(legacy.status, 0, legacy.stderr); + assert.ok(legacy.requests.every(request => !request.isPurge), "upload-only CLI remains explicit"); + const dry = run(["--purge-cache", "--dry-run"], { BUNNY_API_KEY: "", BUNNY_ACCESS_KEY: "" }); + assert.equal(dry.status, 0, dry.stderr); + assert.equal(dry.requests.length, 0); + assert.match(dry.stdout, /would purge/i); + + for (const values of [{ BUNNY_API_KEY: "" }, { BUNNY_API_KEY: "bad\rkey" }, { BUNNY_API_KEY: "bad\tkey" }, + { BUNNY_API_KEY: "bad\nkey" }, { BUNNY_API_KEY: "bad\x01key" }, { BUNNY_API_KEY: "bad\x7fkey" }, + { BUNNY_PULL_ZONE_ID: "" }, { BUNNY_PULL_ZONE_ID: "0" }, { BUNNY_PULL_ZONE_ID: "000" }, + { BUNNY_PULL_ZONE_ID: "-1" }, { BUNNY_PULL_ZONE_ID: "1e3" }, { BUNNY_PULL_ZONE_ID: "1/other" }, + { BUNNY_PULL_ZONE_ID: "9223372036854775808" }]) { + const result = run(["--purge-cache"], values); + assert.notEqual(result.status, 0, "invalid configuration must fail"); + assert.equal(result.requests.length, 0, "invalid purge configuration must fail before uploads"); + } + for (const id of ["1", "00012345", "9223372036854775807"]) { + const result = run(["--purge-cache"], { BUNNY_PULL_ZONE_ID: id, BUNNY_REMOTE_PREFIX: "preview/site" }); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.requests.at(-1).args.includes(`https://api.bunny.net/pullzone/${id}/purgeCache`)); + } + const invalidDry = run(["--purge-cache", "--dry-run"], { BUNNY_PULL_ZONE_ID: "invalid" }); + assert.notEqual(invalidDry.status, 0, "dry run still requires a valid zone ID"); + assert.equal(invalidDry.requests.length, 0); + const failedUpload = run(["--purge-cache"], { PURGE_TEST_UPLOAD_FAIL: "true" }); + assert.notEqual(failedUpload.status, 0); + assert.ok(failedUpload.requests.every(request => !request.isPurge)); + const failedManifest = run(["--purge-cache", "--deployment-manifest", "/dev/null/manifest.json"]); + assert.notEqual(failedManifest.status, 0); + assert.ok(failedManifest.requests.every(request => !request.isPurge)); + for (const status of ["200", "299"]) { + assert.equal(run(["--purge-cache"], { PURGE_TEST_STATUS: status }).status, 0); + } + for (const status of ["199", "301", "401", "403", "429", "500", apiKey]) { + const result = run(["--purge-cache"], { PURGE_TEST_STATUS: status }); + assert.notEqual(result.status, 0, `HTTP ${status} must not report successful deployment`); + assert.doesNotMatch(result.stdout, /purge accepted/i); + } + assert.notEqual(run(["--purge-cache"], { PURGE_TEST_NETWORK_FAIL: "true" }).status, 0); + console.log("Bunny cache purge regressions passed: ordering, dry run, preflight, failed uploads/manifests, HTTP/network errors and secrecy."); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/scripts/test-setup-github-actions.mjs b/scripts/test-setup-github-actions.mjs index 7d18de2..fe09652 100644 --- a/scripts/test-setup-github-actions.mjs +++ b/scripts/test-setup-github-actions.mjs @@ -27,12 +27,15 @@ try { testDryRunNeverInvokesGhOrLeaksSecrets(); testFinalKeyBytesWithoutTrailingNewline(); testInvalidInputStopsBeforeRemotePreflight(); + testPullZoneIdValidation(); + testApiKeyControlCharactersStopBeforeRemotePreflight(); + testCheckRequiresPurgeConfiguration(); testOversizeSecretIsRejectedWithoutTruncation(); testMismatchedAndSymlinkKeysStopBeforeWrites(); testVariableReadbackCorruptionStopsBeforeSecrets(); testRepeatedSetupReplacesInsteadOfAppending(); testPartialFailureNamesCompletedWritesWithoutValues(); - process.stdout.write("GitHub Actions setup transport tests passed (11 cases).\n"); + process.stdout.write("GitHub Actions setup transport tests passed (14 cases).\n"); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } @@ -43,11 +46,15 @@ function testHelpAndRequiredNames() { assert.match(help.stdout, /Windows, macOS, and Linux|Configure Loopwire GitHub Actions/); assert.match(help.stdout, /Values are never accepted as CLI flags or environment variables/); - const required = run(["--", "--print-required", "--scope", "final"], ""); - assert.equal(required.status, 0, required.stderr); - assert.match(required.stdout, /variable: BUNNY_STORAGE_ZONE/); - assert.match(required.stdout, /secret: BUNNY_ACCESS_KEY/); - assert.match(required.stdout, /secret: LOOPWIRE_RELEASE_PRIVATE_KEY/); + for (const scope of ["deploy", "final"]) { + const required = run(["--", "--print-required", "--scope", scope], ""); + assert.equal(required.status, 0, required.stderr); + assert.match(required.stdout, /variable: BUNNY_STORAGE_ZONE/); + assert.match(required.stdout, /variable: BUNNY_PULL_ZONE_ID/); + assert.match(required.stdout, /secret: BUNNY_ACCESS_KEY/); + assert.match(required.stdout, /secret: BUNNY_API_KEY/); + assert.equal(required.stdout.includes("secret: LOOPWIRE_RELEASE_PRIVATE_KEY"), scope === "final"); + } } function testDeployPromptPreservesExactValues() { @@ -55,28 +62,40 @@ function testDeployPromptPreservesExactValues() { const values = { zone: "loopwire-zone", endpoint: "https://ny.storage.bunnycdn.com", + pullZoneId: "12345", hostname: "docs.example.test", prefix: "preview-v1", - access: " '\"$();|& Ω\t " + access: " '\"$();|& Ω\t ", + apiKey: " account-'\"$();|&-key " }; - const input = [values.zone, values.endpoint, values.hostname, values.prefix, values.access, "APPLY"].join("\n"); + const input = [ + values.zone, values.endpoint, values.pullZoneId, values.hostname, values.prefix, values.access, values.apiKey, "APPLY" + ].join("\n"); const result = run(["--repo", "sandwichfarm/loopwire", "--scope", "deploy"], input, { stateDir }); assert.equal(result.status, 0, result.stderr); const state = readState(stateDir); assertBytes(state.variables.BUNNY_STORAGE_ZONE, values.zone); assertBytes(state.variables.BUNNY_STORAGE_ENDPOINT, values.endpoint); + assertBytes(state.variables.BUNNY_PULL_ZONE_ID, values.pullZoneId); assertBytes(state.variables.BUNNY_PULL_ZONE_HOSTNAME, values.hostname); assertBytes(state.variables.BUNNY_REMOTE_PREFIX, values.prefix); assertBytes(state.secrets.BUNNY_ACCESS_KEY, values.access); - assertNoSecretLeak(result, [values.access]); + assertBytes(state.secrets.BUNNY_API_KEY, values.apiKey); + assertNoSecretLeak(result, [values.access, values.apiKey]); assert.match(result.stderr, /FTP & API Access/); assert.match(result.stderr, /Pull Zones/); + assert.match(result.stderr, /https:\/\/dash\.bunny\.net\/account\/api-key/); + assert.match(result.stderr, /BUNNY_API_KEY \[GitHub Actions secret\][\s\S]*Value \(hidden\):/); const calls = readCalls(stateDir); - for (const call of calls.filter((entry) => entry.args[1] === "set")) { + for (const call of calls) { assert.ok(!call.args.includes(values.access), "secret must not appear in gh argv"); + assert.ok(!call.args.some((arg) => arg.includes(values.apiKey)), "API key must not appear in gh argv"); } + const apiKeyWrite = calls.find((call) => call.args[0] === "secret" && call.args[2] === "BUNNY_API_KEY"); + assert.deepEqual(apiKeyWrite.args, ["secret", "set", "BUNNY_API_KEY", "--repo", "sandwichfarm/loopwire"]); + assertBytes(apiKeyWrite.stdinBase64, values.apiKey); const check = run(["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--check"], "", { stateDir @@ -87,8 +106,9 @@ function testDeployPromptPreservesExactValues() { function testWindowsLineEndingsKeepPromptBoundaries() { const stateDir = newStateDir("crlf-boundaries"); - const access = "secret-after-four-distinct-prompts"; - const lines = ["zone-crlf", "", "docs-crlf.example.test", "folder/subfolder", access]; + const access = "secret-after-five-distinct-prompts"; + const apiKey = "crlf-account-api-key"; + const lines = ["zone-crlf", "", "12345", "docs-crlf.example.test", "folder/subfolder", access, apiKey]; const result = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], `${lines.join("\r\n")}\r\n`, @@ -98,15 +118,18 @@ function testWindowsLineEndingsKeepPromptBoundaries() { const state = readState(stateDir); assertBytes(state.variables.BUNNY_STORAGE_ZONE, "zone-crlf"); assertBytes(state.variables.BUNNY_STORAGE_ENDPOINT, "https://storage.bunnycdn.com"); + assertBytes(state.variables.BUNNY_PULL_ZONE_ID, "12345"); assertBytes(state.variables.BUNNY_PULL_ZONE_HOSTNAME, "docs-crlf.example.test"); assertBytes(state.variables.BUNNY_REMOTE_PREFIX, "folder/subfolder"); assertBytes(state.secrets.BUNNY_ACCESS_KEY, access); + assertBytes(state.secrets.BUNNY_API_KEY, apiKey); } function testDryRunNeverInvokesGhOrLeaksSecrets() { const stateDir = newStateDir("dry-run"); const access = "dry-run-secret-$() with spaces"; - const input = ["dry-run-zone", "", "", "", access].join("\n"); + const apiKey = "dry-run-account-secret-$() with spaces"; + const input = ["dry-run-zone", "", "12345", "", "", access, apiKey].join("\n"); const result = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--dry-run"], input, @@ -116,7 +139,9 @@ function testDryRunNeverInvokesGhOrLeaksSecrets() { assert.equal(existsSync(join(stateDir, "calls.jsonl")), false, "dry-run must not invoke gh"); assert.match(result.stdout, /variable: BUNNY_STORAGE_ZONE/); assert.match(result.stdout, /secret: BUNNY_ACCESS_KEY/); - assertNoSecretLeak(result, [access]); + assert.match(result.stdout, /variable: BUNNY_PULL_ZONE_ID/); + assert.match(result.stdout, /secret: BUNNY_API_KEY/); + assertNoSecretLeak(result, [access, apiKey]); } function testFinalKeyBytesWithoutTrailingNewline() { @@ -137,12 +162,15 @@ function testFinalKeyBytesWithoutTrailingNewline() { writeFileSync(publicPath, publicWithoutNewline); const access = "final-secret-Ω-'\"-$()"; + const apiKey = "final-account-api-key"; const input = [ "final-zone", "", + "12345", "docs.final.example.test", "", access, + apiKey, relative(workDir, privatePath), "", "APPLY" @@ -163,7 +191,8 @@ function testFinalKeyBytesWithoutTrailingNewline() { const state = readState(stateDir); assert.deepEqual(Buffer.from(state.secrets.LOOPWIRE_RELEASE_PRIVATE_KEY, "base64"), privateWithoutNewline); assertBytes(state.secrets.BUNNY_ACCESS_KEY, access); - assertNoSecretLeak(result, [access, privateWithoutNewline.toString("utf8")]); + assertBytes(state.secrets.BUNNY_API_KEY, apiKey); + assertNoSecretLeak(result, [access, apiKey, privateWithoutNewline.toString("utf8")]); assert.match(result.stderr, /pnpm release:prepare-key/); } @@ -176,18 +205,98 @@ function testInvalidInputStopsBeforeRemotePreflight() { assert.equal(existsSync(join(stateDir, "calls.jsonl")), false, "invalid input must stop before gh preflight"); } +function testPullZoneIdValidation() { + const invalidIds = ["0", "000", "-1", "+1", "1.5", "1e3", "0x12", "zone-name", "12/3", " 123", "123 ", "9223372036854775808"]; + for (const [index, id] of invalidIds.entries()) { + const stateDir = newStateDir(`invalid-pull-zone-id-${index}`); + const result = run( + ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], + ["invalid-id-zone", "", id].join("\n"), + { stateDir } + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /BUNNY_PULL_ZONE_ID must be a positive integer no greater than 9223372036854775807/); + assert.equal(existsSync(join(stateDir, "calls.jsonl")), false, "invalid Pull Zone ID must stop before gh preflight"); + } + + for (const id of ["1", "9223372036854775807"]) { + const stateDir = newStateDir(`valid-pull-zone-id-${id}`); + const result = run( + ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], + ["valid-id-zone", "", id, "", "", "storage-password", "account-api-key"].join("\n"), + { stateDir } + ); + assert.equal(result.status, 0, result.stderr); + assertBytes(readState(stateDir).variables.BUNNY_PULL_ZONE_ID, id); + } +} + +function testApiKeyControlCharactersStopBeforeRemotePreflight() { + for (const codePoint of [0, 9, 11, 12, 13, 27, 31, 127]) { + const stateDir = newStateDir(`invalid-api-key-${codePoint}`); + const apiKey = `invalid-account-key-${String.fromCodePoint(codePoint)}-suffix`; + const result = run( + ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], + ["invalid-key-zone", "", "12345", "", "", "storage-password", apiKey].join("\n"), + { stateDir } + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /BUNNY_API_KEY must not contain control characters/); + assertNoSecretLeak(result, [apiKey]); + assert.equal(existsSync(join(stateDir, "calls.jsonl")), false, "invalid API key must stop before gh preflight"); + } +} + +function testCheckRequiresPurgeConfiguration() { + for (const scope of ["deploy", "final"]) { + const stateDir = newStateDir(`check-purge-${scope}`); + const statePath = join(stateDir, "state.json"); + const encode = (value) => Buffer.from(value).toString("base64"); + const state = { + variables: { + BUNNY_STORAGE_ZONE: encode("legacy-zone"), + BUNNY_STORAGE_ENDPOINT: encode("https://storage.bunnycdn.com"), + BUNNY_PULL_ZONE_HOSTNAME: encode("docs.example.test") + }, + secrets: { + BUNNY_ACCESS_KEY: encode("legacy-storage-key"), + LOOPWIRE_RELEASE_PRIVATE_KEY: encode("legacy-private-key") + } + }; + writeFileSync(statePath, JSON.stringify(state)); + const args = ["--repo", "sandwichfarm/loopwire", "--scope", scope, "--check"]; + const missing = run(args, "", { stateDir }); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /missing: GitHub Actions variable: BUNNY_PULL_ZONE_ID/); + assert.match(missing.stderr, /missing: GitHub Actions secret: BUNNY_API_KEY/); + + state.variables.BUNNY_PULL_ZONE_ID = encode("12345"); + state.secrets.BUNNY_API_KEY = encode("account-api-key"); + writeFileSync(statePath, JSON.stringify(state)); + const present = run(args, "", { stateDir }); + assert.equal(present.status, 0, present.stderr); + assert.match(present.stdout, /ok: GitHub Actions variable present: BUNNY_PULL_ZONE_ID/); + assert.match(present.stdout, /ok: GitHub Actions secret present: BUNNY_API_KEY/); + assertNoSecretLeak(present, ["account-api-key"]); + assert.ok(readCalls(stateDir).every((call) => !["get", "set"].includes(call.args[1]))); + } +} + function testOversizeSecretIsRejectedWithoutTruncation() { - const stateDir = newStateDir("oversize"); const hugeSecret = "x".repeat(48 * 1024 + 1); - const input = ["oversize-zone", "", "", "", hugeSecret].join("\n"); - const result = run(["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], input, { - stateDir, - maxBuffer: 2 * 1024 * 1024 - }); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /exceeds GitHub's 48 KB value limit; input was not truncated/); - assertNoSecretLeak(result, [hugeSecret]); - assert.equal(existsSync(join(stateDir, "calls.jsonl")), false); + for (const name of ["BUNNY_ACCESS_KEY", "BUNNY_API_KEY"]) { + const stateDir = newStateDir(`oversize-${name}`); + const secrets = name === "BUNNY_ACCESS_KEY" ? [hugeSecret] : ["storage-password", hugeSecret]; + const input = ["oversize-zone", "", "12345", "", "", ...secrets].join("\n"); + const result = run(["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], input, { + stateDir, + maxBuffer: 2 * 1024 * 1024 + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exceeds GitHub's 48 KB value limit; input was not truncated/); + assertNoSecretLeak(result, [hugeSecret]); + assert.equal(existsSync(join(stateDir, "calls.jsonl")), false); + } } function testMismatchedAndSymlinkKeysStopBeforeWrites() { @@ -211,9 +320,11 @@ function testMismatchedAndSymlinkKeysStopBeforeWrites() { const input = `${[ "bad-key-zone", "", + "12345", "docs.bad-key.example.test", "", "bad-key-access", + "bad-key-account-api", privatePath, "" ].join("\n")}\n`; @@ -236,9 +347,11 @@ function testMismatchedAndSymlinkKeysStopBeforeWrites() { const symlinkInput = `${[ "symlink-zone", "", + "12345", "docs.symlink.example.test", "", "symlink-access", + "symlink-account-api", symlinkPath, "" ].join("\n")}\n`; @@ -255,7 +368,8 @@ function testMismatchedAndSymlinkKeysStopBeforeWrites() { function testPartialFailureNamesCompletedWritesWithoutValues() { const stateDir = newStateDir("partial-failure"); const access = "must-not-leak-partial-$()"; - const input = ["partial-zone", "", "", "partial-prefix", access].join("\n"); + const apiKey = "must-not-leak-partial-account-api"; + const input = ["partial-zone", "", "12345", "", "partial-prefix", access, apiKey].join("\n"); const result = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], input, @@ -265,16 +379,18 @@ function testPartialFailureNamesCompletedWritesWithoutValues() { assert.match(result.stderr, /write failed for secret:BUNNY_ACCESS_KEY/); assert.match(result.stderr, /variable:BUNNY_STORAGE_ZONE/); assert.match(result.stderr, /remaining names were not attempted/); - assertNoSecretLeak(result, [access]); + assertNoSecretLeak(result, [access, apiKey]); const state = readState(stateDir); assert.ok(state.variables.BUNNY_STORAGE_ZONE); assert.equal(state.secrets.BUNNY_ACCESS_KEY, undefined); + assert.equal(state.secrets.BUNNY_API_KEY, undefined); } function testVariableReadbackCorruptionStopsBeforeSecrets() { const stateDir = newStateDir("corrupt-readback"); const access = "must-not-write-after-corrupt-readback"; - const input = ["readback-zone", "", "", "", access].join("\n"); + const apiKey = "must-not-write-account-api-after-corrupt-readback"; + const input = ["readback-zone", "", "12345", "", "", access, apiKey].join("\n"); const result = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], input, @@ -282,29 +398,39 @@ function testVariableReadbackCorruptionStopsBeforeSecrets() { ); assert.notEqual(result.status, 0); assert.match(result.stderr, /GitHub variable readback did not match the entered UTF-8 value/); - assertNoSecretLeak(result, [access]); + assertNoSecretLeak(result, [access, apiKey]); const state = readState(stateDir); assert.ok(state.variables.BUNNY_STORAGE_ZONE); assert.equal(state.secrets.BUNNY_ACCESS_KEY, undefined); + assert.equal(state.secrets.BUNNY_API_KEY, undefined); } function testRepeatedSetupReplacesInsteadOfAppending() { const stateDir = newStateDir("replace-not-append"); const first = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], - ["first-zone", "", "", "", "first-secret"].join("\n"), + ["first-zone", "", "12345", "", "", "first-secret", "first-account-api"].join("\n"), { stateDir } ); assert.equal(first.status, 0, first.stderr); const second = run( ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], - ["second-zone", "", "", "", "second-secret"].join("\n"), + ["second-zone", "", "54321", "", "", "second-secret", "second-account-api"].join("\n"), { stateDir } ); assert.equal(second.status, 0, second.stderr); const state = readState(stateDir); assertBytes(state.variables.BUNNY_STORAGE_ZONE, "second-zone"); + assertBytes(state.variables.BUNNY_PULL_ZONE_ID, "54321"); assertBytes(state.secrets.BUNNY_ACCESS_KEY, "second-secret"); + assertBytes(state.secrets.BUNNY_API_KEY, "second-account-api"); + const third = run( + ["--repo", "sandwichfarm/loopwire", "--scope", "deploy", "--yes"], + ["second-zone", "", "54321", "", "", "second-secret", "second-account-api"].join("\n"), + { stateDir } + ); + assert.equal(third.status, 0, third.stderr); + assert.deepEqual(readState(stateDir), state, "repeating setup must preserve the same values"); } function run(args, input, options = {}) { diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index a8149d6..d5ceda6 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -719,9 +719,13 @@ assert_contains "apps/docs/docs/developer/release.md" "--write-env-template /sec assert_contains "apps/docs/docs/developer/release.md" 'no-value template with `0600`' assert_contains "apps/docs/docs/developer/release.md" "prints the same template to stdout" assert_contains "apps/docs/docs/developer/release.md" "--write-env-template " -assert_contains "apps/docs/docs/developer/release.md" "Bunny.net secrets are missing, the deploy job emits a notice" -assert_contains "apps/docs/docs/developer/release.md" "bash scripts/setup-github-secrets.sh --repo --scope deploy --env-file /secure/loopwire-release-secrets.env" -assert_contains "apps/docs/docs/developer/release.md" 'include `BUNNY_PULL_ZONE_HOSTNAME` in that env file' +assert_contains "apps/docs/docs/developer/release.md" 'The deploy job fails before upload when' +assert_contains "apps/docs/docs/developer/release.md" 'pnpm setup:github -- --repo OWNER/REPO --scope deploy' +assert_contains "apps/docs/docs/developer/release.md" 'configure `BUNNY_PULL_ZONE_HOSTNAME` so live smoke' +assert_contains "apps/docs/docs/developer/release.md" 'POST https://api.bunny.net/pullzone/{id}/purgeCache' +assert_contains "apps/docs/docs/developer/github-actions-setup.md" 'Add `BUNNY_API_KEY` under **Environment secrets**' +assert_contains "apps/docs/docs/developer/github-actions-setup.md" '`BUNNY_PULL_ZONE_ID` under **Environment variables**' +assert_contains "apps/docs/docs/developer/github-actions-setup.md" 'does not invalidate copies already cached in browsers' assert_contains "apps/docs/docs/release-notes/unreleased.md" "The Deploy Docs workflow now prints the same safe" assert_contains "apps/docs/docs/release-notes/unreleased.md" "--env-file /secure/loopwire-release-secrets.env" assert_contains "apps/docs/docs/release-notes/unreleased.md" 'the `BUNNY_PULL_ZONE_HOSTNAME` reminder' diff --git a/scripts/verify-github-workflows.sh b/scripts/verify-github-workflows.sh index a658490..5fdc4e9 100755 --- a/scripts/verify-github-workflows.sh +++ b/scripts/verify-github-workflows.sh @@ -139,6 +139,11 @@ assert_contains ".github/workflows/deploy-docs.yml" "BUNNY_REMOTE_PREFIX" assert_contains ".github/workflows/deploy-docs.yml" "Bunny.net deployment configuration is missing:" assert_contains ".github/workflows/deploy-docs.yml" 'missing+=(BUNNY_STORAGE_ZONE)' assert_contains ".github/workflows/deploy-docs.yml" 'missing+=(BUNNY_ACCESS_KEY)' +assert_contains ".github/workflows/deploy-docs.yml" 'missing+=(BUNNY_API_KEY)' +assert_contains ".github/workflows/deploy-docs.yml" 'missing+=(BUNNY_PULL_ZONE_ID)' +assert_occurrences ".github/workflows/deploy-docs.yml" 'BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}' "2" +assert_occurrences ".github/workflows/deploy-docs.yml" 'BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID || secrets.BUNNY_PULL_ZONE_ID }}' "2" +assert_contains ".github/workflows/deploy-docs.yml" 'bash scripts/deploy-docs-bunny.sh --dist dist/site --purge-cache' assert_contains ".github/workflows/deploy-docs.yml" "exit 1" assert_not_contains ".github/workflows/deploy-docs.yml" "skipping deployment" assert_not_contains ".github/workflows/deploy-docs.yml" "steps.bunny.outputs.deploy" diff --git a/scripts/verify-scripts.sh b/scripts/verify-scripts.sh index 67cafb7..af13238 100755 --- a/scripts/verify-scripts.sh +++ b/scripts/verify-scripts.sh @@ -91,6 +91,8 @@ node --check scripts/describe-dsp-provider.mjs node --check scripts/promote-vm-evidence.mjs node --check scripts/restore-background.mjs node --check scripts/verify-docs-deployment-manifest.mjs +node --check scripts/test-docs-cache-purge.mjs +node scripts/test-docs-cache-purge.mjs node --check scripts/verify-desktop-preview.mjs node --check scripts/e2e-desktop-ui.mjs node --check scripts/e2e-desktop-shell.mjs