diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b989ed8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run on the Linux CI runner and in git-bash: keep LF. +*.sh text eol=lf diff --git a/.gitea/workflows/deploy-site.yml b/.gitea/workflows/deploy-site.yml new file mode 100644 index 0000000..baa82a7 --- /dev/null +++ b/.gitea/workflows/deploy-site.yml @@ -0,0 +1,132 @@ +name: Deploy test site + +# Builds site/dist (site/public/ plus demo pages copied out of docs/, see +# site/build.sh) and publishes it to https://cnxvinyl.lol/ on the +# buildfor.life web server (s1), the same Caddy that serves the blog. +# One-time setup (DNS, Caddy vhost, secrets, deploy key): site/README.md. +# +# Secrets (repo or user level): +# VPS_HOST, VPS_USER, VPS_SSH_KEY required; same server as the blog +# VPS_PORT optional, default 22 +# VPS_KNOWN_HOSTS optional pinned host key line(s); +# without it the key is scanned per run +# CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN +# optional; cache purge for cnxvinyl.lol +# (token needs Zone > Cache Purge) + +on: + push: + branches: [master] + paths: + - "site/**" + - "docs/**/*.html" + - ".gitea/workflows/deploy-site.yml" + workflow_dispatch: + +# A newer push supersedes a running deploy. Safe to cancel mid-run: rsync +# writes the non-live directory and the rename swap happens last. +concurrency: + group: deploy-cnxvinyl-lol + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + VPS_PORT: ${{ secrets.VPS_PORT }} + SITE_DIR: /var/www/cnxvinyl.lol + SITE_URL: https://cnxvinyl.lol + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build site/dist + run: bash site/build.sh + + - name: Set up SSH + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }} + run: | + if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_SSH_KEY" ]; then + echo "::error::VPS_HOST, VPS_USER and VPS_SSH_KEY must be set as Actions secrets (see site/README.md)" + exit 1 + fi + port=${VPS_PORT:-22} + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + if [ -n "$VPS_KNOWN_HOSTS" ]; then + printf '%s\n' "$VPS_KNOWN_HOSTS" >> ~/.ssh/known_hosts + else + for i in 1 2 3; do + ssh-keyscan -T 10 -p "$port" "$VPS_HOST" > ~/.ssh/keyscan.out && [ -s ~/.ssh/keyscan.out ] && break + echo "ssh-keyscan attempt $i failed; retrying in 15 s" >&2 + sleep 15 + done + if [ ! -s ~/.ssh/keyscan.out ]; then + echo "::error::ssh-keyscan could not reach $VPS_HOST:$port (server down or runner IP blocked?)" + exit 1 + fi + cat ~/.ssh/keyscan.out >> ~/.ssh/known_hosts + fi + { + echo "Host deploy" + echo " HostName $VPS_HOST" + echo " User $VPS_USER" + echo " Port $port" + echo " IdentityFile ~/.ssh/deploy_key" + echo " IdentitiesOnly yes" + echo " BatchMode yes" + } > ~/.ssh/config + + - name: Ensure rsync is available + run: command -v rsync || (sudo apt-get update && sudo apt-get install -y rsync) || (apt-get update && apt-get install -y rsync) + + - name: Deploy (rsync to the idle copy, then rename swap) + # Two persistent directories alternate as live and previous, as for + # the blog: rsync only ever writes the one that is not live, and the + # swap is three same-filesystem renames, so Caddy never serves a + # half-written tree. The previous build stays on disk for rollback. + run: | + # SC2029: the paths are meant to expand on the runner side + # shellcheck disable=SC2029 + ssh deploy "mkdir -p '$SITE_DIR' '$SITE_DIR-prev' && rm -rf '$SITE_DIR-tmp'" + rsync -az --delete site/dist/ "deploy:$SITE_DIR-prev/" + # shellcheck disable=SC2029 + ssh deploy "mv '$SITE_DIR' '$SITE_DIR-tmp' && mv '$SITE_DIR-prev' '$SITE_DIR' && mv '$SITE_DIR-tmp' '$SITE_DIR-prev'" + # shellcheck disable=SC2029 + live=$(ssh deploy "cat '$SITE_DIR/version.txt'") + echo "live on server: $live" + [ "$live" = "$(cat site/dist/version.txt)" ] || { echo "::error::server holds $live, expected $(cat site/dist/version.txt)"; exit 1; } + + - name: Purge Cloudflare cache + env: + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -z "$CLOUDFLARE_ZONE_ID" ] || [ -z "$CLOUDFLARE_API_TOKEN" ]; then + echo "::warning::CLOUDFLARE_ZONE_ID / CLOUDFLARE_API_TOKEN not set; skipping cache purge" + exit 0 + fi + response=$(curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H "Content-Type: application/json" \ + --data '{"purge_everything":true}') + echo "$response" + echo "$response" | grep -q '"success":\s*true' || { echo "::error::Cloudflare cache purge failed"; exit 1; } + + - name: Check the public URL + # Warning only: DNS, Cloudflare or a basic_auth login can make this + # fail while the files on the server are already correct. + run: | + expected=$(cat site/dist/version.txt) + got=$(curl -sS --max-time 20 "$SITE_URL/version.txt?nocache=$expected" || true) + if [ "$got" = "$expected" ]; then + echo "$SITE_URL serves $expected" + else + echo "::warning::$SITE_URL/version.txt returned '${got:0:80}', expected $expected" + fi diff --git a/.gitignore b/.gitignore index 5c79101..0c392b0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ node_modules/ # Playwright MCP session output .playwright-mcp/ + +# Test site build output (site/build.sh) +site/dist/ diff --git a/README.md b/README.md index a02e10a..0705761 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,10 @@ The detail lives in dedicated documents: and the material lab through extruder, press, trim, optical inspection, finishing, packaging and fulfillment, with the metal path, utilities and energy alongside; open it in a browser, every station is clickable +- [Test site](site/README.md): `site/` builds https://cnxvinyl.lol/, the + internal test site with demo pages from `docs/` (the plant operation + page first) and any static site we want to share by link; Gitea Actions + deploys it to the buildfor.life web server on every push that touches it - [Production timeline](docs/production-timeline.md): how long an order takes stage by stage, launch vs mature configuration, the repress fast path, and what lead time can honestly be quoted to a customer @@ -166,6 +170,7 @@ docs/ ├── crane-workshop-tours.md Overhead crane, workshop, visitor corridor ├── digital-factory.md Data platform, NixOS/Clan fleet, acquisition, app ├── facility-security.md Security, HVAC, fire and life safety, compliance +├── plant-operation.html Animated plant overview (simulated SCADA) ├── process-flow.md Audio file to delivered record, with all checks ├── production-timeline.md Order turnaround, stage by stage ├── project-timeline.md Calendar to first paying release, critical path @@ -175,6 +180,13 @@ docs/ ├── sustainability.md Energy, water, materials commitments ├── traceability.md Process logging and per-order traceability └── worker-safety.md Training, clean rooms, PPE +site/ Internal test site https://cnxvinyl.lol/ +├── public/ Served as is: landing page, static sites +├── build.sh Builds site/dist: public/ plus demo pages from docs/ +├── Caddyfile Virtual host for the web server (applied by hand) +└── README.md Build, deploy and one-time setup +.gitea/workflows/ +└── deploy-site.yml Builds site/ and deploys it on push to master ``` Vendor PDFs and spreadsheets are primary sources and reflect the state of diff --git a/docs/digital-factory.md b/docs/digital-factory.md index 4176d67..1139684 100644 --- a/docs/digital-factory.md +++ b/docs/digital-factory.md @@ -742,8 +742,9 @@ Build onward, part-time at launch; add it to the headcount. - Customer portal domain: the plant's brand domain (new zone on ns1/ns2, second cert on `web01`) or a name under `cnx.network` for launch. Internal testing has its own domain, `cnxvinyl.lol` (registered - 2026-10-02 at Porkbun, on Porkbun's nameservers for now); the brand - domain decision stays open. + 2026-10-02, DNS on Cloudflare): a static demo site built from + [`site/`](../site/README.md) and served by the buildfor.life web server, + not a plant service. The brand domain decision stays open. - Portal availability during a plant WAN outage: accept it (the portal is a view of live data) or add a read replica on the fleet side later. - Accounting and ERP: out of scope for the application; if an ERP is diff --git a/site/Caddyfile b/site/Caddyfile new file mode 100644 index 0000000..a36d637 --- /dev/null +++ b/site/Caddyfile @@ -0,0 +1,40 @@ +# cnxvinyl.lol: internal test site on the buildfor.life web server (s1). +# Static web root /var/www/cnxvinyl.lol, filled by +# .gitea/workflows/deploy-site.yml in the cnx_vinyl repo. +# +# Append these blocks to /etc/caddy/Caddyfile on s1. They reuse the +# cloudflare_only and security_headers snippets defined there for +# buildfor.life (buildfor_life_web: infra/caddy/Caddyfile.example); if +# they are missing, validate fails with "File to import not found: +# cloudflare_only" and they have to be copied in from the example first. +# caddy validate --config /etc/caddy/Caddyfile +# systemctl reload caddy +# +# Cloudflare: both names proxied (orange cloud), SSL/TLS mode "Full (strict)". +# "Flexible" sends plain HTTP to Caddy, which redirects to HTTPS: a loop. +# Same rule as the buildfor.life apex: if a record is ever switched to DNS +# only, remove "import cloudflare_only" first or that name goes dark. + +cnxvinyl.lol { + import cloudflare_only + import security_headers + header X-Frame-Options "SAMEORIGIN" + # Internal testing: keep every page and file out of search engines. + header X-Robots-Tag "noindex, nofollow" + + # Optional login for the whole site. Make the hash on s1 with + # caddy hash-password + # then uncomment and fill in: + # basic_auth { + # + # } + + root * /var/www/cnxvinyl.lol + encode zstd gzip + file_server +} + +www.cnxvinyl.lol { + import cloudflare_only + redir https://cnxvinyl.lol{uri} permanent +} diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..3e7cd7b --- /dev/null +++ b/site/README.md @@ -0,0 +1,77 @@ +# Test site: cnxvinyl.lol + +`https://cnxvinyl.lol/` is the internal test site for this repository: demo +pages from `docs/` (the [plant operation page](../docs/plant-operation.html) +first) and any static site we want to show by link. It runs on the same web +server as buildfor.life (Debian VPS `s1`, Caddy behind Cloudflare) and is +not a plant service; plant systems follow the +[digital factory plan](../docs/digital-factory.md). Every page and file is +sent with `noindex` and `robots.txt` disallows everything; nothing is behind +a login unless `basic_auth` in the Caddyfile is switched on. + +## How it is built + +- `public/` is copied verbatim to the web root: the landing page + (`index.html`), `robots.txt`, and any static site as its own directory + (`public//index.html` is served at `//`). +- `build.sh` assembles `site/dist`: `public/` plus every entry in its + `DEMOS` list, which copies a page out of `docs/` (for example + `docs/plant-operation.html` to `/demos/plant-operation/`), so the docs stay + the one source. It writes `version.txt` (the commit) and fails on any + internal link that does not resolve. +- To add a demo: one line in `DEMOS`, one entry on the landing page. To add + a static site: drop its built output in `public//` and link it. +- Local preview: `bash site/build.sh`, then open `site/dist/index.html`. + +## How it is deployed + +`.gitea/workflows/deploy-site.yml` runs on every push to `master` that +touches `site/`, an HTML page under `docs/`, or the workflow itself, and on +demand (Actions tab, "Run workflow"). It builds, rsyncs `site/dist` into +the idle one of `/var/www/cnxvinyl.lol` and `/var/www/cnxvinyl.lol-prev`, +swaps them by rename (the blog's pattern), checks `version.txt` on the +server, purges the Cloudflare cache when the token is set, and finally +checks the public URL (warning only). + +The deploy owns the whole web root: anything put into +`/var/www/cnxvinyl.lol` by hand disappears on the next deploy. Static sites +go into `public/` instead. + +## One-time setup + +1. DNS in Cloudflare, both proxied (orange cloud): + `cnxvinyl.lol CNAME s1.buildfor.life` and `www CNAME cnxvinyl.lol` + (Cloudflare flattens the apex CNAME, so the site follows s1 if its + address changes). SSL/TLS mode **Full (strict)**: "Flexible" makes a + redirect loop with Caddy. +2. Caddy on s1: append [`Caddyfile`](Caddyfile) to `/etc/caddy/Caddyfile` + (the comments in it list the checks), `caddy validate`, then + `systemctl reload caddy`. Caddy gets the certificate the same way it + does for the buildfor.life apex. +3. Web root: the deploy user must be able to create and rename + directories in `/var/www`, as it already does for the blog. If not: + `sudo install -d -o /var/www/cnxvinyl.lol /var/www/cnxvinyl.lol-prev` + and make `/var/www` writable for that user (the swap renames entries + in it). +4. Secrets in Gitea (repo: Settings > Actions > Secrets, or user level so + every repo of the account gets them): `VPS_HOST`, `VPS_USER`, + `VPS_SSH_KEY`, optionally `VPS_PORT` (default 22), `VPS_KNOWN_HOSTS` + (output of `ssh-keyscan -p s1.buildfor.life`, pins the host key), + `CLOUDFLARE_ZONE_ID` and `CLOUDFLARE_API_TOKEN` (Zone > Cache Purge on + cnxvinyl.lol). The names match the blog's workflow, so the blog's + deploy key and values work unchanged: s1 only checks the key against + the deploy user's `authorized_keys`, whichever repository sends it. + Gitea secrets cannot be read back, so they are entered again here from + the private key file; repo secrets set on `B4L/buildfor_life_web` do not + reach this repository, B4L organisation secrets only reach it if it moves + into B4L. If the private key file is gone, make a new key instead: + `ssh-keygen -t ed25519 -N "" -C cnx_vinyl-deploy -f cnx_vinyl-deploy`, + public half into the deploy user's `~/.ssh/authorized_keys` on s1, + private half into `VPS_SSH_KEY`, then delete the local copy. +5. Runner: the job asks for `ubuntu-latest`, the label the blog's runner + serves. This repository is under a user account, not the B4L + organisation, so a runner registered only for B4L does not pick it up; + if the run stays "Waiting", register the runner for this repository or + instance-wide, or move the repository into B4L. +6. Run the workflow once from the Actions tab and open + `https://cnxvinyl.lol/`. diff --git a/site/build.sh b/site/build.sh new file mode 100644 index 0000000..6b27cb9 --- /dev/null +++ b/site/build.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Assemble the cnxvinyl.lol web root in site/dist. +# +# site/public/ copied verbatim: landing page, robots.txt, and any static +# site dropped in as its own directory (site/public//) +# DEMOS below pages copied out of docs/, so docs/ stays their one source +# +# Run locally with `bash site/build.sh`, then open site/dist/index.html. +# CI (.gitea/workflows/deploy-site.yml) runs the same script and deploys +# site/dist as it is. +set -euo pipefail +cd "$(dirname "$0")/.." + +out=site/dist +rm -rf "$out" +mkdir -p "$out" +cp -R site/public/. "$out/" + +# " " +DEMOS=( + "docs/plant-operation.html demos/plant-operation/index.html" +) +for entry in "${DEMOS[@]}"; do + read -r src dst <<<"$entry" + mkdir -p "$out/$(dirname "$dst")" + cp "$src" "$out/$dst" +done + +# The deploy job reads this back from the server to confirm what is live. +git rev-parse HEAD >"$out/version.txt" 2>/dev/null || echo unknown >"$out/version.txt" + +# Fail on a dead internal link: every href/src in every page that points +# inside the site must resolve to a file in dist. +dead=0 +while IFS= read -r page; do + dir=$(dirname "$page") + while IFS= read -r ref; do + # shellcheck disable=SC2016 # literal ${ and {{ mark JS/template strings + case "$ref" in + '' | '#'* | http:* | https:* | mailto:* | tel:* | data:* | javascript:* | *'${'* | *'{{'*) continue ;; + esac + ref=${ref%%#*} + ref=${ref%%\?*} + case "$ref" in + /*) target="$out$ref" ;; + *) target="$dir/$ref" ;; + esac + [ -d "$target" ] && target="${target%/}/index.html" + if [ ! -f "$target" ]; then + echo "dead link in ${page#"$out"/}: $ref" >&2 + dead=1 + fi + done < <(grep -o -E '(href|src)="[^"]*"' "$page" | sed -E 's/^(href|src)="//; s/"$//') +done < <(find "$out" -name '*.html') +[ "$dead" -eq 0 ] || exit 1 + +echo "built $out: $(find "$out" -type f | wc -l | tr -d ' ') files" diff --git a/site/public/index.html b/site/public/index.html new file mode 100644 index 0000000..0e86885 --- /dev/null +++ b/site/public/index.html @@ -0,0 +1,68 @@ + + + + + + +CNX Vinyl · internal test site + + + +
+

CNX Vinyl

+ internal test site +

+ Demos and static pages from the CNX Vinyl planning repository, a vinyl + pressing plant planned near Chiang Mai. Shared by link only and not + indexed. Figures in the demos are planning estimates. +

+ +

Demos

+ + + +
+ + diff --git a/site/public/robots.txt b/site/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/site/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /