Skip to content

Porting a GitHub Actions workflow to Forgejo Actions

A workflow that runs on GitHub-hosted runners fails in at least one way on a self-hosted Forgejo runner, and the failures are scattered: the artifact API, anonymous api.github.com calls, runner labels, secret names, and a pair of Forgejo-side switches that keep a workflow from running at all. This guide ports this site’s own build-and-deploy pipeline and records each difference with the fix used and the way it was checked.

The worked example is two workflows: deploy.yml (build, test, deploy to Cloudflare Workers static assets, purge the zone cache) and link-check.yml (weekly link check that files an issue on failure). bun run build is bun test tests --bail && astro build && bun run verify:docs, so the build step is the test gate: PRs get the gate, only main (or a manual dispatch) deploys.

Prerequisites: a self-hosted Forgejo instance you can admin (you will read one admin setting), a registered Forgejo runner you can inspect, the workflow file to port, and, for the deploy step shown, a Cloudflare account with a Workers static assets project. The forge and runner themselves are covered in the self-hosted Forgejo reference.

repoerfi/lexicanum(native, Actions unit on)Forgejo 16.0.5Actionspush to main/ PR / dispatchForgejo Runner 13.2.0(container, capacity 4labels: ubuntu-latest,ubuntu-22.04)workflow runjob containercatthehacker/ubuntu:act-22.04build-and-deploydocker job container(host socket, forge bridge network)Cloudflare Workersstatic assetsbun install, build + test,wrangler-action deploypurge-cloudflare-cachejob (separate)cache purge step(main/dispatch only)

