Skip to content

Releasing & Versioning (Ark monorepo)

Two repo-root tools, both runnable from any jj workspace — deploys and versioning are kept separate (deploys are per-app·component; versioning is repo-level across all packages):

Terminal window
# Deploys (scripts/release.sh)
scripts/release.sh # interactive: pick a DEPLOY target
scripts/release.sh jg-ios # jg iOS → TestFlight (via CI)
scripts/release.sh jg-api # jg API → Cloudflare Worker (prod, via CI)
# Versioning (scripts/version.sh) — repo-level, all packages
scripts/version.sh # cut semver versions + changelogs (knope)
DRY_RUN=1 scripts/release.sh # (either tool) show what would happen; touch nothing

The deploy menu lists the app · component targets:

What do you want to promote? (main@origin)
# app comp current → next
1 jg iOS build 28 → 29
2 jg API deploy <main-sha>
  • Always promotes the tip of main@origin — the canonical pushed main, never your local working copy. So a release is identical from any workspace and never carries in-progress edits.
  • Everything ships through GitHub Actions. release.sh only bumps + pushes + creates the GitHub release; CI does the build/upload/deploy.
  • Tags are app-namespaced (jg-testflight-<N>, jg-api-<sha>) so each app·component releases independently. The published release fires the matching workflow, which is tag-guarded so other apps’ releases don’t trigger it.
  • gh release create makes the tag server-side (jj doesn’t author git tags, and secondary .mjolnir workspaces have no local .git).
TagCreated byPurposeTriggers a workflow?
jg-testflight-<N>release.sh jg-iosiOS deploy (N = CFBundleVersion)Yesjg-testflight.yml
jg-api-<sha>release.sh jg-apiWorker deployYesworker-deploy.yml
<pkg>/v<semver>scripts/version.shknope version marker / changelog baseline (e.g. jg/v0.2.0)No (inert)

The two schemes never collide (different patterns) and each app prefixes its own deploy tags (jg-*; trained will add trained-*). The deploy workflows are tag-guarded (if startsWith(tag, 'jg-testflight-') etc.), so a <pkg>/v* version tag is inert — it never starts a build. knope reads the latest <pkg>/v* tag per package to know where each changelog left off; that’s why cutting a version must create one (version.sh does).

  1. Reads CFBundleVersion from apps/jg/src-tauri/gen/apple/project.yml at main@origin, increments it (App Store Connect rejects duplicate build numbers, so the bump is the release), commits onto main, pushes.
  2. Creates a jg-testflight-<N> release → fires .github/workflows/jg-testflight.yml (runs on macos-26, builds in apps/jg, uploads via apps/jg/scripts/deploy.sh).
  3. Restores your working copy so the release doesn’t move you off your change.

apps/jg/scripts/deploy.sh is shared between local and CI and auto-detects:

  • Local — pulls IDs/passwords from 1Password (op read) and reads the .p8/.p12/.mobileprovision from apps/jg/distribute/ (gitignored).
  • CI — when the signing env vars are already set (IOS_CERTIFICATE + APPLE_API_ISSUER + APPLE_API_KEY_PATH), it skips 1Password/distribute/ and uses what the workflow provided.

The build+upload core (cargo tauri ios buildJg.ipaaltool --upload-app) is identical in both, plus a guard that fails the release if dist/output.css is missing/near-empty — defense against shipping an unstyled bundle.

Decoupled from iOS — never coupled to a TestFlight build.

  1. Tags main@origin as jg-api-<sha> (the commit is the version) → fires .github/workflows/worker-deploy.yml.
  2. CI checks out that commit and runs bun x wrangler deploy --env prod --config worker/wrangler.toml.
  3. Re-releasing the same commit just re-runs the deploy (via workflow_dispatch).

The Worker’s runtime secrets (SESSION_JWT_SECRET, ANTHROPIC_API_KEY, …) are set out-of-band with wrangler secret put and persist on the Worker; deploys only ship code.

Versioning — scripts/version.sh + knope (repo-level)

Section titled “Versioning — scripts/version.sh + knope (repo-level)”

Separate from deploys, and not app-specific: versioning is a repo-level operation across all packages. Each package has two version concepts:

  • build counter (iOS CFBundleVersion 28, 29, …) — bumped by release.sh jg-ios on every TestFlight build. Answers “which build?”.
  • semver (the package’s package.json + CHANGELOG.md) — bumped by version.sh. Answers “what version is this?”.

