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
143 changes: 143 additions & 0 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
@@ -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'
<IfModule mod_headers.c>
<FilesMatch "\.(html|json|txt|xml|yml)$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>
HTACCESS
cat > docs/_site/public/.htaccess <<'HTACCESS'
<IfModule mod_headers.c>
<FilesMatch "-[A-Z0-9]{8}(\.min)?\.(js|css|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
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."
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Serve the documentation site from our own server

> **Status:** proposed
> **Status:** approved — 2026-09-04, owner

## Why

Expand Down
22 changes: 11 additions & 11 deletions openspec/changes/serve-the-docs-from-our-own-server/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,52 @@ 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 <key> <user>@<host> 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
`<user>@<host>: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
docfx metadata docs/docfx.json
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
rsync -az --delete --exclude='.well-known' -e 'ssh -i ~/.ssh/stratara_deploy' \
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.
Expand Down
Loading