Text fallback for the diagram:

  1. A push, pull request, or manual dispatch to the repo reaches Forgejo 16.0.5 on the router.
  2. Forgejo queues the run on a Forgejo Runner 13.2.0 container (capacity 4, labels ubuntu-latest and ubuntu-22.04, both mapped to docker://ghcr.io/catthehacker/ubuntu:act-22.04).
  3. The runner starts a job container on the act-22.04 image. The build-and-deploy job runs bun install, the build and test gate, then the Workers deploy and the zone cache purge.
  4. A separate purge-cloudflare-cache job runs after it on main only.
ComponentVersion at time of writing
Forgejo16.0.5 (reports 16.0.5+gitea-1.22.0)
Forgejo Runner13.2.0, in a container next to the forge, capacity 4
Job imageghcr.io/catthehacker/ubuntu:act-22.04 (both labels map to it; there is no ubuntu-24.04 label)
Site buildAstro + Starlight, deployed to Workers static assets (assets.directory: ./dist, workers_dev: false)

Two runner settings shape everything below. The runner’s config is regenerated from forgejo-runner generate-config on every deploy by a one-shot seed container, then edited (sed) for capacity, the job network, the docker_host automount, the cache dir, and privileged; a config-revision env var is bumped to force a restart, because the daemon reads config.yml only at start. Job containers get the host Docker socket (docker_host: automount) and join the forge’s bridge network, because the default per-workflow bridge cannot reach the forge (Docker inter-bridge isolation). The runner carries a 15-minute stop grace period so a restart does not SIGKILL running jobs; Docker’s default is 10 s.

Differences: GitHub Actions vs Forgejo Actions

Section titled “Differences: GitHub Actions vs Forgejo Actions”

Every difference that changed this workflow, with the fix and how it was checked. Vendor links stay inline because this is a guide; the public copy of the finished workflow is at the bottom.

DifferenceFix usedHow it was checked
Artifacts: actions/upload-artifact@v4+ and actions/download-artifact from github.com fail on this runner; Forgejo documents artifact support only for “v3 or patched v4” (its own forks), with no cross-run accessMerge producer and consumer jobs into one job; release matrices become a loop in one jobThe port merged deploy into build (commit a1a3b0e); Forgejo Advanced features for the v3 / patched v4 statement
OIDC: permissions: id-token: write does nothing; Forgejo’s own mechanism is the top-level enable-openid-connect key, which this pipeline did not use or testDrop keyless signing, or use long-lived keys as secretsForgejo vs GitHub Actions and the reference page for the key and its disabled-for-fork-PRs behaviour
The permissions: block is ignored (Forgejo lists it among ignored job keys; the runner logs a warning only); token scope is what the instance grantsDelete the blocklink-check.yml dropped it in a1a3b0e; Forgejo vs GitHub Actions
[skip ci] in a commit message is honoured by Forgejo too (default strings: [skip ci], [ci skip], [no ci], [skip actions], [actions skip], in a commit message or PR title)Do not put [skip ci] on the commit that adds or fixes a workflowConfiguration Cheat Sheet (SKIP_WORKFLOW_STRINGS); observed on another repo 2026-09-25
runs-on: means whatever the runner was registered with, not the GitHub-hosted imageUse a registered label; install missing tools explicitlyRunner registration in docker-compose.router.yml; Forgejo Runner Configuration; Quick start guide for “no matching runner is online”
Bare uses: owner/action@v is prefixed with the instance’s DEFAULT_ACTIONS_URL; Forgejo’s default is https://data.forgejo.org, this instance sets DEFAULT_ACTIONS_URL = githubCheck the admin setting, or write full https://github.com/... URLsapp.ini on this instance; green runs using cloudflare/wrangler-action@v4; Config Cheat Sheet for the github keyword
Secrets live at user, organisation and repository level, lowest level wins; managed in the UI, repo-level also via the API; no environment-scoped secretsPut shared deploy tokens at user or organisation levelGitea Secrets (Forgejo has no equivalent page; this is the page it inherits); GET /api/v1/repos/{owner}/{repo}/actions/secrets returned 200
Secret names: letters, digits, underscore; no leading digit; case-insensitive; names starting GITHUB_ or GITEA_ are rejected (documented) and FORGEJO_ too (observed UI error)Rename (GH_ISSUE_TOKEN in link-check.yml)Gitea Secrets for the documented rules; the UI error observed on this instance 2026-09-18
The github.* context works (“some keys are missing”); github.repository is the forge’s owner/repo path, not GitHub’sHardcode the GitHub path (-R owner/repo) when calling GitHubForgejo vs GitHub Actions; link-check.yml step 5 below
Actions and installers that call the GitHub API anonymously: setup-bun resolves a non-exact version by listing tags on api.github.com and sends a token only when github.server_url is github.com; the unauthenticated limit is 60 requests/hour per originating IP, shared by every job behind the runner’s one public addressPinned-version installers (step 6)setup-bun action.yml (token default expression); setup-bun download-url.ts (tag lookup); GitHub rate limits
gitleaks/gitleaks-action v2+ is licence-gated and checks repo ownership through the GitHub API, which on a forge returns an unexpected type and the action hard-fails with a missing-licence errorDownload the pinned gitleaks release tarball; run the binaryA Go CLI repo’s .forgejo/workflows/ci.yml; gitleaks-action for the licence wording
aquasecurity/trivy-action fetches its upstream repo mid-step, which fails unauthenticated from the runner; a registry pull of a roughly 2 GB image layer during the scan also died with a PROTOCOL_ERROR (three runs)Install the trivy release binary; build with load: true, scan the loaded local image by tag, then push in a second build-push step that hits the layer cacheA container-image repo’s .forgejo/workflows/ci.yml; no vendor page documents the failure, so it is recorded as an observation
d2’s install.sh calls api.github.com to resolve the version and returned 401 from the runnerPinned release tarballThis site’s commit 9b4d31f; d2 install.sh
Job containers share the host daemon via the mounted socket, so -v bind mounts and -p published ports resolve on the host, and testcontainers-style tests that mount the workspace or dial localhost failRunner set privileged: true; such jobs start a job-private dockerd; plain builds keep the host socketdocker-compose.router.yml and this instance’s runner commit 65888aa; Utilizing Docker within Actions for the automount semantics
A pull-mirrored repo never runs its workflows: mirror sync is git data only and mirror-enrolled repos get no Actions unitConvert to a native repo before relying on CIObserved on this instance; no vendor page documents it, so it is recorded as an observation, not a citation
The Actions unit must be enabled per repo; a repo converted by flipping the mirror flag keeps no Actions unit, and there is no API or CLI to enable a unit on 16.0.xTick Actions in the UI, or recreate the repo via POST /user/reposQuick start guide (Units > Overview > Actions); Configuration Cheat Sheet (DEFAULT_REPO_UNITS)
docker/build-push-action with cache-from: type=gha failed: job containers could not reach the runner’s cache endpoint; actions/cache itself works against the runner’s cacheUse registry cache or --mount=type=cache; keep actions/cacheactions/cache@v6 in deploy.yml runs green; Forgejo Advanced features lists actions/cache as supported

Step 1: move the file and enable Actions on the repo

Section titled “Step 1: move the file and enable Actions on the repo”

The original move landed on 2026-09-24 in commit a1a3b0e (“move workflows to Forgejo Actions; build and deploy in one job (no artifact API)”): .github/workflows/ to .forgejo/workflows/, with git recording both files as renames (82% and 78% similarity).

Two conditions gate whether the workflow runs at all.

  1. The repo must be native, not mirror-enrolled. A pull-mirrored repo never runs workflows. Converting means deleting the mirror, creating a native repo, and pushing main only.
  2. The repo needs the Actions unit enabled: Settings -> Units -> Overview -> Actions. A repo created by flipping the mirror flag keeps no Actions unit, and on 16.0.x there is no API or CLI to add one; inserting unit rows by hand broke pushes on this instance. Recreating the repo via POST /user/repos is the working path.

Do not put [skip ci] on the commit that adds the moved file: a workflow file that only ever lands in [skip ci] commits never runs, because Forgejo honours the skip strings too.

One test-side consequence: any repo test that enumerates workflow files has to read .forgejo/workflows as well as .github/workflows. This site’s harness check (“every verify/check script has a CI trigger”) needed that change (commit 8faf27f).

Step 2: match runs-on: to the runner’s labels

Section titled “Step 2: match runs-on: to the runner’s labels”

runs-on: means whatever the runner was registered with. This runner registered two labels, ubuntu-latest and ubuntu-22.04, both mapped to docker://ghcr.io/catthehacker/ubuntu:act-22.04:

runs-on: ubuntu-latest # resolves to the act-22.04 job image on this runner

A label the runner does not have leaves the job pending: “no matching runner is online”. The act image is a smaller image than GitHub’s hosted runner image, so tools that happen to be preinstalled there (shellcheck, for example) are absent: install what a job needs, explicitly.

Bare uses: names are prefixed with the instance’s DEFAULT_ACTIONS_URL. Forgejo’s default for that setting is https://data.forgejo.org; this instance sets DEFAULT_ACTIONS_URL = github, so actions/checkout@v7, actions/cache@v6 and cloudflare/wrangler-action@v4 resolve to github.com and the workflow needed no uses: edits. Check the admin setting on your instance, or write fully qualified https://github.com/owner/action@tag URLs to be instance-agnostic.

The deploy was a second job: deploy-to-cloudflare-workers with needs: build, which re-checked-out, re-installed, and downloaded dist via actions/download-artifact@v8; the build job ended with actions/upload-artifact@v7. On this runner the github.com artifact actions fail (a GHESNotSupportedError at upload), and Forgejo documents artifact support only for v3 or its patched v4 forks, with no cross-run access.

After: one job, build-and-deploy. The deploy and purge steps carry the old job’s condition as step-level if:, and the downstream purge-cloudflare-cache job’s needs: points at the merged job with the same condition in its own if::

# deploy step inside build-and-deploy (secret names shown generically)
- name: Deploy to Workers static assets
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.WRANGLER_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy dist

Release matrices that previously fanned out to per-variant jobs become a loop in one job: there is no artifact to hand between them.

Step 5: drop permissions: and replace the token

Section titled “Step 5: drop permissions: and replace the token”

link-check.yml carried:

permissions:
contents: read
issues: write

Forgejo ignores the block (it logs a warning only). Delete it.

The issue-filing step used secrets.GITHUB_TOKEN with gh issue .... On a forge that token belongs to the forge, not GitHub, so the step now uses a PAT secret and passes the repo explicitly, because github.repository on the forge is the forge path:

- name: File an issue on failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GH_ISSUE_TOKEN }}
run: gh issue create -R erfianugrah/lexicanum --title "Link check failed" --body "$FAILURES"

