diff --git a/.claude/commands/github-review-failures.md b/.claude/commands/github-review-failures.md index fbe080f..bd4b68f 100644 --- a/.claude/commands/github-review-failures.md +++ b/.claude/commands/github-review-failures.md @@ -105,7 +105,7 @@ Look for: Look for: - Gem build errors: missing files in gemspec, syntax errors - Bundle install failures: dependency conflicts -- Deploy workflow: Docker build context, Kamal image/service name mismatch, missing `docs` environment secret +- Deploy workflow: Docker build context, dash image/service name mismatch, missing `docs` environment secret --- @@ -132,7 +132,7 @@ bundle exec rake 1. **Lint/style fixes** first (fast, deterministic) 2. **Spec failures** second (may require understanding the code change) -3. **Build/deploy issues** third (usually gemspec, dependency, or a Kamal name mismatch) +3. **Build/deploy issues** third (usually gemspec, dependency, or a dash name mismatch) --- @@ -171,7 +171,7 @@ If you can identify that certain failures will persist for environmental reasons - **Read before fixing** -- always read the actual failing code before attempting a fix - **Fix the root cause** -- don't add `# rubocop:disable` to bypass lint; fix the actual issue (a targeted `# rubocop:disable` is acceptable only when RuboCop is demonstrably wrong) - **Don't fix unrelated failures** -- if a spec was already failing on main, note it but don't fix it in this PR -- **Deploy vs. test failures** -- the `Deploy docs` workflow only fires on a release/dispatch; a normal PR usually runs lint + specs. A deploy failure is often a Kamal image/service name or missing-secret issue, not a code bug (see the README deploy section). +- **Deploy vs. test failures** -- the `Deploy docs` workflow only fires on a release/dispatch; a normal PR usually runs lint + specs. A deploy failure is often a dash image/service name or missing-secret issue, not a code bug (see the README deploy section). - **Flaky tests** -- if a test passes locally but fails in CI, note it as potentially flaky rather than adding workarounds. - **Don't retry CI blindly** -- diagnose first, fix, then push. Each push triggers a full CI run. diff --git a/.claude/commands/security.md b/.claude/commands/security.md index 6d6ce63..5ffb4d3 100644 --- a/.claude/commands/security.md +++ b/.claude/commands/security.md @@ -20,7 +20,7 @@ RPC of its own — the threat model centers on **HTML output correctness** - Reviewing how config values (brand, nav labels, hrefs, themes) reach the DOM - Reviewing `#render_page` and the `layout: false` full-document render - Auditing files the install generator / `docs-kit new` template writes into a site -- Reviewing the reusable deploy workflow + Kamal secrets scaffolding +- Reviewing the reusable deploy workflow + dash secrets scaffolding ## Key Security Concerns @@ -74,8 +74,8 @@ a(href: item.href) { item.label } # label escaped; href is an attribute (Phlex ```ruby # The install generator / `docs-kit new` template writes initializers, a -# Dockerfile, deploy.yml, .kamal/secrets into a consuming site. -# GOOD: secrets are referenced from ENV ($KAMAL_REGISTRY_PASSWORD), never inlined; +# Dockerfile, deploy.yml, .dash/secrets into a consuming site. +# GOOD: secrets are referenced from ENV ($DASH_REGISTRY_PASSWORD), never inlined; # the scaffold enables the host app's normal protections. # BAD: a generated file that hardcodes a credential, disables CSRF/host auth # wholesale, or ships a master.key / credentials into the repo. diff --git a/.dockerignore b/.dockerignore index 7176836..733314d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -68,4 +68,5 @@ /docs/.rspec /docs/coverage/ /docs/.github/ +/docs/.dash/ /docs/.kamal/ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7d9763a..364ee67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,6 @@ # Reusable docs-site deploy: build the Docker image, push to GHCR, and deploy -# with Kamal to the oss-infrastructure server (behind a Cloudflare Tunnel + -# kamal-proxy). Every docs-kit site calls this so the deploy is defined ONCE. +# with dash to the oss-infrastructure server (behind a Cloudflare Tunnel + +# dash-proxy). Every docs-kit site calls this so the deploy is defined ONCE. # # A site's own .github/workflows/deploy-docs.yml is a thin caller: # @@ -11,7 +11,7 @@ # image: zoolutions/my-docs # secrets: inherit # -# The image is built once (buildx, registry cache) and Kamal deploys it with +# The image is built once (buildx, registry cache) and dash deploys it with # --skip-push, so the image is never built twice. name: Reusable docs deploy @@ -29,14 +29,14 @@ on: type: string service: description: >- - The Kamal service name (must equal `service:` in config/deploy.yml). - Stamped as the `service` image LABEL so Kamal's validate_image passes + The dash service name (must equal `service:` in config/deploy.yml). + Stamped as the `service` image LABEL so dash's validate_image passes on the --skip-push deploy — the image is built here (docker/build-push-action), - not by `kamal build`, so Kamal never injects the label itself. + not by `dash build`, so dash never injects the label itself. required: true type: string ruby_version: - description: "Ruby for the Kamal CLI." + description: "Ruby for the dash CLI." required: false default: "3.4" type: string @@ -68,13 +68,13 @@ on: # agent step errors if SSH_PRIVATE_KEY is missing. secrets: SSH_PRIVATE_KEY: - description: "Deploy key for the Kamal SSH user on the deploy host." + description: "Deploy key for the dash SSH user on the deploy host." required: false DEPLOY_HOST: description: "The deploy host (IP or DNS) — consumed by config/deploy.yml." required: false DEPLOY_DOMAIN: - description: "The public host kamal-proxy routes — consumed by config/deploy.yml." + description: "The public host dash-proxy routes — consumed by config/deploy.yml." required: false concurrency: @@ -136,9 +136,9 @@ jobs: tags: | ${{ inputs.registry }}/${{ inputs.image }}:${{ steps.tag.outputs.image_tag }} ${{ inputs.registry }}/${{ inputs.image }}:latest - # Kamal's validate_image greps for this `service` label on the + # dash's validate_image greps for this `service` label on the # --skip-push deploy; since the image is built here and not by - # `kamal build`, we must stamp it ourselves. + # `dash build`, we must stamp it ourselves. labels: | service=${{ inputs.service }} cache-from: type=registry,ref=${{ inputs.registry }}/${{ inputs.image }}:buildcache @@ -148,7 +148,7 @@ jobs: needs: build runs-on: ubuntu-latest environment: docs - # Pull-only (kamal deploy --skip-push); no image push, so packages: read. + # Pull-only (dash deploy --skip-push); no image push, so packages: read. permissions: contents: read packages: read @@ -161,11 +161,13 @@ jobs: with: ruby-version: ${{ inputs.ruby_version }} - # Install the Kamal CLI directly so a consuming site doesn't need `kamal` in - # its docs Gemfile. `kamal deploy` (not `bundle exec kamal`) then works for + # Install the dash CLI directly so a consuming site doesn't need `dash` in + # its docs Gemfile. `dash deploy` (not `bundle exec dash`) then works for # every site regardless of its bundle. - - name: Install Kamal - run: gem install kamal + - name: Install dash + # Pinned to the 4.x line: the deploy.yml this workflow deploys sets + # `minimum_version: 4.0.0` (dash-proxy identity + host migration). + run: gem install dash -v "~> 4.0" - name: Setup SSH agent uses: webfactory/ssh-agent@v0.9.1 @@ -178,7 +180,7 @@ jobs: run: | mkdir -p ~/.ssh chmod 700 ~/.ssh - # Disable host-key verification for the deploy host. Kamal uses net-ssh, + # Disable host-key verification for the deploy host. dash uses net-ssh, # which raises HostKeyMismatch on any known_hosts conflict (e.g. after # the server is reprovisioned). net-ssh honors ~/.ssh/config, so this # turns verification off for this single known IP. Acceptable here: a @@ -190,22 +192,36 @@ jobs: EOF chmod 600 ~/.ssh/config - - name: Deploy with Kamal (image already pushed by the build job) + # A pre-flight the deploy would otherwise discover one failure at a time: + # SSH + Docker on the host, registry login, proxy state (incl. the 4.0 + # kamal-proxy → dash-proxy migration), published ports, readiness gates. + - name: Doctor — diagnose deploy readiness working-directory: ${{ inputs.working_directory }} env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_DOMAIN: ${{ secrets.DEPLOY_DOMAIN }} + DASH_REGISTRY_PASSWORD: ${{ github.token }} + KAMAL_REGISTRY_PASSWORD: ${{ github.token }} + run: dash doctor + + - name: Deploy with dash (image already pushed by the build job) + working-directory: ${{ inputs.working_directory }} + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_DOMAIN: ${{ secrets.DEPLOY_DOMAIN }} + DASH_REGISTRY_PASSWORD: ${{ github.token }} + # Legacy twin: a site still on a scaffolded .kamal/secrets reads this name. KAMAL_REGISTRY_PASSWORD: ${{ github.token }} IMAGE_TAG: ${{ needs.build.outputs.image_tag }} run: | # DEPLOY_HOST/DEPLOY_DOMAIN are `docs`-environment secrets that only # reach this job because the deploy job sets `environment: docs` AND the # caller sets `secrets: inherit`. If either is dropped they resolve to "" - # with no error and Kamal would deploy against an empty host — fail fast. + # with no error and dash would deploy against an empty host — fail fast. if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_DOMAIN" ]; then echo "::error::DEPLOY_HOST/DEPLOY_DOMAIN empty — is the caller's 'secrets: inherit' set and does the deploy job keep 'environment: docs'?" exit 1 fi - kamal deploy \ + dash deploy \ --skip-push \ --version="$IMAGE_TAG" diff --git a/CLAUDE.md b/CLAUDE.md index 666a5b1..6b6a017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ Layer 2: Registry + values lib/docs_kit/registry.rb (in-memory docs registry mi Layer 1: Config + controller lib/docs_kit/configuration.rb (per-site knobs), lib/docs_kit/controller.rb (#render_page) Layer 0: Core + engine lib/docs_kit.rb, lib/docs_kit/engine.rb (auto-pins docs-nav, mounts the controller assets) Install path lib/generators/docs_kit/install/ (install generator + templates), lib/docs_kit/templates/new_site.rb, exe/docs-kit (docs-kit new) - Deploy .github/workflows/deploy.yml (reusable), deploy-docs.yml (thin caller); Kamal + GHCR + Cloudflare Tunnel + Deploy .github/workflows/deploy.yml (reusable), deploy-docs.yml (thin caller); dash + GHCR + Cloudflare Tunnel ``` ## The mental model diff --git a/README.md b/README.md index c536f95..6217c4b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ fronts Puma with Thruster (`CMD ["./bin/thrust", "./bin/rails", "server"]`) for HTTP caching, compression, and X-Sendfile — and the generator scaffolds the `bin/thrust` binstub if the app lacks one, since the exec-form CMD needs the file to exist in the image. Thruster listens on the routed port -(`HTTP_PORT=3000` — Kamal's `app_port`) and proxies to Puma on `TARGET_PORT=3001`. +(`HTTP_PORT=3000` — dash's `app_port`) and proxies to Puma on `TARGET_PORT=3001`. Without thruster in the *production* bundle (absent, or only in a development/test group that `BUNDLE_WITHOUT` excludes) the CMD falls back to plain `rails server` — never a thrust CMD that would crash at boot. @@ -701,7 +701,7 @@ claude mcp add --transport http docs https://your-docs.example/mcp and can ask Claude to search or read your docs, which now appear as tools. The JSON-RPC is stateless (each `POST` is independent — no SSE session), so it works -behind the existing Kamal/Cloudflare deploy unchanged; `GET`/`DELETE` return +behind the existing dash/Cloudflare deploy unchanged; `GET`/`DELETE` return `405`. When enabled, `/llms.txt` grows a final `## MCP` line advertising the endpoint so agents discover it. @@ -854,7 +854,7 @@ and applies docs-kit's application template, which: - runs `rails g docs_kit:install` (initializers, controllers, a Doc registry, a sample guide page, the Bun/Tailwind build, the docs-nav Stimulus wiring), - syncs the lucide icons and builds the CSS, -- scaffolds Kamal (`config/deploy.yml`, `.kamal/secrets`, an optimized +- scaffolds dash (`config/deploy.yml`, `.dash/secrets`, an optimized multi-stage `Dockerfile` + a `.dockerignore`) and a thin `.github/workflows/deploy-docs.yml` that calls the reusable workflow. @@ -905,7 +905,7 @@ group. Then `bundle exec rubocop` runs the docs-kit cops. The build + deploy is defined **once** in this gem's reusable workflow (`.github/workflows/deploy.yml`). `docs-kit new` scaffolds the caller for you; to wire it by hand a site adds five small things and it deploys to the -oss-infrastructure server (Kamal + GHCR + Cloudflare Tunnel). +oss-infrastructure server (dash + GHCR + Cloudflare Tunnel). **1. A thin caller** — `.github/workflows/deploy-docs.yml`: @@ -923,25 +923,46 @@ jobs: secrets: inherit ``` -**2. `docs/config/deploy.yml`** — `service:` and `image:` MUST match the caller: +**2. `docs/config/deploy.yml`** — `service:` and `image:` MUST match the caller +(the full file `docs-kit new` writes is in `lib/docs_kit/templates/new_site.rb`; +`dash docs proxy` documents every key): ```yaml service: image: zoolutions/ -registry: { server: ghcr.io, username: mhenrixon, password: [KAMAL_REGISTRY_PASSWORD] } +minimum_version: 4.0.0 # dash 4: dash-proxy identity + in-place host migration +retain_containers: 2 +error_pages_path: public # 502/503/504.html served during a deploy gap +registry: { server: ghcr.io, username: mhenrixon, password: [DASH_REGISTRY_PASSWORD] } builder: { arch: amd64, context: .., dockerfile: Dockerfile } # repo root = build context -proxy: { host: <%= ENV["DEPLOY_DOMAIN"] %>, app_port: 3000, ssl: false, healthcheck: { path: /up } } servers: { web: { hosts: [<%= ENV["DEPLOY_HOST"] %>] } } ssh: { user: oss } +proxy: + host: <%= ENV["DEPLOY_DOMAIN"] %> + app_port: 3000 + ssl: false # TLS terminates at Cloudflare + healthcheck: { path: /up, interval: 5, timeout: 30 } + compress: true # zstd/br/gzip at the edge + cache: { enabled: true, max_ttl: 300 } # stores `Cache-Control: public` responses (assets, /llms*.txt) + headers: { response: { set: { X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin }, remove: [Server, X-Powered-By] } } + intercept_errors: [502, 503, 504] + exclude_metrics_paths: [/up] ``` +Deliberately **not** set: `proxy.run` (`port_holder`, `log_format`, …) is +host-wide — every docs site on the shared host boots the same proxy, and a +`run:` block that differs between them makes each alternate deploy reboot it. +`rate_limit`/`deny_ips` need `client_ip.trusted_proxies` pinned to the tunnel's +address to key on visitors rather than on cloudflared; add them per site once +that address is known. + **3. `docs/Dockerfile`** — end the final stage with the matching label: ```dockerfile LABEL service="" ``` -**4. `docs/.kamal/secrets`** — `KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD`. +**4. `docs/.dash/secrets`** — `DASH_REGISTRY_PASSWORD=$DASH_REGISTRY_PASSWORD`. **5. GitHub** — a `docs` environment with secrets `SSH_PRIVATE_KEY`, `DEPLOY_HOST`, `DEPLOY_DOMAIN`. (The registry password is the auto-provided @@ -953,9 +974,15 @@ LABEL service="" > can both push (build job) and pull (deploy) it. A different name becomes an > unlinked user-scoped package `GITHUB_TOKEN` can't pull → the deploy fails. -**First deploy per host:** run `kamal setup` (or `bin/deploy setup`) once to boot -any accessories (e.g. a Postgres accessory); the release workflow runs plain -`kamal deploy`, which doesn't boot accessories. +**First deploy per host:** run `dash setup` (or `bin/deploy setup`) once to boot +any accessories (e.g. a Postgres accessory); the release workflow runs +`dash doctor` (a pre-flight of host, registry, proxy, ports and readiness gates) +and then plain `dash deploy`, which doesn't boot accessories. + +**Upgrading a host to dash 4:** the first 4.x deploy renames the proxy +(`kamal-proxy` → `dash-proxy`, network `kamal` → `dash`, config volume copied) +and costs one short outage on that host while ports 80/443 change hands. It is +idempotent and shared by every site on the host — whichever deploys first pays it. ## CSS — the canonical build diff --git a/app/controllers/docs_kit/mcp_controller.rb b/app/controllers/docs_kit/mcp_controller.rb index e899878..4ffe0e8 100644 --- a/app/controllers/docs_kit/mcp_controller.rb +++ b/app/controllers/docs_kit/mcp_controller.rb @@ -15,7 +15,7 @@ module DocsKit # DocsKit::McpServer / DocsKit::McpTools. # # Stateless JSON-RPC: each POST is independent (no SSE session), so it works - # behind the existing Kamal/Cloudflare deploy unchanged. #create delegates the + # behind the existing dash/Cloudflare deploy unchanged. #create delegates the # whole protocol to DocsKit::McpServer#handle_json — the SDK parses the request, # dispatches the tool, and serializes the response (including JSON-RPC errors), # so the controller never hand-rolls the protocol. diff --git a/docs/.dash/secrets b/docs/.dash/secrets new file mode 100644 index 0000000..8193c1a --- /dev/null +++ b/docs/.dash/secrets @@ -0,0 +1,3 @@ +# In CI the deploy workflow sets this to the job's GITHUB_TOKEN. Locally, +# export it (e.g. DASH_REGISTRY_PASSWORD=$(gh auth token)). +DASH_REGISTRY_PASSWORD=$DASH_REGISTRY_PASSWORD diff --git a/docs/.kamal/secrets b/docs/.kamal/secrets deleted file mode 100644 index e7e0892..0000000 --- a/docs/.kamal/secrets +++ /dev/null @@ -1,3 +0,0 @@ -# In CI the deploy workflow sets this to the job's GITHUB_TOKEN. Locally, -# export it (e.g. KAMAL_REGISTRY_PASSWORD=$(gh auth token)). -KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD diff --git a/docs/Dockerfile b/docs/Dockerfile index e18852f..78e4d77 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -59,7 +59,7 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ # --- Final stage -------------------------------------------------------------- FROM base -# Kamal verifies this label on the --skip-push deploy; must equal `service:` in +# dash verifies this label on the --skip-push deploy; must equal `service:` in # config/deploy.yml (= the repo name, docs-kit). LABEL service="docs-kit" @@ -74,9 +74,9 @@ USER 1000:1000 EXPOSE 3000 # Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on # HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which -# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's +# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (dash's # `app_port: 3000` in config/deploy.yml, the EXPOSE above) — Thruster's default -# is 80, which the non-root user can't reliably bind AND which kamal-proxy would +# is 80, which the non-root user can't reliably bind AND which dash-proxy would # never route to, silently bypassing Thruster straight into Puma. ENV HTTP_PORT="3000" \ TARGET_PORT="3001" diff --git a/docs/app/views/docs/pages/deploy.rb b/docs/app/views/docs/pages/deploy.rb index 36e2ed2..fa8b758 100644 --- a/docs/app/views/docs/pages/deploy.rb +++ b/docs/app/views/docs/pages/deploy.rb @@ -7,7 +7,7 @@ class Deploy < DocsUI::Page title "Deploy" eyebrow "Reference" - def lead = "One reusable workflow deploys every docs-kit site to Kamal + GHCR." + def lead = "One reusable workflow deploys every docs-kit site to dash + GHCR." def content DocsUI::Section("Scaffolded for you") do @@ -16,7 +16,7 @@ def content plain "The CLI writes the whole deploy: " code { "config/deploy.yml" } plain ", " - code { ".kamal/secrets" } + code { ".dash/secrets" } plain ", a " code { "Dockerfile" } plain ", and a " @@ -72,6 +72,78 @@ def content SHELL end + DocsUI::Section("dash-proxy, switched on", description: "The scaffolded deploy.yml uses the proxy, not just the router.") do + prose do + p do + plain "Every site deploys with " + a(href: "https://github.com/zoolutions/dash") { "dash" } + plain " 4 (" + code { "minimum_version: 4.0.0" } + plain ") and turns on the per-app dash-proxy features a docs site benefits from — no per-site tuning, the template writes them:" + end + ul do + li do + code { "compress: true" } + plain " — zstd / brotli / gzip negotiated at the edge; Thruster-encoded responses pass through." + end + li do + code { "cache: { enabled: true, max_ttl: 300 }" } + plain " — an RFC 9111 shared cache. It stores only responses marked " + code { "Cache-Control: public" } + plain " (Propshaft assets, " + code { "/llms.txt" } + plain "); HTML carrying a session cookie is refused by design. " + code { "dash proxy cache stats" } + plain " shows what it holds." + end + li do + code { "headers" } + plain " — " + code { "X-Content-Type-Options" } + plain " / " + code { "Referrer-Policy" } + plain " set once at the proxy; " + code { "Server" } + plain " and " + code { "X-Powered-By" } + plain " stripped." + end + li do + code { "intercept_errors: [502, 503, 504]" } + plain " + " + code { "error_pages_path: public" } + plain " — the site's own status pages during a container swap, not a bare \"Bad Gateway\"." + end + li do + code { "exclude_metrics_paths: [/up]" } + plain " — the health probe stays out of the request histograms." + end + end + p do + plain "Deliberately left alone: " + code { "proxy.run" } + plain " is host-wide (every site on the shared host boots the same proxy; a differing " + code { "run:" } + plain " block reboots it on each alternate deploy), and " + code { "rate_limit" } + plain " / " + code { "deny_ips" } + plain " need " + code { "client_ip.trusted_proxies" } + plain " pinned to the tunnel's address to key on visitors rather than on cloudflared. " + code { "dash docs proxy" } + plain " is the always-current reference." + end + p do + plain "The first dash 4 deploy on a host renames the proxy (" + code { "kamal-proxy" } + plain " → " + code { "dash-proxy" } + plain ") and copies its config volume — one short outage on that host while ports 80/443 change hands, paid once by whichever site deploys first." + end + end + end + DocsUI::Section("The reusable workflow") do prose do p do @@ -122,16 +194,18 @@ def content DocsUI::Callout(:warning) do plain "A name that doesn't match the repo becomes an unlinked package that " code { "GITHUB_TOKEN" } - plain " can't pull — the deploy fails when Kamal tries to fetch the image." + plain " can't pull — the deploy fails when dash tries to fetch the image. Before deploying, the workflow runs " + code { "dash doctor" } + plain ", a pre-flight of host, registry, proxy, ports and readiness gates that fails the job early with one report instead of one failure at a time." end end DocsUI::Section("Secrets") do render DocsUI::PropTable.new( [ - [ "SSH_PRIVATE_KEY", "Deploy key for the Kamal SSH user." ], + [ "SSH_PRIVATE_KEY", "Deploy key for the dash SSH user." ], [ "DEPLOY_HOST", "The deploy host (IP or DNS)." ], - [ "DEPLOY_DOMAIN", "The public host kamal-proxy routes." ] + [ "DEPLOY_DOMAIN", "The public host dash-proxy routes." ] ], headers: [ "Secret", "Purpose" ] ) @@ -152,7 +226,7 @@ def content DocsUI::Callout(:warning) do plain "The caller workflow MUST grant " code { "permissions: packages: write" } - plain " itself — a reusable workflow can't escalate its caller's permissions. Without it the deploy fails at startup, before any Kamal step runs." + plain " itself — a reusable workflow can't escalate its caller's permissions. Without it the deploy fails at startup, before any dash step runs." end end end diff --git a/docs/app/views/docs/pages/installation.rb b/docs/app/views/docs/pages/installation.rb index 137497f..a982fb4 100644 --- a/docs/app/views/docs/pages/installation.rb +++ b/docs/app/views/docs/pages/installation.rb @@ -34,7 +34,7 @@ def new_site_section - runs the install generator, - syncs the lucide icons, - builds the Tailwind CSS, and - - scaffolds the Kamal deploy. + - scaffolds the dash deploy. Then boot it: MD diff --git a/docs/app/views/docs/pages/overview.rb b/docs/app/views/docs/pages/overview.rb index 4a7be69..5e4b58f 100644 --- a/docs/app/views/docs/pages/overview.rb +++ b/docs/app/views/docs/pages/overview.rb @@ -117,7 +117,7 @@ def what_you_get_section and `--sync` runs only the additive wiring to upgrade an existing site without touching your pages. See [Installation](/docs/installation). - **`docs-kit new` + a single reusable deploy workflow** — scaffold a - whole site, then ship it with Kamal + GHCR. See [Deploy](/docs/deploy). + whole site, then ship it with dash + GHCR. See [Deploy](/docs/deploy). MD end end diff --git a/docs/config/deploy.yml b/docs/config/deploy.yml index 09319b9..1314bcd 100644 --- a/docs/config/deploy.yml +++ b/docs/config/deploy.yml @@ -1,9 +1,21 @@ -# Kamal deploy → the oss-infrastructure server (Cloudflare Tunnel + kamal-proxy). +# dash deploy → the oss-infrastructure server (Cloudflare Tunnel + dash-proxy). # service/image = the repo OWNER/REPO so the ghcr package auto-links to the # repo and GITHUB_TOKEN can push + pull it (no PAT). See docs-kit's README. +# `dash docs` / `dash docs proxy` is the always-current reference for every key. service: docs-kit image: zoolutions/docs-kit +# dash 4 renamed the on-host proxy (kamal-proxy → dash-proxy) and migrates a +# host in place; an older CLI must not deploy this config. +minimum_version: 4.0.0 + +# A stateless docs site never rolls back far — keep the host tidy. +retain_containers: 2 + +# Status-named pages (public/502.html, 503, 504) the proxy serves in place of +# the app's during a deploy gap — paired with `proxy.intercept_errors` below. +error_pages_path: public + servers: web: hosts: @@ -15,21 +27,55 @@ ssh: proxy: host: <%= ENV["DEPLOY_DOMAIN"] %> app_port: 3000 + # TLS terminates at Cloudflare; the tunnel reaches the proxy over plain HTTP. ssl: false healthcheck: path: /up interval: 5 timeout: 30 + # --- dash-proxy per-app features ------------------------------------------ + # zstd / br / gzip negotiated at the edge; responses the app already encoded + # (Thruster) pass through untouched. + compress: true + + # RFC 9111 shared cache. Only responses the app marks `Cache-Control: public, + # max-age` are stored (Propshaft assets, /llms*.txt) — HTML carrying a session + # cookie is refused by design. `dash proxy cache stats` shows what it holds. + cache: + enabled: true + max_ttl: 300 + + # Security headers set once here instead of per app; drop server fingerprints. + headers: + response: + set: + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + remove: + - Server + - X-Powered-By + + # Serve public/.html instead of a bare "Bad Gateway" while a + # container is swapped or unhealthy. + intercept_errors: + - 502 + - 503 + - 504 + + # Keep the health probe out of the request histograms. + exclude_metrics_paths: + - /up + registry: server: ghcr.io username: mhenrixon password: - - KAMAL_REGISTRY_PASSWORD + - DASH_REGISTRY_PASSWORD builder: arch: amd64 - # Run kamal from docs/; the build context is the repo root (..) so the path gem + # Run dash from docs/; the build context is the repo root (..) so the path gem # `gem "docs-kit", path: ".."` resolves. dockerfile is relative to docs/. context: .. dockerfile: Dockerfile diff --git a/docs/public/502.html b/docs/public/502.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/docs/public/502.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/docs/public/503.html b/docs/public/503.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/docs/public/503.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/docs/public/504.html b/docs/public/504.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/docs/public/504.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/exe/docs-kit b/exe/docs-kit index b5be969..96a8992 100755 --- a/exe/docs-kit +++ b/exe/docs-kit @@ -8,7 +8,7 @@ # # It runs `rails new` with the right minimal flags and applies docs-kit's # application template (lib/docs_kit/templates/new_site.rb), which adds the gem, -# runs `docs_kit:install`, syncs icons, builds the CSS, and scaffolds Kamal + +# runs `docs_kit:install`, syncs icons, builds the CSS, and scaffolds dash + # the reusable deploy workflow. require "optparse" @@ -27,7 +27,7 @@ unless command == "new" Options: --image GHCR image (default zoolutions/NAME) — use OWNER/REPO for the repo-linked package so GITHUB_TOKEN can push+pull it. - --service Kamal service name (default NAME). + --service dash service name (default NAME). --gem-source How to depend on docs-kit: 'released' (default), 'path:PATH', or 'github:OWNER/REPO'. USAGE diff --git a/lib/docs_kit/templates/new_site.rb b/lib/docs_kit/templates/new_site.rb index b645742..87b6dbe 100644 --- a/lib/docs_kit/templates/new_site.rb +++ b/lib/docs_kit/templates/new_site.rb @@ -14,7 +14,7 @@ # * adds docs-kit + its runtime deps to the Gemfile, # * runs `docs_kit:install` (all the Ruby/CSS/Stimulus wiring), # * syncs the lucide icon set and builds the CSS, -# * scaffolds a deployable Kamal setup that calls docs-kit's reusable workflow. +# * scaffolds a deployable dash setup that calls docs-kit's reusable workflow. # # The generated app is a complete, deployable standalone docs site. @@ -53,14 +53,26 @@ run "bun install --silent" if system("command -v bun >/dev/null 2>&1") run "bun run build:css" if system("command -v bun >/dev/null 2>&1") - # --- deploy scaffolding (Kamal + the reusable workflow) --------------------- + # --- deploy scaffolding (dash + the reusable workflow) --------------------- create_file "config/deploy.yml", <<~YAML - # Kamal deploy → the oss-infrastructure server (Cloudflare Tunnel + kamal-proxy). + # dash deploy → the oss-infrastructure server (Cloudflare Tunnel + dash-proxy). # service/image = the repo OWNER/REPO so the ghcr package auto-links to the # repo and GITHUB_TOKEN can push + pull it (no PAT). See docs-kit's README. + # `dash docs` / `dash docs proxy` is the always-current reference for every key. service: #{service} image: #{image} + # dash 4 renamed the on-host proxy (kamal-proxy → dash-proxy) and migrates a + # host in place; an older CLI must not deploy this config. + minimum_version: 4.0.0 + + # A stateless docs site never rolls back far — keep the host tidy. + retain_containers: 2 + + # Status-named pages (public/502.html, 503, 504) the proxy serves in place of + # the app's during a deploy gap — paired with `proxy.intercept_errors` below. + error_pages_path: public + servers: web: hosts: @@ -72,17 +84,51 @@ proxy: host: <%= ENV["DEPLOY_DOMAIN"] %> app_port: 3000 + # TLS terminates at Cloudflare; the tunnel reaches the proxy over plain HTTP. ssl: false healthcheck: path: /up interval: 5 timeout: 30 + # --- dash-proxy per-app features ------------------------------------------ + # zstd / br / gzip negotiated at the edge; responses the app already encoded + # (Thruster) pass through untouched. + compress: true + + # RFC 9111 shared cache. Only responses the app marks `Cache-Control: public, + # max-age` are stored (Propshaft assets, /llms*.txt) — HTML carrying a session + # cookie is refused by design. `dash proxy cache stats` shows what it holds. + cache: + enabled: true + max_ttl: 300 + + # Security headers set once here instead of per app; drop server fingerprints. + headers: + response: + set: + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + remove: + - Server + - X-Powered-By + + # Serve public/.html instead of a bare "Bad Gateway" while a + # container is swapped or unhealthy. + intercept_errors: + - 502 + - 503 + - 504 + + # Keep the health probe out of the request histograms. + exclude_metrics_paths: + - /up + registry: server: ghcr.io username: mhenrixon password: - - KAMAL_REGISTRY_PASSWORD + - DASH_REGISTRY_PASSWORD builder: arch: amd64 @@ -98,18 +144,24 @@ SECRET_KEY_BASE: "#{SecureRandom.hex(64)}" YAML - create_file ".kamal/secrets", <<~SH + # The status pages `proxy.intercept_errors` serves for a deploy gap — `rails new` + # ships 500.html; the proxy looks for the exact status it intercepted. + %w[502 503 504].each do |status| + create_file "public/#{status}.html", File.read("public/500.html") if File.exist?("public/500.html") + end + + create_file ".dash/secrets", <<~SH # In CI the deploy workflow sets this to the job's GITHUB_TOKEN. Locally, - # export it (e.g. KAMAL_REGISTRY_PASSWORD=$(gh auth token)). - KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + # export it (e.g. DASH_REGISTRY_PASSWORD=$(gh auth token)). + DASH_REGISTRY_PASSWORD=$DASH_REGISTRY_PASSWORD SH # The Dockerfile + .dockerignore are written by `docs_kit:install` (run above in # after_bundle) so a scaffolded site and an upgrading site share ONE optimized, # version-stamped Dockerfile — no divergent copy to maintain here. The generator # derives the LABEL service from the app dir basename (= app_name); if the site - # deploys under a DIFFERENT Kamal service (`--service`), correct the label to - # match config/deploy.yml so Kamal's --skip-push validate_image passes. + # deploys under a DIFFERENT dash service (`--service`), correct the label to + # match config/deploy.yml so dash's --skip-push validate_image passes. gsub_file "Dockerfile", /LABEL service=".*"/, %(LABEL service="#{service}") if service != app_name create_file ".github/workflows/deploy-docs.yml", <<~YAML diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 38c308e..0512c24 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -568,7 +568,7 @@ def app_brand (name || File.basename(destination_root)).to_s.underscore.humanize end - # The Kamal `service` name stamped as the Dockerfile's LABEL — the app dir + # The dash `service` name stamped as the Dockerfile's LABEL — the app dir # basename (a docs site's repo name), matching the `docs-kit new` default. # Used in Dockerfile.tt via <%= docker_service %>. def docker_service diff --git a/lib/generators/docs_kit/install/templates/Dockerfile.tt b/lib/generators/docs_kit/install/templates/Dockerfile.tt index c4d2b40..d8d7d7f 100644 --- a/lib/generators/docs_kit/install/templates/Dockerfile.tt +++ b/lib/generators/docs_kit/install/templates/Dockerfile.tt @@ -7,7 +7,7 @@ # `build` stage. Pair it with the shipped .dockerignore so the build context # stays small (no node_modules, .git, logs, specs, coverage). # -# Build context is the app root; `docker build .` (Kamal: context: "."). +# Build context is the app root; `docker build .` (dash: context: "."). ARG RUBY_VERSION=<%= ruby_version_arg %> FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base @@ -59,9 +59,9 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile && \ # --- Final stage -------------------------------------------------------------- FROM base -# Kamal's validate_image greps this label on a --skip-push deploy; it must equal +# dash's validate_image greps this label on a --skip-push deploy; it must equal # `service:` in config/deploy.yml. The reusable deploy workflow also stamps it, -# but keeping it here means `docker build` alone produces a Kamal-valid image. +# but keeping it here means `docker build` alone produces a dash-valid image. LABEL service="<%= docker_service %>" # Copy ONLY the built artifacts from the build stage: the installed bundle and @@ -79,9 +79,9 @@ EXPOSE 3000 <% if thruster? -%> # Thruster fronts Puma (HTTP caching + compression + X-Sendfile). It listens on # HTTP_PORT and proxies to Puma on TARGET_PORT (it sets PORT for the child, which -# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (Kamal's +# config/puma.rb reads). HTTP_PORT MUST be the port traffic is routed to (dash's # `app_port`, the EXPOSE above) — Thruster's default is 80, which the non-root -# user can't reliably bind AND which kamal-proxy (app_port: 3000) would never +# user can't reliably bind AND which dash-proxy (app_port: 3000) would never # route to, silently bypassing Thruster straight into Puma. ENV HTTP_PORT="3000" \ TARGET_PORT="3001" diff --git a/lib/generators/docs_kit/install/templates/dockerignore b/lib/generators/docs_kit/install/templates/dockerignore index 86cac7a..076b4c8 100644 --- a/lib/generators/docs_kit/install/templates/dockerignore +++ b/lib/generators/docs_kit/install/templates/dockerignore @@ -40,6 +40,7 @@ # CI / deploy / Docker meta — not part of the runtime image. /.github/ +/.dash/ /.kamal/ /Dockerfile* /.dockerignore diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 995f413..3c58d91 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -240,11 +240,11 @@ def capture_stream expect(read("Dockerfile")).to include(%(CMD ["./bin/thrust", "./bin/rails", "server"])) end - it "pins HTTP_PORT to 3000 (kamal-proxy's app_port; non-root can't rely on 80)" do + it "pins HTTP_PORT to 3000 (dash-proxy's app_port; non-root can't rely on 80)" do dockerfile = read("Dockerfile") # Thruster's default HTTP_PORT is 80: as USER 1000 the bind can fail, and - # kamal-proxy routes to app_port 3000 — which would hit Puma directly and + # dash-proxy routes to app_port 3000 — which would hit Puma directly and # silently bypass Thruster. HTTP_PORT=3000 keeps Thruster on the routed # port; TARGET_PORT moves Puma out of the way. expect(dockerfile).to match(/HTTP_PORT="?3000"?/)