Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
55b5caf
Add continuous deployment design spec
JohnRDOrazio Aug 1, 2026
73dee5c
Add continuous deployment implementation plan
JohnRDOrazio Aug 1, 2026
667edac
Add deployment manifest reader and /healthz endpoint
JohnRDOrazio Aug 1, 2026
5e35cd9
Pin crmedr, clbdr and martyrology-texts as submodules
JohnRDOrazio Aug 1, 2026
91dca75
Add release bundle builder
JohnRDOrazio Aug 1, 2026
e9daeeb
Fix plan Task 3: assemble() must refuse a manifest-less tree
JohnRDOrazio Aug 1, 2026
e3056e3
Fix Task 3 round 1: assemble() fails loudly with no manifest
JohnRDOrazio Aug 1, 2026
47ba141
Add on-VPS deploy script with smoke check and rollback
JohnRDOrazio Aug 1, 2026
6794ef6
Fix deploy.sh: path-traversal SIGPIPE bypass, live-release destructio…
JohnRDOrazio Aug 1, 2026
a03bd7d
Add token expiry watch to plan Task 6
JohnRDOrazio Aug 1, 2026
4fb58e3
Fix deploy.sh: name-only screen bypassed by SIGPIPE fix's field-split…
JohnRDOrazio Aug 1, 2026
1c427cb
Fix deploy.sh: signal during flip window read as success, plus hardli…
JohnRDOrazio Aug 1, 2026
9b88190
Fix deploy.sh: signal during smoke phase completed the deploy; screen…
JohnRDOrazio Aug 1, 2026
c71f387
Add VPS provisioning script for martyrology-api deploys
JohnRDOrazio Aug 1, 2026
a81d79c
Fix round 1: harden VPS provisioning script
JohnRDOrazio Aug 2, 2026
0cba733
Normalise world permissions on the release tree at deploy time
JohnRDOrazio Aug 2, 2026
cf89d00
Track uv.lock so release builds resolve deterministically
JohnRDOrazio Aug 2, 2026
9f083eb
Add release deploy workflow, shellcheck CI job and token expiry watch
JohnRDOrazio Aug 2, 2026
a120f73
Fix data-tree layout, deploy permissions, and token-watch issues from…
JohnRDOrazio Aug 2, 2026
16f6179
Mark the CD plan executed and superseded by the committed files
JohnRDOrazio Aug 2, 2026
70920a6
Deploy: group-scoped release tree, APP_DIR passthrough, tag/version g…
JohnRDOrazio Aug 2, 2026
4922b16
Record parked deployment follow-ups from the final review
JohnRDOrazio Aug 2, 2026
585c821
Apply the parked deployment follow-ups
JohnRDOrazio Aug 2, 2026
50d5e87
Fix duplicated bundle members and tighten deploy checks
JohnRDOrazio Aug 2, 2026
9935b8c
Bound the signal harness readiness wait
JohnRDOrazio Aug 2, 2026
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ MARTYROLOGY_OPENFGA_MODEL_ID=
# Curation backend: set ONE of these; local_git_root takes precedence for dev
MARTYROLOGY_GITHUB_TOKEN=
MARTYROLOGY_LOCAL_GIT_ROOT=

# --- Production (VPS) ---------------------------------------------------
# Written to /opt/martyrology/config/runtime.env by setup-vps-deploy-user.sh.
# All paths route through the `current` symlink, so they never change between
# releases. Secrets live in /etc/martyrology/api.env (root:root 0600) instead.
# MARTYROLOGY_PORT=8412
# MARTYROLOGY_MANIFEST_PATH=/opt/martyrology/current/manifest.json
# MARTYROLOGY_DATA_PATH=/opt/martyrology/current/data/editions:/opt/martyrology/current/data/texts
# MARTYROLOGY_CRMEDR_PATH=/opt/martyrology/current/data/crmedr
# MARTYROLOGY_CLBDR_PATH=/opt/martyrology/current/data/clbdr
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,14 @@ updates:
labels:
- "dependencies"
- "github-actions"

- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
labels:
- "dependencies"
- "data"
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,13 @@ jobs:
slug: CatholicOS/martyrology-api
report_type: test_results
files: junit.xml

shellcheck:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: shellcheck
run: shellcheck scripts/deploy/*.sh
294 changes: 294 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
name: Deploy

# Builds a release bundle (api wheel + offline wheelhouse + the three pinned
# data trees + manifest.json), ships it to the VPS, and activates it.
#
# Every ${{ ... }} interpolation is a repo secret/var (trusted). The two
# github.event.release.tag_name uses are bound to an `env:` name and referenced
# as a shell variable, never interpolated into a command line.
#
# See docs/superpowers/specs/2026-08-01-continuous-deployment-design.md

on:
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-martyrology
cancel-in-progress: false

jobs:
deploy:
# Pinned, never ubuntu-latest: the VPS is Ubuntu 24.04 / glibc 2.39 and the
# wheelhouse ABI must match it.
runs-on: ubuntu-24.04
# contents: write is required by the final "Attach manifest to the release"
# step's `gh release upload`. Without it that step 403s after activation
# has already succeeded, marking a successful deploy as a failed run.
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
token: ${{ secrets.SUBMODULE_TOKEN }}
persist-credentials: false

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

- name: Resolve version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"

# deploy.sh accepts only ^v?[0-9]+(\.[0-9]+)*$ — the version becomes
# part of a path and of a filename on the VPS. A PEP 440 prerelease or
# postrelease ("0.2.0rc1", "1.0.0.post1") would otherwise build a full
# bundle, upload it, and be refused on the remote at the very last
# step, after the corpus has already been shipped. Refuse it here.
# A bash conditional, not `printf | grep`: no pipeline means no way
# for `pipefail` plus an early-exiting reader to turn this check's
# result into something other than what it matched.
if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then
echo "::error::pyproject.toml version '${VERSION}' is not deployable: deploy.sh accepts only digits and dots (e.g. 0.2.0). Prerelease and postrelease versions (rc, a, b, .dev, .post) cannot be deployed."
exit 1
fi

# The bundle is named, installed and recorded in manifest.json from
# pyproject.toml, never from the tag. Publishing v0.2.0 without
# bumping pyproject.toml therefore builds and deploys 0.1.0: on a
# host already running 0.1.0, deploy.sh refuses it as the active
# release and the release silently never ships, while the manifest
# attached to v0.2.0 claims api_version 0.1.0. Fail here instead.
# workflow_dispatch has no tag and is deliberately exempt.
if [ "$EVENT_NAME" = "release" ]; then
if [ -z "${RELEASE_TAG:-}" ]; then
echo "::error::release event carried no tag_name"
exit 1
fi
if [ "${RELEASE_TAG#v}" != "$VERSION" ]; then
echo "::error::Release tag ${RELEASE_TAG} does not match the pyproject.toml version ${VERSION}. Bump pyproject.toml (or retag the release) and publish again."
exit 1
fi
fi

echo "value=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building martyrology-api $VERSION"

- name: Build wheel and offline wheelhouse
run: |
# Pinned deliberately. `uv export` output is an input to the
# wheelhouse, so an unpinned uv could change what gets bundled
# between two runs of the same commit — which is precisely the
# determinism tracking uv.lock exists to provide. Bump this
# deliberately, after exercising `uv build` and `uv export` locally.
pip install uv==0.12.1
mkdir -p staging/wheels
uv build --wheel --out-dir dist
uv export --frozen --no-dev --no-emit-project --format requirements-txt -o requirements.txt
pip wheel -r requirements.txt -w staging/wheels
cp dist/*.whl staging/wheels/

- name: Stage data trees
run: |
mkdir -p staging/data
cp -a data/editions staging/data/editions
# Store.__init__ (src/martyrology_api/store.py) walks the *children*
# of each MARTYROLOGY_DATA_PATH entry looking for one holding
# MM.json files, i.e. it expects a directory-of-editions, not the
# vendor/texts repo root. crmedr and clbdr are NOT like this — they
# are consumed whole (Registry.load reads <root>/data/*.json and
# <root>/i18n/) — so only texts is unwrapped here.
cp -a vendor/texts/data/editions staging/data/texts
cp -a vendor/crmedr staging/data/crmedr
cp -a vendor/clbdr staging/data/clbdr
rm -rf staging/data/*/.git