Step 6: replace actions and installers that call the GitHub API anonymously

Section titled “Step 6: replace actions and installers that call the GitHub API anonymously”

The runner has one public address, so every anonymous api.github.com call from every job shares the unauthenticated limit of 60 requests/hour per originating IP. Two installers hit it.

oven-sh/setup-bun@v2 resolves a non-exact version by listing tags on api.github.com and sends a token only when github.server_url is github.com, so on a forge it goes anonymous. Replacement: the bun.sh installer with a pinned version:

- name: Install bun
run: |
curl -fsSL https://bun.sh/install -o /tmp/bun.sh
bash /tmp/bun.sh "bun-v1.3.13"
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"

d2’s install.sh calls api.github.com/repos/terrastruct/d2/releases/... to resolve the version; it returned 401 from the runner. Replacement: the pinned release tarball (commit 9b4d31f, “install.sh hits the GitHub API and 401s”):

- name: Install d2
run: |
curl -fsSL https://github.com/terrastruct/d2/releases/download/d2-v0.7.1/d2-v0.7.1-linux-amd64.tar.gz -o /tmp/d2.tgz
tar -xzf /tmp/d2.tgz -C /tmp d2
install -m 0755 /tmp/d2 "$HOME/.local/bin/d2"
d2 --version

Step 7: security scanners as bare binaries

