Files
TZGyn-skill/SKILL.md
T

721 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: TZGyn-skill
description: Use proactively for ANY development task in TZGyn's working directories — starting a new project in any language (Go, Rust, TypeScript, not just Swift), cloning/studying/porting a third-party repo (reference material goes in .reference/, never the working root), working in an existing TZGyn repo, creating Gitea repos, deploying via Coolify, publishing to the TZGyn Homebrew tap (casks for GUI apps, formulae for CLIs), signing and notarizing macOS builds, cutting releases, or extending this skill. Load it at the start of any dev session on this machine even when the task doesn't mention Gitea, Homebrew, or macOS.
---
# TZGyn dev skill
Personal conventions for TZGyn projects. Source of truth is the Git repo
`https://gitea.tzgyn.dev/TZGyn/TZGyn-skill`; it is installed as a global Crush
skill at `~/.agents/skills/TZGyn-skill` (a symlink to the checkout, so editing
the repo updates the live skill).
The reference implementations to copy from are `~/Git/kapture` and
`~/Git/konference` (macOS menu-bar apps); for iOS, `~/Git/koins/apps/ios`.
## Ground rules
- **Applies from the first command of any session**, in any TZGyn working
directory — before any clone, scaffold, or build. Third-party repos cloned
to study, port, or compare against go straight into `.reference/` (section
2); waiting until the project is a git repo is too late.
- **Host**: Gitea at `https://gitea.tzgyn.dev`, owner `TZGyn`, `tea` login name
`gitea.tzgyn.dev`. **SSH is on port 22222** (web/API stay on 443):
`ssh://git@gitea.tzgyn.dev:22222/TZGyn/<repo>.git`. Add this to
`~/.ssh/config` so the scp-style URL works without a port:
```sshconfig
Host gitea.tzgyn.dev
Port 22222
User git
```
Then `git@gitea.tzgyn.dev:TZGyn/<repo>.git` is enough. HTTPS remains
`https://gitea.tzgyn.dev/TZGyn/<repo>.git`.
- **Ask for the domain.** If a service needs a public hostname, ask for the
exact domain/subdomain **before** scaffolding or deploying. Never assume
`<service>.tzgyn.dev`, a wildcard, or an existing name. Add/verify the DNS
record, configure it in Coolify's `domains` (HTTP) or the TCP passthrough
labels, and record it in README and AGENTS.
- **Push-to-deploy**: once a repo's Gitea webhook is connected to its Coolify
app, `git push origin main` is the deployment. Do not run a manual deploy for
normal changes; use one only for retries, rollbacks, label/env-only changes,
or webhook failures. See sections 8–9.
- **Always print the upgrade script** (the `brew` commands below) in the final
answer after any release or version bump. The user wants it every time.
- **Every macOS GUI release must be notarized and stapled.** An unnotarized
build is not a release: never publish one, and never paper over Gatekeeper
with `xattr -dr com.apple.quarantine` or `brew install --cask --no-quarantine`.
If notarization fails, fix the cause (see
[Troubleshooting](#notarization-troubleshooting)) before publishing. (iOS
apps are not notarized — Apple has no such step; see section 6.)
- Never commit build artifacts. `.gitignore` covers `.build/`, `*.app/`,
`dist/`, `.swiftpm/`, `xcuserdata/`, `DerivedData/`, `.DS_Store`.
(`go build ./...` drops a binary next to the source — ignore it explicitly.)
- Never commit secrets (`.p8`, API keys) or push to a remote unless asked.
- **Reference material goes in `.reference/`, never in the repo.** Every project
that is modelled on another app or repo gets a `.reference/` folder with a
`.gitignore` containing `*` — see [Reference material](#2-reference-material-reference)
for the exact shape and what to put in it.
- Commit messages: concise, why-focused, first line < 72 chars; add a body only
when it helps a future reader.
- Prefer SwiftPM (Swift/SwiftUI) for macOS apps; no external dependencies.
## 1. New project on Gitea
```bash
# 1. Create the repo (as the authenticated user).
tea repos create --name <repo> --desc "<one-line description>" --login gitea.tzgyn.dev
# Do NOT pass --owner TZGyn: that hits /orgs/TZGyn/repos and 404s for a user.
# 2. Initialise and push.
cd ~/Git/<repo>
git init -b main
git add -A && git commit -m "<why>"
# HTTPS remote (works with a token/deploy key):
git remote add origin https://gitea.tzgyn.dev/TZGyn/<repo>.git
# or SSH (port 22222 via ~/.ssh/config; explicit form shown in section 9):
# git remote add origin git@gitea.tzgyn.dev:TZGyn/<repo>.git
git push -u origin main
```
Scaffold every repo with `README.md`, `AGENTS.md` (architecture + commands +
gotchas), `.gitignore`, and `.reference/` (see below). See
`~/Git/simpletopbar` for the minimal shape: `Package.swift`,
`Sources/<Name>/*.swift`, `build-app.sh`, `release.sh`, `scripts/`.
## 2. Reference material (`.reference/`)
Apps and repos that a project is modelled on are research input, not source
code. Keep them in an untracked `.reference/` folder at the repo root so they
are available to every session without ever entering the repository:
```bash
mkdir -p ~/Git/<repo>/.reference
printf '*\n' > ~/Git/<repo>/.reference/.gitignore
```
- The `.gitignore` **inside** `.reference/` contains a single `*`. Ignore the
whole folder there, not in the repo's root `.gitignore`: the folder is then
self-contained and stays untracked even if the root ignore file is rewritten.
- **Any repo cloned to study, port, or compare against is reference material.**
When asked to "check out this repo" (e.g. to reimplement it in another
language), create `.reference/` and its `.gitignore` as the very first step
and clone straight into it — never into the working root, even when the
project is not a git repo yet.
- Clone reference repos inside it:
`git clone --depth 1 <url> .reference/<name>`. Do not vendor them into
`Sources/` or `vendor/`.
- Write one short note per reference (`tangerine.md`, `filee.md`) covering: what
the app does, its interaction model, the formats/features worth matching, and
what we deliberately skip. Notes are untracked too — that is fine; they are
for the next session, not for the public repo.
- Check licences before reusing code. MIT/Apache-2.0 code can be adapted with
attribution; never copy assets, icons, or code from a closed app.
- `.reference/` is never committed, never released, and never required to build.
## 3. Homebrew tap
- The tap clone lives at **`~/.cache/homebrew-tap`**
(origin `https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git`).
- Layout: **GUI apps → `Casks/<name>.rb`**; **CLI tools → `Formula/<name>.rb`**.
- After pushing, sync Homebrew's own clone:
```bash
git -C ~/.cache/homebrew-tap add -A
git -C ~/.cache/homebrew-tap commit -m "Add <name> cask"
git -C ~/.cache/homebrew-tap push
git -C /opt/homebrew/Library/Taps/tzgyn/homebrew-tap pull --ff-only
brew audit --cask tzgyn/tap/<name> # or: brew audit --formula tzgyn/tap/<name>
```
- Users tap once (non-GitHub taps need the full URL):
```bash
brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew trust TZGyn/tap
```
### Cask (GUI app)
```ruby
cask "<name>" do
version "X.Y.Z"
sha256 "<sha256 of the release zip>"
url "https://gitea.tzgyn.dev/TZGyn/<repo>/releases/download/v#{version}/<Asset>.zip"
name "<Display Name>"
desc "<one line>"
homepage "https://gitea.tzgyn.dev/TZGyn/<repo>"
app "<Name>.app"
end
```
### Formula (CLI)
```ruby
class <Name> < Formula
desc "<one line>"
homepage "https://gitea.tzgyn.dev/TZGyn/<repo>"
url "https://gitea.tzgyn.dev/TZGyn/<repo>/releases/download/v0.1.0/<asset>-0.1.0-darwin-arm64.tar.gz"
sha256 "<sha256 of the tarball>"
version "0.1.0"
def install
bin.install "<binary>"
end
end
```
## 4. Releasing a macOS GUI app
Two scripts per repo: `build-app.sh <version>` and `release.sh <version>`.
1. **Build + sign** (`build-app.sh`): `swift build -c release` → handwritten
`Info.plist` (NOT generated from `Package.swift`) → `make-icon.sh` → sign
with the Developer ID identity resolved from the login keychain
(`security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2;exit}'`),
falling back to ad-hoc (`-`). GUI apps set `LSUIElement = true` and
`CFBundleIdentifier = dev.tzgyn.<name>`.
2. **Notarize + staple + zip** (`release.sh`): build → `ditto -c -k --keepParent
<App>.app dist/<App>.zip` → submit to Apple → staple → re-zip → print sha256.
See [Notarization](#5-notarization) for the credentials.
3. **Publish the release** and attach the zip:
```bash
tea releases create --tag v<version> --title "<App> <version>" \
--note "<what changed>" --asset dist/<App>.zip \
--repo TZGyn/<repo> --login gitea.tzgyn.dev
```
Push `main` **before** this: `tea` tags the remote default branch, so an
unpushed commit means the tag points at source that is not what shipped
(the binary would contain changes absent from the tag). If it already
happened, re-point the tag: `git push origin :refs/tags/v<version>` then
`git tag -f v<version> <commit> && git push origin v<version>`.
4. **Bump the cask** (`version` + `sha256`), push the tap, pull Homebrew's
clone, `brew audit`. Verify by downloading the asset back and comparing the
sha256, and `spctl -a -vv <App>.app` → `accepted` /
`source=Notarized Developer ID`. If `spctl` does not say
`source=Notarized Developer ID`, stop and fix notarization first — do not
publish the release or the cask.
5. **Print the upgrade script**:
```bash
brew update
brew upgrade --cask <name>
```
## 5. Notarization
Signing identity: `Developer ID Application: Teh Zi Gyn (RX6767S6BW)`.
App Store Connect API key (shared with Kapture):
| Value | Where |
| --------- | ------------------------------------------------------------ |
| Key file | `~/.config/kapture/AuthKey_K5KXMMSB28.p8` |
| Key ID | `K5KXMMSB28` |
| Issuer ID | `3e25e5dc-8b75-453f-a71c-c7f3feaac628` (not secret) |
| Team ID | `RX6767S6BW` |
Signing must use a hardened runtime and a secure timestamp:
```bash
codesign --force --options runtime --timestamp --sign "$IDENT" "<App>.app"
```
Submit and verify:
```bash
xcrun notarytool submit dist/<App>.zip \
--key "$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8" \
--key-id K5KXMMSB28 --issuer 3e25e5dc-8b75-453f-a71c-c7f3feaac628 \
--wait --output-format json
xcrun stapler staple <App>.app && xcrun stapler validate <App>.app
spctl -a -vv <App>.app # want: accepted, source=Notarized Developer ID
```
`release.sh` **must** notarize. Treat any skip or failure as a failed release:
do not publish the tag, the release, or the cask, and do not suggest
quarantine workarounds (`xattr -dr com.apple.quarantine`,
`brew install --cask --no-quarantine`) — those hand the user a build macOS
rejects. Only publish once `spctl -a -vv <App>.app` reports `accepted` with
`source=Notarized Developer ID`.
### Notarization troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| `HTTP status code: 403. A required agreement is missing or has expired.` | The **Apple Developer Program License Agreement** for the team is unsigned or expired. The Account Holder must accept it in App Store Connect → **Business → Agreements** *and* at `developer.apple.com/account` → **Agreements**. Account-wide: every call fails, including read-only `notarytool history`, until it is accepted; allow a few minutes to propagate. |
| The same 403 *after* accepting | The wrong agreement was accepted (App Store Connect "Paid Apps" instead of the Program License Agreement) or it was accepted under a different team's account. |
| `401 Authentication credentials are incorrect` | Wrong key id / issuer, or the key belongs to another team. |
Confirm the team before blaming the credentials — do not "fix" this by
switching teams or re-signing:
```bash
security find-identity -v -p codesigning # the Developer ID team must match the key's team
spctl -a -vv /Applications/<App>.app # an already-notarized app proves the team/key pair works
xcrun notarytool history --key ... --key-id ... --issuer ... # 403 here = account agreement, not the app
```
`notarytool history` failing the same way as the submit is the tell: the
problem is the account, not the bundle, so there is nothing to change in the
app or the scripts.
## 6. iOS apps
Native SwiftUI iOS apps are built with **XcodeGen** — no CocoaPods, no SwiftPM
packages. `project.yml` is the source of truth; the generated
`<Name>.xcodeproj/` is gitignored and regenerated after any file is added or
removed. Working reference: `~/Git/koins/apps/ios`.
One-time tool install: `brew install xcodegen`.
### Layout and project file
```
apps/ios/
project.yml XcodeGen project (committed)
<Name>/ Swift sources, assets, Info.plist
<Name>/Bridge/ optional ObjC/ObjC++ bridge + bridging header
<Name>/Libs/ optional prebuilt static archives (not bundled)
.gitignore <Name>.xcodeproj/, build/, DerivedData/, xcuserdata/
```
Minimal `project.yml`:
```yaml
name: <Name>
options:
bundleIdPrefix: dev.tzgyn
deploymentTarget:
iOS: "16.0"
xcodeVersion: "16.0"
settings:
base:
SWIFT_VERSION: "5.0"
ENABLE_USER_SCRIPT_SANDBOXING: NO
targets:
<Name>:
type: application
platform: iOS
sources:
- path: <Name>
excludes:
- "Libs" # static archives are linked, never bundled
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.tzgyn.<name>
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: RX6767S6BW
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
SWIFT_OBJC_BRIDGING_HEADER: <Name>/Bridge/<Name>-Bridging-Header.h
LIBRARY_SEARCH_PATHS:
- "$(SRCROOT)/<Name>/Libs"
OTHER_LDFLAGS[sdk=iphoneos*]:
- "$(inherited)"
- "-l<name>_device"
OTHER_LDFLAGS[sdk=iphonesimulator*]:
- "$(inherited)"
- "-l<name>_merged"
info:
path: <Name>/Info.plist
properties:
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIUserInterfaceStyle: Dark
NSCameraUsageDescription: Scan a QR code.
NSFaceIDUsageDescription: Unlock the app and approve payments.
```
- Bundle id convention: `dev.tzgyn.<name>` (Koins predates it and uses
`com.koins.app`; keep an existing id rather than migrating).
- Privacy usage strings are mandatory. A missing `NSCameraUsageDescription`,
`NSPhotoLibraryUsageDescription`, or `NSFaceIDUsageDescription` crashes the
app the first time that API is used.
- Prebuilt static archives need separate device and simulator builds; select
them with `OTHER_LDFLAGS[sdk=…]` as above. If ObjC categories fail to load,
link with `-Wl,-force_load,$(SRCROOT)/<Name>/Libs/lib<name>.a` instead.
- The simulator target uses `CODE_SIGNING_ALLOWED[sdk=iphonesimulator*] = NO`;
device builds use automatic signing with the TZGyn team.
After adding/removing files or changing `project.yml`:
```bash
cd apps/ios && xcodegen generate
```
### Build
```bash
cd apps/ios
xcodegen generate
# Simulator (Apple Silicon)
xcodebuild -project <Name>.xcodeproj -scheme <Name> -configuration Debug \
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 ONLY_ACTIVE_ARCH=NO -derivedDataPath /tmp/<name>-dd build
# Device
xcodebuild -project <Name>.xcodeproj -scheme <Name> -configuration Debug \
-sdk iphoneos -destination 'generic/platform=iOS' \
-allowProvisioningUpdates -derivedDataPath /tmp/<name>-dd-device build
```
`-allowProvisioningUpdates` creates/refreshes the development profile
automatically; no manual profile management is needed with the paid team.
Derived data under `/tmp` is lost on reboot — rebuild rather than chasing a
missing `.app`.
### Install on a physical iPhone (over the network, no cable)
This is the normal way to update a TZGyn iPhone app: build the `iphoneos` slice,
then install over Wi-Fi with `devicectl`. `simctl` only works with simulators.
One-time setup:
1. Connect the iPhone once with a cable, open Xcode → **Window → Devices and
Simulators**, select the phone, and tick **Connect via network**.
2. On the phone: **Settings → Privacy & Security → Developer Mode → On**
(requires a reboot), then trust the Mac when prompted.
Wireless install and launch:
```bash
# 1. Find the paired phone (UDID, ECID, name and DNS name are all accepted).
xcrun devicectl list devices
# want: <phone name> <phone>.coredevice.local <UDID> available (paired)
# 2. Install the device build. This replaces the app in place and keeps its data.
xcrun devicectl device install app --device <UDID|name> --timeout 180 \
/tmp/<name>-dd-device/Build/Products/Debug-iphoneos/<Name>.app
# 3. Launch it (the phone must be unlocked).
xcrun devicectl device process launch --device <UDID|name> <bundle-id>
```
- Use the **device** build path (`-dd-device/.../Debug-iphoneos`), not the
simulator build; installing the wrong slice fails.
- `--timeout 180` is deliberate: the app bundle transfer over Wi-Fi can take a
while, and the default timeout is easy to hit.
- Installing over the network **does not delete app data**. Wallet files, seeds
and preferences survive updates (seeds live in the Keychain).
- If the device shows `unavailable`, or install fails with
`Network.NWError error 60 - Operation timed out`, the phone is asleep,
locked, or off Wi-Fi. Wake/unlock it, keep it on the same network, retry. A
timed-out install leaves the previous build installed.
- Launch errors with `BSErrorCodeDescription = Locked` mean exactly that: the
phone must be unlocked for the launch.
- `devicectl` is for physical devices only; use `simctl` for simulators.
### Simulator workflow
```bash
xcrun simctl list devices available
xcrun simctl boot <UDID>
open -a Simulator
xcrun simctl install <UDID> /tmp/<name>-dd/Build/Products/Debug-iphonesimulator/<Name>.app
xcrun simctl launch <UDID> <bundle-id>
xcrun simctl io <UDID> screenshot /tmp/screen.png
xcrun simctl get_app_container <UDID> <bundle-id> data
```
- Files the app writes (self-test reports, exported data) can be read from the
data container printed by `get_app_container`.
- Simulator preferences are cached by `cfprefsd`; `xcrun simctl spawn <UDID>
defaults delete …` often does not stick. To reset a preference: terminate the
app, `xcrun simctl spawn <UDID> launchctl stop com.apple.cfprefsd.xpc.daemon`,
then edit or delete the key in the plist directly.
### Local self-tests
Gate verification code behind launch arguments, write the report into the app's
Documents folder, and read it back from the container (console output is not
always reachable):
```bash
xcrun simctl launch <UDID> <bundle-id> -<name>SelfTest
DATA=$(xcrun simctl get_app_container <UDID> <bundle-id> data)
cat "$DATA/Documents/<name>-selftest.txt"
```
For unified-log checks:
```bash
xcrun simctl spawn <UDID> log show --last 1m \
--predicate 'subsystem == "<bundle-id>"' --style compact
```
Keep network tests explicit (`-<name>SelfTestNetwork`) so the default launch is
offline and fast.
### Signing and distribution
- **Team: `RX6767S6BW` for every TZGyn app, macOS and iOS.** iOS device builds
use automatic signing (`CODE_SIGN_STYLE: Automatic`, `DEVELOPMENT_TEAM:
RX6767S6BW`) and `-allowProvisioningUpdates`; no manual profiles.
- **iOS apps are not notarized.** Apple has no notarization step for iOS —
device installs are gated by code signing + Developer Mode. Do not add a
`notarytool`/`stapler` step to an iOS build, and do not `spctl` an
`iphoneos` app.
- **macOS GUI releases are always notarized and stapled** (section 5), with the
shared App Store Connect key:
`~/.config/kapture/AuthKey_K5KXMMSB28.p8`, key ID `K5KXMMSB28`, issuer
`3e25e5dc-8b75-453f-a71c-c7f3feaac628`, team `RX6767S6BW`. Never publish a
macOS release until `spctl -a -vv` reports `accepted` /
`source=Notarized Developer ID`.
- **App Store Connect** (only if TestFlight/App Store is ever used) reuses the
same key; koins does not use it yet.
- Android is not codified yet; ask which stack (native, Compose, RN, EAS)
before assuming.
## 7. Extending this skill (self-improvement)
This skill is meant to grow. When the user asks for a new behaviour, preference,
or convention:
1. Edit the relevant section of `SKILL.md` (add a new section if it is a new
topic). Keep it concrete: exact commands, paths, and templates.
2. Keep it truthful — only document what has actually worked; delete steps that
became stale (e.g. a changed path or flag).
3. Commit and push:
```bash
cd ~/Git/TZGyn-skill
git add -A && git commit -m "<what behaviour was added>"
git push
```
4. The installed copy is a symlink, so it updates immediately; a new Crush
session picks it up. Confirm with `crush_info` (the skill should appear under
`[skills]`).
Prefer updating this skill over repeating ad-hoc instructions in a project: if a
convention applies to more than one repo, it belongs here.
## 8. Coolify
Host: `https://coolify.tzgyn.dev`. API: `$COOLIFY_URL/api/v1`, bearer
`$COOLIFY_TOKEN` (keep the token in an env var or secret manager; never commit
it). The UI and API are the same deployment.
### Labels are the config
- Coolify stores an app's labels in `custom_labels` as **base64**. The UI calls
the field **Container Labels**; in v4.5 it is under **General → Container →
Labels** (older versions place it under General/Advanced).
- `is_container_label_readonly_enabled` (UI: **Label management**):
- `true` — “Managed by Coolify (auto-generated)”: Coolify may regenerate
labels from domains/proxy settings and **drop manual additions**.
- `false` — “Managed manually (edit labels yourself)”: `custom_labels` is
kept verbatim; domain/proxy changes no longer update it.
Set it to `false` whenever you add labels Coolify does not generate.
- API-created apps do **not** get the generated HTTP labels merged. Put the
complete set (HTTP routers, middlewares, certresolver, Caddy HTTP labels, and
any TCP routers) in `custom_labels`.
- PATCH only the field, preserving existing labels:
```bash
curl -X PATCH -H "Authorization: Bearer $COOLIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"custom_labels\":\"$(base64 < labels.txt)\"}" \
"$COOLIFY_URL/api/v1/applications/$APP"
```
Decode it back and verify. Nested/double base64 can appear after API updates.
- **After every app update/recreation/deploy, check the TCP lines are still
there.** If they vanish, Traefik terminates the SNI with its default
certificate.
### HTTP vs raw TCP
- `Domains` only creates HTTP(S) routers (Traefik terminates TLS). Do not add a
wildcard domain when the proxy must pass TLS through — that would make
Traefik ask for a wildcard certificate and decrypt tenant traffic.
- For SNI passthrough (tunnels, non-HTTP services), add the full set:
```text
traefik.tcp.routers.<name>.entrypoints=https
traefik.tcp.routers.<name>.rule=HostSNIRegexp(`^.+\.<domain>$`)
traefik.tcp.routers.<name>.service=<name>
traefik.tcp.routers.<name>.tls.passthrough=true
traefik.tcp.services.<name>.loadbalancer.server.port=8443
```
Traefik dials the container port over the `coolify` Docker network; no host
port publish is needed. The app must listen on that port. The `^.+\.` regex
does not match the apex, so the API domain can stay on the HTTP router.
- Keep the HTTP labels in the same `custom_labels` value when using a manual
label set; otherwise the dashboard/API router disappears.
### API and deploys
- Deploy: `POST $COOLIFY_URL/api/v1/deploy?uuid=<app>`.
- App state: `GET /api/v1/applications/<uuid>` (`status`, `updated_at`).
- App logs: `GET /api/v1/applications/<uuid>/logs` (JSON `logs` string).
- The deployment list JSON can contain invalid escapes/HTML; `jq` sometimes
fails. Parse with Python (`json.load`, then join `logs[].output`). Prefer the
specific deployment endpoint or app status over a long poll of
`/deployments`.
- A deploy can transiently report `running:unhealthy`; wait for the app and hit
`/health` before debugging.
- Env vars: `is_runtime` and `is_buildtime`; preview and production entries
can duplicate the same key. Secrets belong in Coolify envs, not the repo.
### Gitea webhook
- **Once this hook is set up and the app is connected, `git push origin main`
is the deployment.** Do not run a manual `POST /deploy` for normal changes;
use it only for retries, rollbacks, label/env-only changes, or webhook
failures. See section 9 for the Gitea side.
- Push-to-deploy uses Coolify's manual Gitea webhook:
`X-Hub-Signature-256` = HMAC-SHA256 of the raw body with the app's
`manual_webhook_secret_gitea`; Gitea sends this natively. A curl replay needs
`X-Gitea-Event: push` and the signature.
- **Gitea's hook edit API does not update the stored secret.** Delete and
recreate the hook (branch filter `main`). The app's secret is regenerated
when the app is recreated, so recreate the hook too.
- The audit log is the oracle: `webhook.deployment.queued` vs
`webhook.gitea.signature_failed`.
- Coolify pins the pushed SHA (`git fetch origin <sha>`), but this Gitea only
serves the current tip by SHA. If a later push lands before the fetch, the
deploy fails with “not our ref”: re-trigger the deploy. Permanent fix inside
the Gitea container:
```bash
git -C /data/git/repositories/TZGyn/<repo>.git config \
uploadpack.allowReachableSHA1InWant true
```
- After any app recreation: recreate the webhook and re-check custom labels.
### App creation/recreation
- Use `domains` in the API (not `fqdn`). `private_key_uuid` cannot be changed
on update; recreate the app with the key. Use storage type `persistent` for
data volumes.
- Verify after deploy: app healthy, the real TCP/SNI path (not just HTTP),
custom labels present, and the webhook queued.
## 9. Gitea
### Access and remotes
- Web/API: `https://gitea.tzgyn.dev`; owner `TZGyn` is a **user**, not an org.
`tea` login: `gitea.tzgyn.dev`.
- **SSH is on port 22222**, user `git`. Put this in `~/.ssh/config`:
```sshconfig
Host gitea.tzgyn.dev
Port 22222
User git
```
Then `git@gitea.tzgyn.dev:TZGyn/<repo>.git` works. The explicit form is
`ssh://git@gitea.tzgyn.dev:22222/TZGyn/<repo>.git`.
Coolify's `git_repository` often uses the scp-style
`git@gitea.tzgyn.dev:22222/TZGyn/<repo>.git`; Coolify ignores the port when
matching `repository.full_name`, so webhook matching still works.
- HTTPS: `https://gitea.tzgyn.dev/TZGyn/<repo>.git`; private repos need a
deploy key or token. Never commit credentials or put a token in the remote
URL recorded in the repo.
- Create repos with:
```bash
tea repos create --name <repo> --desc "<one-line description>" \
--login gitea.tzgyn.dev
```
Do not pass `--owner TZGyn`: that hits `/orgs/TZGyn/repos` and 404s for a
user.
- Keep repos whose release assets feed Homebrew **public**: `brew` downloads
them without Gitea authentication. Verify with an unauthenticated
`curl -I <release-asset-url>`.
### Push-to-deploy
1. Create the Coolify app from the repo (Dockerfile/build pack) with the deploy
key, the **confirmed domain**, env, and persistent volume (section 8).
2. In the Gitea repo: **Settings → Webhooks → Add webhook → Gitea**.
- URL: `https://coolify.tzgyn.dev/webhooks/source/gitea/events/manual`
- Content type: `application/json`
- Secret: the Coolify app's `manual_webhook_secret_gitea`
- Event: push; branch filter: `main`
3. Push `main`. Gitea signs the raw body with `X-Hub-Signature-256`; Coolify
verifies it and queues the deploy. **This is the normal deployment path —
do not run a manual deploy for a normal push.**
4. Manual `POST /api/v1/deploy?uuid=<app>` only for retries, rollbacks,
label/env-only changes, or webhook failures.
- **Editing a hook's secret through Gitea's API does not update the stored
secret** (PATCH returns 200 but deliveries keep failing). Delete and recreate
the hook. The app's secret is regenerated when the Coolify app is recreated,
so recreate the hook then too.
- Coolify audit log: `webhook.deployment.queued` = accepted;
`webhook.gitea.signature_failed` = secret mismatch.
- A curl replay needs the raw body, `X-Gitea-Event: push`, and a valid
signature.
- Tags do not trigger a `main`-filtered deploy. Push `main` before tagging so
the tag points at the source that shipped.
### Releases and deploy keys
- Release:
```bash
tea releases create --tag v<version> --title "<name> <version>" \
--note-file <notes> --asset <artifact> \
--repo TZGyn/<repo> --login gitea.tzgyn.dev
```
Attach every platform asset and verify by downloading it back and comparing
the checksum.
- Coolify source uses a **deploy key** (the shared `gitea` key by default). Add
its public half to the repo. `private_key_uuid` cannot be changed on an app
update; recreate the app to change keys.
- For a Coolify deployment failure caused by a stale SHA, see the
`uploadpack.allowReachableSHA1InWant` note in section 8.
## 10. Screenshots for agents (Kapture CLI)
Kapture ships a `kapture` CLI (installed by the cask) so agents and scripts can
capture app windows or the main display without opening the UI. It is a thin
client: requests go to the running menu-bar app, which holds the Screen
Recording permission — the terminal and the agent need no grant. The app is
launched automatically if it is not running (the first capture after launch is
slower by a second or two).
```bash
kapture list # id, app, title, size
kapture list --json # machine-readable
kapture capture --app Xcode --out /tmp/xcode.png
kapture capture --app Safari --json # {"ok","path","width","height"}
kapture capture --window-id 1234 --out /tmp/w.png
kapture capture --screen --out /tmp/screen.png
```
- **Input:** `--app` takes a localized app name or bundle id (case-insensitive
substring) and captures that app's frontmost window; omit it to capture the
frontmost app's window. `--window-id` takes an id from `kapture list`.
`--screen` captures the main display instead of a window.
- **Output:** `--out <path>` writes the PNG there (`~` expands; relative paths
resolve against the cwd). Without `--out` the file goes to `$TMPDIR` and the
absolute path is printed on stdout. `--json` prints
`{"ok","path","width","height"}`; errors go to stderr.
- **Exit codes:** 0 ok, 1 capture failed or no matching window, 2 usage error,
3 the Kapture app did not respond.
- **For agents:** save screenshots outside the repo (`$TMPDIR` or
`/tmp/kapture/`), never commit them, and read the path from stdout instead of
guessing. To screenshot the app you are building, launch it first
(`open <Name>.app`), wait for its window to appear, then
`kapture capture --app <Name>`; window captures include the chrome and
rounded corners and work even when the window is partly occluded.