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¶
- One commit can change spec and code together across platforms (
/port-changeandmise doccheckwork on one tree). - Each product has its own pipeline, triggered only by changes to its own files or to files it really depends on.
- Only the server deploys continuously: a push to
mainthat touches the server runs its tests; the owner deploys with Kamal by starting the deploy workflow by hand (workflow_dispatchonmain). Nothing else ever deploys a server. - Store apps release only through a release tag (§5), never on a push.
- 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).
- 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). - History is kept where it is clean (§2), so
git log --followworks 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 checksapps/server/**. docs/specs/triggers no app pipeline, only the guard.- A workflow file counts as its product's own file.
- Every workflow has
workflow_dispatchfor 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¶
- Tag a commit on
main, after its CI is green.mise guardrefuses a tag that isn't onmain. - The version in the tag must equal the version in the app (
manifest.xml/version.properties/ XcodeMARKETING_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. - 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. - A failed release is fixed with the next patch version, never by moving the tag:
wear/v1.0.1failed → fix →wear/v1.0.2. - 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 pushcommands to the user. - Android version codes never collide across
:wearand:mobile:versionCode = major*1_000_000 + minor*10_000 + patch*100 + formFactor(1 = wear, 2 = phone), from oneversion.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/checkoutwithpersist-credentials: false.- No
set -x, noenvdumps; signing material goes to$RUNNER_TEMPand is deleted in anif: 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 oftools/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¶
- Rotate first (table above). A pushed secret counts as public, even in a private repo.
- Rewrite history with
git filter-repo; the force-push needs the owner to bypassmise guardonce, by hand (agents never do). - 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,wwwmust already be the new server with the beta data, otherwise every paired watch breaks. Sotrueandfalselead 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 onapi. In development and tests the constraints take the hosts from config (SAILEVER_API_HOST,SAILEVER_WEB_HOST), solocalhostkeeps working. config.hostslists exactly these names (blocks forgedHostheaders). Session cookies are set forbeta/wwwonly, never forapi; the API stays token-only, so no CORS and no CSRF surface onapi.config.action_mailer.default_url_optionsand every link in emails use the web host (betanow,wwwafter 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 theoracle/ociprovider: compartment, VCN, subnet, security list, VM withcloud-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
eforwardMX records and the email forwarding are recreated in Cloudflare (Email Routing) before the switch. The Cloudflare proxy stays off forapi(watches talk TLS straight to the VM);betaandwwwmay use it. - State: remote, in the OCI Object Storage bucket (S3-compatible backend), versioned and private; never in git (
*.tfstateand*.tfvarsare forbidden files). Variables with secrets come from environment variables, not.tfvarsfiles. cloud-init(in git, no secrets): userdeploywith the deploy SSH public key, Docker,cloudflaredrunning the tunnel, unattended security upgrades, SSH password login off, root login off; on the database VM also the data volume mounted at/var/lib/saileverand the Postgres TLS files (private CA made by OpenTofu; Rails connects withsslmode=verify-full).- SSH only through Cloudflare Tunnels, one per VM (
ssh.sailever.comfor the app VM,ssh-db.sailever.comfor the database VM, same Access policies). Port 22 is closed to the internet; only the app VM takes 80/443.cloudflaredon the VM keeps an outbound connection to Cloudflare (tunnelsailever-beta, routessh.sailever.com→ssh://localhost:22). A Cloudflare Access application in front ofssh.sailever.comlets in only the CI service token and the owner's email (one-time PIN). Clients connect withProxyCommand cloudflared access ssh --hostname %h; in CI,cloudflaredreads the service token fromTUNNEL_SERVICE_TOKEN_ID/TUNNEL_SERVICE_TOKEN_SECRET, so no secret appears in any config file. SSH itself stays key-only for userdeploy: 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);planandapplynever run in CI. Later a pipelineinfra.ymlrunstofu fmt -checkandvalidate(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_dispatchonmain. The job: installcloudflared(pinned, checksum verified) and write~/.ssh/configwith theProxyCommand→kamal deploy→ smoke testhttps://beta.sailever.com/upandhttps://api.sailever.com/up. - Rails runs with
RAILS_ENV=productionand its own credentials; Sentryenvironment: beta; the site shows a small "Beta" badge androbots.txtdisallows indexing onbeta. - Restore test after the first backup: restore the latest
pg_dumpinto 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:
- Lower the DNS TTLs a day before.
- Move
www.sailever.com(and the baresailever.com) to the OCI VM;beta.sailever.comredirects towwwwith a permanent 301, path kept, for good (beta watch builds printbeta.sailever.com/pair, §7.2). - Point the deploy workflow and its repository secrets at
production; Sentry environmentproduction. - 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-verifyor a clone withoutcore.hooksPathskips them; the CI guard repeats every check and reports it, but on GitHub Free it can't block a push. - Path filters on
pushcompare 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 onmain. - 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. |