build: cache the tailwind binary so deploys stop fetching it from GitHub #44

Merged
graham merged 2 commits from build/cache-tailwind-binary into main 2026-10-19 17:29:33 +00:00
Owner

OpenSpec change: cache-tailwind-binary-for-container-builds.

Problem

The Build and Push Docker Image job runs on an ephemeral runner with no Docker layer cache, so every deploy's mix assets.deploy re-downloaded the tailwindcss v4.0.9 standalone binary from GitHub (~130 MB). Deploys were slower and coupled to GitHub availability.

What changed

  • .forgejo/scripts/fetch-tailwind-bin.sh — provisions .build-bin/tailwind-<target>-<version>: the version always comes from the config :tailwind block of config/config.exs, the target from the runner arch; fetches with curl --fail --location --retry, verifies a pinned sha256, chmod 0755. resolve mode feeds the cache key; fetch mode is a no-op cache hit when the file is present.
  • .forgejo/workflows/ci.yml (docker job) — resolve version → probe the runner cache service (same pattern as the quality job) → cache .build-bin/ keyed tailwind-bin-<target>-<version> (exact key: no stale-binary fallback) → provision with pinned digests.
  • Dockerfile — copies .build-bin/ into the builder at the exact path deps/tailwind resolves by default (default_bin_path/1 → /app/_build/tailwind-<target>-<version>), so the package skips its GitHub download; absent file → today's download fallback (tracked .build-bin/.keep keeps the COPY valid for local builds).
  • Docs — docs/infrastructure/deployment.md documents the contract: version bump = one fetch + digest update, fallback on cold cache.

