DailyRoundup uses three GitHub Actions workflows that need secrets: two for building and releasing the iOS app, and one for deploying the sync server.
build-check.yml — builds for iOS Simulator (with tests) and macOS (build-only) to verify the app compiles cleanly. Triggered by adding the check-build label to a PR or by manual dispatch from the Actions tab. A lightweight path-check job skips the expensive macOS runners if no iOS/macOS source files were changed. The check-build label is automatically removed after the workflow starts so it can be re-added for subsequent runs.release.yml — runs on every merge to main that touches iOS app files; archives the app, exports an IPA, and uploads it to App Store Connectdeploy.yml — runs on every merge to main that touches roundup-server/**; deploys the sync server over SSH and writes its environment. See Sync Server Deployment below.The release workflow requires nine secrets configured at Settings → Secrets and variables → Actions in the GitHub repository. The distribution certificate, keychain password, and App Store Connect API credentials are shared between the iOS and macOS jobs.
APPLE_TEAM_IDYour 10-character Apple Developer team identifier. Also required by the build check workflow.
Where to find it: Log in at developer.apple.com → Account → Membership details. The Team ID is listed there (e.g. 6P7796G469).
DISTRIBUTION_CERTIFICATE_P12Your Apple Distribution certificate exported as a Base64-encoded .p12 file.
Note: This requires an Apple Distribution certificate, which is separate from the Apple Development certificate used for local builds. Create it first if it doesn’t exist before attempting to create the provisioning profile — the profile creation step requires it.
How to obtain it:
.certSigningRequest file..certSigningRequest file in the browser and click Continue..cer file and double-click it to install it into Keychain Access.distribution.p12, and set a strong password.base64 -i distribution.p12 | pbcopy
DISTRIBUTION_CERTIFICATE_PASSWORDThe password you set when exporting the .p12 file above.
KEYCHAIN_PASSWORDA password used to create a temporary keychain on the CI runner. Use any strong random string — this keychain is deleted after the build completes.
openssl rand -base64 32 | pbcopy
PROVISIONING_PROFILEThe App Store distribution provisioning profile for dev.dcwalker.DailyRoundup, Base64-encoded.
Prerequisites: The DISTRIBUTION_CERTIFICATE_P12 step above must be completed first — the Apple Distribution certificate must exist on developer.apple.com before the profile can be created.
How to obtain it:
If the next screen says “No Certificates are available”, stop here and complete the
DISTRIBUTION_CERTIFICATE_P12steps above first, then return to this step.
DailyRoundup AppStore — this must match the value in DailyRoundup/ExportOptions.plist. Click Generate..mobileprovision file.base64 -i DailyRoundup_AppStore.mobileprovision | pbcopy
ASC_API_KEY_IDThe Key ID of your App Store Connect API key.
Where to find it:
GitHub Actions) and select the Developer role, then click Generate.ABC1234DEF).ASC_ISSUER_IDThe Issuer ID associated with your App Store Connect API keys.
Where to find it: On the same App Store Connect API page (same steps 1–3 above), the Issuer ID is shown at the top of the page as a UUID (e.g. xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
ASC_API_KEY_P8The private key downloaded when creating the App Store Connect API key above.
How to obtain it:
.p8 file immediately — it can only be downloaded once.-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
The release.yml workflow includes a macOS job that archives, exports, and uploads the Mac Catalyst build to App Store Connect. It shares the DISTRIBUTION_CERTIFICATE_P12, DISTRIBUTION_CERTIFICATE_PASSWORD, and KEYCHAIN_PASSWORD secrets with the iOS job and requires one additional secret.
Note: Mac Catalyst apps use the same Apple Distribution certificate as iOS. If your certificate has Platform: All (visible on developer.apple.com → Certificates), no additional certificate setup is needed for the macOS job.
MAC_PROVISIONING_PROFILEThe Mac Catalyst App Store distribution provisioning profile for dev.dcwalker.DailyRoundup, Base64-encoded.
Prerequisites: The MAC_DISTRIBUTION_CERTIFICATE_P12 step above must be completed first — the Apple Distribution certificate must exist on developer.apple.com before the profile can be created.
How to obtain it:
If the next screen says “No Certificates are available”, stop here and complete the
MAC_DISTRIBUTION_CERTIFICATE_P12steps above first, then return to this step.
DailyRoundup Mac AppStore — this must match the value in DailyRoundup/ExportOptions-macOS.plist. Click Generate..provisionprofile file.base64 -i DailyRoundup_Mac_AppStore.provisionprofile | pbcopy
deploy.yml connects to the server over SSH and, in its Write environment config step, regenerates roundup-server/.env from the secrets below.
That file is rewritten in full on every deploy. A value set by hand on the server survives until the next merge to
mainand is then silently dropped. Anything the server needs belongs here, not in a shell session.
Set these at Settings → Secrets and variables → Actions, the same place as the release secrets above.
| Secret | Required | Description |
|---|---|---|
SSH_HOST |
Yes | Hostname or IP of the sync server |
SSH_USER |
Yes | SSH user; also determines the deploy path, /home/<user>/code/dailyroundup |
SSH_PRIVATE_KEY |
Yes | Private key authorized for that user |
SSH_PORT |
No | Defaults to 22 |
Each becomes a line in .env. The server’s own documentation for what they do is the Environment Variables table.
| Secret | Required | Notes |
|---|---|---|
DAILYROUNDUP_BOOTSTRAP_TOKEN |
Yes | Guards account creation |
DAILYROUNDUP_WEBHOOK_URL |
Yes | Public HTTPS URL for the Trello webhook callback |
DAILYROUNDUP_DB_PATH |
No | Written only when set |
DAILYROUNDUP_GOOGLE_CLIENT_ID |
For calendar syncing | See below |
APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_KEY |
For push | All three together; APNS_BUNDLE_ID is written literally by the workflow |
APNS_USE_SANDBOX |
No | Written only when set |
HEALTHCHECK_TOKEN |
No | Written only when set |
DD_AGENT_HOST and DD_DOGSTATSD_PORT are not secrets and are written literally by the workflow.
DAILYROUNDUP_GOOGLE_CLIENT_IDThe OAuth client ID the app authorizes Google accounts with. Calendar syncing cannot refresh access tokens without it: a refresh token is bound to the client that obtained it, so the server has to present the same one.
It must match GoogleOAuthClientID in DailyRoundup/Info.plist exactly. It is not a secret in the cryptographic sense — it is a public iOS client ID already shipped inside the app binary — but it belongs here so that deploys stay reproducible.
Where to find it: the value in DailyRoundup/Info.plist, or console.cloud.google.com → APIs & Services → Credentials → the iOS OAuth 2.0 Client ID.
The consent screen must be in Published status, not Testing. A Testing-status app issues refresh tokens that expire after seven days, which breaks unattended server-side syncing — and it fails later, looking like a revoked grant rather than a configuration choice.
When this secret is absent the deploy still succeeds and logs a warning: every other feature works, and calendar syncing is opt-in. The server then answers the destination picker with error_code: server_misconfigured, which the app surfaces as “this server is not configured for Google calendar access”.
The server runs on the version named in roundup-server/.python-version, which is the same file the four CI workflows install from and the same version ruff.toml targets. Python version in the README lists every reader and the check that keeps them in step. The file holds a major.minor version only — 3.12, not 3.12.4. That is the granularity everything here works at: the interpreter lookup, the venvs/pyX.Y directories below, and the agreement between CI and the server. Patch level is left to whatever the host and the runner each provide.
The Pull code and update dependencies step reads the required version out of the fetched commit with git show, before git reset --hard, and resolves an interpreter for it then. A host that has no usable one gets one installed; if even that fails, the deploy stops there, with the checkout and the virtualenv still the ones the running service was started from — nothing the running service depends on has changed, and the currently-deployed commit keeps serving. (A failed attempt may still have left uv and a partial download under the deploy user’s home; none of it is reachable from the service.) That ordering is deliberate: resolving after the reset would leave the new code on disk against the old virtualenv, and because the unit sets Restart=on-failure, any later crash or reboot would start that new code on the old interpreter with no deploy running at all.
It closes that one case, not the whole window. Once the reset has run, the new commit is on disk against the old interpreter until Activate rebuilt virtualenv — across the migration, drain, systemd and nginx steps. If one of those fails, or the host reboots, the service can still come up on the old interpreter with the new code. Re-running the deploy is the fix; the steps are idempotent.
scripts/ensure-python.sh provides it, so moving the version needs no preparation on the host. Given 3.12 it works down this list and prints the first interpreter that is both the right version and able to build a virtualenv:
python3.12 on PATH — whatever the host already offers, usually the distro’s build. Preferred because it needs no download and no uv.uv python install 3.12, which fetches a prebuilt CPython in seconds rather than compiling one.Debian is the reason step 3 exists. It packages one Python per release — bookworm has 3.11 and nothing else, trixie jumps to 3.13 — so apt-get install python3.12 is not available on the deployed host, and there is no PPA for it as there is on Ubuntu.
The check at each step is deliberately “can this build a virtualenv”, not just “is this the right version”: Debian ships the standard library’s venv module separately as python3.N-venv, so a system interpreter can be the right version and still be unable to do the one thing the deploy needs. Such an interpreter is skipped rather than fatal, and the uv path is used instead.
What the host needs: outbound HTTPS. uv is fetched from
astral.shand its interpreters from GitHub. A platform with no prebuilt CPython is the one case this cannot solve, and it fails saying so — 64-bit ARM and x86-64 are covered, 32-bit ARM may not be.
Two things about this deserve stating plainly rather than being inferred from the code.
What the version pin does and does not buy. UV_VERSION in the script is pinned rather than floating, so a deploy cannot silently pick up a new uv. That is a reproducibility control, not an integrity one: the URL is mutable and no digest is verified, so it is no defence against a substituted or compromised installer — checking a per-architecture sha256 published with the release would be, and is not done here. The pin also says nothing about a host that already has uv, which is used as found. And because uv carries its own list of available CPython builds, the pin bounds which Python versions can be installed at all: a version newer than the pinned uv knows about needs UV_VERSION raised first, which the failure message says.
What “no root” does and does not mean. The script needs no privileges and writes only under the deploy user’s home — ~/.local/bin for uv, ~/.local/share/uv for interpreters, ~/.cache/uv — so removing all of it is deleting those directories rather than unpicking system packages. That is a statement about cleanup, not about blast radius. The deploy user holds passwordless sudo, which later steps use to install systemd units, write the nginx snippet and restart services, so anything running as that user — this installer included — is already inside the deploy’s trust boundary. Fetching a script over the network and piping it to a shell here is a real supply-chain step, and the pin is the only control on it.
To provision a host ahead of time, or to check one, run the script directly — it is idempotent and prints the path it resolved:
bash scripts/ensure-python.sh 3.12
Do not delete a uv-managed interpreter that a virtualenv was built from — uv python uninstall and uv python upgrade both can: the virtualenv references it, and removing it breaks the service. If it does happen, the next deploy repairs it. A dangling venv/bin/python reads as “no interpreter”, which triggers a rebuild, which reinstalls it; the alternating target names described below are what keep that rebuild from clearing the running virtualenv on its way.
Each interpreter gets its own directory under roundup-server/venvs/, and roundup-server/venv — the path in the systemd unit’s ExecStart — is a symlink naming the active one:
roundup-server/
├── venv -> venvs/py3.12
└── venvs/
└── py3.12/
The indirection is not cosmetic. python -m venv writes an absolute shebang into every console script under bin/, so a virtualenv built at one path and renamed into place has a venv/bin/gunicorn pointing at an interpreter that no longer exists — while venv/bin/python, which is a symlink, keeps working. That combination fails silently: every version check the deploy makes would pass, and only the ExecStart the service actually runs would fail, with 203/EXEC. Building each virtualenv at the path it will be used from, and switching a symlink rather than moving a directory, avoids the problem entirely.
The first deploy under this scheme moves the existing hand-made venv directory to venvs/py<its version> and puts the symlink in its place. That one directory is a special case worth knowing when debugging on the box: it was built at the venv path, so its console scripts’ shebangs still name venv, and running them by their own path (venvs/py3.11/bin/gunicorn) silently gets whichever interpreter the symlink currently points at. Reach it by repointing the symlink, not by calling into the directory. Every directory built after this is self-contained.
The step compares the required version to the interpreter inside roundup-server/venv:
venvs/py<version>, and its path is written to roundup-server/.venv-pending. The Run database migrations and Drain in-flight sync jobs steps run against that directory, since it holds this commit’s dependencies. Activate rebuilt virtualenv then checks it really is the expected version and repoints the venv symlink at it, recording what it replaced in roundup-server/.venv-previous..venv-pending is deleted at the start of every deploy. Without that, a run that built a virtualenv and then failed before activating it would leave one behind for a later deploy — including one rolling the version back — to run migrations against and then activate.
A rebuild never builds over the directory the symlink currently points at. A rebuild is not always a version change: a virtualenv whose interpreter has been removed reports no version, which triggers one with the version unchanged, and the obvious target would then be the running virtualenv itself — clearing it before building would take the service down with nothing recorded to roll back to, since .venv-previous is only written at activation. So venvs/pyX.Y and venvs/pyX.Y.alt are used alternately, whichever is not active being the one built into. The loser is pruned once the health check passes, so this settles at one directory rather than accumulating.
Repointing the symlink does not disturb the running process: it was exec’d through venv/bin/gunicorn, whose shebang names venvs/pyX.Y/bin/python3 directly, so its sys.path is a real directory the switch never touches. The exception is the first deploy off the original hand-made venv directory, whose sys.path is the venv path itself; for that one deploy, between this step and the end of Restart service — a separate SSH session and a few systemctl calls — any module the live process has not already imported raises. That is why the switch is placed as late as the step order allows.
The superseded virtualenv is deleted by Remove superseded virtualenv, which runs only after the health check passes. A deploy that fails at the health check therefore leaves it on disk, named in .venv-previous, and rolling back is repointing the symlink:
cd ~/code/dailyroundup/roundup-server
test -s .venv-previous || { echo "nothing recorded to roll back to"; exit 1; }
ln -sfn "$(cat .venv-previous)" venv.swap && mv -T venv.swap venv
sudo systemctl restart dailyroundup@"$USER"
The guard matters: a deploy that failed before Activate rebuilt virtualenv never wrote that file, and without it the ln fails, the mv is skipped, and the restart runs against the interpreter you meant to leave behind.
This rollback does not stick. The deploy triggers on any push to main touching roundup-server/**, so the next such merge rebuilds and re-activates the interpreter you just backed out of. Revert roundup-server/.python-version as well, or the rollback lasts until the next server change.
It also restores the interpreter, not the code — git reset --hard ran in the first step. If the old interpreter cannot run the newly deployed commit, reset the checkout to the previous commit before restarting.
If the venv symlink is missing or broken — a run killed partway through the switch — the next deploy repairs it: Activate rebuilt virtualenv creates the symlink from scratch when it finds nothing to preserve. To fix it by hand, point it at a directory under venvs/:
cd ~/code/dailyroundup/roundup-server
ls venvs/
ln -sfn venvs/py3.12 venv.swap && mv -T venv.swap venv
sudo systemctl restart dailyroundup@"$USER"
Deploying a commit from before .python-version existed is handled rather than blocked: the step logs a warning and falls back to installing requirements into the active virtualenv, leaving its interpreter alone. CI’s Check Python version declarations job fails if the file goes missing from a commit that should have it, so this only happens on a deliberate revert.
CI and the server agree only as far as the last successful deploy: between merging a change to .python-version and the deploy that acts on it, CI is ahead of the server, and nothing reports the deployed interpreter back to CI. And the agreement is major.minor — the runner installs the newest 3.12.x it has while the host builds from whatever python3.12 it has, so patch-level differences between them stay invisible.