- name: Verify staged data shape
run: |
set -euo pipefail
fail() { echo "::error::$1"; exit 1; }

# staging/data/texts must be a directory-of-editions: at least one
# child directory containing 01.json. A glob that matches nothing
# must fail loudly, not pass vacuously, so this counts matches
# explicitly rather than trusting an unexpanded glob literal.
shopt -s nullglob
texts_editions=(staging/data/texts/*/01.json)
shopt -u nullglob
[ "${#texts_editions[@]}" -gt 0 ] \
|| fail "staging/data/texts has no <edition>/01.json; texts staging is wrong (vendor/texts copied whole instead of vendor/texts/data/editions?)"

# staging/data/editions is the public tree from this repo, and has
# the same directory-of-editions shape. It is checked for exactly the
# reason texts is: its presence is what made the earlier disappearance
# of the private corpus survive a green build, because the app came up
# healthy serving editions alone. An unchecked tree is a tree that can
# vanish silently, so it gets the same explicit count.
shopt -s nullglob
public_editions=(staging/data/editions/*/01.json)
shopt -u nullglob
[ "${#public_editions[@]}" -gt 0 ] \
|| fail "staging/data/editions has no <edition>/01.json; the public editions tree is missing or mis-shaped"

[ -f staging/data/crmedr/data/martyrology_ids.json ] \
|| fail "staging/data/crmedr/data/martyrology_ids.json is missing"

[ -f staging/data/clbdr/data/editions.json ] \
|| fail "staging/data/clbdr/data/editions.json is missing"