Verification

  • Script exercised locally: resolve (4.0.9 / arm64 on this Mac), fetch + digest pass + exec bit, cache-hit path, missing-digest → loud ::error::, broken parse → loud ::error::, tampered download → sha256 mismatch → loud fail + file removed.
  • Dockerfile layers replicated in isolated builds: .keep-only context and binary-present context both build; chmod guard no-ops safely. docker build --check clean on the real Dockerfile.
  • No Elixir changes; quality/dialyzer unaffected. The docker job itself is push-only, so its first real cache-hit run lands after merge (tasks 1.1/1.3/3.1 in the change's task list stay open until then).

Related, out of scope: esbuild re-downloads its binaries the same way; the same pattern extends to it later.

OpenSpec change: `cache-tailwind-binary-for-container-builds`. ## Problem The `Build and Push Docker Image` job runs on an ephemeral runner with no Docker layer cache, so every deploy's `mix assets.deploy` re-downloaded the tailwindcss v4.0.9 standalone binary from GitHub (~130 MB). Deploys were slower and coupled to GitHub availability. ## What changed - **`.forgejo/scripts/fetch-tailwind-bin.sh`** — provisions `.build-bin/tailwind-<target>-<version>`: the version always comes from the `config :tailwind` block of `config/config.exs`, the target from the runner arch; fetches with `curl --fail --location --retry`, verifies a pinned sha256, chmod 0755. `resolve` mode feeds the cache key; `fetch` mode is a no-op cache hit when the file is present. - **`.forgejo/workflows/ci.yml` (docker job)** — resolve version → probe the runner cache service (same pattern as the quality job) → cache `.build-bin/` keyed `tailwind-bin-<target>-<version>` (exact key: no stale-binary fallback) → provision with pinned digests. - **`Dockerfile`** — copies `.build-bin/` into the builder at the exact path `deps/tailwind` resolves by default (`default_bin_path/1` → `/app/_build/tailwind-<target>-<version>`), so the package skips its GitHub download; absent file → today's download fallback (tracked `.build-bin/.keep` keeps the COPY valid for local builds). - **Docs** — `docs/infrastructure/deployment.md` documents the contract: version bump = one fetch + digest update, fallback on cold cache. ## Verification - Script exercised locally: resolve (4.0.9 / arm64 on this Mac), fetch + digest pass + exec bit, cache-hit path, missing-digest → loud `::error::`, broken parse → loud `::error::`, tampered download → sha256 mismatch → loud fail + file removed. - `Dockerfile` layers replicated in isolated builds: `.keep`-only context and binary-present context both build; `chmod` guard no-ops safely. `docker build --check` clean on the real Dockerfile. - No Elixir changes; quality/dialyzer unaffected. The docker job itself is push-only, so its first real cache-hit run lands after merge (tasks 1.1/1.3/3.1 in the change's task list stay open until then). Related, out of scope: `esbuild` re-downloads its binaries the same way; the same pattern extends to it later.
build: cache the tailwind binary so deploys stop fetching it from GitHub
Some checks failed
Elixir CI / Dialyzer (pull_request) Successful in 1m1s
Elixir CI / Build and test (pull_request) Successful in 1m24s
Elixir CI / Build and Push Docker Image (pull_request) Has been skipped
skippy-bot/review Skippy review: 2 blocking findings open — see the PR thread
9767d96027
The docker job runs on an ephemeral runner with no layer cache, so every
deploy's `mix assets.deploy` re-downloaded the tailwindcss v4.0.9 binary
from GitHub. The job now provisions `.build-bin/tailwind-<target>-<version>`
once per version (runner cache, sha256-pinned fetch in
.forgejo/scripts/fetch-tailwind-bin.sh) and the Dockerfile copies it to the
path deps/tailwind resolves by default (_build/tailwind-<target>-<version>);
when absent the package's built-in download remains the fallback.
skippy-bot left a comment

🤖 Skippy PR review

2 findings — 2 blocking before merge.

Severity Location Issue
🟠 High .forgejo/scripts/fetch-tailwind-bin.sh:55 Cached binary lands under a filename deps/tailwind never looks for
🟡 Warning .forgejo/scripts/fetch-tailwind-bin.sh:90 A truncated download can poison the cache under the exact key

Reviewed 9767d960279c. Comment skippy review to re-run.

### 🤖 Skippy PR review **2 findings** — 2 blocking before merge. | Severity | Location | Issue | | --- | --- | --- | | 🟠 High | `.forgejo/scripts/fetch-tailwind-bin.sh:55` | Cached binary lands under a filename deps/tailwind never looks for | | 🟡 Warning | `.forgejo/scripts/fetch-tailwind-bin.sh:90` | A truncated download can poison the cache under the exact key | <sub>Reviewed `9767d960279c`. Comment `skippy review` to re-run.</sub> <!-- skippy-pr-review -->
@ -0,0 +52,4 @@
;;
esac
FILENAME="tailwind-${TARGET}-${VERSION}"
First-time contributor

🟠 High — Cached binary lands under a filename deps/tailwind never looks for

FILENAME is tailwind-<TARGET>-<VERSION> where TARGET is the release asset name, so this produces tailwind-tailwindcss-linux-x64-4.0.9. The path the package resolves is tailwind-linux-x64-4.0.9: Tailwind.default_bin_path/1 (tailwind 0.5.1, lib/tailwind.ex:225-238) builds tailwind-<target>-<version> from system_target/1, whose glibc Linux x86_64 target is linux-x64, not tailwindcss-linux-x64. Probed against this project's dep: Tailwind.bin_path("4.0.9") returns _build/tailwind-linux-x64-4.0.9, so in the container COPY .build-bin/ /app/_build/ lands a file that install_and_run/2's File.exists?(bin_path(version)) never sees, and every deploy still downloads the 137MB binary from GitHub. The cache, the digest pinning and the copy are all a silent no-op. Keep the asset name and the bin target separate:

case "$(uname -m)" in
  x86_64) TARGET="linux-x64" ;;
  aarch64 | arm64) TARGET="linux-arm64" ;;
esac
ASSET="tailwindcss-${TARGET}"
FILENAME="tailwind-${TARGET}-${VERSION}"
url="${RELEASE_BASE}/v${VERSION}/${ASSET}"

pinned_digest/0's case arms and the workflow's cache key must follow TARGET to linux-x64 / linux-arm64.

**🟠 High — Cached binary lands under a filename deps/tailwind never looks for** `FILENAME` is `tailwind-<TARGET>-<VERSION>` where `TARGET` is the release *asset* name, so this produces `tailwind-tailwindcss-linux-x64-4.0.9`. The path the package resolves is `tailwind-linux-x64-4.0.9`: `Tailwind.default_bin_path/1` (tailwind 0.5.1, `lib/tailwind.ex:225-238`) builds `tailwind-<target>-<version>` from `system_target/1`, whose glibc Linux x86_64 target is `linux-x64`, not `tailwindcss-linux-x64`. Probed against this project's dep: `Tailwind.bin_path("4.0.9")` returns `_build/tailwind-linux-x64-4.0.9`, so in the container `COPY .build-bin/ /app/_build/` lands a file that `install_and_run/2`'s `File.exists?(bin_path(version))` never sees, and every deploy still downloads the 137MB binary from GitHub. The cache, the digest pinning and the copy are all a silent no-op. Keep the asset name and the bin target separate: ```bash case "$(uname -m)" in x86_64) TARGET="linux-x64" ;; aarch64 | arm64) TARGET="linux-arm64" ;; esac ASSET="tailwindcss-${TARGET}" FILENAME="tailwind-${TARGET}-${VERSION}" url="${RELEASE_BASE}/v${VERSION}/${ASSET}" ``` `pinned_digest/0`'s case arms and the workflow's cache key must follow `TARGET` to `linux-x64` / `linux-arm64`.
skippy-bot marked this conversation as resolved
@ -0,0 +87,4 @@
url="${RELEASE_BASE}/v${VERSION}/${TARGET}"
echo "fetching ${url}"
curl --fail --silent --show-error --location --retry 3 --retry-delay 2 \
First-time contributor

