DEV Community

Cover image for 11 Failures Before My CI/CD Pipeline Worked: A VibeCoder's Guide to Expo + GitHub Actions + DeployGate
kaz-builds-staff
kaz-builds-staff

Posted on • Originally published at x.com

11 Failures Before My CI/CD Pipeline Worked: A VibeCoder's Guide to Expo + GitHub Actions + DeployGate

I can't write code.

That's not quite right. I describe what I want, Claude writes the code, and I focus on what to build. I'm what people call a VibeCoder — someone who builds products with AI, spending their time on product decisions rather than syntax.

I'm building "Arc," a mobile CRM for bodywork therapists, using Expo, React Native, and Supabase. The MVP is 85% done — 28 files, 27 screens, 18 of them functional.

The problem wasn't building it. The problem was getting it onto a tester's phone.


The last mile is the hardest mile

Writing code is fast. Tell Claude "build this screen" and something functional appears in minutes.

But putting that build on a tester's iPhone requires:

  1. Building with Xcode
  2. Generating an .ipa file
  3. Uploading it somewhere
  4. Telling testers "there's a new version"

For a VibeCoder, this "last mile" is the biggest bottleneck — not writing code, but delivering what you've written.

TestFlight? Apple's review queue adds hours or days. Expo Go? Can't use native modules. EAS Build (cloud)? Monthly subscription for a solo dev feels wrong.

The answer turned out to be DeployGate + a self-hosted GitHub Actions runner on my 13-inch M5 MacBook Air. Push to main, wait ~10 minutes, and every tester gets the latest build at a fixed URL. $0/month. Unlimited builds.


What I wanted

Simple, right? Here's what actually happened — 11 failures before attempt #12 finally worked.


Attempt #1 — Workflow collision

Error: Build failed at 3:54 — crashed on "Embed Pods Frameworks."

Cause: Two workflows triggered on the same push to main — one for DeployGate, one for TestFlight. My Mac's Xcode resources couldn't handle both simultaneously.

Fix: Moved the TestFlight workflow out of the active directory.

mkdir -p .github/workflows-disabled
mv .github/workflows/build-and-submit.yml .github/workflows-disabled/
Enter fullscreen mode Exit fullscreen mode

Attempts #2–3 — Binary files in the repo

Error: Every push uploaded 15MB of binary data.

Cause: A .ipa file and an Apple certificate had been accidentally committed.

Fix: Added them to .gitignore and purged from history.

build-*.ipa
AppleWWDRCAG3.cer
Enter fullscreen mode Exit fullscreen mode

Lesson: build artifacts never belong in a repository.


Attempts #4–5 — Package drift

Error: Same "Embed Pods Frameworks" crash.

Cause: Between sessions, react-native had silently updated from 0.83.2 to 0.83.4, and react-native-worklets appeared as a new peer dependency of react-native-reanimated.

How I found it: Checked out the last known working commit's package.json and rebuilt.

git checkout 89fcbe9 -- package.json package-lock.json
npm ci
eas build --platform ios --profile preview --local
# → Build successful
Enter fullscreen mode Exit fullscreen mode

Fix: Pinned packages to the stable version, then formally added the missing dependency.


Attempts #6–7 — Stale runner cache

Error: Builds pass locally, fail in CI.

Cause: The self-hosted runner's working directory had stale node_modules and Xcode DerivedData from previous runs.

Fix: Added a cache-clearing step to the workflow.

- name: Clean caches
  run: |
    rm -rf ~/Library/Developer/Xcode/DerivedData/Arc-*
    rm -rf /tmp/eas-build-local-nodejs
    rm -rf /tmp/eas-cli-nodejs
Enter fullscreen mode Exit fullscreen mode

Attempts #8–9 — Credentials source ignored

Error: Console showed Using remote iOS credentials (Expo server) when I wanted local credentials.

Cause: The EAS_CREDENTIALS_SOURCE=local environment variable was silently ignored by eas-cli 18.5.0.

Fix: Set it directly in eas.json.

"preview": {
  "distribution": "internal",
  "credentialsSource": "local"
}
Enter fullscreen mode Exit fullscreen mode

Attempt #10 — Missing credentials file

Error: credentials.json does not exist in the project root directory

Cause: credentials.json is in .gitignore (as it should be), so the runner's checkout didn't include it.

Fix: Copied it from the Mac's home directory in the workflow.

