Skip to content

Forgejo Actions Fallback

.forgejo/workflows/ holds a standby copy of every pipeline in .github/workflows/, so the homelab can keep building, packaging and deploying when github.com is unavailable. Forgejo reads .forgejo/workflows/ and ignores .github/workflows/ entirely; GitHub does the reverse. The two directories never see each other’s files, which is why the workflows are duplicated rather than shared.

When something is broken, go to TROUBLESHOOTING.md — it lists every failure hit while bringing this up, by symptom.

Everything runs on forgejo.toolsera.lan, against the forgejo-runner stack on the ubuntu-vm host.

WorkflowSame as GitHubChanged
publish-package.ymltag pkg/<name>-<version>, build.sh, --expected-versionpublishes to the Forgejo Debian registry instead of Nexus; release created through the Forgejo API
publish-docker-image.ymltag image/<name>-<version>, Harbor target, BWS credentialsrunner label only
deploy-docs.ymlpush to main, npm test, npm run build, Cloudflare Pagesno setup-node (the job image ships Node 22), no wrangler-action (npx wrangler instead)

Three substitutions recur, all of them there to remove a dependency on github.com at run time:

  • Actions are fully qualified as https://data.forgejo.org/actions/.... Forgejo would resolve a bare actions/checkout@v4 against its own DEFAULT_ACTIONS_URL anyway, but spelling it out means the workflow does not change meaning if that default does.
  • Marketplace actions with no Forgejo mirror are replaced by the command they wrap. cloudflare/wrangler-action becomes npx wrangler pages deploy; softprops/action-gh-release becomes curl against the Forgejo release API.
  • runs-on: deb-builder — a single label the runner maps to a job image, in place of GitHub’s [self-hosted, homelab, deb-builder] list.

Every workflow also sets NODE_EXTRA_CA_CERTS at the top. Job containers get the host CA bundle bind-mounted in, which covers curl and apt, but Node ships its own trust store and would otherwise fail to clone from forgejo.toolsera.lan over Step CA TLS — in the checkout step, before anything else has run.

Forgejo needs a copy of the repo that is current when GitHub is not.

It must be a normal repository, not a pull mirror. A mirror is read-only, so a pkg/* or image/* tag can only reach it by syncing from GitHub — during a GitHub outage nothing syncs, nothing can be pushed, and the standby can never be triggered. Forgejo also disables the Actions unit on mirrors by design, which is what a missing Actions tab means. Neither is fixable by ticking a setting; the repository has to stop being a mirror.

Note the owner is unknown224 on Forgejo and markkpamy on GitHub — the paths differ.

Git SSH does not go through forgejo.toolsera.lan. The toolsera.lan zone wildcards to Traefik (192.168.1.14), which does not listen on 222, so the clone URL Forgejo displays does not work from the LAN. Forgejo’s SSH is on the host running the docker-apps-cifs stack — 192.168.1.180:222. Give it an alias so the remote URL survives the address changing:

~/.ssh/config
Host forgejo
HostName 192.168.1.180
Port 222
User git

Then add Forgejo as its own remote and leave origin alone:

Terminal window
git remote add forgejo forgejo:unknown224/infrastructure-toolkit.git
git push forgejo main
git push forgejo --tags

Tags matter as much as main — they are what trigger the package and image pipelines, so a release has to be tagged on both forges to be built on both.

A second push URL on origin would give one-command dual push, but it misbehaves in the two ways that matter here. Git only falls back to a remote’s fetch URL for pushing while that remote has no pushurl at all, so adding the Forgejo URL alone silently stops pushes reaching GitHub — the GitHub URL has to be re-added explicitly, which reads like a no-op and is not. And with both URLs on one remote, a git push during a GitHub outage fails on the GitHub URL, leaving a partial result that depends on URL order. A separate remote keeps each forge independently reachable, which is the entire point. For one-command convenience without the coupling:

Terminal window
git config alias.pushall '!git push origin && git push forgejo'

Repository → Settings → Actions → Secrets:

SecretUsed byNotes
BWS_ACCESS_TOKENimageSame machine-account token as GitHub
BWS_PROJECT_IDimageOptional; scopes the BWS injection
PACKAGE_TOKENpackageForgejo token with write:package
CLOUDFLARE_API_TOKENdocs
CLOUDFLARE_ACCOUNT_IDdocs
RELEASE_TOKENpackageOnly if the automatic run token cannot create releases

Names here cannot begin with FORGEJO_ — Forgejo reserves that prefix, the same way it reserves GITHUB_, and rejects the secret or variable on save. That is why these are PACKAGE_TOKEN and DEB_COMPONENT rather than the FORGEJO_* names the scripts read internally; the workflow maps one to the other.

Repository → Settings → Actions → Variables:

VariableDefault when unsetMeaning
PACKAGE_OWNERthe repository ownerUser or org whose package registry receives the .deb
DEB_DISTRIBUTIONbookwormAPT suite
DEB_COMPONENTmainAPT component
DEB_OVERWRITEfalseReplace an existing package of the same version instead of failing
CLOUDFLARE_PAGES_PROJECTinfrastructure-toolkit-docsPoint the standby docs build at a different Pages project

Nexus is GitHub Actions’ target and Forgejo never writes to it. The Forgejo pipeline publishes to the Forgejo Debian registry and stops there.

That keeps the two from racing: on an ordinary release both forges see the same pkg/* tag and build the same .deb, and if both then uploaded it to Nexus the second one would either be rejected as a duplicate or overwrite the first, with two rebuild-index calls landing on top of each other. With separate targets each run is unambiguous, and the standby pipeline stays green without doing anything contended.

The trade-off is on the consuming side: a package released while GitHub is down reaches Nexus only when someone re-pushes the tag to GitHub afterwards. Hosts that need to install it in the meantime have to already have the Forgejo APT source configured — see the next section. Add it as a second source now, while nothing is broken; an unused source costs one extra index fetch per apt-get update.

The docs and image workflows share their targets happily and need no such split: pushing an identical image tag to Harbor overwrites, and a duplicate Pages deployment is just an extra deployment record.

Installing from the Forgejo package registry

Section titled “Installing from the Forgejo package registry”

The Forgejo registry signs and rebuilds its APT metadata on upload, so unlike Nexus there is no rebuild-index call. Add it as a second source alongside the Nexus one that custom_apt_repo configures — APT merges the two and installs whichever has the higher version:

Terminal window
owner=unknown224
sudo curl -fsSL \
"https://forgejo.toolsera.lan/api/packages/${owner}/debian/repository.key" \
-o "/etc/apt/keyrings/forgejo-${owner}.asc"
echo "deb [signed-by=/etc/apt/keyrings/forgejo-${owner}.asc] \
https://forgejo.toolsera.lan/api/packages/${owner}/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/forgejo-${owner}.list
sudo apt-get update

The host must already trust the Step CA for that HTTPS fetch to work — that is what the step_ca_client role does.

Worth running once, and after any change to either forge:

  1. Bump a package version, commit, push to both remotes.
  2. Push the pkg/<name>-<version> tag to Forgejo only — this is the outage being simulated, so GitHub gets nothing.
  3. Confirm the Forgejo run went green, the .deb is listed under the owner’s Packages tab, and a release exists with the .deb attached.
  4. On a host with the Forgejo APT source configured, apt-get update followed by apt-cache policy <package> shows the new version coming from forgejo.toolsera.lan, and it installs.
  5. Push the tag to GitHub to let the normal pipeline put the same version into Nexus and cut the GitHub release.