---
title: Build and Deploy iOS Apps with Gitea Actions
description: iOS builds need a Mac, and Gitea Actions doesn't come with one. Build, sign, and ship to TestFlight from a Linux runner with Capawesome Cloud.
date:
  created: 2026-09-27
  updated: 2026-09-27
authors:
  - robingenz
categories:
  - Cloud
  - Guides
faq: true
---

# Build and Deploy iOS Apps with Gitea Actions

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.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Thousands of teams ship faster with Capawesome Cloud Native Builds and Live Updates" src="https://capawesome.io/assets/banners/cloud-teams-ship-faster-with-capacitor.png" />
  </a>
</div>

**Key takeaways:**

- gitea.com registers runners only for its `gitea` organization, 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 `host` label, but those jobs run without isolation, and Xcode, signing, and uploads stay your responsibility.
- A Gitea Actions job on `ubuntu-latest` can run `apps: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](https://docs.gitea.com/usage/actions/faq/){:target="_blank"} states that runners are registered only for the `gitea` organization's repositories, so every other user brings their own. [Gitea Cloud](https://about.gitea.com/products/cloud/){:target="_blank"}, the managed offering from CommitGo, starts runners on demand, but neither its product page nor its [pricing page](https://about.gitea.com/pricing){:target="_blank"} 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](https://capawesome.io/integrations/capacitor/){:target="_blank"}, 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](https://docs.gitea.com/runner/installation/binary/){:target="_blank"}, 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:

```bash
gitea-runner register --no-interactive \
  --instance https://gitea.example.com \
  --token <registration_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:

```yaml
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](https://docs.gitea.com/runner/){:target="_blank"} 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](https://docs.gitea.com/runner/labels/){:target="_blank"} 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 -exportArchive` needs an `ExportOptions.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](https://capawesome.io/){:target="_blank"} runs iOS builds on Apple Silicon M4 machines and exposes them through the [Capawesome CLI](../../cloud/cli/index.md), 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](../../cloud/native-builds/build-stacks.md), 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](https://console.cloud.capawesome.io){:target="_blank"} or with `npx @capawesome/cli apps:create`.

1. **Connect Gitea.** Create a Gitea access token under **Settings → Applications** with the `read:user`, `read:organization`, and `read:repository` scopes (or `write:repository` if 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](../../cloud/integrations/gitea.md) walks through each field.
2. **Upload signing assets.** Upload your distribution certificate (`.p12`) and its provisioning profile under a name such as `Production iOS`. The [iOS certificates guide](../../cloud/native-builds/certificates/ios.md) has the details, and the free [iOS Certificate Generator](https://capawesome.io/tools/ios-certificate-generator/){:target="_blank"} creates the CSR and `.p12` in your browser if there's no Mac at hand.
3. **Add a destination.** Create an [Apple App Store destination](../../cloud/app-store-publishing/destinations/apple-app-store.md) named `TestFlight` with an App Store Connect API key, so Capawesome Cloud can upload builds for you.
4. **Store the token.** Create an [API token](../../cloud/accounts/tokens.md) 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** as `CAPAWESOME_APP_ID`. Gitea rejects secret and variable names that start with `GITEA_` or `GITHUB_`, so keep the `CAPAWESOME_` 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`:

```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`](../../cloud/cli/commands.md#appsbuildscreate) 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](../../cloud/native-builds/build-types.md) 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](../../cloud/cli/scripting.md) 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](../../cloud/native-builds/environments.md), and to pick a specific Xcode, set `--stack`.

A release now starts with a version tag pushed to Gitea:

```bash
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](https://docs.gitea.com/usage/protected-tags){:target="_blank"} 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](https://docs.gitea.com/usage/actions/comparison/){:target="_blank"} notes that `jobs.<id>.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:

```yaml
on:
  push:
    branches:
      - main
```

The promote workflow takes a build number as input and calls [`apps:deployments:create`](../../cloud/cli/commands.md#appsdeploymentscreate). Save it as `.gitea/workflows/ios-promote.yaml`:

```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](https://blog.gitea.com/release-of-1.23.0){:target="_blank"} 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](../../cloud/native-builds/share-a-build.md).

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

```yaml
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](../../cloud/native-builds/build-without-git.md) 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](../../cloud/native-builds/firewall-access.md) 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:

1. Give the Gitea access token the `write:repository` scope, so Capawesome Cloud can register the webhook.
2. 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 as `v1.4.0-rc.1` ([trigger on tags](../../cloud/automations/trigger-on-tags.md)).
3. Choose iOS as the platform and attach the `Production iOS` certificate and the `TestFlight` destination in the [build settings](../../cloud/automations/attach-build-settings.md).

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](../../cloud/automations/webhooks.md) 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.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## 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](./how-to-build-and-deploy-ios-apps-without-a-mac.md). If you have questions, join the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}, and [subscribe to the Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"} to stay up to date.
