diff --git a/openspec/changes/serve-the-docs-from-our-own-server/.openspec.yaml b/openspec/changes/serve-the-docs-from-our-own-server/.openspec.yaml new file mode 100644 index 0000000..3c7e760 --- /dev/null +++ b/openspec/changes/serve-the-docs-from-our-own-server/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-03 +skip_specs: true diff --git a/openspec/changes/serve-the-docs-from-our-own-server/design.md b/openspec/changes/serve-the-docs-from-our-own-server/design.md new file mode 100644 index 0000000..9fd172b --- /dev/null +++ b/openspec/changes/serve-the-docs-from-our-own-server/design.md @@ -0,0 +1,174 @@ +# Design — Serve the documentation site from our own server + +## Context + +See proposal.md → *Why*. What follows is only the state a reader needs to act, established by +inspection on 2026-09-03 rather than assumed. + +**The server.** Plesk, root reachable as `ssh stratara-server`. The vhost is +`/var/www/vhosts/stratara.tech`, its document root `httpdocs/`, owned by the system user +`stratara.tech_d4hk78khxi`. Today `httpdocs/` holds a 455-byte Vite placeholder from 2025-08-24 and +a `.well-known/` directory. + +**Plesk serves through Apache, not nginx alone.** `/var/www/vhosts/system/loomweaver.dev/conf/nginx.conf` +proxies `location /` to `https://127.0.0.1:7081`. That is why `.htaccess` works, and why LoomWeaver +uses it for cache headers rather than server configuration it would have to re-apply after every +Plesk change. + +**The blocker nobody would guess.** The system user's login shell is `/bin/false`: + +``` +loomweaver.dev_1kv2wmu3zbc shell=/bin/bash ← rsync deploys here today +stratara.tech_d4hk78khxi shell=/bin/false ← cannot log in at all +hiveweaver.dev_unmgvri06j shell=/bin/false +``` + +rsync-over-SSH needs a shell. Until the subscription's SSH access is changed in Plesk, the deploy +key is irrelevant — authentication would succeed and the session would end immediately. + +**The proven pattern.** `loomweaver.dev`'s `.github/workflows/deploy.yml` in the private LoomWeaver +repository: `actions/checkout`, build, write the deploy key from a base64 secret, `rsync -az --delete` +to `${DEPLOY_USER}@${DEPLOY_HOST}:httpdocs/`, then curl the live domain and grep for content that +must be there. It runs on push to `main` filtered by path, never on a pull request — a fork's run +gets no secrets, which is what makes a public repository safe to run it in. Its key lives at +`/var/www/vhosts/loomweaver.dev/.ssh/authorized_keys`, in the vhost home, not in `httpdocs`. + +**Where the site is right now.** GitHub Pages serves `docs.stratara.tech`; the apex resolves to the +Plesk server. Mail, `sonar.stratara.tech`, `MX`, `SPF`, `DMARC` and `DKIM` are all intact after +today's rollback and must stay that way. + +## Goals / Non-Goals + +**Goals** + +- `stratara.tech` serves the documentation, deployed from `main` without a human copying files. +- No window in which neither host answers. The cutover is ordered so that a working host always + exists. +- `docs.stratara.tech` keeps resolving, permanently, because published packages link to it. +- The deploy cannot report success while the site is broken. + +**Non-Goals** + +- Deploying anything but the documentation site. No demo application, no API. +- Changing how DocFX builds. The same `docfx build --warningsAsErrors` produces the same `_site`. +- Server hardening, Plesk upgrades, or touching the other vhosts. +- Keeping GitHub Pages as a warm standby. Two publishers of the same content is how the two hosts + drifted apart today. + +## Decisions + +### 1. rsync to the vhost, not a Plesk git-deploy or an FTP upload + +Plesk can pull from git itself, and it can take an FTP upload. Both were rejected: the build needs +the .NET SDK and DocFX, which the server does not have and should not gain, and an FTP upload has no +identity to revoke. rsync over SSH with a dedicated key matches what already works one vhost over, +which means one pattern to understand rather than two. + +*Evidence: `loomweaver.dev`'s deploy workflow, running since 2025-08.* + +### 2. `--delete`, but never against `.well-known` + +`rsync -az --delete` is what makes the deployed tree equal the built tree; without it, a page renamed +in `docs/` lingers forever. It would also remove `httpdocs/.well-known/`, which Plesk uses for ACME +challenges. + +Plesk's nginx routes `/.well-known/acme-challenge/` to `/var/www/vhosts/default/htdocs`, so the +directory in `httpdocs` is not actually needed — which is why LoomWeaver has never noticed. Relying +on that is a bet on a Plesk implementation detail surviving an upgrade, and losing the bet means +certificate renewal fails silently until the certificate expires. `--exclude='.well-known'` costs +nothing and removes the bet. + +### 3. `docs.stratara.tech` becomes a 301, served by the same server + +The alternative — leaving GitHub Pages publishing that host forever purely as a redirect — keeps a +second publisher of the same content alive, which is exactly the configuration that produced today's +split. Instead the subdomain moves to the Plesk server and is redirected there. + +**This is the one step with an ordering constraint that bites.** The moment its DNS leaves +`yesbert.github.io`, GitHub Pages stops being reachable for it, so the redirect must already work on +the server before the record moves. + +### 4. The environment gate stays, and it is not ceremony + +`environment: production` with a required reviewer means the deploy waits for a human. The workflow +holds a key that can write the document root of a public site. A dependency compromised in the +`actions/*` supply chain, or a bad merge, otherwise ships unattended. This mirrors both LoomWeaver +and this repository's own `nuget-org` gate, and it is the same reasoning: the action is +outward-facing and not cheaply reversible. + +### 5. The post-deploy check asks the live domain + +Copying files successfully is not evidence that a site works. The check fetches +`https://stratara.tech/`, greps for content that must be present, fetches a deep page and the +redirect from the old host, and fails the run if any of them is wrong. A deploy that reports success +over a broken site is worse than one that fails, because nobody looks. + +### 6. Two facts about GitHub Pages that are not discoverable from its documentation + +Both were established today by direct observation, and both cost hours. + +**The `CNAME` file and the repository setting are two different switches, and they do different +things.** With the Actions-based deployment (`build_type: workflow`), the `CNAME` file in the +uploaded artifact does **not** set the repository's custom domain — that was the behaviour of the +old branch publishing. Setting `docs/CNAME` to `stratara.tech` and deploying left the setting on +`docs.stratara.tech`; the domain had to be set through +`gh api -X PUT repos/yesbert/Stratara/pages -f cname=stratara.tech`. + +But the same file **does** decide which hostname Pages answers for. Rolling the setting back to +`docs.stratara.tech` was not enough: with `stratara.tech` still inside the deployed artifact, +`docs.stratara.tech` kept returning *There isn't a GitHub Pages site here* until the artifact was +rebuilt. Both switches have to agree, and rolling back means doing both. + +**A `CNAME` on an apex shadows every other record type of that name.** While the apex carried +`CNAME → yesbert.github.io`, queries for `MX`, `TXT` and `NS` at `stratara.tech` all returned that +CNAME. Mail to `info@stratara.tech` — the address in the imprint — could not be delivered, because +no `MX` existed and an MTA falls back to the `A` record, which was GitHub Pages. The records were +never deleted; removing the CNAME brought `MX 10 mail.crosslabs.eu.`, the SPF and the +`google-site-verification` token back unchanged. + +*Evidence: `dig` against `ns1.antagus.de` and public resolvers, and the GitHub Pages 404 body, all +on 2026-09-03. The consequence for this change is decision 3's ordering constraint and task 6's +verification.* + +## Risks / Trade-offs + +**Uptime becomes ours.** GitHub Pages has a CDN and an operations team; the Plesk server has +neither. → Accepted deliberately: the apex has to point at that server regardless, so the site would +depend on it either way. The `.htaccess` cache headers reduce origin hits. + +**A broken deploy replaces a working site.** `--delete` means a build that produces a wrong tree +publishes a wrong tree. → The post-deploy check fails the run, and rollback is re-running the +workflow from the last good commit — the source of truth is `main`, not the server. + +**The deploy key can write a public document root.** → Its own key, one vhost, no root, guarded by +the environment reviewer; revoking it is one line in `authorized_keys`. Never in a pull-request +trigger, so a fork cannot reach it. + +**The redirect is a single point of failure for published links.** The nuget.org READMEs for 4.0.3 +cannot be edited. → Task 6 verifies the redirect against the live host, and the check runs on every +deploy thereafter, so a regression fails a run rather than silently rotting. + +**Certificate for the apex.** Plesk's Let's Encrypt must cover `stratara.tech` and, after the move, +`docs.stratara.tech`. → Verified explicitly rather than assumed; renewal is Plesk's job and the +`.well-known` exclusion protects the challenge path. + +## Migration Plan + +Ordered so that a working host always exists at every point. Step 3 already replaces the placeholder +on the apex with the documentation; what it does not do is take anything away. + +1. **Enable SSH** for the `stratara.tech` subscription in Plesk (`/bin/bash`, as `loomweaver.dev` + has). Nothing else can start. +2. **Install the deploy key** and register the secrets. Prove it with a dry-run rsync. +3. **Add the workflow**, deploy, and check `stratara.tech`. `docs.stratara.tech` is still served by + Pages throughout — there is a working host the whole time. +4. **Configure the redirect** for `docs.stratara.tech` on the server, and verify it by `Host` header + before any DNS moves. +5. **Move the DNS** for `docs.stratara.tech`. Both hosts now answer from our server. +6. **Retire Pages**: delete `docs.yml` and `docs/CNAME`, unpublish the repository's Pages site. +7. **Move the repository's references** and rewrite the privacy policy's hosting section. +8. **Umami**, which counts nothing until its domain matches. + +**Rollback.** Before step 5, revert the DNS thought and nothing is lost — Pages still serves the +documentation. After step 6, rollback means restoring `docs.yml` and `docs/CNAME` **and** the Pages +repository setting; decision 6 explains why doing only one of the two leaves a 404. 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 new file mode 100644 index 0000000..2394df8 --- /dev/null +++ b/openspec/changes/serve-the-docs-from-our-own-server/proposal.md @@ -0,0 +1,90 @@ +# Serve the documentation site from our own server + +> **Status:** proposed + +## Why + +The documentation site is to become the public homepage at `stratara.tech`, and on 2026-09-03 that +move was attempted by pointing the apex at GitHub Pages. It was rolled back within the hour: too +much else already answers on that apex, and it has to keep pointing at the Plesk server. The +domain cannot move to the site, so the site has to move to the domain. + +That is not a consolation prize. `loomweaver.dev` is already deployed to the same server from +GitHub Actions over rsync, so the pattern exists, is proven, and is one this project can copy rather +than invent. It also improves the story the site tells about itself: the privacy policy currently +has to name GitHub Pages in the United States as the host of a site whose subject matter is +tamper-evidence and GDPR erasure. Afterwards it names a German server whose logs belong to the +controller. + +No framework behaviour changes. No published package changes. Nothing a consumer of a NuGet package +can observe changes. + +## What Changes + +- The DocFX site is deployed by rsync to the `stratara.tech` Plesk vhost instead of to GitHub Pages, + from a new workflow that mirrors `loomweaver.dev`'s: an ed25519 deploy key, an `environment` with + a required reviewer so neither an accident nor a compromised dependency ships unattended, and a + post-deploy check that asks the live domain rather than trusting the upload. +- `stratara.tech` serves the documentation. The apex A record does not move. +- `docs.stratara.tech` keeps working, as a **301 to the apex**. This is not optional politeness: the + package READMEs already published to nuget.org under 4.0.3 link to that host, and those cannot be + edited after the fact. +- `docs.yml`, `docs/CNAME` and the repository's GitHub Pages publication are retired, in that order + and only after the new host is verified. +- The repository's references move to `stratara.tech` again — the same 33 that were moved and + reverted today, plus the README docs badge. +- The privacy policy's hosting section is rewritten: the controller's own server, his own logs, no + transfer to the United States, and no GitHub Pages. +- Cache headers for the static site, as `.htaccess` — which works because Plesk's nginx proxies to + Apache rather than serving the directory itself. +- The site domain is corrected in Umami Cloud. Until that is done, nothing is counted. + +**Not in scope.** Moving `sonar.stratara.tech`, anything about mail, and the Plesk vhost's other +settings. Mail was collateral damage of today's rollback and is already repaired. + +## Capabilities + +### New Capabilities + +None. This is an infrastructure and documentation arc; `skip_specs: true` is set in +`.openspec.yaml`. Specs describe what a consumer of a published package observes, and where a +documentation site is hosted is not that. + +### Modified Capabilities + +None. + +## Impact + +**Retired by this change:** + +- `.github/workflows/docs.yml` — the DocFX → GitHub Pages deployment, superseded by the new rsync + workflow. Delete only after the new host serves and the redirect is in place. +- `docs/CNAME` — meaningless once Pages no longer publishes this repository. +- The repository's GitHub Pages publication itself (repository setting), which is the last step. + +**Changed:** + +- `docs/legal/privacy.md` — the *Hosting and server log files* section, and the description in the + front matter. +- `README.md`, `llms.txt`, `SUPPORT.md`, `CONTRIBUTING.md`, `.github/ISSUE_TEMPLATE/config.yml`, + `.github/ISSUE_TEMPLATE/question.md`, `samples/Stratara.Sample.TamperProof/README.md`, + `samples/Stratara.Sample.Encryption/README.md` — host references, and the README docs badge whose + label has to name the host it links to. +- `docs/docfx.json` — `_appBaseUrl`. + +**Outside the repository**, and therefore the part that cannot be done by a pull request alone: + +- The Plesk subscription for `stratara.tech`: SSH must be enabled for its system user, which today + has `/bin/false` as its shell where `loomweaver.dev`'s has `/bin/bash`. +- A deploy key pair, its public half in that user's `~/.ssh/authorized_keys`, its private half in + the repository's secrets. +- DNS: `docs.stratara.tech` moves from its `yesbert.github.io` CNAME to the server, once the + redirect is configured there. +- Umami Cloud: the website's domain. + +**Two traps that cost hours today and must not be rediscovered.** Both are recorded in design.md +with the evidence: with the Actions-based Pages deployment the `CNAME` file in the artifact does +*not* set the custom domain, but it *does* decide which hostname Pages answers for; and a `CNAME` on +an apex shadows the `MX`, `TXT` and `NS` records of the same domain without deleting them, which +silently broke mail to `info@stratara.tech` until it was removed. 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 new file mode 100644 index 0000000..ec7056a --- /dev/null +++ b/openspec/changes/serve-the-docs-from-our-own-server/tasks.md @@ -0,0 +1,131 @@ +Ordered per design.md → *Migration Plan*. The guarantee is not that nothing changes — group 2 +replaces the placeholder on the apex with the documentation — but that **a working host exists at +every point**: `docs.stratara.tech` stays served by GitHub Pages until group 3 moves it, and Pages +stays published as a rollback path until group 4. + +Tasks marked **owner** cannot be done from a pull request: they are Plesk, DNS, GitHub secrets or +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 + `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 + 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 + 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 + 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 + `@: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`: + 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 + 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 + 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 + `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 + 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/` + 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. +- [ ] 2.7 Run it once by `workflow_dispatch`, approve the gate, and confirm `stratara.tech` serves + the documentation while `docs.stratara.tech` is still served by GitHub Pages. Proof: both + hosts return 200 and the same landing content. + +## 3. Keep the old host alive + +- [ ] 3.1 **owner** In Plesk, add `docs.stratara.tech` to the server (subdomain or domain alias of + `stratara.tech`) and configure a permanent redirect to `https://stratara.tech`, preserving the + path — a bare redirect to the root would turn every deep link in the published package + READMEs into a homepage visit. +- [ ] 3.2 Verify the redirect **before** any DNS moves, by asking the server directly with the + right `Host`. It must return `301` with a `Location` of the same path on the apex: + + ```bash + curl -sI --resolve docs.stratara.tech:443:217.154.79.173 \ + https://docs.stratara.tech/concepts/why-event-sourcing.html + ``` + +- [ ] 3.3 **owner** Ensure Plesk's Let's Encrypt certificate covers `docs.stratara.tech` as well as + `stratara.tech`. Without it, step 3.4 makes every link to the old host a TLS error rather + than a redirect. +- [ ] 3.4 **owner** Move the DNS: `docs.stratara.tech` from its `yesbert.github.io` CNAME to the + server. Proof: `dig +short @ns1.antagus.de docs.stratara.tech` returns `217.154.79.173`, and + `curl -sIL https://docs.stratara.tech/` lands on the apex with 200. + +## 4. Retire GitHub Pages + +- [ ] 4.1 Delete `.github/workflows/docs.yml` and `docs/CNAME`. +- [ ] 4.2 Update the workflow table in `.claude/CLAUDE.md` — it lists `docs.yml` as the deployment + of this site. +- [ ] 4.3 **owner** Unpublish the repository's GitHub Pages site in the repository settings. Do this + last: while it is published, it remains a rollback path (design.md → *Rollback*). + +## 5. Move the repository's references + +- [ ] 5.1 Re-apply what PR #55 did and PR #56 reverted: `stratara.tech` in `README.md`, `llms.txt`, + `SUPPORT.md`, `CONTRIBUTING.md`, `.github/ISSUE_TEMPLATE/config.yml`, + `.github/ISSUE_TEMPLATE/question.md`, both hero-sample READMEs, and `_appBaseUrl` in + `docs/docfx.json`. Leave `CHANGELOG.md` and the archived + `2026-09-03-give-every-reader-their-door` alone — dated records are not rewritten, and the + redirect keeps their links working. +- [ ] 5.2 Set the README docs badge back to `docs-stratara.tech` so its label names the host it + links to (the reason it was changed in #56). +- [ ] 5.3 Confirm no live reference to the old host remains: `grep -rn 'docs\.stratara\.tech'` + returns only `CHANGELOG.md` and the archived change. + +## 6. Say where the site is hosted + +- [ ] 6.1 Rewrite *Hosting and server log files* in `docs/legal/privacy.md`: the controller's own + server, his own logs, a German location, and no transfer to the United States. Remove the + GitHub, Inc. paragraph and its two links. +- [ ] 6.2 Update the front-matter `description` in the same file, which names the host. +- [ ] 6.3 Confirm the rest of the policy is still true after the move: the *No third-party requests + before you agree* section, the localStorage keys, and the Umami paragraph. Only the host + changed. + +## 7. Analytics + +- [ ] 7.1 **owner** In Umami Cloud, set the website's domain to `stratara.tech`. Proof: a visit to + the apex appears in the dashboard after accepting the notice. Until this is done nothing is + counted, whatever the banner says. + +## 8. Close it out + +- [ ] 8.1 `./scripts/local-gauntlet.sh` green, and `docfx build docs/docfx.json --warningsAsErrors` + with 0 warnings. +- [ ] 8.2 `NoDocumentationPage_LoadsAnImageFromAnotherHost` in + `tests/Stratara.Documentation.Tests/LandingBadgeTests.cs` still passes — the move must not + reintroduce a third-party request. +- [ ] 8.3 Open the pull request through the `/pr` skill, and record in `.claude/roadmap/STATE.md` + that the site is no longer on GitHub Pages, including the two traps from design.md → decision + 6 so the next session does not rediscover them.