Skip to content

CI/CD Pipeline

LucidPal uses Fastlane for build automation and Woodpecker CI for continuous delivery.

Woodpecker CI

Dashboard: https://ci.lucidpal.app

AgentHostBackendLabelsHandles
Linux agentLXC 120 woodpecker on pve-mirna (192.168.1.184)Dockerplatform=linuxWebsite, API deploys
macOS agentMacBook Pro (M3 Max)localplatform=macosiOS 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>.

LaneCommandDescription
devicefastlane ios deviceBuild debug IPA and install on connected iPhone via xcrun devicectl
betafastlane ios betaBuild release IPA, increment build number, and upload to TestFlight
releasefastlane ios releaseBuild release IPA and submit to App Store Connect (manual review trigger)
provisionfastlane ios provisionRegister App IDs (app.lucidpal, app.lucidpal.widget) and App Group on Apple Developer Portal
generatefastlane ios generateRegenerate .xcodeproj from project.yml via XcodeGen
preparefastlane ios prepareCheck device connectivity via xcrun xctrace (USB or Wi-Fi, retries up to 10×)
certsfastlane ios certsSync App Store certificates and profiles via match
enable_groupsfastlane ios enable_groupsEnable App Groups capability on both App IDs

Lane Details

device

  1. Runs prepare → verifies device is reachable
  2. Runs generate → regenerates .xcodeproj
  3. Builds a Debug IPA with development export method
  4. On CI: writes the ASC API key .p8 to /tmp/AuthKey_<id>.p8 for provisioning, then deletes it
  5. Installs via xcrun devicectl device install app --device <DEVICE_CORE_ID>

beta

  1. Conditionally runs setup_ci (keychain init) — skipped on macOS agent (has persistent login keychain)
  2. Runs generate
  3. Loads ASC API key from env vars
  4. Syncs appstore profiles via match (readonly)
  5. Increments build number to latest_testflight_build_number + 1
  6. Updates manual code signing settings for LucidPal and LucidPalWidget targets
  7. Builds a Release IPA with app-store export method
  8. Uploads to TestFlight (skip_waiting_for_build_processing: true)

release

  1. Runs generate
  2. Increments build number
  3. Builds a Release IPA with automatic provisioning
  4. 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)

PropertyValue
TriggerPush to main touching apps/lucidpal-ios/**
AgentmacOS agent (MacBook Pro M3 Max, platform=macos)
Timeout60 minutes

Steps:

  1. bundle install (Ruby gems)
  2. bundle exec fastlane ios beta with secrets injected as env vars

Deploy Website (deploy-website.yml)

PropertyValue
TriggerPush to main touching apps/lucidpal-website/**
AgentLinux agent (LXC 120, platform=linux)
TargetCF Pages --branch main → lucidpal.app (prod)

Steps:

  1. npm install + nx build lucidpal-website
  2. Build Cloudflare Functions
  3. wrangler pages deploy ... --branch main

Deploy Dev (deploy-dev.yml)

PropertyValue
TriggerManual only (Woodpecker UI → New Pipeline)
AgentLinux agent (LXC 120, platform=linux)
TargetsCF 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:

  1. npm install + nx build lucidpal-website
  2. Build Cloudflare Functions
  3. wrangler pages deploy ... --branch dev

Steps — deploy-api:

  1. npm install
  2. wrangler 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.

PropertyValue
Repolucid-fabrics/lucidpal (Woodpecker repo ID 2)
TriggerPush to main or manual
AgentLinux agent (LXC 120, platform=linux)
TargetCF Pages lucidpal-docs → docs.lucidpal.app
SecretsCLOUDFLARE_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).

PolicyRule
Owner accessEmail wassimmehanna@gmail.com
Service tokenwoodpecker-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/repos

Service 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:

SecretUsed By
APP_STORE_CONNECT_API_KEY_IDbeta, device (CI)
APP_STORE_CONNECT_API_ISSUER_IDbeta, device (CI)
APP_STORE_CONNECT_API_KEY_CONTENTbeta, device (CI)
MATCH_PASSWORDbeta
MATCH_GIT_BASIC_AUTHORIZATIONbeta
CLOUDFLARE_API_TOKENdeploy-website, deploy-dev
CLOUDFLARE_ACCOUNT_IDdeploy-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)

VariableDefaultDescription
DEVICE_UDID00008150-000C08842604401CDevice UDID (xcrun xctrace list devices)
DEVICE_CORE_IDF8C4D569-E846-5445-B4EC-8B4B48714D01CoreDevice 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 device

For 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 TestFlight

Key differences from local:

AspectLocalCI
KeychainLogin keychain (existing)Persistent macOS agent keychain (no setup_ci)
ASC API keyOptional (Xcode session)Required via Woodpecker secrets
setup_ciSkippedSkipped (persistent agent keychain)
Device installxcrun devicectl to physical deviceNot run (beta lane only)

Infrastructure

ComponentLocationDetails
Woodpecker serverLXC 120 (woodpecker) on pve-mirnaDocker, woodpeckerci/woodpecker-server:v3
Linux agentSame LXCDocker, woodpeckerci/woodpecker-agent:v3, Docker backend
macOS agentMacBook Pro (M3 Max)Binary at ~/bin/woodpecker-agent, launchd service
Cloudflare tunnelLXC 120Tunnel ID 39b3eccc-aeef-4fee-8aac-5d115da4b054ci.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: 1

nesting=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 1

2. 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:

FieldValue
Application nameLucidPal CI
Homepage URLhttps://ci.lucidpal.app
Authorization callback URLhttps://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 32

Start:

bash
cd /opt/woodpecker && docker compose up -d

5. 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 cloudflared

Current tunnel ID: 39b3eccc-aeef-4fee-8aac-5d115da4b054.

6. First Login & Admin Promotion

  1. Navigate to https://ci.lucidpal.app
  2. Log in with GitHub (OAuth)
  3. 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>\";"
'
  1. Set WOODPECKER_OPEN=false and 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 bundler

Download 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-agent

launchd 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: PATH must 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 0

Logs:

bash
tail -f ~/Library/Logs/woodpecker/agent.log
tail -f ~/Library/Logs/woodpecker/agent.err

Reload after plist changes:

bash
launchctl unload ~/Library/LaunchAgents/woodpecker-agent.plist
launchctl load ~/Library/LaunchAgents/woodpecker-agent.plist

8. Add Repository

  1. Go to https://ci.lucidpal.appRepositoriesAdd repository
  2. Select lucid-fabrics/lucidpal-dev
  3. Woodpecker installs a webhook on GitHub automatically
  4. 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 like git-lfs, bundle, fastlane, xcodebuild are not found.
  • git-lfs must be installed (brew install git-lfs && git lfs install) — Woodpecker's clone step runs git lfs fetch and 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 beta

Do not use image: alpine (no such binary on macOS) or type: commands (not valid in Woodpecker v3.14).

Internal — not for distribution