🟡 Warning — A truncated download can poison the cache under the exact key

curl writes straight to out_path, so a connection that dies mid-transfer (137MB over a flaky link, retries exhausted) exits non-zero under set -e and leaves a partial file there. Only the digest-mismatch branch below removes it, and that branch is never reached. The step fails, but the cache post-step still saves .build-bin under tailwind-bin-<target>-<version>, and from then on the cache-hit path at line 77 returns 0 on [ -f ] without hashing, so every deploy copies a truncated, non-executable file into /app/_build and mix assets.deploy dies on it with an opaque exec error until the entry is evicted by hand. Download to ${out_path}.part and mv it into place after the digest check, or verify the digest on the cache-hit path too.

**🟡 Warning — A truncated download can poison the cache under the exact key** curl writes straight to `out_path`, so a connection that dies mid-transfer (137MB over a flaky link, retries exhausted) exits non-zero under `set -e` and leaves a partial file there. Only the digest-mismatch branch below removes it, and that branch is never reached. The step fails, but the cache post-step still saves `.build-bin` under `tailwind-bin-<target>-<version>`, and from then on the cache-hit path at line 77 returns 0 on `[ -f ]` without hashing, so every deploy copies a truncated, non-executable file into `/app/_build` and `mix assets.deploy` dies on it with an opaque exec error until the entry is evicted by hand. Download to `${out_path}.part` and `mv` it into place after the digest check, or verify the digest on the cache-hit path too.
skippy-bot marked this conversation as resolved
fix: correct the cached tailwind filename and re-verify cache hits
All checks were successful
Elixir CI / Dialyzer (pull_request) Successful in 1m10s
Elixir CI / Build and test (pull_request) Successful in 1m38s
skippy-bot/review Skippy review: clean — no open findings
Elixir CI / Build and Push Docker Image (pull_request) Has been skipped
f78d02ec6f
Review findings on 9767d960:

- deps/tailwind resolves /app/_build/tailwind-linux-<arch>-<version> —
  system_target/1 returns "linux-x64" without the "tailwindcss-" prefix,
  which only exists in the release-asset URL template. The cached file was
  stored under the asset name, a path the package never looks for, so the
  builder silently fell back to downloading every time. The script now
  separates the URL target (tailwindcss-linux-<arch>) from the stored
  filename (tailwind-linux-<arch>-<version>).

- actions/cache saves in its post step even after a failed fetch, so a
  truncated download could poison the exact cache key. Cache hits are now
  re-verified against the pinned digest (mismatch -> warning + fresh
  download), and downloads stage to a .partial file, moving into place only
  after the digest passes.
First-time contributor

Resolved 2 of 2 earlier findings — the cached file is now tailwind-linux-<arch>-<version> (matches default_bin_path/1 on tailwind 0.5.1), and downloads stage to .partial with the cache-hit path re-hashing before use, so a truncated entry self-heals instead of reaching the builder.

Nothing new in 9767d96..f78d02e; the pinned digests match the release's own sha256sums.txt for v4.0.9.

Resolved 2 of 2 earlier findings — the cached file is now `tailwind-linux-<arch>-<version>` (matches `default_bin_path/1` on tailwind 0.5.1), and downloads stage to `.partial` with the cache-hit path re-hashing before use, so a truncated entry self-heals instead of reaching the builder. Nothing new in `9767d96..f78d02e`; the pinned digests match the release's own `sha256sums.txt` for v4.0.9. <!-- skippy-pr-review -->
graham merged commit 7630643ec6 into main 2026-10-19 17:29:33 +00:00
graham deleted branch build/cache-tailwind-binary 2026-10-19 17:29:33 +00:00
Sign in to join this conversation.
No description provided.