Section titled “Step 7: security scanners as bare binaries”

Two scanner actions break on a forge, and the fix for both is the pinned release binary.

gitleaks/gitleaks-action v2+ is licence-gated (a free licence key is required for organisation-owned repos) and determines ownership through the GitHub API; on a forge that lookup returns an unexpected type and the action fails with a missing-licence error. In a Go CLI repo’s CI, pin the version you have vetted and download it directly:

- name: gitleaks
env:
GITLEAKS_VERSION: "put the release you vetted here"
run: |
curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o /tmp/gl.tgz
tar -xzf /tmp/gl.tgz -C /tmp gitleaks
/tmp/gitleaks git --no-banner # exits 1 on findings

aquasecurity/trivy-action fetches its upstream repo mid-step, which fails unauthenticated from the runner, and a registry pull of a roughly 2 GB image layer during the scan also died with a PROTOCOL_ERROR (three runs). In a container-image repo’s CI: install the trivy release binary, build the image with load: true, scan the loaded local image by tag, then push in a second build-push step that hits the layer cache.

Step 8: container-heavy tests (testcontainers, bind mounts)

Section titled “Step 8: container-heavy tests (testcontainers, bind mounts)”

Job containers share the host daemon through the mounted socket, so a -v ./x:/y bind mount and a -p published port resolve on the host, not inside the job container. Tests that mount the workspace or dial localhost on a published port fail against that topology.

This runner is set privileged: true (runner commit 65888aa, “privileged jobs (job-private dockerd for bind-mount/port tests)”). Jobs that need real container semantics start a job-private dockerd - vfs storage driver, its own socket, DOCKER_HOST exported via $GITHUB_ENV - and such suites additionally needed a forward-chain firewall rule on the host accepting DNATed published-port traffic between the Docker bridges. Running a job-private dockerd is effectively root on the host: privileged: true plus the mounted Docker socket means a job container can reach anything the host daemon can, so this setting stays limited to trusted repos. Plain image builds keep the host socket; do not pay the private-daemon cost for them.

Step 9: secrets, including the deploy token

Section titled “Step 9: secrets, including the deploy token”

Forgejo keeps secrets at user, organisation and repository level, with the lowest level taking precedence. They are managed in the UI; the repository-level API exists (GET /api/v1/repos/{owner}/{repo}/actions/secrets returns the list on this instance; GET /api/v1/user/actions/secrets is a 404, so user-level secrets are UI-only).

