Release

A release ships up to four channels: Homebrew (notarized app zip + CLI tarball on https://minuta.tools/downloads/, plus the ramil-k/homebrew-minuta tap), TestFlight iOS, Mac App Store (TestFlight for Mac) and the site (minuta.tools, whose /downloads/version.json drives the in-app update check). scripts/release.sh runs all of it in order; every step is also a script of its own.

Each script prints its usage with --help (the comment block at its top).

One command

scripts/release.sh --app 1.0.6              # app only, CLI keeps its version
scripts/release.sh --app 1.0.6 --cli 0.1.4  # app and CLI
FlagEffect
--app X.Y.ZRequired. New MARKETING_VERSION; must be greater than the current one.
--build NNew CURRENT_PROJECT_VERSION. Default: current + 1. Must be greater than the current one.
--cli X.Y.ZNew CLI version (must be greater than the current one). Without it the CLI keeps its version and, when that equals the tap formula’s, is not rebuilt (see CLI tarball rule).
--only brew,site,ios,macRelease only these channels (comma-separated).
--skip ios,...Release all channels except these. Mutually exclusive with --only.
--no-ui-testsSkip both UI-test runs (Shared and MinutaTests still run).
--no-testsSkip all tests.
--no-pushNo git push of this repo’s main at the end. The tap commit is still pushed by brew-tap: the site deploys right after it and must not advertise a version whose cask is unpublished.
--dry-runBuild everything, publish nothing; see Dry run.
--resumeContinue a failed run, skipping completed steps; also accepts a tree that already carries --app without a recorded bump. See Resume.
--yes, -yDo not ask for confirmation after the plan. Required when stdin is not a terminal.
--preflight-onlyRun the read-only checks, print the plan and exit.
--no-fetchSkip git fetch origin main in preflight; the behind-origin check uses the last fetched origin/main.

After preflight the script prints the plan (versions, channels, tests, rough time per step, log directory) and asks Proceed? [y/N]. At the end it prints a table of steps with status and duration.

Steps

StepWhat it doesRun on its own
preflightRead-only checks (below), prints the plan. Always reruns, also on --resume.scripts/release.sh --app X.Y.Z --preflight-only
testsTest gate: Shared, MinutaTests on iPhone 17 simulator and Catalyst, UI tests Mac first, then iPhone. Stops at the first failure.scripts/release/test-gate.sh
bumpRewrites MARKETING_VERSION / CURRENT_PROJECT_VERSION in all three targets of Minuta/project.yml (and the CLI version in Shared/Sources/MinutaCLI/Minuta.swift with --cli), runs xcodegen generate, verifies all six build configurations in project.pbxproj carry the new values and none the old. Does not commit.scripts/release/bump-version.sh --app X.Y.Z
commitCommit release X.Y.Z with the version files and the screenshots the UI tests regenerated. Refuses if anything else changed. Skipped in dry runs.(orchestrator only)
brew-buildNotarized Homebrew artifacts in dist/; MINUTA_SKIP_CLI=1 when the CLI version equals the tap formula’s.scripts/release-brew.sh
brew-uploadrsync dist/ to the server, then fetch every file back from https://minuta.tools/downloads/ and require HTTP 200 and the same sha256.scripts/upload-brew-artifacts.sh
brew-tapPoints the cask (and the formula, if a new CLI was built) at the uploaded artifacts, brew audit, commit minuta X / cli Y in the tap, push over SSH (also with --no-push).scripts/release/bump-tap.sh
iosArchive MinutaArchive for generic/platform=iOS with TestFlight.xcconfig, signing checks, export + upload with ExportOptions-ios.plist.scripts/release/testflight.sh ios
macSame for generic/platform=macOS,variant=Mac Catalyst with TestFlight-mac.xcconfig and ExportOptions-mac.plist.scripts/release/testflight.sh mac
siteminuta.tools/deploy.sh (pnpm build + rsync), then checks that the live version.json reports the new version and Minuta-X.Y.Z.zip answers 200.scripts/release/deploy-site.sh
pushgit push origin main.

Preflight checks

  • the requested versions are greater than the current ones (or, on resume, the tree already carries them; see Resume);
  • branch main, clean working tree (on --resume only a warning), not behind origin/main after git fetch;
  • tools on PATH: xcodegen, pnpm, brew, jq, xcodebuild, swift;
  • the xcconfig of each selected signed channel: TestFlight.xcconfig (ios), TestFlight-mac.xcconfig (mac), DeveloperID.xcconfig (brew);
  • for brew: the minuta-notary notarytool profile answers notarytool history, a Developer ID Application identity of team G86VRDBMDK is in the keychain, the tap checkout exists and is clean, git ls-remote origin works in the tap without prompting; reads the formula’s CLI version to decide whether the CLI is rebuilt;
  • for ios or mac: an Apple Distribution identity of team G86VRDBMDK in security find-identity -v -p codesigning;
  • for mac: the installer identity named by installerSigningCertificate in Minuta/ExportOptions-mac.plist, in security find-identity -v (installer identities are not code-signing identities, so -p codesigning does not list them);
  • for ios and mac: every provisioning profile named under provisioningProfiles in Minuta/ExportOptions-ios.plist / ExportOptions-mac.plist is installed and not expired, matched by its Name (decoded with security cms -D) in ~/Library/MobileDevice/Provisioning Profiles/ or ~/Library/Developer/Xcode/UserData/Provisioning Profiles/;
  • for brew or site: ssh root@universal.ramilkarimov.me works non-interactively (BatchMode);
  • when the push step will run (no --no-push, no --dry-run): git ls-remote origin works for this repo without prompting.

The git ls-remote checks run with GIT_TERMINAL_PROMPT=0 and ssh -o BatchMode=yes, so a missing key or an HTTPS remote that wants credentials fails instead of prompting.

Every failed check prints FAIL with a hint; the run stops after the whole list.

The Apple ID signed into Xcode cannot be checked reliably; for ios and mac the plan prints a reminder instead, and a missing login fails later, in the upload of the ios or mac step.

Test gate options

scripts/release/test-gate.sh takes --no-ui-tests, --no-mac-ui, --no-ios-ui, --no-unit (skips Shared and both MinutaTests runs) and --no-tests. The orchestrator passes only --no-ui-tests and --no-tests through. UI tests go through Minuta/scripts/run-uitests.sh; its shared /tmp/uitest-output.log is copied to ui-mac-xcodebuild.log / ui-ios-xcodebuild.log right after each run. Before the Mac run it warns when NSFontPanelAttributes is set for tools.minuta.app (see failures).

Other step-script options

  • bump-tap.sh --tap PATH --dist PATH --no-push --check: --check rewrites the files in the given tap and prints the diff only (no pull, no network, no audit, no commit), meant for a copy of the tap. brew audit runs only when the tap is the installed one (it takes tap names, not paths).
  • testflight.sh ios|mac --no-upload: stop after the archive and the signing checks.
  • deploy-site.sh --expect X.Y.Z: version version.json must report (default: MARKETING_VERSION from Minuta/project.yml).
  • release-brew.sh env: MINUTA_SKIP_CLI=1, MINUTA_CLI_CACHE=0, MINUTA_NOTARIZE=0 (forces the unsigned fallback, never upload its output), NOTARY_PROFILE (default minuta-notary).

Prerequisites on the release machine

  • Untracked xcconfigs in Minuta/ (holder-specific signing, never committed, never moved into project.yml):
    • TestFlight.xcconfig (iOS): template in TESTFLIGHT-ORG.md section 4 and Publishing to TestFlight section 4;
    • TestFlight-mac.xcconfig (Mac App Store): TESTFLIGHT-ORG.md section 8 and 702 section 8;
    • DeveloperID.xcconfig (Homebrew): Mac distribution plan.
  • notarytool keychain profile minuta-notary: xcrun notarytool store-credentials minuta-notary ... (setup in 919).
  • Certificates in the login keychain, each with its private key: Developer ID Application (Homebrew), Apple Distribution (TestFlight iOS and Mac), Mac installer certificate (3rd Party Mac Developer Installer, the full name is in Minuta/ExportOptions-mac.plist), plus the AppStore / MacAppStore provisioning profiles.
  • Apple ID signed into Xcode (Settings > Accounts) with access to the holder’s App Store Connect team: -exportArchive uploads with it. If ASC_KEY_PATH, ASC_KEY_ID and ASC_ISSUER_ID are all set, testflight.sh passes an App Store Connect API key instead (-authenticationKeyPath / -authenticationKeyID / -authenticationKeyIssuerID). No such key is available yet.
  • SSH: key for root@universal.ramilkarimov.me loaded in ssh-agent (preflight uses BatchMode, so a passphrase prompt counts as failure); the tap checkout at $(brew --repository)/Library/Taps/ramil-k/homebrew-minuta must have an SSH remote (git@github.com:ramil-k/homebrew-minuta.git), an HTTPS remote cannot push non-interactively; the same goes for this repo’s origin.
  • Tools: xcodegen, pnpm, brew, jq (plus Xcode). The tap must be tapped (brew tap ramil-k/minuta). If brew audit refuses the third-party tap, run brew trust ramil-k/minuta once.

Channel order

brew-build → brew-upload → brew-tap → ios → mac → site → push.

  • Artifacts before the tap. bump-tap.sh requires every artifact URL it is about to reference to answer 200 before rewriting anything.
  • Artifacts before the site. The site build reads MARKETING_VERSION from Minuta/project.yml, so after the bump it always advertises the new version in /downloads/version.json and links Minuta-X.Y.Z.zip, whether or not that file exists on the server. Installed Mac apps with the update check enabled read version.json and send users to a download that would 404. The orchestrator therefore refuses the site step until brew-upload has completed in this release; with brew not selected it requires Minuta-X.Y.Z.zip to be served already. Note that nginx answers a missing file under /downloads/ with the site’s HTML page and status 200, so the scripts treat a text/html answer as missing.
  • Push last. A push to main starts CI on the self-hosted runner, which runs on the same Mac (see CI).

CLI tarball rule

The CLI tarball minuta-cli-X.Y.Z.tar.gz is built and uploaded only when the CLI version changes. A rebuilt tarball under the same name gets a new sha256 (timestamps, signature); uploading it would break the published formula, whose sha256 no longer matches.

  • The orchestrator compares the target CLI version (--cli, or the current one) with the version in the tap’s Formula/minuta.rb. Equal: release-brew.sh runs with MINUTA_SKIP_CLI=1.
  • MINUTA_SKIP_CLI=1: no CLI build, no tarball in dist/; dist/SHA256SUMS gets the CLI line copied from the formula so the server copy stays complete. Fails if the source CLI version differs from the formula’s (the site would link a tarball that does not exist).
  • bump-tap.sh updates the formula only when dist/ holds a tarball with a new version, and stops with an error if dist/ holds a tarball with the formula’s version but a different sha256.
  • Cache of the notarized CLI: a notarized tarball is stored in build/release-cache/ under the CLI version and a hash of the git index entries of Shared/, scripts/cli-completions/ and scripts/generate-cli-manuals.sh, so a rerun reuses it instead of notarizing again. The cache is neither read nor written while those paths have uncommitted changes. MINUTA_CLI_CACHE=0 ignores it.
  • Building the CLI regenerates scripts/cli-completions/; if that changes tracked files, the orchestrator warns at the end that the tree is not clean. Commit them if intended.

Dry run

--dry-run runs preflight, the tests, the bump, release-brew.sh and both archives with their signing checks (testflight.sh --no-upload), and skips the commit, brew-upload, brew-tap, the App Store Connect uploads, the site deploy and the push. Keep in mind:

  • Preflight is the same as for a real run (clean tree, SSH, certificates, notary profile).
  • release-brew.sh notarizes for real: the dry run submits to Apple and fills dist/ with notarized artifacts of a version that is never committed. Do not upload them by hand.
  • Before the bump it copies the files the bump can touch (Minuta/project.yml, Shared/Sources/MinutaCLI/Minuta.swift and the files of Minuta/Minuta.xcodeproj except xcuserdata) to build/release/X.Y.Z-dry-run/bump-snapshot/, and after the bump records the sha256 of what it wrote. At the end it restores only the files the bump changed, and only while they still hold exactly what the bump wrote; a file changed since then is left as is with a warning (the pre-bump copy stays in the snapshot directory). Files the bump created are removed under the same condition. There is no blanket git checkout, so unrelated changes in a resumed dry run survive.
  • It leaves the regenerated screenshots as they are (reports their count), warns about any other change, and deletes its state directory (logs stay; the snapshot stays only when a file could not be restored).
  • State and logs live in build/release/X.Y.Z-dry-run/, separate from a real run of the same version. A failed dry run can be continued with --dry-run --resume.

Resume

Each completed step writes build/release/X.Y.Z/state/STEP.done (its duration in seconds). With --resume the orchestrator reruns preflight and skips every step that has a marker. Values resolved on the first run (build number, CLI version, versions before the bump) are pinned in state/*.value; passing a different --build or --cli on resume is an error. A dirty working tree is only a warning on resume.

If no bump is recorded (the state directory was removed, or the bump and commit were done by hand) but the tree already carries exactly the requested --app version, --resume adopts it: the build number is read from Minuta/project.yml (a --build or pinned build that differs is a preflight failure), a given --cli must equal the CLI version in the tree, and bump is marked done. commit is marked done too when the version files do not differ from HEAD; otherwise the commit step commits them. The remaining steps then run as usual. In a dry run an adopted bump is not undone at the end, unless the dry run itself made it (a snapshot from an interrupted bump exists).

Without --resume, a run whose state directory already has completed steps refuses to start (“continue it with —resume, or remove build/release/X.Y.Z to start over”), and a run whose tree already carries the requested version fails preflight with the matching --resume command.

Logs and failures

  • Logs: build/release/X.Y.Z/logs/ (dry runs: build/release/X.Y.Z-dry-run/logs/). One STEP.log per orchestrator step; the step scripts add their own logs next to it: shared.log, unit-ios.log + unit-ios.xcresult, unit-mac.*, ui-mac.log + ui-mac-xcodebuild.log, ui-ios.*, testflight-ios-archive.log, testflight-ios-export.log, testflight-mac-*, site-deploy.log.
  • Run on their own, the step scripts log to build/release/CURRENT/logs/ (the app version in project.yml at that moment) unless RELEASE_LOG_DIR is set. release-brew.sh and upload-brew-artifacts.sh print to the terminal.
  • Outputs: dist/ (Homebrew artifacts + SHA256SUMS), build/testflight/ios/ and build/testflight/mac/ (archive and export; each platform cleans only its own directory), build/release-cache/ (notarized CLI).
  • Every step runs with stdin from /dev/null, so anything that would prompt fails instead of hanging. Success is the command’s own exit code; nothing is decided by grep in a pipeline.

On failure the orchestrator prints error: step 'NAME' failed (exit N), the log path and its last 40 lines, then the step table and:

error: release step 'NAME' failed
    log:    build/release/X.Y.Z/logs/NAME.log
    resume: scripts/release.sh --app X.Y.Z ... --resume

Fix the cause and run the printed resume command. For a failure inside a step script (for example tests), the tail of the outer log shows the inner step’s own log path. The commit step runs inside the orchestrator and has no log file: its error is printed right above the step table, followed by the same resume line.

Typical failures

SymptomCause / fix
stdin is not a terminal; pass --yesRunning without a TTY; add --yes.
Preflight HEAD is behind origin/main / git fetch origin main failedgit pull --ff-only; for a fetch problem retry or use --no-fetch.
Preflight Minuta/....xcconfig missingCreate it from the template (see prerequisites).
Preflight notarytool keychain profile minuta-notary missing or invalidxcrun notarytool store-credentials minuta-notary ....
Preflight ssh ... failed (BatchMode)Load the key into ssh-agent, check the host.
Preflight tap checkout not foundbrew tap ramil-k/minuta.
Preflight no 'Apple Distribution' identity / installer identity ... not in the keychainImport the holder’s certificate with its private key (TESTFLIGHT-ORG.md).
Preflight provisioning profile ... not installed / has expiredDownload the profile (Xcode, Settings > Accounts > Download Manual Profiles, or the developer portal).
Preflight git ls-remote origin failedThe remote must be SSH with the key loaded in ssh-agent.
Preflight the tree is at X.Y.Z (N), but build M was requested on --resumeThe tree already carries the version; drop --build (or remove the stale state directory).
ui-mac fails broadly with “unable to find hit point”Environmental, not the app: a phantom Fonts panel restored from NSFontPanelAttributes (defaults delete tools.minuta.app NSFontPanelAttributes), or someone used the Mac during the run. Read the failure screenshots in the xcresult first.
expected 3 MARKETING_VERSION and CURRENT_PROJECT_VERSION entriesA target was added or removed in project.yml; update APP_TARGET_COUNT in scripts/release/lib.sh.
unexpected changes in the working tree, not committingSomething other than version files and screenshots changed after preflight; inspect, then resume.
get-task-allow entitlement presentAdd CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO to DeveloperID.xcconfig.
... is not signed with Developer IDStale DerivedData: rm -rf Minuta/build.
notarization of ... failedThe printed notary log names the offending file.
codesign “bundle format unrecognized” on MinutaTray.bundleThe tray copy: in project.yml must be destination: plugins without subpath (a subpath built from CONTENTS_FOLDER_PATH nests a second Minuta.app).
Homebrew app build fails to sign MinutaWidgetsIt must build the MinutaArchive scheme, not Minuta.
brew-upload: answered HTTP ... (HTML fallback page: file missing) or sha mismatchThe server does not serve dist/ as uploaded; check the rsync output and nginx for /downloads/.
dist/minuta-cli-... was rebuilt under the formula's version with a new sha256Bump the CLI version, or restore the previous tarball on the server (sha in the error).
brew audit ... failed; tap changes revertedRead the audit output; if Homebrew refuses the third-party tap, brew trust ramil-k/minuta.
git push failed in the tapRemote must be SSH. The commit is kept; --resume reruns brew-tap, which pushes it.
signing check(s) failed in ios / macWrong xcconfig or profiles; see 702 troubleshooting.
exportArchive exited 0 but its log has no 'Upload succeeded'Read testflight-PLATFORM-export.log; usually Xcode account or agreement issues.
“Build already exists” from App Store ConnectThe build number was used; release again with a higher --build.
Site step silent for a long timeAVIF re-encoding, see durations; check with sample PID for av1_* frames before killing it.
version.json reports appVersion ..., expected ...Deploy went through but the live manifest differs (cache, wrong tree); inspect site-deploy.log.

Durations

StepRough time
tests (full)about 50 min; the UI tests alone about 40 min, and they take over the screen (do not use the Mac meanwhile)
tests (--no-ui-tests)about 10 min
brew-buildabout 15 min; notarization takes a few minutes per submission (app, plus CLI when it is rebuilt)
ios, macabout 10 min each; App Store Connect then processes the build for 5-30 min
siteabout 5 min normally; after a full UI-test run every screenshot changed, so the build recomputes the AVIF cache: 20-60 min of silent work at high CPU, not a hang
pushseconds; then CI on the self-hosted runner (the same Mac) runs the build, the unit tests and the simulator UI tests; the Catalyst UI tests run only on a manual run, so the screen stays free