Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/commands/github-review-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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)

---

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions .claude/commands/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,5 @@
/docs/.rspec
/docs/coverage/
/docs/.github/
/docs/.dash/
/docs/.kamal/
56 changes: 36 additions & 20 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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:
#
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`:

Expand All @@ -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: <repo>
image: zoolutions/<repo>
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="<repo>"
```

**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
Expand All @@ -953,9 +974,15 @@ LABEL service="<repo>"
> 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

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/docs_kit/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/.dash/secrets
Original file line number Diff line number Diff line change
@@ -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
3 changes: 0 additions & 3 deletions docs/.kamal/secrets

This file was deleted.

6 changes: 3 additions & 3 deletions docs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down
Loading
Loading