The Cloudflare deploy token is a user-level secret (commit 42dabb3, “deploy with the user-level … secret (rotated token)”), so one secret serves every repo owned by that user; a repo-level secret of the same name would override it.

Name rules: letters, digits, underscore; no leading digit; case-insensitive (the UI uppercases). Names starting GITHUB_ or GITEA_ are rejected outright, and FORGEJO_ was rejected by the UI here. A GitHub-side GITHUB_PUSH_TOKEN-style name must be renamed, which is why link-check.yml uses GH_ISSUE_TOKEN.

CheckHowExpected on this instance
Deploy on maingit push to maingreen build-and-deploy (build + test gate, then deploy and purge), then purge-cloudflare-cache
Gate on a PR or branch pushopen a PRbuild-and-deploy runs build + test; deploy, purge and purge-cloudflare-cache all skipped by their if:
Manual dispatchworkflow_dispatch on a non-main refbuild + test run; deploy and purge steps skipped
Run state over RESTGET /api/v1/repos/{owner}/{repo}/actions/runs (and .../runs/{id}/jobs)workflow_runs[] with id, title, event, status; per-job status under jobs

Timing, measured from the runs API: the first successful run after the move (manual dispatch, 2026-09-24, cold caches) took 7 min 5 s; subsequent deploy runs on 2026-09-27 took 61 s, 57 s, 57 s, 61 s.

Two API quirks to check against, verified on this instance on 2026-09-29: the event field reads push for scheduled runs, while trigger_event holds the real trigger (schedule, workflow_dispatch); and the limit query parameter was not honoured (asked 5, got 15).

  • The first Forgejo run of deploy.yml failed after 34 s; the cause is the one recorded in git - d2’s install.sh hit api.github.com and got 401, fixed in 9b4d31f. The other two early failures have no recorded cause; do not attribute them.
  • A bulk find-and-replace of setup-bun left an orphaned with: bun-version block under a run: step, a workflow schema error (unknown property). When replacing a uses: with a run:, delete the with: block with it.
  • The anonymous GitHub limit is shared by every job behind the runner’s single public IP, which is why ported workflows using setup-bun failed on 2026-09-23.
  • The runner recreate that used Docker’s default 10 s stop timeout SIGKILLed the runner mid-job and orphaned job containers on the host daemon. The 15-minute stop_grace_period and an orphan reaper on the host fixed it.
  • Pushing all tags to a freshly converted native repo fires one release run per historical tag, because tag-push workflows are evaluated from the default branch’s workflow files. Push main only; if it happens, cancel at run, job and task level.
  • A tag pushed onto a commit that already has a push run created no new run (mechanism inferred, not confirmed). The release tag goes out with a new commit.
  • A testcontainers-style suite needs the job-private dockerd plus the host firewall rule for published-port traffic; the failure mode is a bind mount that resolves on the host.
  • Forgejo’s enable-openid-connect key exists but is untested on this runner; the ported workflows dropped keyless signing rather than rewiring it, so treat that key as available-but-unverified here.
  • The weekly link-check.yml schedule run on 2026-09-28 failed twice over, per the job log fetched from GET /api/v1/repos/{owner}/{repo}/actions/jobs/{id}/logs: the check found 9 dead links (repos made private or deleted), and the issue-filing step then exited 127 with gh: command not found, because the act-22.04 image does not ship the GitHub CLI. The workflow now installs a pinned gh release on the failure path only. The issue-filing path stays unverified until a run confirms it files.
FileWhat it is
.forgejo/workflows/deploy.ymlBuild, test, deploy, in-job purge; separate purge-cloudflare-cache job
.forgejo/workflows/link-check.ymlWeekly link check (Monday 08:00 UTC), issue on failure
wrangler.jsoncWorkers static assets deploy (assets.directory: ./dist, workers_dev: false)
scripts/purge-cache.tsZone cache purge; exits 0 with a warning if no credentials are set
package.jsonScripts build, deploy, verify:docs:links
tests/harness.test.tsAsserts every verify/check script has a CI trigger, reading .forgejo/workflows as well as .github/workflows

Public view of the workflow (backup copy of the repo): .forgejo/workflows/deploy.yml.