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

- **`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 Connect
- **`deploy.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](#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.

---

## 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.com](https://developer.apple.com) → **Account → 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.com](https://developer.apple.com) → **Account** → 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:
   ```sh
   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.

```sh
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.com](https://developer.apple.com) → **Account** → 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:
   ```sh
   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](https://appstoreconnect.apple.com) 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.com](https://developer.apple.com) → **Account** → 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:
   ```sh
   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](../README.md#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.com](https://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".

### Python version and the virtualenv

The server runs on the version named in [`roundup-server/.python-version`](../roundup-server/.python-version), which is the same file the four CI workflows install from and the same version [`ruff.toml`](../roundup-server/ruff.toml) targets. [Python version](../README.md#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`](../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 spot** — `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.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
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`:

- **They match** — requirements are installed into the active virtualenv through the symlink, which is what every deploy did before this was added.
- **They differ, or there is no virtualenv** — a new one is built at `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.

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

```bash
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/`:

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