Skip to content

fix: stop docs deploys from deleting assets live pages still load - #915

Merged
slayerjain merged 1 commit into
mainfrom
fix/docs-deploy-keep-live-assets
Sep 23, 2026
Merged

slayerjain merged 1 commit into
mainfrom
fix/docs-deploy-keep-live-assets

Conversation

@slayerjain

Copy link
Copy Markdown
Member

Problem

keploy.io/docs pages rendered but nothing on them was interactive. For example, the macOS tab on /docs/server/installation/ didn't respond to clicks. The HTML being served referenced a runtime~main.<hash>.js that had already been deleted from the bucket, so React never hydrated.

Root cause: the deploy (reggionick/s3-deploy with delete-removed) uploads the new build and then deletes every object the new build doesn't contain. Docusaurus names every JS/CSS chunk by content hash, so each deploy deleted the previous build's chunks straight away. Anything still using the previous HTML then broke: an open tab on its next click, a cached page, or a second deploy running concurrently. Two merges 17 s apart (#912, #913) triggered the concurrent case in production. Production was restored by re-running the latest deploy; this PR fixes the cause.

The action's cache: input was also word-split into a malformed Cache-Control: max-age=public, on every object (visible live with curl -I).

Fix

  • main.yml

    • A concurrency group so only one deploy runs at a time; a deploy already running is never cancelled.
    • actions/checkout with ref: main, so a run deploys main as it is when the run starts. A queued, cancelled or re-run deploy can never ship an older main than the one before it.
  • .github/scripts/deploy-docs.sh replaces the action:

    1. Hashed docs/assets/ go up first, immutable, and are never deleted in the same pass.
    2. A per-deploy manifest is written (docs/_deploy/asset-manifests/) before any page.
    3. Static files, then .txt/.md, then pages last.
    4. CloudFront is invalidated. Once the invalidation has landed, static files the build dropped are deleted, and then the deploy is marked complete.
    5. The prune deletes an asset only once every page that used it was replaced more than 14 days ago. The window is counted from the newest completed deploy, so a half-failed deploy can't shorten it. Prune problems log a warning and never fail a deploy; steps 1–4 fail loudly.
  • Cache headers per class:

    Files Cache-Control
    Hashed assets max-age=31536000, immutable
    Pages and text max-age=0, s-maxage=86400, must-revalidate
    Other static files max-age=3600

    .txt and .md (llms.txt, llms-full.txt, the page copies) are also served with charset=utf-8, which the aws CLI otherwise drops.

  • .github/scripts/deploy-docs-test.sh, run in build_and_check.yml on every PR, deploys that PR's real build into a local S3 and CloudFront (moto). It replays the incident and the weeks after it:

    • a deploy that dies once its pages are up, whose own chunk must still outlive it by the retention window

    • every chunk renamed, which forces a prune of more than 1,000 keys

    • every same-site asset URL in every live page must resolve, including the half-failed state and v1 pages still open in a tab

    • pass order, headers, charsets, and content-hashed names under assets/

    • refused deploys must not have made a single aws call

    An aws shim restores two S3 behaviors moto lacks: the 1,000-key limit on delete-objects, and the empty quiet-mode response. It also returns invalidation status the way CloudFront does (InProgress, then Completed, or AccessDenied), logs calls for the order check, and injects the mid-deploy failure. A sleep shim logs how long the deploy waited, so the invalidation wait is checked exactly rather than by the clock.

Verification

  • Checks: 66/66 on the real 2,674-file build (1,316 assets) and on the synthetic site, repeated.
  • Mutation testing: 22 regressions reintroduced, and each makes the test fail. They include the incident's same-pass asset delete, the malformed header, a failed deploy counted as the boundary, a failed deploy's assets not kept, pages before assets, manifest after pages, static files deleted before the invalidation lands (or on timeout, or when its status can't be read), matching only moto's COMPLETED, a poll loop that stops sleeping or overshoots its wait budget, done marker before invalidation, a missing distribution-ID guard, a single delete batch over 1,000 keys, and a missing charset.
  • Deploys now take a little longer: they wait for the CloudFront invalidation to land (usually 1–5 minutes, capped by DOCS_INVALIDATION_WAIT_SECONDS, default 15 minutes). If it hasn't landed by then, dropped static files are left for the next deploy to delete, with a warning.
  • shellcheck is clean. actionlint is clean apart from the existing setup-node@v3 warning.

⚠️ Merge and rollout

  • Merge only when no CI (main.yml) run is in progress or queued: gh run list -R keploy/docs -w main.yml -L 5. Runs created before this change aren't in the concurrency group, so they won't wait for the first new deploy. Their delete-removed would undo it.
  • Never re-run a deploy run created before this merge. A re-run replays its original YAML, which is the old action.
  • First deploy: everything is re-uploaded with the new headers. Nothing is pruned for 14 days, while manifests build up. After that, assets from before the first manifest are deleted.
  • Permissions — one new one: cloudfront:GetInvalidation on the docs distribution. The deploy polls it before deleting static files the build dropped. Please make sure the deploy user has it before merging: without it, every deploy fails after the pages are live, naming the permission (deliberately loud, so removed files like a retracted PDF don't stay public indefinitely). Everything else is what the old action already used (S3 list/get/put/delete and CreateInvalidation). Manifests live under docs/ like everything else.
  • Compression: the old action uploaded text pre-gzipped, which forced Content-Encoding: gzip even on clients that asked for identity. The new deploy uploads files uncompressed. CloudFront's automatic compression is already on for these paths (verified live on /docs/pdf/*.pdf and /docs/assets/*.map, which the old action never gzipped), so browsers now get brotli where they support it and correct content negotiation.

Every deploy ran reggionick/s3-deploy with delete-removed: upload the new
build, then delete every object it did not contain. Docusaurus names each
JS/CSS chunk by content hash, so the previous build's chunks vanished the
moment a deploy ran, and anything still holding the previous HTML - an
open tab, a cached page, or a second deploy running at the same time -
requested chunks that no longer existed and never hydrated. Two merges
17s apart (#912, #913) did exactly that to production: the pages rendered
but no control on them worked, including the macOS tab on
/docs/server/installation/.

The action's cache input was also word-split into the malformed header
"Cache-Control: max-age=public," on every object.

- main.yml: one deploy at a time (concurrency group, never cancelling a
  deploy in flight), and each run deploys main as it is when it starts,
  so a queued, cancelled or re-run deploy can never ship an older main.
- .github/scripts/deploy-docs.sh replaces the action. Hashed assets go up
  first and are never deleted in the same pass; a per-deploy manifest is
  written before any page; static files, text and pages follow, pages
  last; CloudFront is invalidated, dropped static files are deleted once
  it has landed, and the deploy is marked complete. Assets are pruned only
  once every page that used them was replaced more than 14 days ago,
  counted from the newest completed deploy, so a half-failed deploy can
  never cut the window short.
- Correct cache headers per class: immutable for hashed assets,
  revalidate for pages and text, one hour for other static files; UTF-8
  charset on .txt and .md (llms.txt, llms-full.txt, page copies).
- .github/scripts/deploy-docs-test.sh deploys the PR's real build into a
  local S3 (moto) on every PR and replays the incident and the weeks
  after it: a deploy that dies once its pages are up, >1000-key prunes,
  every asset a live or still-open page loads, and the step order.

Signed-off-by: slayerjain <shubhamkjain@outlook.com>
@slayerjain
slayerjain merged commit 8a43943 into main Sep 23, 2026
6 checks passed
@slayerjain
slayerjain deleted the fix/docs-deploy-keep-live-assets branch September 23, 2026 06:30
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.

1 participant