DailyRoundup

CI/CD Setup

DailyRoundup uses three GitHub Actions workflows that need secrets: two for building and releasing the iOS app, and one for deploying the sync server.

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.


Required Secrets

APPLE_TEAM_ID

Your 10-character Apple Developer team identifier. Also required by the build check workflow.

Where to find it: Log in at developer.apple.comAccount → Membership details. The Team ID is listed there (e.g. 6P7796G469).


DISTRIBUTION_CERTIFICATE_P12

Your 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:

  1. Go to developer.apple.comAccount → click Certificates, IDs & Profiles under Program resources.
  2. Click Certificates in the left sidebar, then click the + button next to the “Certificates” heading.
  3. On the “Create a New Certificate” screen, the page lists many certificate types across Software and Services sections. Under Software (near the top), select Apple Distribution — it is the second option — and click Continue.
  4. Generate a Certificate Signing Request (CSR) from your Mac:
    • Open Keychain Access → menu bar → Keychain Access → Certificate Assistant → Request a Certificate From a Certificate Authority
    • Enter your email address, select Saved to disk, click Continue, and save the .certSigningRequest file.
  5. Upload the .certSigningRequest file in the browser and click Continue.
  6. Download the generated .cer file and double-click it to install it into Keychain Access.
  7. In Keychain Access → My Certificates, find Apple Distribution: … (it should have a private key disclosure triangle).
  8. Right-click → Export → save as distribution.p12, and set a strong password.
  9. Base64-encode it and copy to clipboard:
    base64 -i distribution.p12 | pbcopy
    
  10. Paste the result as the secret value.

DISTRIBUTION_CERTIFICATE_PASSWORD

The password you set when exporting the .p12 file above.


KEYCHAIN_PASSWORD

A 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_PROFILE

The 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:

  1. Go to developer.apple.comAccount → click Certificates, IDs & Profiles under Program resources.
  2. Click Profiles in the left sidebar. If no profiles exist yet, click Generate a profile; otherwise click the + button next to the “Profiles” heading.
  3. On the “Register a New Provisioning Profile” screen, scroll to the Distribution section, select App Store Connect, and click Continue.
  4. On the “Select an App ID” screen, choose XC dev dcwalker DailyRoundup (6P7796G469.dev.dcwalker.DailyRoundup) from the dropdown and click Continue.

    If the next screen says “No Certificates are available”, stop here and complete the DISTRIBUTION_CERTIFICATE_P12 steps above first, then return to this step.

  5. Select your Apple Distribution certificate and click Continue.
  6. Name the profile exactly DailyRoundup AppStore — this must match the value in DailyRoundup/ExportOptions.plist. Click Generate.
  7. Download the .mobileprovision file.
  8. Base64-encode it and copy to clipboard:
    base64 -i DailyRoundup_AppStore.mobileprovision | pbcopy
    
  9. Paste the result as the secret value.

ASC_API_KEY_ID

The Key ID of your App Store Connect API key.

Where to find it:

  1. Log in to App Store Connect and click Users and Access in the top navigation bar.
  2. Click the Integrations tab (in the secondary tab bar below “Users and Access”).
  3. Click App Store Connect API in the left sidebar.
  4. Click the + button to create a new key. Give it a name (e.g. GitHub Actions) and select the Developer role, then click Generate.
  5. The Key ID is shown in the key list (e.g. ABC1234DEF).

ASC_ISSUER_ID

The 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_P8

The private key downloaded when creating the App Store Connect API key above.

How to obtain it:

  1. When you create the API key, download the .p8 file immediately — it can only be downloaded once.
  2. Copy the full file contents as the secret value, including the header and footer lines:
    -----BEGIN PRIVATE KEY-----
    ...
    -----END PRIVATE KEY-----
    

macOS Release

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_PROFILE

The 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:

  1. Go to developer.apple.comAccount → click Certificates, IDs & Profiles under Program resources.
  2. Click Profiles in the left sidebar, then click the + button next to the “Profiles” heading.
  3. On the “Register a New Provisioning Profile” screen, scroll to the Distribution section, select App Store Connect, and click Continue.
  4. On the “Select an App ID” screen, a Profile Type radio appears with Mac and Mac Catalyst options. Select Mac Catalyst — DailyRoundup is a Mac Catalyst app, not a native Mac app, and choosing the wrong type will cause a signing failure at export time. Then choose XC dev dcwalker DailyRoundup (6P7796G469.dev.dcwalker.DailyRoundup) from the App ID dropdown and click Continue.

    If the next screen says “No Certificates are available”, stop here and complete the MAC_DISTRIBUTION_CERTIFICATE_P12 steps above first, then return to this step.

  5. Select your Apple Distribution certificate and click Continue.
  6. Name the profile exactly DailyRoundup Mac AppStore — this must match the value in DailyRoundup/ExportOptions-macOS.plist. Click Generate.
  7. Download the .provisionprofile file.
  8. Base64-encode it and copy to clipboard:
    base64 -i DailyRoundup_Mac_AppStore.provisionprofile | pbcopy
    
  9. Paste the result as the secret value.

Sync Server Deployment

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 main and 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.

Connection

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

Application environment

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_ID

The 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.comAPIs & 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”.

Python version and the virtualenv

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.

Where the interpreter comes from

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:

  1. python3.12 on PATH — whatever the host already offers, usually the distro’s build. Preferred because it needs no download and no uv.
  2. An interpreter uv already knows about, so a host that has been through this once does not reach the network again.
  3. A uv-managed build it installs on the spotuv 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.sh and 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.

Layout on the server

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.

What a deploy does

The step compares the required version to the interpreter inside roundup-server/venv:

.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.

Rolling back

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"

Reverting the change itself

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.

Two limits

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.