From afc8db4aeb4e436c1e8529f192ce94675fbb2429 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 26 Sep 2026 13:25:24 +0000 Subject: [PATCH 1/2] Add the thirdshift.app deploy config: Caddy block, publish script and timer site/deploy/ holds the Caddy site block (automatic TLS, www redirect, /install.sh 302 to the latest installer), a pull-only publish script that swaps a copy of site/ into the web root atomically and records the commit, systemd units that run it every five minutes, and the install README. Co-Authored-By: Claude Opus 5.5 --- site/deploy/Caddyfile | 18 ++ site/deploy/README.md | 63 +++++ site/deploy/publish.sh | 70 ++++++ site/deploy/thirdshift-site-publish.service | 18 ++ site/deploy/thirdshift-site-publish.timer | 10 + tests/packaging.rs | 4 +- tests/site_deploy.rs | 265 ++++++++++++++++++++ 7 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 site/deploy/Caddyfile create mode 100644 site/deploy/README.md create mode 100755 site/deploy/publish.sh create mode 100644 site/deploy/thirdshift-site-publish.service create mode 100644 site/deploy/thirdshift-site-publish.timer create mode 100644 tests/site_deploy.rs diff --git a/site/deploy/Caddyfile b/site/deploy/Caddyfile new file mode 100644 index 000000000..35aeb7dfc --- /dev/null +++ b/site/deploy/Caddyfile @@ -0,0 +1,18 @@ +# thirdshift.app, served from the copy of site/ that publish.sh swaps in. +# Caddy gets and renews the TLS certificates itself. + +thirdshift.app { + # The short install command always gets the latest release's installer. + # A 302, not a 301, so clients never cache one release's address. + redir /install.sh https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh 302 + + # publish.sh never copies the deploy directory; this is a second guard. + respond /deploy/* 404 + + root * /srv/thirdshift.app/current + file_server +} + +www.thirdshift.app { + redir https://thirdshift.app{uri} permanent +} diff --git a/site/deploy/README.md b/site/deploy/README.md new file mode 100644 index 000000000..6fa96c690 --- /dev/null +++ b/site/deploy/README.md @@ -0,0 +1,63 @@ +# Deploying thirdshift.app + +This directory holds the configuration that serves thirdshift.app. It is never served itself. + +- `Caddyfile`: the site block. It serves the published copy of `site/` with automatic TLS, permanently redirects `www.thirdshift.app` to `https://thirdshift.app`, and sends `/install.sh` with a 302 to the latest release's installer. +- `publish.sh`: fetches `main` into a checkout kept only for deploying. When `site/` (apart from this directory) has changed since the last publish, it copies it into the web root and swaps it in with one rename. It writes the published commit to `commit.txt` at the site root. It is safe to re-run, and does nothing when nothing changed. +- `thirdshift-site-publish.service` and `.timer`: run `publish.sh` every five minutes as the unprivileged `thirdshift-site` user. + +The deploy only ever pulls from the public repository, so GitHub holds no credentials for the server. There are no analytics. + +## Install + +Installing is a manual step. Run these as root on the server, which already runs Caddy. + +1. Create the user, the deploy checkout and the web root: + + ```sh + useradd --system --home-dir /var/lib/thirdshift-site --create-home --shell /usr/sbin/nologin thirdshift-site + install -d -o thirdshift-site -g thirdshift-site -m 755 /srv/thirdshift.app + sudo -u thirdshift-site git clone https://github.com/JacobStephens2/thirdshift.git /var/lib/thirdshift-site/checkout + ``` + +2. Publish once by hand, and check that `/srv/thirdshift.app/current` points at a copy of `site/` with a `commit.txt`: + + ```sh + sudo -u thirdshift-site sh /var/lib/thirdshift-site/checkout/site/deploy/publish.sh /var/lib/thirdshift-site/checkout /srv/thirdshift.app + cat /srv/thirdshift.app/current/commit.txt + ``` + +3. Install and start the timer: + + ```sh + cp /var/lib/thirdshift-site/checkout/site/deploy/thirdshift-site-publish.service \ + /var/lib/thirdshift-site/checkout/site/deploy/thirdshift-site-publish.timer \ + /etc/systemd/system/ + systemctl daemon-reload + systemctl enable --now thirdshift-site-publish.timer + systemctl list-timers thirdshift-site-publish.timer + ``` + +4. Add the contents of `Caddyfile` to the server's Caddyfile, then validate and reload: + + ```sh + caddy validate --config /etc/caddy/Caddyfile + systemctl reload caddy + ``` + + On a host with SELinux enforcing, label the web root so Caddy may read it: + + ```sh + semanage fcontext -a -t httpd_sys_content_t '/srv/thirdshift.app(/.*)?' + restorecon -R /srv/thirdshift.app + ``` + +5. Check it from anywhere: + + ```sh + curl -I https://thirdshift.app # 200 + curl -I https://www.thirdshift.app # 301 to https://thirdshift.app/ + curl -I https://thirdshift.app/install.sh # 302 to the latest thirdshift-installer.sh + ``` + +The timer runs `publish.sh` from the deploy checkout, so changes to the script go live with the next publish. Changes to the units or the Caddy block need steps 3 or 4 again. Publish logs are in `journalctl -u thirdshift-site-publish`. diff --git a/site/deploy/publish.sh b/site/deploy/publish.sh new file mode 100755 index 000000000..47049d326 --- /dev/null +++ b/site/deploy/publish.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Publishes thirdshift.app: fetches main into a checkout kept for deploying +# and, when the served part of site/ changed since the last publish, copies it +# into the web root and swaps it in atomically. Safe to re-run; it does +# nothing when nothing changed. +# +# Usage: publish.sh +# +# Web root layout: +# releases/./ one copy of site/ per publish, without deploy/ +# current -> releases/... the copy Caddy serves; swapped with one rename +# current/commit.txt the commit of main the copy was taken from +# +# Needs only git and GNU coreutils (mv -T), so it runs on Linux. +set -eu + +# The body is a function so the whole script is read before the checkout it +# lives in is moved to the new commit. +main() { + if [ "$#" -ne 2 ]; then + echo "usage: publish.sh " >&2 + exit 2 + fi + checkout=$1 + root=$2 + + git -C "$checkout" fetch --quiet origin +refs/heads/main:refs/remotes/origin/main + commit=$(git -C "$checkout" rev-parse --verify 'origin/main^{commit}') + + previous= + if [ -L "$root/current" ]; then + previous=$(readlink "$root/current") + if site_unchanged_since "$(cat "$root/current/commit.txt" 2>/dev/null)"; then + return + fi + fi + + git -C "$checkout" checkout --quiet --force --detach "$commit" + + mkdir -p "$root/releases" + release=$(mktemp -d "$root/releases/$commit.XXXXXX") + chmod 755 "$release" + cp -R "$checkout/site/." "$release/" + rm -rf "$release/deploy" + printf '%s\n' "$commit" >"$release/commit.txt" + + rm -f "$root/current.new" + ln -s "releases/${release##*/}" "$root/current.new" + mv -T "$root/current.new" "$root/current" + + # Keep the copy just replaced, for requests still reading it. + for old in "$root"/releases/*; do + [ -e "$old" ] || continue + case "releases/${old##*/}" in + "releases/${release##*/}" | "$previous") ;; + *) rm -rf "$old" ;; + esac + done + + echo "published $commit" +} + +# Whether main's served site, site/ without deploy/, matches the commit given. +site_unchanged_since() { + [ -n "$1" ] && + git -C "$checkout" cat-file -e "$1^{commit}" 2>/dev/null && + git -C "$checkout" diff --quiet "$1" "$commit" -- site ':(exclude)site/deploy' +} + +main "$@" diff --git a/site/deploy/thirdshift-site-publish.service b/site/deploy/thirdshift-site-publish.service new file mode 100644 index 000000000..1d1d776b3 --- /dev/null +++ b/site/deploy/thirdshift-site-publish.service @@ -0,0 +1,18 @@ +[Unit] +Description=Publish thirdshift.app from the main branch +Documentation=https://github.com/JacobStephens2/thirdshift/tree/main/site/deploy +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=thirdshift-site +Group=thirdshift-site +Environment=HOME=/var/lib/thirdshift-site +ExecStart=/bin/sh /var/lib/thirdshift-site/checkout/site/deploy/publish.sh /var/lib/thirdshift-site/checkout /srv/thirdshift.app +UMask=0022 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/thirdshift-site /srv/thirdshift.app diff --git a/site/deploy/thirdshift-site-publish.timer b/site/deploy/thirdshift-site-publish.timer new file mode 100644 index 000000000..89fd0342e --- /dev/null +++ b/site/deploy/thirdshift-site-publish.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Publish thirdshift.app every five minutes +Documentation=https://github.com/JacobStephens2/thirdshift/tree/main/site/deploy + +[Timer] +OnBootSec=1min +OnUnitActiveSec=5min + +[Install] +WantedBy=timers.target diff --git a/tests/packaging.rs b/tests/packaging.rs index 65ada265a..b3f33ebc9 100644 --- a/tests/packaging.rs +++ b/tests/packaging.rs @@ -1,5 +1,5 @@ //! The crates.io package: it carries the sources and the Factory skills the -//! binary embeds, and none of the repository's own agent tooling, docs or +//! binary embeds, and none of the repository's own agent tooling, docs, site or //! tests. use std::process::Command; @@ -26,7 +26,7 @@ fn package_list() -> Vec { #[test] fn package_leaves_out_the_repository_tooling() { for path in package_list() { - for excluded in [".agents/", ".grok/", ".claude/", "docs/", "tests/"] { + for excluded in [".agents/", ".grok/", ".claude/", "docs/", "site/", "tests/"] { assert!(!path.starts_with(excluded), "{path} is in the package"); } } diff --git a/tests/site_deploy.rs b/tests/site_deploy.rs new file mode 100644 index 000000000..10d21aef7 --- /dev/null +++ b/tests/site_deploy.rs @@ -0,0 +1,265 @@ +//! The thirdshift.app deploy configuration in `site/deploy/`: the publish +//! script run against a local origin and a temporary web root, and the Caddy +//! block and systemd units checked with their own tools where installed. +//! +//! The publish script targets the Linux server and swaps its symlink with GNU +//! `mv -T`, so its tests run on Linux only. + +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; + +fn deploy_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("site/deploy") +} + +fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output() + .expect("could not run git"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +/// Whether `tool` is on PATH; the Caddy and systemd checks skip without it. +fn installed(tool: &str) -> bool { + Command::new(tool).arg("version").output().is_ok() +} + +/// A bare origin, a contributor's clone that pushes to it, the deploy +/// checkout the publish script fetches into, and an empty web root. +struct Deploy { + temp: TempDir, +} + +impl Deploy { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let deploy = Deploy { temp }; + git( + deploy.root(), + &["init", "-q", "--bare", "-b", "main", "origin.git"], + ); + git(deploy.root(), &["clone", "-q", "origin.git", "contributor"]); + deploy.commit(&[ + ("site/index.html", "home v1"), + ("site/prompts/index.html", "prompts v1"), + ("site/deploy/publish.sh", "not for the web"), + ("README.md", "readme v1"), + ]); + git(deploy.root(), &["clone", "-q", "origin.git", "checkout"]); + fs::create_dir(deploy.web_root()).unwrap(); + deploy + } + + fn root(&self) -> &Path { + self.temp.path() + } + + fn web_root(&self) -> PathBuf { + self.root().join("www") + } + + /// Commits `files` in the contributor's clone and pushes them to main. + fn commit(&self, files: &[(&str, &str)]) -> String { + let clone = self.root().join("contributor"); + for (path, contents) in files { + let path = clone.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + git(&clone, &["add", "-A"]); + git(&clone, &["commit", "-q", "-m", "change"]); + git(&clone, &["push", "-q", "origin", "HEAD:main"]); + git(&clone, &["rev-parse", "HEAD"]) + } + + fn publish(&self) { + let output = Command::new(deploy_dir().join("publish.sh")) + .arg(self.root().join("checkout")) + .arg(self.web_root()) + .output() + .expect("could not run publish.sh"); + assert!( + output.status.success(), + "publish.sh failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// The directory the web root's `current` symlink points at. + fn current(&self) -> PathBuf { + fs::canonicalize(self.web_root().join("current")).unwrap() + } + + fn served(&self, path: &str) -> String { + fs::read_to_string(self.web_root().join("current").join(path)).unwrap() + } + + /// Every entry under the web root, with modification times, so a run + /// that changes anything at all shows up as a difference. + fn snapshot(&self) -> Vec<(PathBuf, std::time::SystemTime)> { + fn walk(dir: &Path, out: &mut Vec<(PathBuf, std::time::SystemTime)>) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let meta = fs::symlink_metadata(entry.path()).unwrap(); + out.push((entry.path(), meta.modified().unwrap())); + if meta.is_dir() { + walk(&entry.path(), out); + } + } + } + let mut out = Vec::new(); + walk(&self.web_root(), &mut out); + out.sort(); + out + } +} + +#[test] +#[cfg(target_os = "linux")] +fn publishing_serves_the_site_without_the_deploy_directory_and_records_the_commit() { + let deploy = Deploy::new(); + let head = git(&deploy.root().join("contributor"), &["rev-parse", "HEAD"]); + + deploy.publish(); + + assert_eq!(deploy.served("index.html"), "home v1"); + assert_eq!(deploy.served("prompts/index.html"), "prompts v1"); + assert_eq!(deploy.served("commit.txt"), format!("{head}\n")); + assert!(!deploy.current().join("deploy").exists()); + assert!(!deploy.current().join("README.md").exists()); +} + +#[test] +#[cfg(target_os = "linux")] +fn publishing_again_with_no_new_commits_changes_nothing() { + let deploy = Deploy::new(); + deploy.publish(); + let before = deploy.snapshot(); + + deploy.publish(); + + assert_eq!(deploy.snapshot(), before); +} + +#[test] +#[cfg(target_os = "linux")] +fn a_commit_outside_the_served_site_changes_nothing() { + let deploy = Deploy::new(); + deploy.publish(); + let before = deploy.snapshot(); + deploy.commit(&[ + ("README.md", "readme v2"), + ("site/deploy/publish.sh", "still not for the web"), + ]); + + deploy.publish(); + + assert_eq!(deploy.snapshot(), before); +} + +#[test] +#[cfg(target_os = "linux")] +fn a_commit_to_the_site_swaps_in_a_new_copy() { + let deploy = Deploy::new(); + deploy.publish(); + let old = deploy.current(); + + let head = deploy.commit(&[("site/index.html", "home v2")]); + deploy.publish(); + + assert_ne!(deploy.current(), old); + assert!( + fs::symlink_metadata(deploy.web_root().join("current")) + .unwrap() + .is_symlink() + ); + assert_eq!(deploy.served("index.html"), "home v2"); + assert_eq!(deploy.served("commit.txt"), format!("{head}\n")); + // The copy it replaced is left whole for requests still reading it. + assert_eq!( + fs::read_to_string(old.join("index.html")).unwrap(), + "home v1" + ); +} + +#[test] +#[cfg(target_os = "linux")] +fn only_the_live_and_previous_copies_are_kept() { + let deploy = Deploy::new(); + deploy.publish(); + for n in 2..=4 { + deploy.commit(&[("site/index.html", &format!("home v{n}"))]); + deploy.publish(); + } + + let kept = fs::read_dir(deploy.web_root().join("releases")) + .unwrap() + .count(); + + assert_eq!(kept, 2); + assert_eq!(deploy.served("index.html"), "home v4"); +} + +#[test] +fn the_caddy_block_adapts() { + if !installed("caddy") { + eprintln!("skipping: caddy is not installed"); + return; + } + let output = Command::new("caddy") + .args(["adapt", "--adapter", "caddyfile", "--config"]) + .arg(deploy_dir().join("Caddyfile")) + .output() + .unwrap(); + assert!( + output.status.success(), + "caddy adapt: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json = String::from_utf8(output.stdout).unwrap(); + for expected in [ + r#""thirdshift.app""#, + r#""www.thirdshift.app""#, + r#""/install.sh""#, + "https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh", + "302", + "301", + ] { + assert!(json.contains(expected), "{expected} is missing from {json}"); + } +} + +#[test] +fn the_systemd_units_verify() { + if !installed("systemd-analyze") { + eprintln!("skipping: systemd-analyze is not installed"); + return; + } + let output = Command::new("systemd-analyze") + .args(["verify", "--man=no"]) + .arg(deploy_dir().join("thirdshift-site-publish.service")) + .arg(deploy_dir().join("thirdshift-site-publish.timer")) + .output() + .unwrap(); + assert!( + output.status.success() && output.stderr.is_empty(), + "systemd-analyze verify: {}", + String::from_utf8_lossy(&output.stderr) + ); +} From b15bf1c0812223b60b783432a313673468028fd3 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Sat, 26 Sep 2026 13:29:52 +0000 Subject: [PATCH 2/2] Keep the deploy checkout on main and tidy the deploy tests A commit that only touched site/deploy/ left the deploy checkout on the old commit, so reinstalling the units or Caddy block from it would have installed stale files. The checkout now always follows main; only the copy and swap is skipped. The web root's copies live in copies/ rather than releases/, which already means GitHub releases here, and the tests keep the machine's git config out and check the adapted Caddy routes. Co-Authored-By: Claude Opus 5.5 --- site/deploy/README.md | 2 +- site/deploy/publish.sh | 52 +++++++++++--------- tests/packaging.rs | 4 +- tests/site_deploy.rs | 108 ++++++++++++++++++++++++++++++----------- 4 files changed, 110 insertions(+), 56 deletions(-) diff --git a/site/deploy/README.md b/site/deploy/README.md index 6fa96c690..c28bd0f32 100644 --- a/site/deploy/README.md +++ b/site/deploy/README.md @@ -60,4 +60,4 @@ Installing is a manual step. Run these as root on the server, which already runs curl -I https://thirdshift.app/install.sh # 302 to the latest thirdshift-installer.sh ``` -The timer runs `publish.sh` from the deploy checkout, so changes to the script go live with the next publish. Changes to the units or the Caddy block need steps 3 or 4 again. Publish logs are in `journalctl -u thirdshift-site-publish`. +Every publish moves the deploy checkout to the tip of `main`, even when nothing served changed. The timer runs `publish.sh` from that checkout, so changes to the script take effect on the next run. Changes to the units or the Caddy block need steps 3 or 4 again, which copy them from the same checkout. Publish logs are in `journalctl -u thirdshift-site-publish`. diff --git a/site/deploy/publish.sh b/site/deploy/publish.sh index 47049d326..4b89efbc1 100755 --- a/site/deploy/publish.sh +++ b/site/deploy/publish.sh @@ -1,15 +1,15 @@ #!/bin/sh -# Publishes thirdshift.app: fetches main into a checkout kept for deploying -# and, when the served part of site/ changed since the last publish, copies it -# into the web root and swaps it in atomically. Safe to re-run; it does +# Publishes thirdshift.app: moves a checkout kept for deploying to the tip of +# main and, when the served part of site/ changed since the last publish, +# copies it into the web root and swaps it in atomically. Safe to re-run; it does # nothing when nothing changed. # # Usage: publish.sh # # Web root layout: -# releases/./ one copy of site/ per publish, without deploy/ -# current -> releases/... the copy Caddy serves; swapped with one rename -# current/commit.txt the commit of main the copy was taken from +# copies/./ one copy of site/ per publish, without deploy/ +# current -> copies/... the copy Caddy serves; swapped with one rename +# current/commit.txt the commit of main the copy was taken from # # Needs only git and GNU coreutils (mv -T), so it runs on Linux. set -eu @@ -26,33 +26,35 @@ main() { git -C "$checkout" fetch --quiet origin +refs/heads/main:refs/remotes/origin/main commit=$(git -C "$checkout" rev-parse --verify 'origin/main^{commit}') + # Always, so the units and Caddy block installed from the checkout are + # main's even when no served file changed. + git -C "$checkout" checkout --quiet --force --detach "$commit" previous= if [ -L "$root/current" ]; then previous=$(readlink "$root/current") - if site_unchanged_since "$(cat "$root/current/commit.txt" 2>/dev/null)"; then + published=$(cat "$root/current/commit.txt" 2>/dev/null || true) + if site_unchanged "$checkout" "$published" "$commit"; then return fi fi - git -C "$checkout" checkout --quiet --force --detach "$commit" - - mkdir -p "$root/releases" - release=$(mktemp -d "$root/releases/$commit.XXXXXX") - chmod 755 "$release" - cp -R "$checkout/site/." "$release/" - rm -rf "$release/deploy" - printf '%s\n' "$commit" >"$release/commit.txt" + mkdir -p "$root/copies" + copy=$(mktemp -d "$root/copies/$commit.XXXXXX") + chmod 755 "$copy" + cp -R "$checkout/site/." "$copy/" + rm -rf "$copy/deploy" + printf '%s\n' "$commit" >"$copy/commit.txt" rm -f "$root/current.new" - ln -s "releases/${release##*/}" "$root/current.new" + ln -s "copies/${copy##*/}" "$root/current.new" mv -T "$root/current.new" "$root/current" # Keep the copy just replaced, for requests still reading it. - for old in "$root"/releases/*; do + for old in "$root"/copies/*; do [ -e "$old" ] || continue - case "releases/${old##*/}" in - "releases/${release##*/}" | "$previous") ;; + case "copies/${old##*/}" in + "copies/${copy##*/}" | "$previous") ;; *) rm -rf "$old" ;; esac done @@ -60,11 +62,13 @@ main() { echo "published $commit" } -# Whether main's served site, site/ without deploy/, matches the commit given. -site_unchanged_since() { - [ -n "$1" ] && - git -C "$checkout" cat-file -e "$1^{commit}" 2>/dev/null && - git -C "$checkout" diff --quiet "$1" "$commit" -- site ':(exclude)site/deploy' +# site_unchanged +# Whether the served site, site/ without deploy/, is the same at both commits. +# An empty or unknown published commit counts as changed. +site_unchanged() { + [ -n "$2" ] && + git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null && + git -C "$1" diff --quiet "$2" "$3" -- site ':(exclude)site/deploy' } main "$@" diff --git a/tests/packaging.rs b/tests/packaging.rs index b3f33ebc9..6dbab9db4 100644 --- a/tests/packaging.rs +++ b/tests/packaging.rs @@ -1,6 +1,6 @@ //! The crates.io package: it carries the sources and the Factory skills the -//! binary embeds, and none of the repository's own agent tooling, docs, site or -//! tests. +//! binary embeds, and none of the repository's own agent tooling, docs, site +//! or tests. use std::process::Command; diff --git a/tests/site_deploy.rs b/tests/site_deploy.rs index 10d21aef7..d4b82bc8f 100644 --- a/tests/site_deploy.rs +++ b/tests/site_deploy.rs @@ -10,15 +10,25 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::SystemTime; +use serde_json::Value; use tempfile::TempDir; fn deploy_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("site/deploy") } +/// Keeps the machine's git configuration (signing, hooks, default branch) out +/// of the test's git and the publish script's. +fn without_user_git_config(command: &mut Command) -> &mut Command { + command + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") +} + fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") + let output = without_user_git_config(&mut Command::new("git")) .args(args) .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test") @@ -44,39 +54,51 @@ fn installed(tool: &str) -> bool { /// checkout the publish script fetches into, and an empty web root. struct Deploy { temp: TempDir, + /// The commit on main before the test adds any. + first_commit: String, } impl Deploy { fn new() -> Self { let temp = TempDir::new().unwrap(); - let deploy = Deploy { temp }; git( - deploy.root(), + temp.path(), &["init", "-q", "--bare", "-b", "main", "origin.git"], ); - git(deploy.root(), &["clone", "-q", "origin.git", "contributor"]); - deploy.commit(&[ + git(temp.path(), &["clone", "-q", "origin.git", "contributor"]); + let mut deploy = Deploy { + temp, + first_commit: String::new(), + }; + deploy.first_commit = deploy.commit(&[ ("site/index.html", "home v1"), ("site/prompts/index.html", "prompts v1"), ("site/deploy/publish.sh", "not for the web"), ("README.md", "readme v1"), ]); - git(deploy.root(), &["clone", "-q", "origin.git", "checkout"]); + git( + deploy.temp_dir(), + &["clone", "-q", "origin.git", "checkout"], + ); fs::create_dir(deploy.web_root()).unwrap(); deploy } - fn root(&self) -> &Path { + fn temp_dir(&self) -> &Path { self.temp.path() } + fn checkout(&self) -> PathBuf { + self.temp_dir().join("checkout") + } + fn web_root(&self) -> PathBuf { - self.root().join("www") + self.temp_dir().join("www") } /// Commits `files` in the contributor's clone and pushes them to main. fn commit(&self, files: &[(&str, &str)]) -> String { - let clone = self.root().join("contributor"); + let clone = self.temp_dir().join("contributor"); for (path, contents) in files { let path = clone.join(path); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -89,8 +111,8 @@ impl Deploy { } fn publish(&self) { - let output = Command::new(deploy_dir().join("publish.sh")) - .arg(self.root().join("checkout")) + let output = without_user_git_config(&mut Command::new(deploy_dir().join("publish.sh"))) + .arg(self.checkout()) .arg(self.web_root()) .output() .expect("could not run publish.sh"); @@ -112,8 +134,8 @@ impl Deploy { /// Every entry under the web root, with modification times, so a run /// that changes anything at all shows up as a difference. - fn snapshot(&self) -> Vec<(PathBuf, std::time::SystemTime)> { - fn walk(dir: &Path, out: &mut Vec<(PathBuf, std::time::SystemTime)>) { + fn snapshot(&self) -> Vec<(PathBuf, SystemTime)> { + fn walk(dir: &Path, out: &mut Vec<(PathBuf, SystemTime)>) { for entry in fs::read_dir(dir).unwrap() { let entry = entry.unwrap(); let meta = fs::symlink_metadata(entry.path()).unwrap(); @@ -134,13 +156,15 @@ impl Deploy { #[cfg(target_os = "linux")] fn publishing_serves_the_site_without_the_deploy_directory_and_records_the_commit() { let deploy = Deploy::new(); - let head = git(&deploy.root().join("contributor"), &["rev-parse", "HEAD"]); deploy.publish(); assert_eq!(deploy.served("index.html"), "home v1"); assert_eq!(deploy.served("prompts/index.html"), "prompts v1"); - assert_eq!(deploy.served("commit.txt"), format!("{head}\n")); + assert_eq!( + deploy.served("commit.txt"), + format!("{}\n", deploy.first_commit) + ); assert!(!deploy.current().join("deploy").exists()); assert!(!deploy.current().join("README.md").exists()); } @@ -159,11 +183,11 @@ fn publishing_again_with_no_new_commits_changes_nothing() { #[test] #[cfg(target_os = "linux")] -fn a_commit_outside_the_served_site_changes_nothing() { +fn a_commit_outside_the_served_site_changes_nothing_served() { let deploy = Deploy::new(); deploy.publish(); let before = deploy.snapshot(); - deploy.commit(&[ + let head = deploy.commit(&[ ("README.md", "readme v2"), ("site/deploy/publish.sh", "still not for the web"), ]); @@ -171,6 +195,13 @@ fn a_commit_outside_the_served_site_changes_nothing() { deploy.publish(); assert_eq!(deploy.snapshot(), before); + // The deploy checkout still follows main, so the units and Caddy block a + // human installs from it are current. + assert_eq!(git(&deploy.checkout(), &["rev-parse", "HEAD"]), head); + assert_eq!( + fs::read_to_string(deploy.checkout().join("site/deploy/publish.sh")).unwrap(), + "still not for the web" + ); } #[test] @@ -208,7 +239,7 @@ fn only_the_live_and_previous_copies_are_kept() { deploy.publish(); } - let kept = fs::read_dir(deploy.web_root().join("releases")) + let kept = fs::read_dir(deploy.web_root().join("copies")) .unwrap() .count(); @@ -232,17 +263,36 @@ fn the_caddy_block_adapts() { "caddy adapt: {}", String::from_utf8_lossy(&output.stderr) ); - let json = String::from_utf8(output.stdout).unwrap(); - for expected in [ - r#""thirdshift.app""#, - r#""www.thirdshift.app""#, - r#""/install.sh""#, - "https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh", - "302", - "301", - ] { - assert!(json.contains(expected), "{expected} is missing from {json}"); - } + let config: Value = serde_json::from_slice(&output.stdout).unwrap(); + + let site = caddy_route(&config, "thirdshift.app"); + assert!(site.contains(r#""handler":"file_server""#), "{site}"); + assert!(site.contains(&redirect( + 302, + "https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh" + )), "{site}"); + let www = caddy_route(&config, "www.thirdshift.app"); + assert!( + www.contains(&redirect(301, "https://thirdshift.app{http.request.uri}")), + "{www}" + ); +} + +/// The adapted Caddy route that matches `host`, as JSON text. +fn caddy_route(config: &Value, host: &str) -> String { + let routes = &config["apps"]["http"]["servers"]["srv0"]["routes"]; + routes + .as_array() + .unwrap() + .iter() + .find(|route| route["match"][0]["host"][0] == host) + .unwrap_or_else(|| panic!("no route for {host} in {routes}")) + .to_string() +} + +/// How a redirect handler reads in adapted Caddy JSON. +fn redirect(status: u16, location: &str) -> String { + format!(r#""headers":{{"Location":["{location}"]}},"status_code":{status}"#) } #[test]