diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..56557ac --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,143 @@ +# Deploy the documentation site to stratara.tech. +# +# Builds the DocFX site exactly as docs.yml did and copies it by rsync to the document root of the +# stratara.tech vhost on our own server. Runs on merges to main that touch the site, and can be +# started by hand. Never on a pull request: the job holds a deploy key, and a fork's run gets no +# secrets — which is exactly the protection that makes a public repository safe to run this in. +# +# The `production` environment carries a required reviewer, so the deploy waits for a human. The key +# can write the document root of a public site; a bad merge or a compromised action would otherwise +# ship unattended. Same reasoning as the nuget-org gate in release.yml. +name: Deploy site + +on: + push: + branches: [main] + paths: + - docs/** + - llms.txt + - llms-full.txt + - .github/workflows/deploy-site.yml + workflow_dispatch: + +# Two deploys must not interleave: rsync --delete from two runs at once leaves neither tree intact. +concurrency: + group: deploy-site + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Build and deploy + runs-on: ubuntu-latest + timeout-minutes: 30 + # The approval gate. Nothing below runs until a reviewer confirms. + environment: + name: production + url: https://stratara.tech + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install DocFX + run: dotnet tool install -g docfx + + # The same two commands docs.yml ran, so the move changes where the site lives and nothing + # about how it is built. + - name: Build DocFX site (warningsAsErrors) + run: | + docfx metadata docs/docfx.json + docfx build docs/docfx.json --warningsAsErrors + + # llms.txt and llms-full.txt live at the repository root and are not DocFX resources; they are + # served from the site root so that the trigger paths above mean something. + - name: Add llms.txt to the site root + run: cp llms.txt llms-full.txt docs/_site/ + + # Plesk's nginx proxies to Apache, so cache headers are an .htaccess file rather than server + # configuration. Only the fingerprinted files under public/ are immutable: DocFX also writes + # unfingerprinted main.css, main.js and docfx.min.* there, and those must revalidate or a + # template change is never seen. Everything the browser navigates to or the search reads + # (HTML, JSON, TXT, XML, YML) revalidates on every request. + - name: Add cache headers + run: | + set -euo pipefail + cat > docs/_site/.htaccess <<'HTACCESS' + + + Header set Cache-Control "no-cache" + + + HTACCESS + cat > docs/_site/public/.htaccess <<'HTACCESS' + + + Header set Cache-Control "public, max-age=31536000, immutable" + + + HTACCESS + + - name: Prepare the deploy key + env: + DEPLOY_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_B64 }} + DEPLOY_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "$DEPLOY_KEY_B64" | base64 -d > ~/.ssh/stratara_deploy + chmod 600 ~/.ssh/stratara_deploy + echo "$DEPLOY_KNOWN_HOSTS" >> ~/.ssh/known_hosts + + # --delete makes the deployed tree equal the built tree, so a renamed page does not linger. + # .well-known is excluded because Plesk keeps its ACME challenge directory there; deleting it + # on every deploy is a bet on a Plesk routing detail that costs nothing to avoid. + - name: Deploy + env: + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + set -euo pipefail + rsync -az --delete --exclude='.well-known' -e 'ssh -i ~/.ssh/stratara_deploy' \ + docs/_site/ "${DEPLOY_USER}@${DEPLOY_HOST}:httpdocs/" + + # A deploy that reports success while the site is broken is worse than one that fails, so this + # asks the live domain rather than trusting the upload. + - name: Check what is actually served + run: | + set -euo pipefail + # Here-strings, not a pipe into grep: with pipefail, `grep -q` exits on the first match, a + # curl still writing the rest of a large page gets a broken pipe, and the check fails on a + # page that was correct. + contains() { + local body + body=$(curl -fsS "$1") + grep -qi -- "$2" <<<"$body" || { echo "$3"; exit 1; } + } + status_is() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' "$1") + [ "$code" = "$2" ] || { echo "$3 — got $code"; exit 1; } + } + header_has() { + local headers + headers=$(curl -fsSI "$1") + grep -qi -- "$2" <<<"$headers" || { echo "$3"; exit 1; } + } + # The apex itself must answer, not redirect: a 301 to www would mean Plesk's preferred + # domain still points the other way, and every published link would take a detour. + status_is https://stratara.tech/ 200 "the apex does not answer with the site itself" + contains https://stratara.tech/ "Start small, keep the receipts" "the landing page is not the documentation" + status_is https://stratara.tech/concepts/why-event-sourcing.html 200 "a deep page is missing" + status_is https://stratara.tech/legal/imprint.html 200 "the imprint is not served" + status_is https://stratara.tech/legal/privacy.html 200 "the privacy policy is not served" + status_is https://stratara.tech/assets/badges/nuget.svg 200 "the badges are not served from our own host" + status_is https://stratara.tech/llms.txt 200 "llms.txt is not served" + header_has https://stratara.tech/ 'cache-control: no-cache' "the no-cache header is missing on the landing page" + echo "stratara.tech answers, deep links resolve, and the pages that must not go stale revalidate." diff --git a/openspec/changes/serve-the-docs-from-our-own-server/proposal.md b/openspec/changes/serve-the-docs-from-our-own-server/proposal.md index 2394df8..72f0c3a 100644 --- a/openspec/changes/serve-the-docs-from-our-own-server/proposal.md +++ b/openspec/changes/serve-the-docs-from-our-own-server/proposal.md @@ -1,6 +1,6 @@ # Serve the documentation site from our own server -> **Status:** proposed +> **Status:** approved — 2026-09-04, owner ## Why diff --git a/openspec/changes/serve-the-docs-from-our-own-server/tasks.md b/openspec/changes/serve-the-docs-from-our-own-server/tasks.md index ec7056a..a794b61 100644 --- a/openspec/changes/serve-the-docs-from-our-own-server/tasks.md +++ b/openspec/changes/serve-the-docs-from-our-own-server/tasks.md @@ -8,33 +8,33 @@ Umami. Each one names how to prove it worked. ## 1. Open the door on the server -- [ ] 1.1 **owner** In Plesk, enable SSH access for the `stratara.tech` subscription's system user +- [x] 1.1 **owner** In Plesk, enable SSH access for the `stratara.tech` subscription's system user `stratara.tech_d4hk78khxi` (shell `/bin/bash`, as `loomweaver.dev_1kv2wmu3zbc` already has). Proof: `ssh stratara-server 'grep stratara.tech_d4hk78khxi /etc/passwd'` no longer ends in `/bin/false`. -- [ ] 1.2 Generate an ed25519 deploy key pair named for this purpose. The private half must never +- [x] 1.2 Generate an ed25519 deploy key pair named for this purpose. The private half must never be written to the repository or to a file that survives the session. -- [ ] 1.3 Install the public half in `/var/www/vhosts/stratara.tech/.ssh/authorized_keys`, owned by +- [x] 1.3 Install the public half in `/var/www/vhosts/stratara.tech/.ssh/authorized_keys`, owned by the vhost user, `700` on `.ssh` and `600` on the file — the layout `/var/www/vhosts/loomweaver.dev/.ssh/` already has. Proof: `ssh -i @ true` succeeds. -- [ ] 1.4 **owner** Register the repository secrets `DEPLOY_SSH_KEY_B64` (base64 of the private +- [x] 1.4 **owner** Register the repository secrets `DEPLOY_SSH_KEY_B64` (base64 of the private half), `DEPLOY_USER`, `DEPLOY_HOST`, and the variable `DEPLOY_KNOWN_HOSTS` (`ssh-keyscan` output for the host). Proof: `gh secret list` and `gh variable list` show all four. -- [ ] 1.5 Prove the path end to end before any workflow exists: `rsync -n -az` a throwaway file to +- [x] 1.5 Prove the path end to end before any workflow exists: `rsync -n -az` a throwaway file to `@:httpdocs/` and confirm it is listed but, being a dry run, not written. ## 2. The deploy workflow -- [ ] 2.1 Add `.github/workflows/deploy-site.yml`, modelled on `loomweaver.dev`'s `deploy.yml`: +- [x] 2.1 Add `.github/workflows/deploy-site.yml`, modelled on `loomweaver.dev`'s `deploy.yml`: trigger on push to `main` filtered to `docs/**`, `llms.txt`, `llms-full.txt` and the workflow itself, plus `workflow_dispatch`; **never** on `pull_request`, so a fork's run gets no secrets; `concurrency` group so two deploys cannot interleave; `permissions: contents: read`. -- [ ] 2.2 **owner** Create the `production` environment with a required reviewer, and reference it +- [x] 2.2 **owner** Create the `production` environment with a required reviewer, and reference it from the job. Proof: the first run stops and waits, exactly as `release.yml` does at `nuget-org`. -- [ ] 2.3 Build step — the same two commands `docs.yml` runs today, so the build is unchanged by +- [x] 2.3 Build step — the same two commands `docs.yml` runs today, so the build is unchanged by this arc: ```bash @@ -42,10 +42,10 @@ Umami. Each one names how to prove it worked. docfx build docs/docfx.json --warningsAsErrors ``` -- [ ] 2.4 Write `docs/_site/.htaccess` with cache headers before uploading: HTML, JSON, TXT and XML +- [x] 2.4 Write `docs/_site/.htaccess` with cache headers before uploading: HTML, JSON, TXT and XML `no-cache`; hashed assets under `public/` `immutable, max-age=31536000`. `.htaccess` is the right mechanism because Plesk's nginx proxies to Apache (design.md → *Context*). -- [ ] 2.5 Deploy step. The exclusion is decision 2 — without it the ACME challenge directory is +- [x] 2.5 Deploy step. The exclusion is decision 2 — without it the ACME challenge directory is deleted on every deploy: ```bash @@ -53,7 +53,7 @@ Umami. Each one names how to prove it worked. docs/_site/ "${DEPLOY_USER}@${DEPLOY_HOST}:httpdocs/" ``` -- [ ] 2.6 Post-deploy check that asks the live domain and fails the run otherwise: `https://stratara.tech/` +- [x] 2.6 Post-deploy check that asks the live domain and fails the run otherwise: `https://stratara.tech/` contains the landing hero string; a deep page such as `/concepts/why-event-sourcing.html` returns 200; `/legal/imprint.html` returns 200; `/assets/badges/nuget.svg` returns 200 and is served from our own host.