- name: Copy credentials
  run: |
    cp ~/client-crm/credentials.json ./credentials.json
    cp -r ~/client-crm/credentials ./credentials
Enter fullscreen mode Exit fullscreen mode

Attempt #11 — Locked keychain

Error: Everything compiled fine. Failed only at the final code-signing step ("Embed Pods Frameworks").

Cause: The self-hosted runner runs as a LaunchAgent without access to the GUI session's keychain.

Fix: Explicitly unlock the keychain in the workflow.

- name: Unlock keychain
  run: security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" ~/Library/Keychains/login.keychain-db
Enter fullscreen mode Exit fullscreen mode

Store your Mac login password in GitHub Secrets as KEYCHAIN_PASSWORD.


Attempt #12 — It worked 🎉

✔ Using local iOS credentials (credentials.json)
✔ Archive Succeeded
✔ Successfully exported and signed the ipa file
✔ Uploading to DeployGate...
✔ Distribution page updated
Enter fullscreen mode Exit fullscreen mode

From git push to a tester opening the build on their phone: about 10 minutes.


The final workflow

Here's the complete GitHub Actions workflow that survived all 11 failures:

name: Build & Deploy to DeployGate
on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  build-and-deploy:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Clean caches
        run: |
          rm -rf ~/Library/Developer/Xcode/DerivedData/Arc-*
          rm -rf /tmp/eas-build-local-nodejs
          rm -rf /tmp/eas-cli-nodejs

      - name: Install dependencies
        run: npm ci

      - name: Copy credentials
        run: |
          cp ~/client-crm/credentials.json ./credentials.json
          cp -r ~/client-crm/credentials ./credentials

      - name: Unlock keychain
        run: security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" ~/Library/Keychains/login.keychain-db

      - name: Build .ipa
        run: eas build --platform ios --profile preview --local --non-interactive

      - name: Upload to DeployGate & update distribution page
        run: |
          IPA_FILE=$(ls build-*.ipa | head -1)
          curl -X POST \
            "https://deploygate.com/api/users/${DEPLOYGATE_OWNER}/apps" \
            -H "Authorization: Bearer ${DEPLOYGATE_API_TOKEN}" \
            -F "file=@${IPA_FILE}" \
            --form-string "distribution_key=${DEPLOYGATE_DIST_KEY}" \
            --form-string "release_note=$(git log -1 --pretty=%B)"
        env:
          DEPLOYGATE_OWNER: ${{ secrets.DEPLOYGATE_OWNER }}
          DEPLOYGATE_API_TOKEN: ${{ secrets.DEPLOYGATE_API_TOKEN }}
          DEPLOYGATE_DIST_KEY: ${{ secrets.DEPLOYGATE_DIST_KEY }}
Enter fullscreen mode Exit fullscreen mode

The DeployGate upload API accepts both the .ipa file and distribution page parameters in a single call — pass distribution_key to update an existing distribution page and release_note to auto-populate release notes from your commit message. Testers access the same fixed URL every time. No new links to send, no app store to check.


Why DeployGate instead of alternatives?

I tried the obvious options first:

  • TestFlight: Apple's review queue for the first build of each version can take hours to days. When you're iterating multiple times per day, that's a dealbreaker.
  • EAS Build (cloud): Works great, but the monthly cost adds up for a solo developer building across multiple projects.
  • Expo Go: Fine for early prototyping, but the moment you add native modules (and you will), it stops working.

DeployGate fit because: no review queue, free tier for indie devs, and an API simple enough to add to a GitHub Actions workflow in one curl command. Testers don't need accounts — they open a link and install.


What I learned

After 11 failures, every lesson is burned in:

  1. One workflow per trigger. Two workflows on the same push with shared Xcode resources will collide.
  2. Pin your packages. npm ci only protects you if package-lock.json hasn't drifted.
  3. Self-hosted runners need manual cache hygiene. Unlike cloud runners, they accumulate state between builds.
  4. eas.json over environment variables. Some CLI tools silently ignore env vars for credential configuration.
  5. Keychain access is not automatic. LaunchAgent processes can't see your GUI keychain without explicit unlock.
  6. Keep credentials out of the repo, but script their placement. Copy them from a known location on the build machine.

I'm a solo developer building multiple products — a CRM for bodywork therapists, a learning tool for structural consultants, and a few things I can't talk about yet.

Making mistakes along the way and sharing all of it here. Follow @kazbuildsstaff for real build-in-public, not the highlight reel.

Top comments (0)