diff --git a/.gitea/workflows/deploy-placeholder.yml b/.gitea/workflows/deploy-placeholder.yml new file mode 100644 index 0000000..38198b8 --- /dev/null +++ b/.gitea/workflows/deploy-placeholder.yml @@ -0,0 +1,103 @@ +# ----------------------------------------------------------------------------- +# cameleer-website — Deploy under-construction placeholder +# +# MANUAL TRIGGER ONLY. Replaces the live cameleer.io docroot with a static +# "back shortly" page. Recovery: trigger Actions → deploy → Run workflow on +# the desired main commit. +# +# Shares the deploy-production concurrency group with deploy.yml so the two +# workflows queue rather than race on the same docroot. +# +# This workflow does NOT run npm/astro. The placeholder is hand-authored +# static HTML in placeholder/, deliberately decoupled from the main build so +# it can ship even when the main build is broken (which is the worst case in +# which a placeholder is needed). +# +# Required secrets (repo settings → Actions → Secrets): +# SFTP_HOST, SFTP_USER, SFTP_PATH, SFTP_KEY, SFTP_KNOWN_HOSTS +# PUBLIC_SALES_EMAIL +# ----------------------------------------------------------------------------- + +name: deploy-placeholder + +on: + workflow_dispatch: + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Substitute sales email into placeholder + env: + PUBLIC_SALES_EMAIL: ${{ secrets.PUBLIC_SALES_EMAIL }} + run: | + set -e + : "${PUBLIC_SALES_EMAIL:?PUBLIC_SALES_EMAIL secret must be set}" + sed -i "s|__SALES_EMAIL__|${PUBLIC_SALES_EMAIL}|g" placeholder/index.html + if grep -q '__SALES_EMAIL__' placeholder/index.html; then + echo "Token __SALES_EMAIL__ still present after substitution — refusing to ship." + exit 1 + fi + + - name: Configure SSH + env: + SFTP_KEY: ${{ secrets.SFTP_KEY }} + SFTP_KNOWN_HOSTS: ${{ secrets.SFTP_KNOWN_HOSTS }} + run: | + set -e + : "${SFTP_KEY:?SFTP_KEY secret must be set}" + : "${SFTP_KNOWN_HOSTS:?SFTP_KNOWN_HOSTS secret must be set}" + mkdir -p ~/.ssh + printf '%s\n' "$SFTP_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "$SFTP_KNOWN_HOSTS" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + if ! command -v rsync >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then SUDO=sudo; else SUDO=; fi + $SUDO apt-get update -qq + $SUDO apt-get install -y --no-install-recommends rsync openssh-client + fi + + - name: Deploy via rsync + env: + SFTP_USER: ${{ secrets.SFTP_USER }} + SFTP_HOST: ${{ secrets.SFTP_HOST }} + SFTP_PATH: ${{ secrets.SFTP_PATH }} + run: | + : "${SFTP_USER:?SFTP_USER secret must be set}" + : "${SFTP_HOST:?SFTP_HOST secret must be set}" + : "${SFTP_PATH:?SFTP_PATH secret must be set}" + # Hetzner Webhosting splits SSH into two ports: + # port 22 — SFTP only, no remote command exec + # port 222 — full SSH with shell exec (rsync needs this) + # `--rsync-path=/usr/bin/rsync` tells the local rsync where to find + # the remote binary on Hetzner's locked-down PATH. + # `BatchMode=yes` disables interactive prompts. + rsync -avz --delete --rsync-path=/usr/bin/rsync \ + -e "ssh -p 222 -i $HOME/.ssh/id_ed25519 -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts" \ + placeholder/ "$SFTP_USER@$SFTP_HOST:$SFTP_PATH/" + + - name: Post-deploy smoke test + run: | + set -e + echo "Confirming the placeholder is live on www.cameleer.io..." + # Cache-bust per run so Cloudflare's edge can't serve a stale response + # that masks a failed deploy. ?cb=$GITHUB_RUN_ID forces a fresh cache key; + # the no-cache request header tells any well-behaved cache to revalidate. + CB="$GITHUB_RUN_ID" + BODY=$(curl -sf -H 'Cache-Control: no-cache' "https://www.cameleer.io/?cb=$CB") + echo "$BODY" | grep -qF 'Routes are remapping' \ + || { echo "Sentinel string missing — placeholder did not land."; exit 1; } + echo "$BODY" | grep -qF 'mailto:' \ + || { echo "mailto: link missing — sales email substitution may have failed."; exit 1; } + curl -sfI -H 'Cache-Control: no-cache' "https://www.cameleer.io/cameleer-logo.png?cb=$CB" > /dev/null \ + || { echo "cameleer-logo.png not reachable on the live origin."; exit 1; } + echo "Placeholder is live." diff --git a/OPERATOR-CHECKLIST.md b/OPERATOR-CHECKLIST.md index cadb4ce..16f29a2 100644 --- a/OPERATOR-CHECKLIST.md +++ b/OPERATOR-CHECKLIST.md @@ -87,8 +87,7 @@ workflows read them via the `${{ secrets.* }}` context. - [ ] Fill in `src/pages/imprint.astro` `operator` object with real legal details. - [ ] Fill in `operatorContact` in `src/pages/privacy.astro`. -- [ ] Review the "Why us" / nJAMS wording in `src/components/sections/WhyUs.astro` for trademark safety. -- [ ] Confirm MID-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other. +- [ ] Confirm Starter-tier retention: spec says **7 days**; `cameleer-saas/HOWTO.md` says **30 days**. Reconcile one side or the other. ## 5. First deploy diff --git a/README.md b/README.md index 06899a2..28b52ec 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ See `.env.example`. All are `PUBLIC_*` (build-time, embedded in HTML). Rollback: trigger the deploy workflow on the previous `main` commit (Actions UI lets you pick a ref). +### Placeholder mode + +To put the site into "back shortly" mode, trigger Gitea → **Actions → deploy-placeholder → Run workflow**. To bring the real site back, trigger **Actions → deploy → Run workflow** on the desired `main` commit. Both workflows share the `deploy-production` concurrency group, so they can never run simultaneously. + +The placeholder is hand-authored static HTML in `placeholder/` and does NOT depend on `npm`/`astro build` — it is deliberately decoupled from the main build so it can ship even when that build is broken. + +**Scope note.** The placeholder serves HTTP 200 (not 503), so Cloudflare's edge will cache it normally. This is fine for short planned maintenance windows. For longer outages or incident fallback, purge Cloudflare's cache (or set a short-TTL Cache Rule for the maintenance window) before triggering recovery via `deploy.yml`, otherwise the edge may serve the placeholder past recovery until TTL expires. + **Security headers** (HSTS, CSP, X-Frame-Options, etc.) are owned by **Cloudflare Transform Rules**, not by anything in this repo. Hetzner Webhosting L ignores file-based `.htaccess` (`AllowOverride None`), so origin-side header config is impossible from code. See `OPERATOR-CHECKLIST.md` §2. See [`OPERATOR-CHECKLIST.md`](./OPERATOR-CHECKLIST.md) for the one-time Hetzner + Cloudflare setup. diff --git a/docs/superpowers/plans/2026-04-25-under-construction-placeholder.md b/docs/superpowers/plans/2026-04-25-under-construction-placeholder.md new file mode 100644 index 0000000..67ce520 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-under-construction-placeholder.md @@ -0,0 +1,518 @@ +# Under-construction placeholder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a branded "back shortly" page for cameleer.io plus a manual-trigger Gitea workflow that swaps it onto the live origin on demand, recoverable by re-running the existing `deploy.yml`. + +**Architecture:** Standalone HTML in a top-level `placeholder/` directory, plus two PNG asset copies. A new `.gitea/workflows/deploy-placeholder.yml` rsyncs that directory to the same Hetzner docroot used by `deploy.yml` (`--delete` enabled, `deploy-production` concurrency group shared so the two workflows queue rather than race). No Astro build dependency, so the placeholder still ships when the main build is broken — which is the worst case where one is needed. + +**Tech Stack:** Plain HTML5 + inlined CSS, Google Fonts (DM Sans, JetBrains Mono), bash + rsync over SSH:222, Vitest 1 for static-content assertions. + +**Spec:** `docs/superpowers/specs/2026-04-25-under-construction-placeholder-design.md` (commit `9b0c36b`). + +--- + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `placeholder/index.html` | Create | Static under-construction page. Single self-contained file, references the two sibling PNGs by relative path. Contains `__SALES_EMAIL__` substitution token (used twice — `mailto:` href and link text). | +| `placeholder/cameleer-logo.png` | Create (copy) | Hero logo. Copy of `public/icons/cameleer-192.png` (~36 KB). | +| `placeholder/favicon.png` | Create (copy) | Browser tab icon. Copy of `public/icons/cameleer-32.png` (~2.4 KB). | +| `src/placeholder.test.ts` | Create | Static assertions that the placeholder HTML has the contract the deploy workflow depends on (sentinel string, token, references, no JS, etc.). Lives in `src/` because `vitest.config.ts` only discovers `src/**/*.test.ts`. | +| `.gitea/workflows/deploy-placeholder.yml` | Create | Manual-dispatch workflow: substitute sales email → rsync `placeholder/` to docroot → smoke-test the live origin. | +| `README.md` | Modify | Append a "Placeholder mode" subsection under "Deployment". | + +--- + +## Task 1: Add tests for the placeholder HTML + +**Why first:** Establishes the contract the workflow depends on — sentinel string, substitution token, asset references — before any markup is written, so we can't accidentally drop one of them later. + +**Files:** +- Create: `src/placeholder.test.ts` + +- [ ] **Step 1: Write the failing test file** + +Create `src/placeholder.test.ts` with this exact content: + +```typescript +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const placeholderDir = join(process.cwd(), 'placeholder'); +const indexPath = join(placeholderDir, 'index.html'); + +describe('placeholder/index.html', () => { + const html = readFileSync(indexPath, 'utf8'); + + it('starts with the HTML5 doctype', () => { + expect(html.toLowerCase().trimStart()).toMatch(/^/); + }); + + it('has the back-shortly title', () => { + expect(html).toContain('Cameleer — Back shortly'); + }); + + it('is not indexable by search engines', () => { + expect(html).toContain(''); + }); + + it('declares the dark color-scheme matching the live site', () => { + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('contains the sentinel string the deploy workflow greps for', () => { + // The workflow's post-deploy smoke test fails if this string is missing. + expect(html).toContain('Routes are remapping'); + }); + + it('uses the live hero subhead verbatim', () => { + expect(html).toContain( + 'Cameleer is the hosted runtime and observability platform for Apache Camel — auto-traced, replay-ready, cross-service correlated. The 3 AM page becomes a 30-second answer.' + ); + }); + + it('contains __SALES_EMAIL__ tokens at both the mailto href and the link text', () => { + const matches = html.match(/__SALES_EMAIL__/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it('contains no other __TOKEN__ style placeholders', () => { + // Guard against a forgotten token that would survive the sed substitution. + const allTokens = html.match(/__[A-Z][A-Z0-9_]+__/g) ?? []; + const nonSales = allTokens.filter((t) => t !== '__SALES_EMAIL__'); + expect(nonSales).toEqual([]); + }); + + it('references the sibling cameleer-logo.png by relative path', () => { + expect(html).toContain('src="./cameleer-logo.png"'); + }); + + it('references the sibling favicon.png by relative path', () => { + expect(html).toContain('href="./favicon.png"'); + }); + + it('has no