shopt -s nullglob
wheels=(staging/wheels/*.whl)
shopt -u nullglob
[ "${#wheels[@]}" -gt 0 ] \
|| fail "staging/wheels contains no .whl files"

echo "Staged data shape OK: ${#texts_editions[@]} text edition(s), ${#public_editions[@]} public edition(s), ${#wheels[@]} wheel(s)."

- name: Assemble bundle
id: bundle
env:
VERSION: ${{ steps.version.outputs.value }}
run: |
mkdir -p out
BUNDLE="$(python scripts/deploy/build_bundle.py \
--version "$VERSION" --api-version "$VERSION" \
--staging staging --out out --repo-root .)"
NAME="$(basename "$BUNDLE")"
# Generated from inside out/ so field 2 of the checksum file is the
# bundle's bare basename. deploy.sh does not run `sha256sum -c`: it
# parses the file, asserts field 2 equals the bundle's basename, and
# compares the digest itself (deploy.sh:163-172). A checksum written
# from the repo root would name "out/<bundle>" and be refused there.
(cd out && sha256sum "$NAME" > "$NAME.sha256")
echo "path=$BUNDLE" >> "$GITHUB_OUTPUT"
ls -la out

- name: Setup SSH
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }}
run: |
if [ -z "$VPS_SSH_KEY" ] || [ -z "$VPS_HOST_KEY" ]; then
echo "ERROR: secrets.VPS_SSH_KEY or vars.VPS_HOST_KEY is empty."
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$VPS_HOST_KEY" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts

- name: Verify the pinned host key covers the target
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
if ! ssh-keygen -F "$VPS_HOST" -f ~/.ssh/known_hosts >/dev/null; then
echo "ERROR: vars.VPS_HOST_KEY has no key for $VPS_HOST."
exit 1
fi

- name: Sanity-check the pinned key against DNS SSHFP records
continue-on-error: true
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }}
run: |
# Non-fatal drift detector, mirroring cdcf-website's deploy workflow.
# The pinned key is the trust anchor; DNS is only corroboration, so a
# mismatch warns rather than blocks (DNS may simply lag a rotation).
PIN_FPS=$(printf '%s\n' "$VPS_HOST_KEY" \
| awk '$1 ~ /^(ssh-|ecdsa-)/ || $2 ~ /^(ssh-|ecdsa-)/' \
| ssh-keygen -l -f - 2>/dev/null \
| awk '{print $2}' | sed 's/^SHA256://' | sort -u)
if [ -z "$PIN_FPS" ]; then
echo "::warning::Could not derive fingerprints from VPS_HOST_KEY; skipping drift check."
exit 0
fi
DNS_FPS=$(dig +short SSHFP "$VPS_HOST" 2>/dev/null | awk '{print toupper($3)}' | sort -u)
if [ -z "$DNS_FPS" ]; then
echo "::warning::No SSHFP records published for $VPS_HOST; skipping drift check."
exit 0
fi
for fp in $PIN_FPS; do
echo "$DNS_FPS" | grep -qi "$fp" \
|| echo "::warning::Pinned key $fp not advertised in SSHFP for $VPS_HOST. Either DNS lags reality or VPS_HOST_KEY is stale."
done

- name: Upload bundle
env:
VPS_USERNAME: ${{ secrets.VPS_USERNAME }}
VPS_HOST: ${{ secrets.VPS_HOST }}
APP_DIR: ${{ vars.APP_DIR }}
BUNDLE: ${{ steps.bundle.outputs.path }}
run: |
if [ -z "$VPS_USERNAME" ] || [ -z "$VPS_HOST" ] || [ -z "$APP_DIR" ]; then
echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty."
exit 1
fi
# scp's remote destination is not a local path: it is handed to the
# remote end as a shell word and expanded there, exactly like the
# command in the "Activate release" step below. So it needs the same
# quoting dialect — printf %q renders $APP_DIR as a single literal
# word for the remote bash, instead of letting whitespace or a shell
# metacharacter in it re-split the destination there.
REMOTE_APP_DIR="$(printf '%q' "$APP_DIR")"
for attempt in 1 2 3; do
echo "Upload attempt $attempt..."
if scp -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \
"$BUNDLE" "$BUNDLE.sha256" \
"${VPS_USERNAME}@${VPS_HOST}:${REMOTE_APP_DIR}/incoming/"; then
echo "Upload succeeded on attempt $attempt"
exit 0
fi
[ "$attempt" -lt 3 ] && echo "Retrying in 15s..." && sleep 15
done
echo "All upload attempts failed"
exit 1

- name: Activate release
env:
VPS_USERNAME: ${{ secrets.VPS_USERNAME }}
VPS_HOST: ${{ secrets.VPS_HOST }}
APP_DIR: ${{ vars.APP_DIR }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
if [ -z "$VPS_USERNAME" ] || [ -z "$VPS_HOST" ] || [ -z "$APP_DIR" ]; then
echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty."
exit 1
fi
# deploy.sh defaults APP_DIR to /opt/martyrology when it is unset, and
# nothing here used to export it — so pointing vars.APP_DIR anywhere
# else uploaded the bundle to one directory and looked for it in
# another, failing every deploy with "bundle not found". Pass it
# explicitly. printf %q quotes each value for the remote bash, so a
# path or version containing a space or a shell metacharacter is
# passed as one literal word rather than being re-split there.
REMOTE_APP_DIR="$(printf '%q' "$APP_DIR")"
REMOTE_VERSION="$(printf '%q' "$VERSION")"
ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \
"${VPS_USERNAME}@${VPS_HOST}" \
"APP_DIR=${REMOTE_APP_DIR} bash ${REMOTE_APP_DIR}/bin/deploy.sh ${REMOTE_VERSION}"

- name: Attach manifest to the release
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG" staging/manifest.json --clobber
Loading