An iOS build needs Xcode, Xcode needs macOS, and Gitea Actions doesn't come with a Mac. Your Node, Go, or Android jobs run fine on Linux runners, but the iOS build has nowhere to go. In this guide, we'll set up Gitea Actions iOS builds that start on the Linux runner you already have and hand the macOS work to Capawesome Cloud, which compiles and signs the app and uploads it to TestFlight. We'll also cover the self-hosted Mac alternative, a Gitea server behind a firewall, and a setup that needs no workflow file at all.
Key takeaways:
- gitea.com registers runners only for its
giteaorganization, and Gitea Cloud names no OS for its on-demand runners, so iOS builds need your own Mac or a build service. - Gitea Runner can run jobs directly on a Mac through a
hostlabel, but those jobs run without isolation, and Xcode, signing, and uploads stay your responsibility. - A Gitea Actions job on
ubuntu-latestcan runapps:builds:create, which builds, signs, and submits the app to TestFlight on Capawesome Cloud's Apple Silicon M4 machines. - If Capawesome Cloud can't reach your Gitea server, the runner checks out the code and uploads it with
--path, so no inbound firewall rule is needed. - Capawesome Cloud Automations build on Gitea tag pushes through a webhook, with no workflow file and no runner.
macOS runners on Gitea
Gitea Actions only runs jobs on runners that someone has registered with the instance, so the runners available to you depend on where your Gitea lives. On gitea.com, the Gitea Actions FAQ states that runners are registered only for the gitea organization's repositories, so every other user brings their own. Gitea Cloud, the managed offering from CommitGo, starts runners on demand, but neither its product page nor its pricing page names an operating system, and the Enterprise plan lists Kubernetes autoscaling runners. A self-hosted Gitea instance has exactly the runners your team registers.
An iOS build can't avoid macOS for its final steps. Xcode, xcodebuild, and codesign only run on Apple hardware, whether the project is native Swift, Capacitor, or Cordova. That leaves two options. You can register a Mac as a runner, or keep the job on Linux and send the build to a service that runs Macs.
Self-hosting a Mac runner
Gitea Runner (formerly act_runner) ships official macOS binaries, so a Mac mini can pick up Gitea Actions jobs like any other runner. To run jobs directly on the Mac instead of in a container, register the runner with a host label:
gitea-runner register --no-interactive \
--instance https://gitea.example.com \
--token \
--name mac-mini-1 \
--labels macos:host
A workflow then targets the Mac with runs-on: macos, and every step uses the Xcode installed on that machine:
jobs:
build-ios:
runs-on: macos
steps:
- uses: actions/checkout@v4
- run: xcodebuild -project MyApp.xcodeproj -scheme MyApp -archivePath build/MyApp.xcarchive archive
The Gitea Runner documentation lists the trade-offs of host mode. Jobs on a host label run without isolation from each other, a docker:// action or a service container still needs a Docker daemon on the Mac, and the labels guide recommends distinct label names so a workflow written for ubuntu-latest never runs unsandboxed on your machine. The binary installation guide includes a launchd plist that keeps the runner alive across reboots.
The archive step above is the easy part. Everything after it is yours to build and maintain:
- Xcode upgrades. When Apple raises the minimum SDK for App Store submissions, every Mac runner needs the new Xcode installed and tested.
- Signing. The distribution certificate lives in a keychain that has to be unlocked for non-interactive jobs, and provisioning profiles have to be installed and renewed on each machine.
-
Export and upload.
xcodebuild -exportArchiveneeds anExportOptions.plist, and the TestFlight upload needs App Store Connect credentials and a tool such as fastlane. - The machine itself. macOS updates, disk space filled by DerivedData and old archives, and builds queueing behind each other on a single Mac.
If your team already runs Macs and someone owns them, this setup works. Otherwise, the rest of this guide keeps the job on Linux.
Offloading to Capawesome Cloud
Capawesome Cloud runs iOS builds on Apple Silicon M4 machines and exposes them through the Capawesome CLI, so a Gitea Actions job only needs Node.js to start one. The job runs apps:builds:create on your existing ubuntu-latest runner. Capawesome Cloud then clones the commit from your Gitea server through a Git connection, runs the web build and native sync for Capacitor and Cordova apps, compiles with Xcode, signs the app with a certificate you uploaded once, and can submit the result to TestFlight.
The default Gitea Runner image (docker.gitea.com/runner-images:ubuntu-latest) already includes Node.js, so the job needs no actions/setup-node step. Because Capawesome Cloud clones the repository itself, it needs no actions/checkout either. Each build starts in a fresh macOS environment, and Xcode versions come from the selected build stack, so moving to a new Xcode means changing one flag instead of reinstalling a Mac.
One-time setup
The Capawesome Cloud side of the setup takes four steps. The Gitea connection belongs to your Capawesome Cloud organization and is reused by every app, and everything else is set up once per app. Create the app first, either in the Console or with npx @capawesome/cli apps:create.
-
Connect Gitea. Create a Gitea access token under Settings → Applications with the
read:user,read:organization, andread:repositoryscopes (orwrite:repositoryif you plan to use Automations, covered below). Add a Gitea connection in Capawesome Cloud with that token. For a self-hosted server, enable Self-hosted or enterprise instance and enter its URL; on gitea.com, leave the option off. Then open the app's Git repository page and connect the repository. The Gitea integration guide walks through each field. -
Upload signing assets. Upload your distribution certificate (
.p12) and its provisioning profile under a name such asProduction iOS. The iOS certificates guide has the details, and the free iOS Certificate Generator creates the CSR and.p12in your browser if there's no Mac at hand. -
Add a destination. Create an Apple App Store destination named
TestFlightwith an App Store Connect API key, so Capawesome Cloud can upload builds for you. -
Store the token. Create an API token and add it to your Gitea repository under Settings → Actions → Secrets as
CAPAWESOME_TOKEN, which the CLI reads automatically. Add the app ID under Settings → Actions → Variables asCAPAWESOME_APP_ID. Gitea rejects secret and variable names that start withGITEA_orGITHUB_, so keep theCAPAWESOME_prefix.
The release workflow
The release workflow builds and ships the iOS app whenever someone pushes a version tag. Save it as .gitea/workflows/ios-release.yaml:
name: iOS release
on:
push:
tags:
- "v*"
jobs:
release-ios:
runs-on: ubuntu-latest
env:
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
steps:
- run: |
npx @capawesome/cli@4.22.0 apps:builds:create \
--app-id "${{ vars.CAPAWESOME_APP_ID }}" \
--platform ios \
--type app-store \
--certificate "Production iOS" \
--destination "TestFlight" \
--git-ref "${{ gitea.sha }}" \
--yes
The apps:builds:create command builds the exact commit behind the tag. ${{ gitea.sha }} is Gitea's context for that commit, and ${{ github.sha }} works as an alias if you're porting a GitHub workflow. --type app-store produces an IPA for TestFlight and the App Store; the other iOS build types are simulator, development, ad-hoc, and enterprise. --destination submits the signed build once it succeeds.
The command waits for the build and exits non-zero if it fails, so a failed build marks the Gitea Actions run as failed. Gitea sets CI=true in every run, which keeps the CLI from prompting for input, and the CLI scripting guide explains the rest of its non-interactive behavior. Pinning the CLI version keeps runs reproducible. To inject build-time variables and secrets, add --environment with an environment, and to pick a specific Xcode, set --stack.
A release now starts with a version tag pushed to Gitea:
git tag v1.4.0
git push origin v1.4.0
Anyone who can push a v* tag can now ship to TestFlight. A protected tag rule for v* under Settings → Tags limits that to the users and teams you choose.
Manual release approval
Gitea Actions has no deployment approvals. The comparison with GitHub Actions notes that jobs..environment is ignored, so a required-reviewer environment won't pause a job. A pattern that works instead is to split building from shipping. Build on every push to main without a destination, then promote a chosen build with a manually dispatched workflow.
For the build half, reuse the release workflow with a branch trigger and drop the --destination line:
on:
push:
branches:
- main
The promote workflow takes a build number as input and calls apps:deployments:create. Save it as .gitea/workflows/ios-promote.yaml:
name: iOS promote
on:
workflow_dispatch:
inputs:
build_number:
description: Build number to submit to TestFlight
required: true
jobs:
promote-ios:
runs-on: ubuntu-latest
env:
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
steps:
- run: |
npx @capawesome/cli@4.22.0 apps:deployments:create \
--app-id "${{ vars.CAPAWESOME_APP_ID }}" \
--build-number "${{ inputs.build_number }}" \
--destination "TestFlight"
Dispatching with inputs requires Gitea 1.23 or later, which added manual runs with an input form in the Actions UI. Take the build number from the build list in the Capawesome Cloud Console. Gitea has no reviewer list to check against, so the gate is whoever starts the run.
Builds for QA don't have to go through TestFlight at all. An ad-hoc build with --share gets a public install link for testers, as described in Share a build.
Servers behind a firewall
Capawesome Cloud has to reach your Gitea server to clone a repository by --git-ref. When the server is only reachable from inside your network, the Gitea Actions runner is usually the one machine that already has access. Let it check out the code and upload the source with --path instead:
steps:
- uses: actions/checkout@v4
- run: |
npx @capawesome/cli@4.22.0 apps:builds:create \
--app-id "${{ vars.CAPAWESOME_APP_ID }}" \
--platform ios \
--type app-store \
--certificate "Production iOS" \
--destination "TestFlight" \
--path . \
--yes
The job keeps the same env block as the release workflow. The CLI packs the directory into an archive, respecting your .gitignore, and uploads it, so no inbound connection to your network is needed. The build without Git guide lists what you give up. These builds show no commit metadata in the Console, can't be started or re-run from the Console, and don't work with Automations. By default, actions/checkout itself is downloaded from github.com, so the runner needs outbound access there.
To keep the Git connection instead, the firewall guide compares a reverse tunnel (the recommended option), repository mirroring, and IP allowlisting.
Builds without a workflow
Capawesome Cloud Automations start builds from Gitea pushes without any Gitea Actions workflow. An automation receives your repository's push events through a webhook and builds when a matching branch or tag arrives, so a tag automation can replace the release workflow entirely:
- Give the Gitea access token the
write:repositoryscope, so Capawesome Cloud can register the webhook. - On the app's Automations page, create an automation with the trigger type Tag and the pattern
v*. A second pattern,!v*-*, skips pre-release tags such asv1.4.0-rc.1(trigger on tags). - Choose iOS as the platform and attach the
Production iOScertificate and theTestFlightdestination in the build settings.
Capawesome Cloud registers the webhook for you. On a self-hosted instance that registration can fail, for example because of missing permissions, and the webhook guide then lists the URL and secret to add in Gitea's repository settings by hand. Automations clone through the Git connection, so they need a Gitea server that Capawesome Cloud can reach. They also work on an instance with no registered runners, and a commit with [skip ci] in its message skips them.
The trade-off is control. An automation builds every matching tag, while a workflow can run your tests first and start the build only when they pass.
FAQ
Does Gitea Actions have macOS runners?
Gitea Actions doesn't come with a Mac. gitea.com registers runners only for the gitea organization's repositories, and Gitea Cloud describes on-demand runners without naming an operating system. You can register your own Mac with Gitea Runner using a host label, or run the job on Linux and hand the iOS build to a service such as Capawesome Cloud.
Can Gitea Runner build iOS apps on my own Mac?
Yes. Register Gitea Runner on the Mac with a label such as macos:host and target it with runs-on: macos. The steps then run directly on the machine with its installed Xcode and without isolation between jobs, and you maintain Xcode, the signing keychain, and the App Store Connect upload yourself.
Do I need Gitea Actions at all?
No. Capawesome Cloud Automations trigger iOS builds on Gitea branch or tag pushes through a webhook and can submit the result to TestFlight, with no workflow file and no runner. Use Gitea Actions when tests or other jobs should run before the build.
Does this work with a Gitea server behind a firewall?
Yes. A runner inside your network can run actions/checkout and upload the source with apps:builds:create --path ., so Capawesome Cloud never connects to your server. To keep the Git connection and Automations instead, expose the server through a reverse tunnel, a mirror, or an IP allowlist.
Which workflow directory does Gitea read?
Gitea looks for .gitea/workflows first and falls back to .github/workflows, using the first directory that exists. Once a repository has a .gitea/workflows directory, Gitea ignores the workflows in .github/workflows.
Does this work for Capacitor and Cordova apps?
Yes. Capawesome Cloud builds native Swift and Objective-C projects as well as Capacitor and Cordova apps, and it runs the web build and native sync for the hybrid ones. The Gitea workflow is the same for all of them.
Try it yourself
If iOS is the one job your Gitea runners can't handle, connect your Gitea server, upload a certificate, and push a tag. Capawesome Cloud comes with a 14-day free trial, which covers your first builds.
Final thoughts
Start with a tag automation if pushing a tag is your whole release process. Move to the Gitea Actions workflow once tests have to pass before a build starts, and switch to --path when Capawesome Cloud can't reach your server. Register a Mac as a runner only if someone on your team already maintains Macs.
For the same flow without a CI layer, read How to Build and Deploy iOS Apps Without Owning a Mac. If you have questions, join the Capawesome Discord server, and subscribe to the Capawesome newsletter to stay up to date.
Top comments (2)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support
Do not follow any external links! DEV.to uses Sloan for automated messages, this is likely phishing.