Add the cnxvinyl.lol test site with a Gitea deploy workflow
Deploy test site / deploy (push) Canceled after 0s

site/ builds the internal test site: public/ served as is (landing page,
robots.txt, static sites) plus demo pages copied out of docs/ (the plant
operation page at /demos/plant-operation/). build.sh fails on dead
internal links and stamps version.txt.

.gitea/workflows/deploy-site.yml rsyncs site/dist to the buildfor.life
web server with the blog's two-directory rename swap, checks version.txt
on the server, purges Cloudflare when a token is set, and checks the
public URL. Secret names match the blog's workflow.

site/Caddyfile is the virtual host to append on s1 (noindex everywhere,
Cloudflare-only like the blog apex, www redirect). site/README.md has the
one-time setup: DNS, Caddy, web root, secrets, runner.
This commit is contained in:
grabowski committed 2026-10-02 20:59:38 +02:00
1 parent 0e033784a2
commit 560d073019
10 files changed
+396 -2

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run on the Linux CI runner and in git-bash: keep LF.
*.sh text eol=lf
+132
View File
@@ -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
+3
View File
@@ -23,3 +23,6 @@ node_modules/
# Playwright MCP session output
.playwright-mcp/
# Test site build output (site/build.sh)
site/dist/
+12
View File
@@ -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
+3 -2
View File
@@ -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
+40
View File
@@ -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 {
# <user> <hash>
# }
root * /var/www/cnxvinyl.lol
encode zstd gzip
file_server
}
www.cnxvinyl.lol {
import cloudflare_only
redir https://cnxvinyl.lol{uri} permanent
}
+77
View File
@@ -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/<name>/index.html` is served at `/<name>/`).
- `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/<name>/` 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 <deploy user> /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 <port> 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/`.
+57
View File
@@ -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/<name>/)
# 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/"
# "<source in the repo> <path under the web root>"
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"
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>CNX Vinyl · internal test site</title>
<style>
:root {
--bg: #0a0f1a; --panel: #0f1626; --stroke: #2a3a56;
--fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main { max-width: 760px; margin: 0 auto; padding: 48px 20px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0.02em; }
.tag {
display: inline-block; margin-top: 6px; padding: 2px 8px;
border: 1px solid var(--stroke); border-radius: 4px;
color: var(--mute); font-size: 13px;
}
p.lead { color: var(--mute); margin: 18px 0 32px; }
h2 {
font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--mute); margin: 32px 0 12px;
}
ul { list-style: none; margin: 0; padding: 0; }
li a {
display: block; padding: 14px 16px; margin-bottom: 10px;
background: var(--panel); border: 1px solid var(--stroke); border-radius: 6px;
color: var(--fg); text-decoration: none;
}
li a:hover, li a:focus-visible { border-color: var(--accent); }
li strong { display: block; color: var(--accent); font-weight: 600; }
li span { color: var(--mute); font-size: 14px; }
footer { margin-top: 40px; color: var(--mute); font-size: 13px; }
</style>
</head>
<body>
<main>
<h1>CNX Vinyl</h1>
<span class="tag">internal test site</span>
<p class="lead">
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.
</p>
<h2>Demos</h2>
<ul>
<li>
<a href="demos/plant-operation/">
<strong>Plant overview (simulated SCADA)</strong>
<span>The whole plant animated, from PVC intake and the material lab
through extruder, press, trim, optical inspection, finishing,
packaging and fulfillment, with the metal path, utilities and energy
alongside. Every station is clickable.</span>
</a>
</li>
</ul>
<footer>Built from <code>site/</code> in the cnx_vinyl repository.</footer>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /