| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .forgejo/workflows | ||
| build-and-publish-image | ||
| tests/fixtures | ||
| README.md | ||
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
nullaccessclaim. 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 producedsha256:24e63e0cd27dc5b72c9225e5c4b8565f9457efdbff84c6b7995a446d34308a17, the same digest the Deployment inflorian/homelabalready 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/homelabhosted ADR-0002).