scripts/version.sh runs knope (knope.toml is the package registry + engine; bookkeeping-only) to compute each package’s next semver from the conventional commits routed to it, update its changelog + version file, then — the jj way — commits/pushes the bumps to main@origin and creates a <pkg>/v<version> tag server-side for every package that moved. Those tags are what make the next versions count forward (knope reads the latest <pkg>/v* per package).

  • Commit scoping: every package declares scopes in knope.toml, and an app/library change is scoped with that package’s name (feat(jg): add capture hint, feat(ui): …) so it lands in the right changelog. Repo-infra commits use non-package scopes (feat(ci): …, chore(release): …, chore(repo): …) so they’re excluded from every package’s changelog — and can’t accidentally bump a package.
  • Adding a package: add a [packages.<name>] block (with scopes) to knope.toml; version.sh discovers it automatically and tags <name>/v*.
  • Pre-1.0 semantics: knope treats feat as a patch bump (0.1.0 → 0.1.1) and breaking changes as minor — the leading 0 absorbs one level.
  • Baselined “from now”: jg/v0.1.0 marks the starting point, so changelogs track forward (no back-history dump).
  • Don’t run knope prepare-release standalone in this jj repo — it leaves bumps uncommitted + untagged. Always go through version.sh.
  • knope does not create GitHub releases, and <pkg>/v* tags are inert, so versioning never collides with the deploy flow.

Required GitHub Actions secrets (on 7hoenix/ark)

Section titled “Required GitHub Actions secrets (on 7hoenix/ark)”

Run these from your colocated jg checkout (~/Main/Code/jg), where distribute/ holds the real signing files. Note R=7hoenix/ark — the secrets live on the monorepo now, not 7hoenix/jg:

SecretSource
APPLE_API_ISSUER1Password: Apple creds for deploying/APPLE_API_ISSUER
APPLE_API_KEY_ID1Password: Apple creds for deploying/APPLE_API_KEY_ID
APPLE_API_KEY_P8contents of distribute/app-store-connect/AuthKey_<KEYID>.p8
IOS_CERTIFICATEbase64 of distribute/*.p12
IOS_CERTIFICATE_PASSWORD1Password: Apple creds for deploying/p12 password
IOS_MOBILE_PROVISIONbase64 of distribute/*.mobileprovision
CLOUDFLARE_API_TOKENCloudflare dashboard → API token scoped to Edit Workers (for jg-api)
CLOUDFLARE_ACCOUNT_ID(optional) only if the token spans multiple accounts
Terminal window
R=7hoenix/ark
gh secret set APPLE_API_ISSUER -R "$R" --body "$(op read 'op://Eng/Apple creds for deploying/APPLE_API_ISSUER')"
gh secret set APPLE_API_KEY_ID -R "$R" --body "$(op read 'op://Eng/Apple creds for deploying/APPLE_API_KEY_ID')"
gh secret set IOS_CERTIFICATE_PASSWORD -R "$R" --body "$(op read 'op://Eng/Apple creds for deploying/p12 password' | tr -d '\n')"
gh secret set APPLE_API_KEY_P8 -R "$R" < distribute/app-store-connect/AuthKey_*.p8
gh secret set IOS_CERTIFICATE -R "$R" --body "$(base64 -i distribute/*.p12)"
gh secret set IOS_MOBILE_PROVISION -R "$R" --body "$(base64 -i distribute/*.mobileprovision)"
gh secret set CLOUDFLARE_API_TOKEN -R "$R" --body "<token from Cloudflare dashboard>"

iOS builds on GitHub-hosted macos-26 (Xcode 26 / iOS 26 SDK — Apple now mandates it for App Store Connect uploads). Worker deploys on ubuntu-latest. macOS minutes bill at 10×; cargo + iOS-target caching (keyed on Xcode version + iOS config) blunts repeat-run cost.

release.sh always operates on the tip of main@origin (fetch → act → push), never local state, so it behaves identically from any jj workspace — the colocated checkout or any .mjolnir/workspaces/… agent workspace.

  • Different projects (e.g. jg + trained releasing together): fully independent — tags are app-prefixed, each app has its own workflow + concurrency: group, and version files are separate. No interference.
  • Same project, concurrent (two workspaces both releasing jg): serialized at two points. (1) jj git push --bookmark main rejects a non-fast-forward, so only the first writer lands; the second fails loudly — re-running re-fetches and picks up the next build/version number. No silent collision. (2) Each deploy workflow has concurrency: { group: jg-testflight, cancel-in-progress: false }, so two builds queue rather than race the same build number on a runner.
  • Concurrent feature PRs: each branch is its own CI run (concurrency: ci-${{ github.ref }}); merges to main serialize on GitHub.

The failure mode for a same-project race is “the second release errors on push, you re-run it” — safe, just expect the retry.

Known first-run risks (can’t be verified without secrets + a macOS runner)

Section titled “Known first-run risks (can’t be verified without secrets + a macOS runner)”
  • Keychain/provisioning on a clean runner. Tauri’s iOS build imports the cert (IOS_CERTIFICATE/_PASSWORD) and installs the profile (IOS_MOBILE_PROVISION) from those env vars. If signing fails on the first run, add an explicit keychain-import step before the build.
  • altool key discovery. The workflow writes the .p8 to ~/.appstoreconnect/private_keys/AuthKey_<KEYID>.p8 (altool’s magic dir) AND exports APPLE_API_KEY_PATH.
  • bun workspace hoisting. Deps install at the ark root; if make prod-assets can’t find elm/tailwindcss in CI, that’s the first place to look.
  • First cold iOS build is slow (~15–25 min) until the cache warms.