Skip to content

Monorepo, release tags, beta server, nothing leaks

Status: in progress. Steps 0–8 are done (§9): the monorepo is github.com/trahfo/sailever (private, pushed 2026-10-14, worked in at ~/Workspace/sailever), every product except the Garmin apps has its CI workflow, and the new server is live at beta.sailever.com / api.sailever.com since 2026-10-17 (beta_server_next_steps.md). Open: Garmin CI, the release workflows (step 9) and the launch (step 10).

This plan: - moves the five repos into one repository with history; - gives every product its own build, test and release pipeline, so a change to the DataField never builds, tests or deploys the server; - defines release tags as the only way to start a store build (§5); - puts the new server (logbook, pairing, upload) on Oracle Cloud, built entirely with infrastructure as code, at beta.sailever.com, while today's www.sailever.com keeps running untouched (§7); - makes security the first rule: no secret may ever reach git, an image, a log or an artifact, and every push passes the same deterministic checks locally, in agent hooks and on GitHub (§6).

1. Goals and rules

  1. One commit can change spec and code together across platforms (/port-change and mise doccheck work on one tree).
  2. Each product has its own pipeline, triggered only by changes to its own files or to files it really depends on.
  3. Only the server deploys continuously: a push to main that touches the server runs its tests; the owner deploys with Kamal by starting the deploy workflow by hand (workflow_dispatch on main). Nothing else ever deploys a server.
  4. Store apps release only through a release tag (§5), never on a push.
  5. No secrets in git, images, logs, artifacts or infrastructure state. Signing keys, keystores and tokens live in a password manager (source of truth) and in GitHub repository Actions secrets (copies for CI).
  6. Defence in depth: the same checks run in git hooks, in the Claude Code hook and in CI. A check skipped locally (--no-verify) is still reported by the CI guard (GitHub Free can't block the push, §6.4).
  7. History is kept where it is clean (§2), so git log --follow works in the new paths.

2. Audit (2026-10-14)

Check Result
Secret scan of the full history, all branches, all 5 repos (gitleaks git --log-opts=--all) 16 hits, all false positives: the example tokens in the API docs and a track scenario, the commented-out example key that Devise's generator writes into devise.rb, the token alphabet in the Garmin TripStore.mc. They are allow-listed by exact value in .gitleaks.toml; with it, all five histories scan clean.
Key and credential files ever added (all histories) developer_key in the root repo (the Garmin signing key; binary, so gitleaks doesn't see it). The root repo was never pushed anywhere. Removed from history on 2026-10-14 (step 0). Nothing else: config/master.key was never committed, config/credentials*.yml.enc are encrypted.
.kamal/secrets, every version Only references ($VAR, kamal secrets fetch), never values.
Docker image .dockerignore excludes .env*, config/master.key, .git, .kamal, deploy config. Assets precompile with a dummy SECRET_KEY_BASE; RAILS_MASTER_KEY only enters at runtime.
GHCR image ghcr.io/trahfo/sailever Private (anonymous pull refused). Kamal prefixes the registry a second time: the server runs ghcr.io/ghcr.io/trahfo/sailever. Fix in the new server's config: image: trahfo/sailever-server.
www.sailever.com Runs f218b42 (deployed by GitHub Actions on 2026-06-27) = trahfo/sailever-rails origin/main. It has no login, logbook area or pairing, and no watch API (/api/v1/devices/… answers 404); those are only in 30 unpushed local main commits and in feat/web-redesign (34 more).
Server access Kamal deploys over SSH as root to a Hetzner host that also runs three other apps. A leaked CI SSH key would be root on all of them. The new server avoids this (§7).
DNS sailever.com at Namecheap (registrar DNS, email forwarding via eforward* MX). api.sailever.com and beta.sailever.com have no records.
Watch apps Garmin Config.mc and Wear OS release API_BASE_URL both point at https://api.sailever.com/api/v1 (since 2026-10-17). The published Garmin 1.0.x builds are the old public beta without pairing or upload; they only send the silent device registration to www.sailever.com/api/v1. From 1.1.0 every Garmin build pairs and uploads (Config.PUBLIC_BETA removed 2026-10-18).
GitHub push protection Server-side secret push protection needs GitHub Secret Protection, which isn't available for private repos on a personal account. mise guard (§6) replaces it.

Decision on history: keep it. The only real secret was developer_key, now removed from the root history. The key itself can't be rotated (Garmin rejects updates signed with another key); it never left this Mac. Every repo is scanned again before its first push (mise guard push does this on every push anyway).

3. Layout

sailever/                         github.com/trahfo/sailever (private)
├── apps/
│   ├── server/                   Rails 8, the new server (today sailever-rails main + feat/web-redesign)
│   ├── garmin-watchapp/          Monkey C     (today sailever-watchapp)
│   ├── garmin-datafield/         Monkey C     (today sailever-datafield)
│   ├── android/                  one Gradle build
│   │   ├── core/                 plain Kotlin: SLTB, track recording, sun/moon, settings (shared)
│   │   ├── wear/                 Wear OS watch app   (today sailever-watchapp-android/app)
│   │   └── mobile/               Android phone app   (new)
│   └── apple/                    one Xcode project
│       ├── Shared/               Swift package: SLTB, calculations (shared)
│       ├── iOS/                  iPhone app          (new)
│       └── watchOS/              Apple Watch app     (new, embedded in the iOS app)
├── infra/
│   └── oci/                      OpenTofu: Oracle Cloud network, VM, volumes, backups (§7)
├── docs/                         specs, ADRs, plans, marketing
├── tools/                        tracksim, doccheck, guard
├── tracks/                       shared simulated tracks (test fixtures for every app)
├── bin/                          setup, guard, doccheck, tracksim
├── .githooks/                    pre-commit, commit-msg, pre-push → mise guard
├── .gitleaks.toml                secret rules + allow-list of the known example values
└── .github/
    ├── workflows/                guard.yml + one workflow per product + infra.yml
    ├── CODEOWNERS
    └── dependabot.yml            bundler, gradle, github-actions, terraform

The old site (www.sailever.com, f218b42) is not imported into main. It stays in trahfo/sailever-rails with its own pipeline until the new server takes over www (§7.5); then that repo is archived.

Why Android and Apple are one build each: - Android: phone and watch share the app ID com.sailever (the Wear Data Layer only connects apps with the same ID and signing key) and one Play listing. One Gradle build with :core, :wear, :mobile shares code, version file and signing config. - Apple: an Apple Watch app is delivered inside its iOS app and reviewed with it. One Xcode project, one archive, one release.

4. What triggers what

Product Path filter (CI runs) On pull request / push On main Release
Guard (§6.3) everything secrets, forbidden files, sizes, mise doccheck same —
Server apps/server/**, docs/sltb/** quality + tests + system tests deploy to beta by workflow_dispatch on main only continuous, no tags
Infrastructure infra/** tofu fmt -check, tofu validate (no credentials) — none: tofu plan/apply run on the owner's Mac (§7.4)
Garmin WatchApp apps/garmin-watchapp/**, docs/sltb/** compile for a small and a large screen — tag (§5)
Garmin DataField apps/garmin-datafield/** compile for a small and a large screen — tag (§5)
Android (Wear + phone) apps/android/**, docs/sltb/** :core:test, assembleDebug, lint — tag (§5)
Apple (iOS + watchOS) apps/apple/**, docs/sltb/** xcodebuild test on a macOS runner — tag (§5)
Track simulator tools/tracksim/**, tracks/** pytest — —
  • Shared inputs only test, never deploy. docs/sltb/ (SLTB reference and golden vectors) runs the tests of the server and the watch apps; only the deploy job checks apps/server/**.
  • docs/specs/ triggers no app pipeline, only the guard.
  • A workflow file counts as its product's own file.
  • Every workflow has workflow_dispatch for a manual run without a commit.

5. Release tags

A release tag is the only way to start a store build. Pushing commits never releases an app. Tags are permanent: never moved, never deleted, never reused. After step 9 this section moves into the root AGENTS.md and a /release skill.

5.1 Format

<product>/v<major>.<minor>.<patch>[-beta.<n>|-rc.<n>]
Product Tag example Workflow What it builds Where it goes Then by hand
garmin-watchapp garmin-watchapp/v1.4.0 garmin-watchapp-release.yml prod .iq (prod AppID) + store images GitHub Release (private) upload in the Connect IQ developer dashboard
garmin-watchapp garmin-watchapp/v1.4.0-beta.1 same beta .iq (beta AppID) GitHub Release upload to the beta listing
garmin-datafield garmin-datafield/v1.2.2 garmin-datafield-release.yml .iq GitHub Release Connect IQ upload
wear wear/v1.0.0 android-release.yml :wear .aab, signed with the upload key Play internal testing promote to closed / production in the Play Console
android android/v1.0.0 android-release.yml :mobile .aab Play internal testing promote in the Play Console
apple apple/v1.0.0 apple-release.yml iOS archive with the embedded watchOS app TestFlight submit for review in App Store Connect

The server has no release tags: it deploys from main (§4).

5.2 Rules

  1. Tag a commit on main, after its CI is green. mise guard refuses a tag that isn't on main.
  2. The version in the tag must equal the version in the app (manifest.xml / version.properties / Xcode MARKETING_VERSION). The release workflow compares both and stops on a mismatch. Bump the version in a normal commit ([dist] bump wear to 1.0.1) first, then tag that commit.
  3. Annotated tags with release notes: git tag -a wear/v1.0.1 -m "<release notes>". The workflow uses the message for the GitHub Release and, where the store supports it, the store's "what's new" text.
  4. A failed release is fixed with the next patch version, never by moving the tag: wear/v1.0.1 failed → fix → wear/v1.0.2.
  5. Pushing a tag is a release decision. Agents never create or push release tags on their own. They prepare the version bump and the notes, then hand the exact git tag / git push commands to the user.
  6. Android version codes never collide across :wear and :mobile: versionCode = major*1_000_000 + minor*10_000 + patch*100 + formFactor (1 = wear, 2 = phone), from one version.properties.

5.3 How it is enforced

Where Check
mise guard push (pre-push hook, Claude Code hook) tag name matches §5.1; tag points to a commit on main; no tag is deleted or moved
Release workflow version in tag = version in app; the tag's commit passed CI on main; runs only on a release tag or workflow_dispatch; signing secrets are repository secrets

6. Security model

 Claude Code hook ──► git pre-commit ──► git pre-push ──► GitHub CI guard ──► deploys/releases by hand
 (mise guard agent)    (mise guard staged)  (mise guard push)  (mise guard range, reports)  (workflow_dispatch on main / tag)

6.1 Git hooks: built (step 1)

mise setup switches them on (git config core.hooksPath .githooks); mise guard doctor checks a clone. All checks live in tools/guard/guard.py, so hooks, the agent hook and CI run identical code. Without gitleaks the guard refuses to run (fails closed).

Hook Runs Blocks when
pre-commit mise guard staged gitleaks finds a secret in the staged changes; a forbidden file is staged; a file is larger than 5 MB
commit-msg mise guard msg the subject lacks a [prefix] from the multi-commit list; the message contains a secret
pre-push mise guard push the remote isn't in tools/guard/allowed-remotes.txt; any pushed commit adds a secret, a forbidden file or a file over 5 MB; main is deleted or force-pushed; a tag breaks §5

Forbidden files: tools/guard/forbidden.txt (signing keys, keystores, certificates, .env*, Rails master keys, Firebase and service-account files, SSH keys, .npmrc/.netrc/.pgpass, Terraform state and variables, database dumps). Exceptions: tools/guard/allowed-paths.txt and .gitleaks.toml, each entry with a reason, changed only in a commit of its own.

6.2 Claude Code guardrails: built (step 1)

Root .claude/settings.json: - Deny rules: agents may not read secret files (.env*, developer_key, keystores, .p8/.p12/.pem, master keys, *.tfvars, *.tfstate, ~/.config/sailever/, ~/.ssh/, ~/.oci/), skip hooks, change core.hooksPath, force-push, or touch GitHub secrets, variables or repo settings. - PreToolUse hook mise guard agent on every Bash call: blocks the same bypasses even when phrased differently, blocks git push while the hooks are off, and runs mise guard staged before git commit. - Path-aware Stop hooks (after step 3): each product's gate first runs git status --porcelain -- apps/<product> and exits at once when its product is untouched.

Gemini and Antigravity have no hook system; the git hooks and the CI guard cover them.

6.3 CI guard: the layer that can't be skipped

.github/workflows/guard.yml runs on every pull request and push, no path filter, permissions: { contents: read }: 1. mise guard range <base> <head>, the pre-push checks over all new commits; 2. weekly: gitleaks over the full history, in case the rules improve; 3. mise doccheck check.

Its job guard always runs, so a skipped local check still shows up red on GitHub. It can't block a push (no rulesets on GitHub Free, §6.4).

6.4 GitHub repository settings

GitHub Free, private repo: no rulesets, no environments. mise guard protects main and release tags locally; sensitive workflows start only by hand.

Setting Value
Account 2FA with a passkey or hardware key. Only the owner can change visibility; a ruleset can't prevent switching to public.
main and tags */v* No rulesets: mise guard push (git hook + Claude Code hook) blocks deleting or force-pushing main and moving or deleting release tags (§5.3, §6.1).
Actions Only GitHub-owned actions and an allow-list (dorny/paths-filter, ruby/setup-ruby, docker/*, gradle/actions, opentofu/setup-opentofu, google-github-actions/auth), each pinned to a full commit SHA. Default token permissions read. No pull_request_target.
Secrets No environments: every deploy, signing and cloud secret is a repository Actions secret (§6.6). Workflows that use them run only by workflow_dispatch (server deploy) or on a release tag (store releases), and refuse unless github.ref is refs/heads/main or a release tag. Fork PR workflows off.
Dependabot Alerts and security updates on; version updates for bundler, gradle, github-actions, terraform.
Code scanning CodeQL isn't available for private personal repos; Brakeman and bundler-audit (in bin/quality) cover the server.

6.5 Pipeline hygiene

  • permissions: {} at workflow level; each job asks for the minimum.
  • actions/checkout with persist-credentials: false.
  • No set -x, no env dumps; signing material goes to $RUNNER_TEMP and is deleted in an if: always() step.
  • Artifacts never contain keys; build artifacts kept 7 days, store packages attached to GitHub Releases (private).
  • OIDC instead of stored keys where the target supports it: Google Play (Workload Identity Federation). The Cloudflare Access service token for SSH stays a stored repository secret; the OpenTofu credentials never leave the owner's Mac (§7.4).
  • The server image is built with context apps/server/ only; a CI step lists the image's files and fails on any match of tools/guard/forbidden.txt.

6.6 Secrets inventory

Moved to docs/credentials.md: every secret with its password-manager entry, its GitHub secret or file, and where it is made.

6.7 If a secret gets pushed anyway

  1. Rotate first (table above). A pushed secret counts as public, even in a private repo.
  2. Rewrite history with git filter-repo; the force-push needs the owner to bypass mise guard once, by hand (agents never do).
  3. Add a rule to tools/guard/ so the same mistake can't pass again.

7. Beta server on Oracle Cloud

7.1 Why and what

The new server (logbook, pairing, upload, the web redesign) runs beside today's site, on its own Oracle Cloud Infrastructure (OCI) tenancy, built entirely with infrastructure as code. www.sailever.com keeps running on Hetzner, unchanged, until the new server takes over (§7.5).

Host name Points to Serves
www.sailever.com Hetzner (today) the current site, f218b42, untouched
beta.sailever.com OCI app VM the new web app: sign-in, logbook, pairing page
api.sailever.com OCI app VM the watch and phone API (/api/v1/…), from day one and for good

7.2 How the apps find the server: a stable API host instead of a /beta switch

Decided 2026-10-14: apps use the fixed API host api.sailever.com; the website runs at beta.sailever.com during the beta. A /beta switch on the old site (answering true or false, apps picking beta or www) was considered and dropped:

  • Both branches of the switch must end on the same server anyway. A watch paired on beta holds a device token that only the beta database knows. When the switch flips to www, www must already be the new server with the beta data, otherwise every paired watch breaks. So true and false lead to the same place, and the switch adds nothing.
  • It costs a request before every sync, on a watch whose network is slow and rationed (Garmin through the phone, Wear OS requesting a network per call), and it adds failure cases: timeout, 5xx, captive portal. What does the app do then?
  • It needs a change and a deploy of the old site on the root-SSH Hetzner host, the one host we want to touch least.
  • Old app versions stay out there forever. With the switch, every installed old version keeps asking www/beta. With a fixed API host, nothing in any app ever has to change.

With api.sailever.com: - Garmin Config.API_BASE_URL and the Wear OS release API_BASE_URL change to https://api.sailever.com/api/v1 before the next store build. No released app calls the server today (§2), so nothing breaks. - During the beta, api and beta point to the OCI VM. At launch, www moves there too (§7.5) and the apps don't notice. - If a hard switch-off of the beta API is ever needed, it's one DNS change or a maintenance flag inside the new server, not app logic.

One app, several host names. kamal-proxy accepts all of the server's host names for the same container; Rails routes by request.host:

# config/routes.rb
constraints(host: "api.sailever.com") do          # watch and phone API only
  namespace(:api) { namespace(:v1) { … } }
end
constraints(host: /\A(beta|www)\.sailever\.com\z/) do   # website: sign-in, logbook, /pair
  …
end
# after launch (§7.6), first in the file:
constraints(host: "beta.sailever.com") do
  get "(*path)", to: redirect(status: 301) { |_, req| "https://www.sailever.com#{req.fullpath}" }
end
  • The API answers 404 on beta/www, the website 404 on api. In development and tests the constraints take the hosts from config (SAILEVER_API_HOST, SAILEVER_WEB_HOST), so localhost keeps working.
  • config.hosts lists exactly these names (blocks forged Host headers). Session cookies are set for beta/www only, never for api; the API stays token-only, so no CORS and no CSRF surface on api.
  • config.action_mailer.default_url_options and every link in emails use the web host (beta now, www after launch).

Pairing address on the watch. The watches print sailever.com/pair today (Garmin PairHint, Wear OS OtherViews.kt, spec sync-and-pairing.md), and that address reaches the old site during the beta. So: - Beta builds show beta.sailever.com/pair; the text becomes a per-build constant (Garmin Config, Wear OS BuildConfig), the menu subtitle reads "Link to beta.sailever.com". Spec updated in step 8. - Builds after launch show sailever.com/pair again. - beta.sailever.com redirects to www permanently (301, path kept), so every beta build still installed on a watch keeps leading to the right pairing page. The beta DNS record and certificate are never removed. - Invitations, store listings and the beta website say beta.sailever.com/pair.

7.3 OCI resources (Always Free)

Home region eu-amsterdam-1 (tenancy created 2026-10-14; data stays in the EU; Always Free resources only exist in the home region, and the home region can't be changed later).

Resource Always Free limit Use
Ampere A1 Flex (arm64) 4 OCPU, 24 GB RAM in total 2 VMs, 1 OCPU / 6 GB each: the app VM (Rails with Kamal, kamal-proxy with Let's Encrypt) and the database VM (Postgres 16 as a Kamal accessory, TLS, private subnet only). 2 OCPU / 12 GB left for a second app server behind the free load balancer later.
Block volumes 200 GB in total, 5 backups 3 × 50 GB boot (two VMs now, a second app server later) + data volume 50 GB for Postgres on the database VM; volume backup policy (weekly)
Object Storage 20 GB nightly pg_dump, encrypted with age on the VM before upload, 30-day lifecycle; the OpenTofu state bucket (versioned, private)
VCN — public subnet (app VM) and private subnet (database VM; NAT and service gateway, no charge); network security groups: 80/443 from anywhere to the app VM, 5432 only from the app VM to the database VM, no SSH from the internet
Flexible load balancer 1, 10 Mbps later, in front of two app servers
Vault 150 secrets, software keys optional later: runtime secrets fetched by Kamal (kamal secrets fetch)
Monitoring / Notifications free tiers CPU, disk and instance-down alarms by email

Two Always Free pitfalls, both avoided by upgrading the tenancy to Pay As You Go (still free inside the limits; set a budget alarm at 1 €): - A1 capacity in Amsterdam is often "out of host capacity" for free-only accounts. - Oracle reclaims idle Always Free VMs (CPU below 20 % for 7 days). A low-traffic beta qualifies.

7.4 Infrastructure as code

  • OpenTofu (open-source Terraform) in infra/oci/ with the oracle/oci provider: compartment, VCN, subnet, security list, VM with cloud-init, block volume and backup policy, Object Storage buckets, budget and alarms. Cloudflare provider for the DNS records.
  • DNS moves from Namecheap to Cloudflare (free plan; nameserver change at Namecheap, registration stays). Namecheap's DNS API needs IP allow-listing and an account threshold, so it doesn't fit IaC. The two eforward MX records and the email forwarding are recreated in Cloudflare (Email Routing) before the switch. The Cloudflare proxy stays off for api (watches talk TLS straight to the VM); beta and www may use it.
  • State: remote, in the OCI Object Storage bucket (S3-compatible backend), versioned and private; never in git (*.tfstate and *.tfvars are forbidden files). Variables with secrets come from environment variables, not .tfvars files.
  • cloud-init (in git, no secrets): user deploy with the deploy SSH public key, Docker, cloudflared running the tunnel, unattended security upgrades, SSH password login off, root login off; on the database VM also the data volume mounted at /var/lib/sailever and the Postgres TLS files (private CA made by OpenTofu; Rails connects with sslmode=verify-full).
  • SSH only through Cloudflare Tunnels, one per VM (ssh.sailever.com for the app VM, ssh-db.sailever.com for the database VM, same Access policies). Port 22 is closed to the internet; only the app VM takes 80/443. cloudflared on the VM keeps an outbound connection to Cloudflare (tunnel sailever-beta, route ssh.sailever.com → ssh://localhost:22). A Cloudflare Access application in front of ssh.sailever.com lets in only the CI service token and the owner's email (one-time PIN). Clients connect with ProxyCommand cloudflared access ssh --hostname %h; in CI, cloudflared reads the service token from TUNNEL_SERVICE_TOKEN_ID / TUNNEL_SERVICE_TOKEN_SECRET, so no secret appears in any config file. SSH itself stays key-only for user deploy: Access and the SSH key are two independent locks.
  • Tunnel, Access application and DNS are OpenTofu resources (Cloudflare provider). The state then holds the tunnel token, so OpenTofu's client-side state encryption is on (passphrase in the password manager).
  • tofu runs on the owner's Mac only (infra/oci/README.md); plan and apply never run in CI. Later a pipeline infra.yml runs tofu fmt -check and validate (no credentials).

7.5 Deploying the new server

  • Kamal config in apps/server/config/deploy.yml:
  • image: trahfo/sailever-server (private GHCR, new package);
  • servers: [ ssh.sailever.com ], ssh: user: deploy, proxy_command: "cloudflared access ssh --hostname %h";
  • proxy: hosts: [ beta.sailever.com, api.sailever.com ], ssl: true;
  • Postgres as an accessory on the database VM (ssh-db.sailever.com) with its data on the block volume, reachable only from the app subnet over TLS.
  • arm64 images: Kamal's remote builder on the VM itself (native arm64, reached through the tunnel via ~/.ssh/config), so no QEMU emulation and no paid arm runners.
  • Repository secrets hold everything the deploy needs; the job runs only by workflow_dispatch on main. The job: install cloudflared (pinned, checksum verified) and write ~/.ssh/config with the ProxyCommand → kamal deploy → smoke test https://beta.sailever.com/up and https://api.sailever.com/up.
  • Rails runs with RAILS_ENV=production and its own credentials; Sentry environment: beta; the site shows a small "Beta" badge and robots.txt disallows indexing on beta.
  • Restore test after the first backup: restore the latest pg_dump into a throwaway container and run a row count. Repeated monthly.

7.6 Launch: the new server takes over www

When the Android apps are through review and the beta is good:

  1. Lower the DNS TTLs a day before.
  2. Move www.sailever.com (and the bare sailever.com) to the OCI VM; beta.sailever.com redirects to www with a permanent 301, path kept, for good (beta watch builds print beta.sailever.com/pair, §7.2).
  3. Point the deploy workflow and its repository secrets at production; Sentry environment production.
  4. Stop the old container on Hetzner, archive trahfo/sailever-rails (read-only), delete its secrets and its GHCR access, remove its image.

Data: the beta database is the production database from day one, so the launch moves no data. Nothing from the old site needs migrating (it has no accounts).

8. Risks

  • Hooks are local. --no-verify or a clone without core.hooksPath skips them; the CI guard repeats every check and reports it, but on GitHub Free it can't block a push.
  • Path filters on push compare against the previous push. A force-push or the first push of a branch can run every workflow. That costs minutes, never a wrong deploy: deploy only starts by hand on main.
  • Always Free changes: Oracle can change the free tier. Everything is code, so moving to another provider means a new infra/ module, not a rebuild by hand.
  • One app VM, one database VM: no redundancy during the beta (a load balancer and a second app server come later). Nightly encrypted backups plus weekly volume backups; restore tested monthly.
  • macOS minutes: Apple builds count 10× on private repos; they run only on apps/apple/** changes and release tags.
  • All-or-nothing access: someone who should see only the server needs a separate repo or mirror.

9. Steps

# Step Status / done when
0 Secrets out of history. developer_key removed from the root history (git filter-repo), kept in the password manager and ~/.config/sailever/developer_key (mode 600), git-ignored. ✅ 2026-10-14. All five histories scan clean with .gitleaks.toml.
1 Guardrails: mise guard + tools/guard/, .githooks/, .gitleaks.toml, mise setup, Claude Code deny rules and PreToolUse hook. ✅ 2026-10-14. Tested: a staged .jks, a fake AWS key, a 6 MB file, a message without prefix, a push to another remote, a tag v1, --no-verify, --force, gh secret are all blocked.
2 Settle the server branches: fast-forward local main of sailever-rails to feat/web-redesign (it builds on main; 34 commits ahead). trahfo/sailever-rails main on GitHub stays at f218b42 = www. ✅ 2026-10-14. Imported feat/web-redesign directly as apps/server; trahfo/sailever-rails is unchanged.
3 Build the monorepo with history. For each repo: fresh clone → git filter-repo --to-subdirectory-filter apps/<name> → merge with --allow-unrelated-histories. Move sailever-watchapp-android/{app,core} to apps/android/{wear,core}. Remove the gitlinks. ✅ 2026-10-14. 245 commits; an empty db/development.sqlite3 (committed in 2025) dropped from the new history; mise guard range "" main clean over the full history.
4 Fix paths: AGENTS.md files, mise doccheck code references, monkey.jungle key path (SAILEVER_DEV_KEY), release scripts, tracksim ../tracks, hooks, spec links. ✅ 2026-10-14. Android module renamed :app → :wear; per-app skill links dropped; Rails gate runs from the root. Verified: doccheck 0 errors; Garmin WatchApp and DataField compile (small + large screen); Android :core:test :wear:assembleDebug lintDebug 0 errors; Rails bin/quality --ci PASSED, bin/rails test 258 runs, 0 failures.
5 GitHub settings (§6.4): Actions allow-list, read-only token default, no fork PR workflows, Dependabot (no rulesets or environments on GitHub Free). Then the first push of main (through mise guard push). ✅ 2026-10-15. Settings as §6.4 (allow-list with SHA pinning, read-only token, no fork workflows); Dependabot on; first push 2026-10-14 through mise guard push. Rulesets and environments dropped (GitHub Free).
6 Product workflows one at a time: guard, Garmin ×2, Android, track simulator, server CI (without deploy). ✅ 2026-10-14 to 2026-10-23 except Garmin: guard.yml, server.yml, android.yml, tracksim.yml, plus tiles.yml, postgres.yml (PostGIS image) and worker.yml, each path-filtered. Open: Garmin WatchApp and DataField CI (needs the Connect IQ SDK on Linux).
7 OCI + DNS: OCI tenancy (Amsterdam ✅ created; upgrade to Pay As You Go, budget alarm), Cloudflare DNS with email routing, Cloudflare Zero Trust (service token), infra/oci/ with the tunnel and Access application, first tofu apply. ✅ 2026-10-17. Three VMs since 2026-10-20 (app, db, worker), SSH only through the tunnels, beta/api resolve to the app VM.
8 Deploy the new server to beta (§7.5); API host api.sailever.com in the Garmin and Wear OS configs; backups and restore test. ✅ 2026-10-17 (/up green on both hosts; backup restored 2026-10-18; API host in both watch apps). Open: a watch pairing and uploading on beta, recorded end to end (beta checklist 6.3).
9 Release workflows (§5) and the /release skill; move §5 into the root AGENTS.md. Tag wear/v1.0.0 produces an internal-testing build.
10 Launch (§7.6), then an ADR for the whole setup; delete this plan. www served by the new server; old repo archived.