Reusable Forgejo Actions components for this forge
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claude Code 36ac9f7647
All checks were successful
Self-test / build-and-publish-image, from this commit (push) Successful in 11s
Self-test / build-and-publish-image, as a consumer sees it (@v1) (push) Successful in 11s
README: record what the first two consumers measured
The repository must be public — a cross-repository action is fetched with the
CONSUMER job's credentials, so while this was private florian/hoffest died with
a 403 before its first step. The self-test cannot catch that: there the consuming
workflow IS this repository. And this forge's DEFAULT_ACTIONS_URL is github.com,
not code.forgejo.org, which is what makes the absolute URL mandatory rather than
stylistic.
2026-08-14 00:27:50 +00:00
.forgejo/workflows selftest: also consume the component the way a consumer does 2026-08-14 00:18:54 +00:00
build-and-publish-image build-and-publish-image, factored out of florian/hoffest 2026-08-14 00:17:53 +00:00
tests/fixtures build-and-publish-image, factored out of florian/hoffest 2026-08-14 00:17:53 +00:00
README.md README: record what the first two consumers measured 2026-08-14 00:27:50 +00:00

ci-components

Reusable Forgejo Actions components for git.services.beisel.it, versioned, with a test suite that runs without a real consumer.

GitLab has a first-class concept for this — a component repository whose own pipeline exercises each component. Forgejo Actions has the mechanism (composite actions resolved as uses: <url>@<ref>) but nothing on this forge used it, and nothing on this forge tested a workflow except by running it on something real. This repository is the answer to florian/homelab#611.

Component What it does
build-and-publish-image Builds a container image with the classic builder and pushes it to a Forgejo registry, returning the digest the registry answered with.

Using a component

      - name: Build and publish
        id: publish
        uses: https://git.services.beisel.it/florian/ci-components/build-and-publish-image@v1
        with:
          image: florian/<your-app>
          token: ${{ secrets.REGISTRY_TOKEN }}

      - run: echo "  image: ${{ steps.publish.outputs.ref }}"

The absolute-URL form is required, not optional. A bare uses: florian/ci-components/...@v1 resolves against the forge's DEFAULT_ACTIONS_URL — measured 2026-08-14 by reading a job log, that is github.com on this instance, not code.forgejo.org and not this forge. The short form would silently fetch a repository that does not exist there.

This repository must stay public, and that is load-bearing. The runner fetches a cross-repository action with the consumer job's credentials, which reach the consumer's own repository and nothing else. While this repository was private, florian/hoffest failed before its first step with:

git fetch 'https://git.services.beisel.it/florian/ci-components' # ref=v1
could not fetch remote 'origin': remote: User permission denied ... error: 403

Worth knowing because the self-test does not catch this: in the self-test the consuming workflow is this repository, so its job token can read it, and the cross-repository job passed green for the one reason that does not generalise. Only a second consumer exposed it. There is nothing secret here — the credential lives on each consumer — so public is also simply correct.

ref is the fully pinned registry/image:tag@sha256:.... That is the line a Kubernetes Deployment should take; a Workload in florian/homelab pins a digest, and nothing here publishes latest precisely so that nobody is invited to stop.

The push credential — mint one per consumer

A component cannot carry a credential and this one never will. One runner on this forge executes branch code from every repository (florian/homelab, hosted ADR-0006), so a token stored here would hand every consumer's push rights to every other consumer. Each consumer supplies its own, and that is the whole of the answer to "one token per repository or one org-level secret".

There is also nothing cheaper available. Measured by florian/homelab#570 on 2026-08-13 against Forgejo 16.0.1 and runner v12.11.1:

  • The auto-injected job token authenticates but cannot push. It fails at the first blob upload with unauthorized. Confirmed with two independent clients, so it is the grant and not the client.
  • An Actions OIDC token can never work. Forgejo mints it as an identity token for federating to a third party — it authenticates the job to someone else, not to Forgejo.
  • This registry issues tokens with a null access claim. An HTTP 200 from the token endpoint proves nothing, and decoding the JWT proves nothing. Only a real push distinguishes a grant from a refusal, which is why the self-test below reads the image back rather than trusting a status code.

Mint the narrow token on docker-01, where Forgejo runs as a native systemd service — no browser and no basic auth needed:

ssh docker-01 sudo -u git /usr/local/bin/forgejo --config /etc/forgejo/app.ini \
  admin user generate-access-token \
  --username florian --token-name <consumer-repo-name> --scopes write:package --raw

Token names must be unique per user, so name it after the repository that will hold it — then one can be revoked without disturbing the others. Store it as the Actions secret REGISTRY_TOKEN on that repository:

curl -X PUT -H "Authorization: token $FJ_TOKEN" -H 'Content-Type: application/json' \
  -d '{"data":"<the token>"}' \
  https://git.services.beisel.it/api/v1/repos/florian/<consumer>/actions/secrets/REGISTRY_TOKEN

write:package covers deletion as well as publication; there is no separate delete:package scope. It cannot read code and cannot touch a repository.

Pulls need no credential at all — the registry serves florian/* anonymously, which is why no manifest in florian/homelab carries an imagePullSecret.

Versioning

Consumers pin a major tag: @v1. It is a moving tag, re-pointed as fixes land, so a consumer picks up a corrected component without a commit of its own — the entire point of factoring the logic out. @v1.0.0 and @<sha> also work for a consumer that wants to freeze.

That mobility is the trade this repository makes deliberately: whoever can push here changes what runs in every consumer's job. On a single-operator forge that is the same trust boundary the consumers already sit in, and the alternative — a commit in every consumer for every fix — is the drift the components exist to end.

The self-test

.forgejo/workflows/selftest.yml runs on every push and pull request. It builds a two-line fixture (FROM scratch, so Docker Hub cannot make it red), publishes it to a scratch tag under florian/ci-components-selftest, asserts the outputs are shaped correctly, pulls the image back by the digest the push reported, and then deletes the package version — with if: always(), so a failed assertion still cleans up.

The pull-back is the load-bearing assertion. A digest the registry cannot resolve is a digest the kubelet cannot resolve either, and on this registry a status code is not evidence.

Consumers

  • florian/hoffest — the reference this was factored out of. Its first run through the component produced sha256:24e63e0cd27dc5b72c9225e5c4b8565f9457efdbff84c6b7995a446d34308a17, the same digest the Deployment in florian/homelab already pins — so the refactor is output-identical, not merely green.
  • florian/wedshare — red from 2026-07-16 to 2026-08-14 on the OIDC dead end. Adopting the component was the fix. The Service itself is Not live (florian/homelab hosted ADR-0002).