# Deploy Doctrine — Kinetic Gain operator surfaces **Authoritative reference for FTP-deploy patterns across the kineticgain.com estate.** Generated 2026-06-04 after the 59-deploy chain. Codifies what we learned the hard way so future bulk deploys don't repeat the same FTP cap saturations, ERESOLVE panics, and dist-static surprises. --- ## 1. Hostinger FTP capacity reality ### What you can rely on - **Isolated single deploys succeed reliably** — one deploy with zero other FTP traffic ≈ 99% success in ~2 minutes. - **Deploys are independent at the path level** — `/abm/` and `/billing/` are different FTP target dirs, no interference at file write time. ### What will bite you - **FTP cap is backend, not request-rate.** Spacing requests 30s, 60s, or 90s apart all produce similar failure rates (~25-30% per attempt) during a burst. - **Cap recovery takes hours, not minutes.** After a 59-deploy burst, the cap stayed saturated for ~3-4 hours before recovering enough for a single apex deploy to land. - **Control-socket timeout is the canary.** `Error: Timeout (control socket)` at ~30s means cap is saturated. Build/verify steps will have already passed. ### Bulk-deploy doctrine | Deploy count | Approach | |---|---| | 1-5 | Just fire them. Parallel is fine. | | 6-20 | Serial with completion-wait between each (1 deploy at a time, wait for HTTP 200 before firing next). | | 21-50 | Serial with completion-wait + 60s sleep between attempts. Expect ~80% success on first pass, ~10% on retry, ~10% manual. | | 50+ | Schedule across multiple hours. First burst of 20-30, wait 1 hour, retry failures. Repeat. | ### Workflow-level retry (REQUIRED on all deploy.yml) Every deploy.yml using `SamKirkland/FTP-Deploy-Action@v4.3.5` MUST have the retry pattern: ```yaml - name: FTP Deploy (attempt 1) id: ftp_first continue-on-error: true uses: SamKirkland/FTP-Deploy-Action@v4.3.5 with: server: ${{ secrets.FTP_SERVER }} username: ${{ secrets.FTP_USERNAME }} password: ${{ secrets.FTP_PASSWORD }} local-dir: ./${{ env.DEPLOY_DIR }}/ server-dir: // exclude: | **/.git* **/.git*/** **/.github/** **/node_modules/** - name: Sleep before retry (let FTP cap drain) if: steps.ftp_first.outcome == 'failure' run: sleep 60 - name: Retry FTP Deploy if first attempt failed if: steps.ftp_first.outcome == 'failure' uses: SamKirkland/FTP-Deploy-Action@v4.3.5 with: # ...same as above ``` **NOTE**: The `sleep 60` between attempts is critical. Without it, both attempts hit the same cap window and both fail (we proved this on 2026-06-04). --- ## 2. Build-step doctrine ### Verify-step search list (REQUIRED) ```bash if [ -d "site" ]; then DEPLOY_DIR="site" elif [ -d "dist-static" ]; then DEPLOY_DIR="dist-static" elif [ -d "public" ]; then DEPLOY_DIR="public" elif [ -d "out" ]; then DEPLOY_DIR="out" elif [ -d "_site" ]; then DEPLOY_DIR="_site" elif [ -d "dist" ]; then DEPLOY_DIR="dist" elif [ -d "build" ]; then DEPLOY_DIR="build" else echo "FAIL: no site/, dist-static/, public/, out/, _site/, dist/, or build/ produced" exit 1 fi ``` The order matters: `dist-static/` checked BEFORE `dist/` because Codex TypeScript prerender scripts emit static HTML to `dist-static/` while `dist/` contains compiled JS. ### npm install pattern (REQUIRED for repos with package.json) ```yaml - name: Install run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps ``` Codex-shipped repos commonly have package.json/lockfile drift (eslint@10 in deps + eslint@9 resolved in lockfile). `--legacy-peer-deps` papers over this without affecting runtime behavior. ### Build step (handles repos with no build script) ```yaml - name: Build run: | if npm run | grep -q "^ prerender$"; then npm run prerender elif npm run | grep -q "^ build$"; then npm run build else echo "No build/prerender script (likely WordPress/static repo) - using repo root as deploy source" fi ``` For WordPress repos or static repos with `scripts:{}`, this skip-branch is essential. **Real-world example**: `wordpress-regulatory-disclosure-kit` (disclosure.kineticgain.com) has empty `scripts:{}` in package.json — its WordPress plugin assets live in `/public` and `/plugin` directly. Without the `elif` branch, `npm run build` errors with "Missing script: build" → deploy fails before FTP. With the `elif`+`else` branch, build is skipped and the verify-step's `public/` fallback finds the deploy dir. Fix landed 2026-06-04 (commit `a842ee6` in disclosure repo). --- ## 3. Subdomain provisioning doctrine See `/docs/subdomain-inventory.md` for live ground-truth. ### When to create a new subdomain vs path - **Burn a slot ONLY when**: the surface has a distinct buyer identity (e.g., `pulse.kineticgain.com`, `trust.kineticgain.com`). - **Use path-based ONLY when**: the surface extends an existing buyer-distinguishable parent (e.g., `/embedded/runbook/`, `/calculators/build-vs-buy/`). ### hPanel provisioning gotchas - **Plan upgrades take ~1hr** in transfer mode — wait for DNS to settle before firing deploys. - **Subdomain wizard creates BOTH** a directory in `/public_html/` AND a DNS record. If you see directory but no DNS, the wizard failed mid-flight — re-run. - **Files-before-DNS pattern works** — FTP deploys can write to `/public_html//` even before the DNS record exists. Subdomain serves content the moment DNS is added. - **DNS propagation is 2-15 min** typically. Hostinger's authoritative NS is `ns1.dns-parking.com` / `ns2.dns-parking.com` (legacy shard, still active). --- ## 4. Deployment failure triage flowchart When a deploy fails: 1. **Check the actual error mode** (not just "FAIL"): - `Timeout (control socket)` → FTP cap (see §1). Just retry after wait. - `ERESOLVE` → npm conflict. Patch deploy.yml to use `--legacy-peer-deps`. - `no site/, dist/, or build/` → build output dir mismatch. Add the actual dir to verify-step search list. - `npm error code` (other) → check package.json scripts exist. - `getaddrinfo failed` for the target subdomain → DNS not provisioned. Escalate to Miz for hPanel wizard. 2. **NEVER blindly retry** — diagnose first. The 59-deploy chain wasted hours re-firing failures that had source-level issues. 3. **Single isolated test** — before patching deploy.yml or escalating, prove the issue isn't transient FTP cap by firing ONE deploy with no other activity. --- ## 5. Reusable scripts All in `C:\Users\chaus\AppData\Local\Temp\`: - `stage_59_deploys.py` — fire N deploys serially with configurable spacing + monitor - `serial_retry_failures.py` — wait-for-completion retry pattern - `patch_19_deploys.py` — apply deploy.yml hardening patch (build-output + FTP retry + ERESOLVE fix) across multiple repos - `dns_audit_59.py` — DNS-resolve check on a subdomain list - `build_subdomain_inventory.py` — regenerate `/docs/subdomain-inventory.md` - `refresh_sitemap_for_59.py` — probe sitemap.xml + update apex sitemap-index --- ## 6. Provenance - Authored 2026-06-04 after the 59-subdomain mass-deploy chain (task #367) - Lessons captured from: 113 hPanel provisioning · 19 broken-build patches · 4 apex deploy retries · 1 GSC resubmit - Source MEMORY entries: `feedback_hostinger_ftp_cap.md`, `feedback_codex_batch_build_drift.md`, `reference_subdomain_inventory.md`