---
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-28
authors:
  - robingenz
categories:
  - Cloud
  - Guides
faq: true
---

# Build and Deploy iOS Apps with Gitea Actions

Gitea Actions runs your Node, Go, and Android jobs on the Linux runners you already have. Then you add an iOS build, and the job has nowhere to go. Xcode only runs on macOS, and a macOS runner in Gitea Actions means bringing your own Mac, installing Gitea Runner on it, and letting every job run directly on that machine with no container or VM in between. In this guide, we'll set up Gitea Actions iOS builds that start on your existing `ubuntu-latest` runner and hand the macOS work to Capawesome Cloud. It compiles the app on Apple Silicon M4 machines, signs it with your certificate, and uploads it to TestFlight, so you ship iOS apps without owning, renting, or maintaining a single Mac. We'll also cover the self-hosted Mac runner, a Gitea server behind a firewall, and a tag-triggered release 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 Actions runs macOS jobs only on a Mac you register yourself with Gitea Runner, so iOS builds need your own hardware 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.

## The macOS requirement

An iOS build ends on a Mac no matter where it starts. 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. Gitea Actions supports macOS runners, but only the ones you register yourself. Neither gitea.com, which registers runners only for the `gitea` organization's repositories according to the [Gitea Actions FAQ](https://docs.gitea.com/usage/actions/faq/){:target="_blank"}, nor [Gitea Cloud](https://about.gitea.com/products/cloud/){:target="_blank"}, whose [plans](https://about.gitea.com/pricing){:target="_blank"} list on-demand and Kubernetes autoscaling runners, hands you a Mac.

That leaves two options. Register a Mac of your own, or keep the job on Linux and send the build to a service that runs Macs for you.

## Your own Mac runner

Gitea Runner (formerly act_runner) ships [official macOS binaries](https://docs.gitea.com/runner/installation/binary/){:target="_blank"}, so a Mac mini in the office can pick up Gitea Actions jobs like any other runner. Xcode has to be installed on that Mac, and the jobs have to run on the machine itself rather than in a container, which is what the `host` label does:

```bash
gitea-runner register --no-interactive \
  --instance https://gitea.example.com \
  --token <registration_token> \
  --name mac-mini-1 \
  --labels macos:host
```

A workflow targets the Mac with `runs-on: macos`, and every step runs with 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
```

Host mode comes with conditions that the [Gitea Runner documentation](https://docs.gitea.com/runner/){:target="_blank"} spells out. Jobs run directly on the Mac with no isolation from each other or from the machine, so one job's leftovers become the next job's starting point. A `docker://` action or a service container still needs a Docker daemon on the Mac. The [labels guide](https://docs.gitea.com/runner/labels/){:target="_blank"} recommends distinct label names, so a workflow written for `ubuntu-latest` never lands unsandboxed on your hardware. The binary installation guide includes a launchd plist that restarts the runner after a reboot.

The archive step is the easy part. Everything after it lands on your team:

- **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 eaten 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. If nobody wants that job, the rest of this guide keeps the iOS build on Linux.

## Build on Linux instead

[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 all a Gitea Actions job needs is Node.js. The job runs `apps:builds:create` on your existing `ubuntu-latest` runner. Capawesome Cloud 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, with a destination attached, submits the result to TestFlight.

The workflow ends up shorter than the Mac version. The default Gitea Runner image (`docker.gitea.com/runner-images:ubuntu-latest`) already includes Node.js, so there's no `actions/setup-node` step, and because Capawesome Cloud clones the repository itself, there's no `actions/checkout` either. Each build starts in a fresh macOS environment, and the Xcode version comes 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 takes four steps, and you never repeat them for the same app. The Gitea connection belongs to your Capawesome Cloud organization and serves every app; everything else is per app. Create the app first, 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.

## Release on tag push

Push a version tag, and the workflow builds and ships the iOS app. 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 as soon as it succeeds.

The command waits for the build and exits non-zero if it fails, so a failed build fails the Gitea Actions run with it. 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`.

From here on, a release is two commands:

```bash
git tag v1.4.0
git push origin v1.4.0
```

That also means anyone who can push a `v*` tag can 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. What works instead is to split the build from the upload. Build on every push to `main` without a destination, then promote the build you choose 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. The build number comes from the build list in the Capawesome Cloud Console. Gitea keeps no reviewer list, so the gate is whoever has permission to start the run.

QA builds 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).

## Gitea 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. And since `actions/checkout` is downloaded from github.com by default, 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 with no Gitea Actions workflow and no runner. 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. [Auto-Submit a Capacitor App to TestFlight and Google Play](./auto-submit-capacitor-app-to-testflight-and-google-play.md) sets up the same tag automation for both stores, with the Google Play destination and the errors to expect on the first release.

## FAQ

### Does Gitea Actions have macOS runners?

Only the ones you register. Gitea Runner ships macOS binaries, so you can enroll your own Mac with a `host` label, but gitea.com and Gitea Cloud don't hand you one. The alternative is to keep 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.

## Related Posts

- [Build and Deploy iOS Apps with GitLab CI/CD](./build-and-deploy-ios-apps-with-gitlab-ci-cd.md)
- [Build & Ship iOS Apps with Bitbucket Pipelines](./build-and-deploy-ios-apps-with-bitbucket.md)
- [How to Build and Deploy iOS Apps Without Owning a Mac](./how-to-build-and-deploy-ios-apps-without-a-mac.md)

## Try it yourself

If iOS is the one job your Gitea runners can't take, 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.
