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.
Architecture
Section titled “Architecture”Text fallback for the diagram:
- A push, pull request, or manual dispatch to the repo reaches Forgejo 16.0.5 on the router.
- Forgejo queues the run on a Forgejo Runner 13.2.0 container (capacity 4, labels
ubuntu-latestandubuntu-22.04, both mapped todocker://ghcr.io/catthehacker/ubuntu:act-22.04). - The runner starts a job container on the act-22.04 image. The
build-and-deployjob runsbun install, the build and test gate, then the Workers deploy and the zone cache purge. - A separate
purge-cloudflare-cachejob runs after it on main only.
| Component | Version at time of writing |
|---|---|
| Forgejo | 16.0.5 (reports 16.0.5+gitea-1.22.0) |
| Forgejo Runner | 13.2.0, in a container next to the forge, capacity 4 |
| Job image | ghcr.io/catthehacker/ubuntu:act-22.04 (both labels map to it; there is no ubuntu-24.04 label) |
| Site build | Astro + 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.
| Difference | Fix used | How 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 access | Merge producer and consumer jobs into one job; release matrices become a loop in one job | The 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 test | Drop keyless signing, or use long-lived keys as secrets | Forgejo 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 grants | Delete the block | link-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 workflow | Configuration 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 image | Use a registered label; install missing tools explicitly | Runner 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 = github | Check the admin setting, or write full https://github.com/... URLs | app.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 secrets | Put shared deploy tokens at user or organisation level | Gitea 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’s | Hardcode the GitHub path (-R owner/repo) when calling GitHub | Forgejo 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 address | Pinned-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 error | Download the pinned gitleaks release tarball; run the binary | A 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 cache | A 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 runner | Pinned release tarball | This 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 fail | Runner set privileged: true; such jobs start a job-private dockerd; plain builds keep the host socket | docker-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 unit | Convert to a native repo before relying on CI | Observed 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.x | Tick Actions in the UI, or recreate the repo via POST /user/repos | Quick 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 cache | Use registry cache or --mount=type=cache; keep actions/cache | actions/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.
- 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
mainonly. - 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/reposis 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 runnerA 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.
Step 3: decide how uses: resolves
Section titled “Step 3: decide how uses: resolves”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.
Step 4: merge jobs that passed artifacts
Section titled “Step 4: merge jobs that passed artifacts”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 distRelease 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: writeForgejo 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 --versionStep 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 findingsaquasecurity/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.
Verification
Section titled “Verification”| Check | How | Expected on this instance |
|---|---|---|
| Deploy on main | git push to main | green build-and-deploy (build + test gate, then deploy and purge), then purge-cloudflare-cache |
| Gate on a PR or branch push | open a PR | build-and-deploy runs build + test; deploy, purge and purge-cloudflare-cache all skipped by their if: |
| Manual dispatch | workflow_dispatch on a non-main ref | build + test run; deploy and purge steps skipped |
| Run state over REST | GET /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).
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- The first Forgejo run of
deploy.ymlfailed after 34 s; the cause is the one recorded in git - d2’sinstall.shhitapi.github.comand got 401, fixed in9b4d31f. The other two early failures have no recorded cause; do not attribute them. - A bulk find-and-replace of
setup-bunleft an orphanedwith: bun-versionblock under arun:step, a workflow schema error (unknown property). When replacing auses:with arun:, delete thewith: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-bunfailed 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_periodand 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
mainonly; 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-connectkey 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.ymlschedule run on 2026-09-28 failed twice over, per the job log fetched fromGET /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 withgh: command not found, because the act-22.04 image does not ship the GitHub CLI. The workflow now installs a pinnedghrelease on the failure path only. The issue-filing path stays unverified until a run confirms it files.
File reference
Section titled “File reference”| File | What it is |
|---|---|
.forgejo/workflows/deploy.yml | Build, test, deploy, in-job purge; separate purge-cloudflare-cache job |
.forgejo/workflows/link-check.yml | Weekly link check (Monday 08:00 UTC), issue on failure |
wrangler.jsonc | Workers static assets deploy (assets.directory: ./dist, workers_dev: false) |
scripts/purge-cache.ts | Zone cache purge; exits 0 with a warning if no credentials are set |
package.json | Scripts build, deploy, verify:docs:links |
tests/harness.test.ts | Asserts 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.
Related docs
Section titled “Related docs”- Forgejo as the primary forge on the NixOS edge router - the forge and runner this guide runs on, including the runner’s privileged posture.
- GitHub Actions with Cloudflare - the GitHub-hosted variant of the same deploy; this guide is the Forgejo port of it, and the merge in step 4 exists because its upload-artifact / download-artifact pattern fails here.