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):
# Deploys (scripts/release.sh)scripts/release.sh # interactive: pick a DEPLOY targetscripts/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 packagesscripts/version.sh # cut semver versions + changelogs (knope)
DRY_RUN=1 scripts/release.sh … # (either tool) show what would happen; touch nothingThe 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>Principles
Section titled “Principles”- 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.shonly 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 createmakes the tag server-side (jj doesn’t author git tags, and secondary.mjolnirworkspaces have no local.git).
Tags — two independent schemes
Section titled “Tags — two independent schemes”| Tag | Created by | Purpose | Triggers a workflow? |
|---|---|---|---|
jg-testflight-<N> | release.sh jg-ios | iOS deploy (N = CFBundleVersion) | Yes — jg-testflight.yml |
jg-api-<sha> | release.sh jg-api | Worker deploy | Yes — worker-deploy.yml |
<pkg>/v<semver> | scripts/version.sh | knope 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).
jg · iOS → TestFlight
Section titled “jg · iOS → TestFlight”- Reads
CFBundleVersionfromapps/jg/src-tauri/gen/apple/project.ymlatmain@origin, increments it (App Store Connect rejects duplicate build numbers, so the bump is the release), commits ontomain, pushes. - Creates a
jg-testflight-<N>release → fires.github/workflows/jg-testflight.yml(runs onmacos-26, builds inapps/jg, uploads viaapps/jg/scripts/deploy.sh). - Restores your working copy so the release doesn’t move you off your change.
deploy.sh: one script, two modes
Section titled “deploy.sh: one script, two modes”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/.mobileprovisionfromapps/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 build → Jg.ipa → altool --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.
jg · API → Cloudflare Worker (prod)
Section titled “jg · API → Cloudflare Worker (prod)”Decoupled from iOS — never coupled to a TestFlight build.
- Tags
main@originasjg-api-<sha>(the commit is the version) → fires.github/workflows/worker-deploy.yml. - CI checks out that commit and runs
bun x wrangler deploy --env prod --config worker/wrangler.toml. - 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
CFBundleVersion28, 29, …) — bumped byrelease.sh jg-ioson every TestFlight build. Answers “which build?”. - semver (the package’s
package.json+CHANGELOG.md) — bumped byversion.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
scopesinknope.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 (withscopes) toknope.toml;version.shdiscovers it automatically and tags<name>/v*. - Pre-1.0 semantics: knope treats
featas a patch bump (0.1.0 → 0.1.1) and breaking changes as minor — the leading0absorbs one level. - Baselined “from now”:
jg/v0.1.0marks the starting point, so changelogs track forward (no back-history dump). - Don’t run
knope prepare-releasestandalone in this jj repo — it leaves bumps uncommitted + untagged. Always go throughversion.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:
| Secret | Source |
|---|---|
APPLE_API_ISSUER | 1Password: Apple creds for deploying/APPLE_API_ISSUER |
APPLE_API_KEY_ID | 1Password: Apple creds for deploying/APPLE_API_KEY_ID |
APPLE_API_KEY_P8 | contents of distribute/app-store-connect/AuthKey_<KEYID>.p8 |
IOS_CERTIFICATE | base64 of distribute/*.p12 |
IOS_CERTIFICATE_PASSWORD | 1Password: Apple creds for deploying/p12 password |
IOS_MOBILE_PROVISION | base64 of distribute/*.mobileprovision |
CLOUDFLARE_API_TOKEN | Cloudflare dashboard → API token scoped to Edit Workers (for jg-api) |
CLOUDFLARE_ACCOUNT_ID | (optional) only if the token spans multiple accounts |
R=7hoenix/arkgh 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_*.p8gh 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>"Runner
Section titled “Runner”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.
Multiple workspaces at once
Section titled “Multiple workspaces at once”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 mainrejects 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 hasconcurrency: { 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 tomainserialize 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. altoolkey discovery. The workflow writes the.p8to~/.appstoreconnect/private_keys/AuthKey_<KEYID>.p8(altool’s magic dir) AND exportsAPPLE_API_KEY_PATH.- bun workspace hoisting. Deps install at the ark root; if
make prod-assetscan’t findelm/tailwindcssin CI, that’s the first place to look. - First cold iOS build is slow (~15–25 min) until the cache warms.