Appearance
CI/CD Pipeline
LucidPal uses Fastlane for build automation and Woodpecker CI for continuous delivery.
Woodpecker CI
Dashboard: https://ci.lucidpal.app
| Agent | Host | Backend | Labels | Handles |
|---|---|---|---|---|
| Linux agent | LXC 120 woodpecker on pve-mirna (192.168.1.184) | Docker | platform=linux | Website, API deploys |
| macOS agent | MacBook Pro (M3 Max) | local | platform=macos | iOS builds, TestFlight |
Agents use gRPC to connect to the Woodpecker server at 192.168.1.184:9000 (LAN).
Fastlane Lanes
All lanes run from apps/lucidpal-ios/ via bundle exec fastlane ios <lane>.
| Lane | Command | Description |
|---|---|---|
device | fastlane ios device | Build debug IPA and install on connected iPhone via xcrun devicectl |
beta | fastlane ios beta | Build release IPA, increment build number, and upload to TestFlight |
release | fastlane ios release | Build release IPA and submit to App Store Connect (manual review trigger) |
provision | fastlane ios provision | Register App IDs (app.lucidpal, app.lucidpal.widget) and App Group on Apple Developer Portal |
generate | fastlane ios generate | Regenerate .xcodeproj from project.yml via XcodeGen |
prepare | fastlane ios prepare | Check device connectivity via xcrun xctrace (USB or Wi-Fi, retries up to 10×) |
certs | fastlane ios certs | Sync App Store certificates and profiles via match |
enable_groups | fastlane ios enable_groups | Enable App Groups capability on both App IDs |
Lane Details
device
- Runs
prepare→ verifies device is reachable - Runs
generate→ regenerates.xcodeproj - Builds a
DebugIPA withdevelopmentexport method - On CI: writes the ASC API key
.p8to/tmp/AuthKey_<id>.p8for provisioning, then deletes it - Installs via
xcrun devicectl device install app --device <DEVICE_CORE_ID>
beta
- Conditionally runs
setup_ci(keychain init) — skipped on macOS agent (has persistent login keychain) - Runs
generate - Loads ASC API key from env vars
- Syncs
appstoreprofiles viamatch(readonly) - Increments build number to
latest_testflight_build_number + 1 - Updates manual code signing settings for
LucidPalandLucidPalWidgettargets - Builds a
ReleaseIPA withapp-storeexport method - Uploads to TestFlight (
skip_waiting_for_build_processing: true)
release
- Runs
generate - Increments build number
- Builds a
ReleaseIPA with automatic provisioning - Submits via
deliver(does not auto-submit for review — manual trigger in App Store Connect)
provision
Run once after a bundle ID rename or new capability. Requires FASTLANE_USER and FASTLANE_PASSWORD (Apple ID credentials).
Woodpecker Pipelines
Pipeline files live in .woodpecker/ at the repo root.
TestFlight (testflight.yml)
| Property | Value |
|---|---|
| Trigger | Push to main touching apps/lucidpal-ios/** |
| Agent | macOS agent (MacBook Pro M3 Max, platform=macos) |
| Timeout | 60 minutes |
Steps:
bundle install(Ruby gems)bundle exec fastlane ios betawith secrets injected as env vars
Deploy Website (deploy-website.yml)
| Property | Value |
|---|---|
| Trigger | Push to main touching apps/lucidpal-website/** |
| Agent | Linux agent (LXC 120, platform=linux) |
| Target | CF Pages --branch main → lucidpal.app (prod) |
Steps:
npm install+nx build lucidpal-website- Build Cloudflare Functions
wrangler pages deploy ... --branch main
Deploy Dev (deploy-dev.yml)
| Property | Value |
|---|---|
| Trigger | Manual only (Woodpecker UI → New Pipeline) |
| Agent | Linux agent (LXC 120, platform=linux) |
| Targets | CF Pages --branch dev → dev.lucidpal.pages.dev + API → api-dev.lucidpal.app |
Use this when you want to preview changes on the dev environment without merging to main.
Steps — build-and-deploy-website:
npm install+nx build lucidpal-website- Build Cloudflare Functions
wrangler pages deploy ... --branch dev
Steps — deploy-api:
npm installwrangler deploy --config apps/lucidpal-api/wrangler.toml(no--env production→ default env = dev)
The API default env in wrangler.toml maps to api-dev.lucidpal.app (D1: lucidpal-db-dev, ENVIRONMENT=development).
Deploy Docs (separate repo)
Docs are deployed from the lucid-fabrics/lucidpal repo (not this one). That repo has its own .woodpecker/ pipeline registered on the same Woodpecker instance.
| Property | Value |
|---|---|
| Repo | lucid-fabrics/lucidpal (Woodpecker repo ID 2) |
| Trigger | Push to main or manual |
| Agent | Linux agent (LXC 120, platform=linux) |
| Target | CF Pages lucidpal-docs → docs.lucidpal.app |
| Secrets | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID (same token, added to repo 2 in Woodpecker) |
There is no docs.yml in this repo's .woodpecker/ directory.
Cloudflare Access (ci.lucidpal.app)
The Woodpecker dashboard is protected by Cloudflare Access (app ID f1faad46-3213-4721-904e-4e91f91b4556).
| Policy | Rule |
|---|---|
| Owner access | Email wassimmehanna@gmail.com |
| Service token | woodpecker-cli (for programmatic API calls) |
Accessing the dashboard (laptop, phone, browser): First visit triggers a one-click email auth screen → code sent to wassimmehanna@gmail.com → 24h session cookie.
Programmatic API access (scripts, curl): Use the CF Access service token headers:
bash
curl -H "CF-Access-Client-Id: 56810fce47ca685081d41f04910c3f7a.access" \
-H "CF-Access-Client-Secret: <secret>" \
-H "Authorization: Bearer <woodpecker-jwt>" \
https://ci.lucidpal.app/api/reposService token credentials and the Woodpecker JWT construction are in secrets.md.
Required Secrets
See secrets.md for full details, storage locations, and rotation instructions.
Configure in Woodpecker → Repository → Settings → Secrets:
| Secret | Used By |
|---|---|
APP_STORE_CONNECT_API_KEY_ID | beta, device (CI) |
APP_STORE_CONNECT_API_ISSUER_ID | beta, device (CI) |
APP_STORE_CONNECT_API_KEY_CONTENT | beta, device (CI) |
MATCH_PASSWORD | beta |
MATCH_GIT_BASIC_AUTHORIZATION | beta |
CLOUDFLARE_API_TOKEN | deploy-website, deploy-dev |
CLOUDFLARE_ACCOUNT_ID | deploy-website, deploy-dev |
Woodpecker Personal Access Token
See secrets.md for storage and rotation instructions.
Trigger deploy-dev manually:
bash
curl -s -X POST "https://ci.lucidpal.app/api/repos/1/pipelines" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"branch":"main"}'Local Env Vars (optional overrides)
| Variable | Default | Description |
|---|---|---|
DEVICE_UDID | 00008150-000C08842604401C | Device UDID (xcrun xctrace list devices) |
DEVICE_CORE_ID | F8C4D569-E846-5445-B4EC-8B4B48714D01 | CoreDevice ID (xcrun devicectl list devices) |
Local Developer Workflow
bash
# One-time setup: register App IDs and App Group
fastlane ios provision
# Regenerate Xcode project after project.yml changes
fastlane ios generate
# Deploy to your iPhone (USB or Wi-Fi)
fastlane ios deviceFor device installation, the ASC API key env vars are optional locally — Xcode's existing session handles provisioning automatically.
CI Workflow (Woodpecker)
Push to main (apps/lucidpal-ios/**) →
macOS agent (MacBook Pro M3 Max) →
bundle install →
fastlane ios beta →
generate → match certs → build Release → upload TestFlightKey differences from local:
| Aspect | Local | CI |
|---|---|---|
| Keychain | Login keychain (existing) | Persistent macOS agent keychain (no setup_ci) |
| ASC API key | Optional (Xcode session) | Required via Woodpecker secrets |
setup_ci | Skipped | Skipped (persistent agent keychain) |
| Device install | xcrun devicectl to physical device | Not run (beta lane only) |
Infrastructure
| Component | Location | Details |
|---|---|---|
| Woodpecker server | LXC 120 (woodpecker) on pve-mirna | Docker, woodpeckerci/woodpecker-server:v3 |
| Linux agent | Same LXC | Docker, woodpeckerci/woodpecker-agent:v3, Docker backend |
| macOS agent | MacBook Pro (M3 Max) | Binary at ~/bin/woodpecker-agent, launchd service |
| Cloudflare tunnel | LXC 120 | Tunnel ID 39b3eccc-aeef-4fee-8aac-5d115da4b054 → ci.lucidpal.app |
| Compose file | /opt/woodpecker/docker-compose.yml on LXC 120 |
Full Setup Guide
This documents everything needed to stand up a fresh Woodpecker CI instance identical to the current one.
1. LXC Container (Proxmox)
LXC 120 on pve-mirna (192.168.1.2). Config at /etc/pve/lxc/120.conf:
arch: amd64
cores: 8
features: nesting=1 # required for Docker inside LXC
hostname: woodpecker
memory: 16384 # 16 GB
net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth
onboot: 1
ostype: debian
rootfs: local-lvm:vm-120-disk-0,size=50G
swap: 2048
unprivileged: 1nesting=1 is mandatory — Docker requires it inside an unprivileged LXC.
Create via Proxmox UI or:
bash
pct create 120 <debian-template> \
--hostname woodpecker --cores 8 --memory 16384 \
--rootfs local-lvm:50 --features nesting=1 --unprivileged 12. Docker on the LXC
bash
pct exec 120 -- bash -c '
apt-get update && apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
'3. GitHub OAuth App
Create at GitHub → Settings → Developer settings → OAuth Apps:
| Field | Value |
|---|---|
| Application name | LucidPal CI |
| Homepage URL | https://ci.lucidpal.app |
| Authorization callback URL | https://ci.lucidpal.app/authorize |
Save the Client ID and Client Secret — used in the Compose file below.
Current app: Client ID Ov23liVtWoZtHzqP7PzN.
4. Woodpecker Docker Compose
/opt/woodpecker/docker-compose.yml:
yaml
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
container_name: woodpecker-server
restart: unless-stopped
ports:
- 8000:8000 # HTTP (proxied by Cloudflare tunnel)
- 9000:9000 # gRPC (agents connect here over LAN)
volumes:
- woodpecker-server-data:/var/lib/woodpecker/
environment:
- WOODPECKER_OPEN=false
- WOODPECKER_ADMIN=wmehanna # GitHub username of first admin
- WOODPECKER_HOST=https://ci.lucidpal.app
- WOODPECKER_GITHUB=true
- WOODPECKER_GITHUB_CLIENT=<oauth-client-id>
- WOODPECKER_GITHUB_SECRET=<oauth-client-secret>
- WOODPECKER_AGENT_SECRET=<random-64-char-hex>
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
container_name: woodpecker-agent-linux
restart: unless-stopped
depends_on:
- woodpecker-server
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WOODPECKER_SERVER=woodpecker-server:9000
- WOODPECKER_AGENT_SECRET=<same-secret-as-above>
- WOODPECKER_MAX_WORKFLOWS=4
- WOODPECKER_FILTER_LABELS=platform=linux
- WOODPECKER_BACKEND=docker
volumes:
woodpecker-server-data:Generate the agent secret:
bash
openssl rand -hex 32Start:
bash
cd /opt/woodpecker && docker compose up -d5. Cloudflare Tunnel (ci.lucidpal.app)
The LXC has no public IP — traffic reaches it via a Cloudflare tunnel.
bash
# On the LXC
pct exec 120 -- bash
# Install cloudflared
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | gpg --dearmor -o /usr/share/keyrings/cloudflare-main.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \
> /etc/apt/sources.list.d/cloudflared.list
apt-get update && apt-get install -y cloudflared
# Authenticate (opens browser — run from a machine with GUI or copy URL)
cloudflared tunnel login # select lucidpal.app zone
# Create tunnel
cloudflared tunnel create woodpecker-ci
# Configure /root/.cloudflared/config.yml
cat > /root/.cloudflared/config.yml <<EOF
tunnel: <tunnel-id>
credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress:
- hostname: ci.lucidpal.app
service: http://localhost:8000
- service: http_status:404
EOF
# Add DNS CNAME in Cloudflare (or via CLI)
cloudflared tunnel route dns woodpecker-ci ci.lucidpal.app
# Install as systemd service
cloudflared service install
systemctl enable --now cloudflaredCurrent tunnel ID: 39b3eccc-aeef-4fee-8aac-5d115da4b054.
6. First Login & Admin Promotion
- Navigate to
https://ci.lucidpal.app - Log in with GitHub (OAuth)
- If your GitHub login doesn't match
WOODPECKER_ADMIN, promote via SQLite:
bash
pct exec 120 -- bash -c '
sqlite3 /var/lib/docker/volumes/woodpecker_woodpecker-server-data/_data/woodpecker.sqlite \
"UPDATE users SET admin=1 WHERE login=\"<github-username>\";"
'- Set
WOODPECKER_OPEN=falseand restart so no other users can self-register.
7. macOS Agent (iOS Builds)
The macOS agent runs natively (no Docker) on the MacBook Pro M3 Max using the local backend.
Prerequisites on the Mac:
bash
# Xcode + Command Line Tools (must already be installed for iOS builds)
xcode-select --install
# Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# git-lfs (required — Woodpecker clone step runs git lfs fetch)
brew install git-lfs && git lfs install
# Ruby bundler (for Fastlane)
gem install bundlerDownload agent binary:
bash
mkdir -p ~/bin
curl -L https://github.com/woodpecker-ci/woodpecker/releases/download/v3.14.0-rc.1/woodpecker-agent_darwin_arm64.tar.gz \
| tar -xz -C ~/bin
chmod +x ~/bin/woodpecker-agentlaunchd plist at ~/Library/LaunchAgents/woodpecker-agent.plist:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>woodpecker.agent</string>
<key>ProgramArguments</key>
<array>
<string>/Users/<username>/bin/woodpecker-agent</string>
<string>agent</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>WOODPECKER_SERVER</key>
<string>192.168.1.184:9000</string>
<key>WOODPECKER_AGENT_SECRET</key>
<string><same-agent-secret-as-compose></string>
<key>WOODPECKER_MAX_WORKFLOWS</key>
<string>2</string>
<key>WOODPECKER_BACKEND</key>
<string>local</string>
<key>WOODPECKER_FILTER_LABELS</key>
<string>platform=macos</string>
<key>WOODPECKER_HEALTHCHECK_ADDR</key>
<string>:3100</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/<username>/Library/Logs/woodpecker/agent.log</string>
<key>StandardErrorPath</key>
<string>/Users/<username>/Library/Logs/woodpecker/agent.err</string>
</dict>
</plist>Critical:
PATHmust be set explicitly. launchd does not inherit the shell PATH, so Homebrew binaries (git-lfs,bundle,fastlane) are invisible without it.
Load the agent:
bash
mkdir -p ~/Library/Logs/woodpecker
launchctl load ~/Library/LaunchAgents/woodpecker-agent.plist
launchctl list | grep woodpecker # should show PID and exit code 0Logs:
bash
tail -f ~/Library/Logs/woodpecker/agent.log
tail -f ~/Library/Logs/woodpecker/agent.errReload after plist changes:
bash
launchctl unload ~/Library/LaunchAgents/woodpecker-agent.plist
launchctl load ~/Library/LaunchAgents/woodpecker-agent.plist8. Add Repository
- Go to
https://ci.lucidpal.app→ Repositories → Add repository - Select
lucid-fabrics/lucidpal-dev - Woodpecker installs a webhook on GitHub automatically
- Add all secrets under Repository → Settings → Secrets (see Required Secrets above)
macOS Agent Setup Notes
The macOS agent runs as a launchd service (~/Library/LaunchAgents/woodpecker-agent.plist) using the local backend — steps run natively, not in Docker.
Key configuration:
- PATH must include
/opt/homebrew/bin— launchd does not inherit the user shell PATH. Without this, tools likegit-lfs,bundle,fastlane,xcodebuildare not found. git-lfsmust be installed (brew install git-lfs && git lfs install) — Woodpecker's clone step runsgit lfs fetchand fails if git-lfs is absent.
Pipeline step syntax for local backend:
The Woodpecker schema requires an image field on every step. For the local backend, image is used as the binary entrypoint (not a Docker image). Use image: bash for steps that just run shell commands:
yaml
steps:
- name: beta
image: bash # local backend uses this as the shell binary
directory: apps/lucidpal-ios
commands:
- bundle install
- bundle exec fastlane ios betaDo not use image: alpine (no such binary on macOS) or type: commands (not valid in Woodpecker v3.14).