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
18 changes: 18 additions & 0 deletions site/deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions site/deploy/README.md
Original file line number Diff line number Diff line change
@@ -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
```

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`.
74 changes: 74 additions & 0 deletions site/deploy/publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/bin/sh
# 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 <checkout> <web-root>
#
# Web root layout:
# copies/<commit>.<random>/ 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

# 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 <checkout> <web-root>" >&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}')
# 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")
published=$(cat "$root/current/commit.txt" 2>/dev/null || true)
if site_unchanged "$checkout" "$published" "$commit"; then
return
fi
fi

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 "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"/copies/*; do
[ -e "$old" ] || continue
case "copies/${old##*/}" in
"copies/${copy##*/}" | "$previous") ;;
*) rm -rf "$old" ;;
esac
done

echo "published $commit"
}

# site_unchanged <checkout> <published commit> <commit>
# 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 "$@"
18 changes: 18 additions & 0 deletions site/deploy/thirdshift-site-publish.service
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions site/deploy/thirdshift-site-publish.timer
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions tests/packaging.rs
Original file line number Diff line number Diff line change
@@ -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 or
//! tests.
//! binary embeds, and none of the repository's own agent tooling, docs, site
//! or tests.

use std::process::Command;

Expand All @@ -26,7 +26,7 @@ fn package_list() -> Vec<String> {
#[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");
}
}
Expand Down
Loading