|
| 1 | +# Releasing FigyTerm |
| 2 | + |
| 3 | +How to cut a release, what the pipeline does, and how to fix it when it breaks. |
| 4 | + |
| 5 | +Related: [`UPDATE-SYSTEM.md`](./UPDATE-SYSTEM.md) covers the in-app update system this |
| 6 | +pipeline feeds. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## Cutting a release |
| 11 | + |
| 12 | +```bash |
| 13 | +git tag v0.0.7 |
| 14 | +git push origin v0.0.7 |
| 15 | +``` |
| 16 | + |
| 17 | +That's it. The tag drives everything — the version in `package.json`, |
| 18 | +`tauri.conf.json` and `Cargo.toml` is rewritten from it at build time, so those |
| 19 | +committed values don't need to be correct. |
| 20 | + |
| 21 | +Tags must be `vMAJOR.MINOR.PATCH` with an optional prerelease suffix |
| 22 | +(`v1.0.0-beta.1`). Anything with a suffix ships as a GitHub prerelease and is |
| 23 | +excluded from the updater's "latest" endpoint. |
| 24 | + |
| 25 | +To rebuild an existing tag, use **Actions → Release → Run workflow** and enter the |
| 26 | +tag. The workflow is re-run safe: it PATCHes the existing release rather than |
| 27 | +creating a duplicate, and replaces `latest.json` instead of colliding with it. |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## What the pipeline does |
| 32 | + |
| 33 | +``` |
| 34 | +create-release → build-macos (aarch64 + x64) → updater-manifest → publish-release |
| 35 | +``` |
| 36 | + |
| 37 | +| Job | Does | |
| 38 | +|-----|------| |
| 39 | +| `create-release` | Validates the tag, renders release notes, creates a **draft** release. Runs once so the body isn't raced by the build matrix. | |
| 40 | +| `build-macos` | Builds and bundles both architectures, uploads `.dmg` + `.app.tar.gz` + `.sig` to the draft. | |
| 41 | +| `updater-manifest` | Composes `latest.json` from both architectures' signatures and uploads it. | |
| 42 | +| `publish-release` | Undrafts. This is what makes the release visible and updatable. | |
| 43 | + |
| 44 | +The release stays a **draft** until every job succeeds. A failure part-way leaves a |
| 45 | +draft release with partial assets — see [Recovering](#recovering-from-a-failed-run). |
| 46 | + |
| 47 | +--- |
| 48 | + |
| 49 | +## Required secrets |
| 50 | + |
| 51 | +Only one: |
| 52 | + |
| 53 | +| Secret | Value | |
| 54 | +|--------|-------| |
| 55 | +| `TAURI_SIGNING_PRIVATE_KEY` | Full contents of `~/.tauri/figyterm.key`, both lines including the `untrusted comment:` line. | |
| 56 | + |
| 57 | +Set it with `gh secret set TAURI_SIGNING_PRIVATE_KEY < ~/.tauri/figyterm.key`, or paste |
| 58 | +it at **Settings → Secrets and variables → Actions → New repository secret**. |
| 59 | + |
| 60 | +### Why there is no `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secret |
| 61 | + |
| 62 | +The key was generated without a password, and **GitHub's web UI won't save a secret |
| 63 | +with an empty value**. You don't need to: `${{ secrets.MISSING }}` evaluates to an |
| 64 | +empty string, so the workflow sets the variable to `""` either way. Tauri reads that |
| 65 | +as "no password" and proceeds. |
| 66 | + |
| 67 | +What matters is that the variable is *set*, not that the secret exists — an unset |
| 68 | +variable can make Tauri prompt for a password and hang the job. The workflow always |
| 69 | +sets it. |
| 70 | + |
| 71 | +If you later switch to a password-protected key, just add the secret. No workflow |
| 72 | +change needed. |
| 73 | + |
| 74 | +### Backing up the signing key |
| 75 | + |
| 76 | +`~/.tauri/figyterm.key` has no recovery path. GitHub secrets are write-only, so you |
| 77 | +cannot read it back out of Actions. If you lose the local copy and the machine, every |
| 78 | +installed client stops accepting updates permanently and every user has to reinstall |
| 79 | +by hand. Put it in a password manager. |
| 80 | + |
| 81 | +--- |
| 82 | + |
| 83 | +## Troubleshooting |
| 84 | + |
| 85 | +### `failed to run command security import: failed to import keychain certificate` |
| 86 | + |
| 87 | +``` |
| 88 | +security: SecKeychainItemImport: One or more parameters passed to a function were not valid. |
| 89 | +failed to bundle project: failed codesign application: failed to run command |
| 90 | +security import: failed to import keychain certificate |
| 91 | +``` |
| 92 | + |
| 93 | +**Cause:** an `APPLE_CERTIFICATE` env var is set to an empty string. A referenced |
| 94 | +secret that doesn't exist still *defines* the variable, and the Tauri bundler checks |
| 95 | +whether `APPLE_CERTIFICATE` is **present**, not whether it's non-empty. Seeing it |
| 96 | +present, it tries to import a certificate from an empty value and fails. |
| 97 | + |
| 98 | +**Fix:** don't reference Apple signing secrets at all until you actually have them. |
| 99 | +The `Build Tauri app` step must have no `APPLE_*` variables in its `env:` block. |
| 100 | + |
| 101 | +This is the one asymmetry worth remembering: for `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, |
| 102 | +present-and-empty is exactly right. For `APPLE_CERTIFICATE`, present-and-empty is |
| 103 | +fatal. "Wire it up now, add the secret later" works for the first and breaks the |
| 104 | +second. |
| 105 | + |
| 106 | +### `FigyTerm_<version>_<arch>.app.tar.gz.sig is missing from the release` |
| 107 | + |
| 108 | +**Cause:** `TAURI_SIGNING_PRIVATE_KEY` isn't set, so the bundler produced no updater |
| 109 | +signature. |
| 110 | + |
| 111 | +**Fix:** add the secret, then re-run the workflow for that tag. |
| 112 | + |
| 113 | +This check is deliberate. Without it the release would publish successfully with a |
| 114 | +`latest.json` that never matches anything, and the auto-updater would silently do |
| 115 | +nothing for every user — a failure you'd only discover much later. |
| 116 | + |
| 117 | +### DMG bundling fails locally with `error running bundle_dmg.sh` |
| 118 | + |
| 119 | +**Cause:** a volume named `FigyTerm` is already mounted — usually a previously |
| 120 | +downloaded FigyTerm `.dmg` still open in Finder. The bundler can't create its own |
| 121 | +volume under that name. |
| 122 | + |
| 123 | +**Fix:** eject it (`ls /Volumes`, then `hdiutil detach /Volumes/FigyTerm`), or skip |
| 124 | +DMG bundling while iterating: |
| 125 | + |
| 126 | +```bash |
| 127 | +npm run tauri build -- --bundles app |
| 128 | +``` |
| 129 | + |
| 130 | +CI is unaffected — runners have no stray mounts. |
| 131 | + |
| 132 | +### Verifying a signature locally |
| 133 | + |
| 134 | +To confirm a build's signature matches the public key in `tauri.conf.json` — i.e. |
| 135 | +that installed clients will actually accept it: |
| 136 | + |
| 137 | +```bash |
| 138 | +python3 -c " |
| 139 | +import base64, json |
| 140 | +sig_text = base64.b64decode(open('src-tauri/target/release/bundle/macos/FigyTerm.app.tar.gz.sig').read()).decode() |
| 141 | +sig = base64.b64decode(sig_text.splitlines()[1]) |
| 142 | +pub_text = base64.b64decode(json.load(open('src-tauri/tauri.conf.json'))['plugins']['updater']['pubkey']).decode() |
| 143 | +pub = base64.b64decode(pub_text.splitlines()[1]) |
| 144 | +print('MATCH' if sig[2:10] == pub[2:10] else 'MISMATCH — clients would reject this update')" |
| 145 | +``` |
| 146 | + |
| 147 | +Build with the key in the environment first: |
| 148 | + |
| 149 | +```bash |
| 150 | +TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/figyterm.key)" \ |
| 151 | +TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" \ |
| 152 | +npm run tauri build -- --bundles app |
| 153 | +``` |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Recovering from a failed run |
| 158 | + |
| 159 | +A failed run leaves a **draft** release holding whatever assets did upload. Nothing is |
| 160 | +public yet, so there's no rush. |
| 161 | + |
| 162 | +**Same tag, after fixing the workflow:** push the fix, then **Actions → Release → Run |
| 163 | +workflow** with the tag. It reuses the draft, refreshes the notes, and re-uploads. |
| 164 | +Stale assets from the failed attempt are replaced. |
| 165 | + |
| 166 | +Note the re-run builds from the **tag's** commit, not your branch — the checkout uses |
| 167 | +`ref: <tag>`. A workflow fix on `dev` won't be picked up until the tag points at it. |
| 168 | +Either move the tag: |
| 169 | + |
| 170 | +```bash |
| 171 | +git tag -f v0.0.7 && git push -f origin v0.0.7 |
| 172 | +``` |
| 173 | + |
| 174 | +or, cleaner, abandon the tag and cut the next one. |
| 175 | + |
| 176 | +**Starting over:** delete the draft release in the GitHub UI, then |
| 177 | +`git push --delete origin v0.0.7 && git tag -d v0.0.7`. |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +## Enabling notarization later |
| 182 | + |
| 183 | +Currently out of scope — an Apple Developer ID is $99/yr and there's no budget. See |
| 184 | +[`UPDATE-SYSTEM.md` §7.4](./UPDATE-SYSTEM.md). Recorded here so the door stays open. |
| 185 | + |
| 186 | +Nothing else depends on it: the update system works unsigned, and notarization only |
| 187 | +improves *first* install (no `xattr`, no "unidentified developer", no "damaged and |
| 188 | +can't be opened"). |
| 189 | + |
| 190 | +When there is a Developer ID, add these secrets **and** the matching `env:` block to |
| 191 | +the `Build Tauri app` step — both together, never the block alone: |
| 192 | + |
| 193 | +```yaml |
| 194 | + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} |
| 195 | + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} |
| 196 | + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} |
| 197 | + APPLE_ID: ${{ secrets.APPLE_ID }} |
| 198 | + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} |
| 199 | + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} |
| 200 | +``` |
| 201 | +
|
| 202 | +`APPLE_PASSWORD` is an app-specific password, not the Apple ID password. |
0 commit comments