Every job in jobs.toml and the Zendesk webhook run on
one machine, from a root-owned clone at /opt/session-ops. install.sh
does the work: accounts, venv, env files, units, timers, and migrating an older layout.
| Unit | What it is |
|---|---|
session-ops@<job>.timer → .service |
One per job with a schedule; a generated drop-in sets its account, env files and schedule. |
session-ops@<job>.path → .service |
For a job with discord = true: starts it when /run requests it. |
session-ops-queue.timer → .service |
Starts the jobs in jobs.toml's [queue], which then run one at a time in its order. |
zendesk-relay.service |
Always on, 127.0.0.1:8080: Zendesk's claude: note webhooks. |
session-ops-discord.service |
Always on, 127.0.0.1:8081: the /run and /versions slash commands. See Discord commands. |
session-ops-alert@.service |
Every unit's OnFailure= backstop; see session-ops-silence. |
session-ops list shows the jobs; session-ops run <job> [--dry-run] [-- job arguments]
runs one as its timer does. State is under /var/lib/session-ops/<job>/, copies of a
run under its runs/ for 14 days.
session-ops run does not read the job's env files: its unit loads them, as root. By
hand, give it the account and env files its drop-in sets, in
/etc/systemd/system/session-ops@<job>.service.d/job.conf:
systemd-run --pipe --wait -p User=sessionops \
-p EnvironmentFile=/etc/session-ops/store-versions.env \
-p StateDirectory=session-ops/store-versions -p StateDirectoryMode=0711 -p UMask=0077 \
/opt/session-ops/.venv/bin/session-ops run store-versions --dry-runNeeds systemd 252+, Python 3.11+ at /usr/bin/python3, git, and nginx with certbot.
As root:
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh
git clone https://github.com/session-foundation/session-shared-scripts /opt/session-ops
/opt/session-ops/deploy/install.shThen fill the env files and run install.sh again. It enables each job once its env
files have content.
cp /opt/session-ops/deploy/nginx-webhooks.conf /etc/nginx/sites-available/webhooks.session.codes
ln -s /etc/nginx/sites-available/webhooks.session.codes /etc/nginx/sites-enabled/
nginx -t && certbot --nginx --redirect -d webhooks.session.codes && nginx -t && systemctl reload nginxSeed the Crowdin duplicates once, or reconciliation refuses to run. About ten minutes, posting nothing:
systemd-run --pipe --wait -p User=crowdin -p EnvironmentFile=/etc/session-ops/crowdin.env \
-p StateDirectory=session-ops/crowdin-duplicates -p StateDirectoryMode=0711 -p UMask=0077 \
/opt/session-ops/.venv/bin/crowdin-reconcile-duplicates --seed --croql \
--state /var/lib/session-ops/crowdin-duplicates/duplicates.jsonFrom a host that ran the digests out of /opt/zendesk, install.sh copies their env
files and state over. Remove /opt/zendesk, /etc/zendesk and /var/lib/zendesk
(and the github-prs equivalents) once both digests have run from the new units.
What /run and /versions do, and who may run them:
session-ops-discord. To set them up:
-
In the Developer Portal, create an application. Put its public key, its application id, the server's id and who may run the commands in
/etc/session-ops/discord.env, then runinstall.shagain. -
Invite it with the
applications.commandsscope only:https://discord.com/oauth2/authorize?client_id=<app id>&scope=applications.commands. -
Copy the
location = /discord/interactionsblock fromnginx-webhooks.confinto the live/etc/nginx/sites-available/webhooks.session.codes, which certbot owns, thennginx -t && systemctl reload nginx. Until then the route is a 404 and Discord will not save the URL. -
Set its Interactions Endpoint URL to
https://webhooks.session.codes/discord/interactions. Discord checks it with a signed PING before saving. -
Register the commands, with the bot token from the Bot page, typed in rather than kept:
read -rs DISCORD_BOT_TOKEN && export DISCORD_BOT_TOKEN set -a && . /etc/session-ops/discord.env && set +a /opt/session-ops/.venv/bin/session-ops discord-register
-
Every member sees the commands. In Server Settings → Integrations, limit them to the channels that run jobs. Seeing them is not running them: the relay's allowlist decides.
Register again after changing which jobs have discord = true.
Each /etc/session-ops/<name>.env has a commented <name>.env.example beside it,
installed from env/, saying what goes in it.
/etc/session-ops/expiry.toml is not a secret: the expiry dates
token-expiry cannot read from an API, from expiry.toml.example.
/etc/session-ops/store-versions.toml is not a secret either, and has to be mode 644: who
store-versions tags, from store-versions.toml.example.
systemctl list-timers 'session-ops*'
systemctl start session-ops@<job>.service && journalctl -fu session-ops@<job>
# Re-run one job by its own service: starting session-ops-queue.service again re-runs
# every queued job that has already finished today.
systemctl start session-ops-alert@test.service # posts to the alerts channel
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 127.0.0.1:8080/zendesk/notes \
-H 'Content-Type: application/json' -d '{"ticket_id":"1"}' # expect 401
curl -sS -o /dev/null -w '%{http_code}\n' -X POST 127.0.0.1:8081/discord/interactions -d '{}' # expect 401
systemctl list-units 'session-ops@*.path' # one per ready job with discord = true, activeCreate /etc/session-ops/rehearsal before the first install.sh. Every job then runs
against live data without writing over production: pull requests come from
rehearsal/… branches, session-localization gets a pull request instead of a push, and
no Zendesk ticket is solved or written to. Point every webhook URL at your own
channels, set RELAY_DRY_RUN=1, and leave Zendesk's webhook on the production host.
To end it, stop the timers, then close the rehearsal pull requests:
for link in /etc/systemd/system/timers.target.wants/session-ops@*.timer; do
[ -L "$link" ] && systemctl disable --now "${link##*/}"
done
for link in /etc/systemd/system/paths.target.wants/session-ops@*.path; do
[ -L "$link" ] && systemctl disable --now "${link##*/}"
done
systemctl disable --now session-ops-queue.timer zendesk-relay.service session-ops-discord.service
for repo in session-android session-ios session-localization session-desktop-dynamic-assets \
session-desktop session-app session-website session-appium session-playwright; do
gh pr list -R "session-foundation/$repo" --state open --json number,headRefName \
-q '.[] | select(.headRefName | startswith("rehearsal/")) | .number' |
xargs -r -I{} gh pr close -R "session-foundation/$repo" {} --delete-branch
doneRemove the marker only to promote the host, once the production host is off.
git -C /opt/session-ops pull && /opt/session-ops/deploy/install.shcertbot owns the live nginx file: apply a change to nginx-webhooks.conf to
/etc/nginx/sites-available/webhooks.session.codes by hand, then
nginx -t && systemctl reload nginx.
A reboot is harmless: a killed run writes no state, and a missed schedule runs once.
Unanswered claude: notes keep claude-queued, and the PR digest reaches back to its
last full post. The Zendesk digest's window is a fixed 72 hours, so after a longer gap
re-run it with one that covers the gap:
systemd-run --pipe --wait -p User=zendesk -p EnvironmentFile=/etc/session-ops/zendesk.env \
-p UMask=0077 /opt/session-ops/.venv/bin/session-ops run zendesk-digest -- --window-hours 168