Feasibility only. The output is an answer, not code to keep.
#235 left commit reading unknown in every environment Portainer builds, because Portainer's build context has no .git. The chosen fix is to move image builds into Gitea Actions, push to Gitea's container registry, and have Portainer pull rather than build — CI knows the commit and can bake it in.
That reverses decisions currently documented in both compose files, so it is worth knowing it actually works before designing around it.
The three unknowns
Can the runner build an image at all? The workflows use services:, which proves it can start containers, but not that a job can run docker build. That needs a Docker socket or buildx in the job.
Can it push to Gitea's container registry? The packages API answers, and lists nothing — the registry has never been used here, so "enabled" is unconfirmed. Pushing also needs a token with package write scope.
Can Portainer pull from it? Needs registry credentials configured in Portainer, and the Docker daemon on the NAS to trust the certificate gitea.bermudalamb.synology.me serves.
The probe
One throwaway workflow_dispatch workflow. No push or pull_request trigger, so it cannot run on its own and nothing existing is touched. Run by hand, once.
It reports the runner's Docker capabilities first, so a failure at any later step is still informative; builds a trivial image to isolate "can it build and push" from "does the real build work"; then builds the real Dockerfile and times it, because runner cost is a live concern and the answer shapes the design.
Bounded by timeout-minutes, following the reasoning already in backend-integration.yml: a hang should cost minutes of runner time rather than hours.
Then, by hand: add the registry in Portainer and pull the pushed tag.
Why the timing step matters
backend-integration.yml is workflow_dispatch because on 2026-08-18 a job held the runner for 3h12m and kept everything else queued behind it. Any design that builds an image on every merge to main puts that work on the same single runner. Knowing what a real build costs there is part of deciding whether this is worth doing.
Afterwards
The workflow file is deleted whichever way it goes, and any pushed package is deleted. Nothing here is intended to survive.
Feasibility only. The output is an answer, not code to keep.
#235 left `commit` reading `unknown` in every environment Portainer builds, because Portainer's build context has no `.git`. The chosen fix is to move image builds into Gitea Actions, push to Gitea's container registry, and have Portainer pull rather than build — CI knows the commit and can bake it in.
That reverses decisions currently documented in both compose files, so it is worth knowing it actually works before designing around it.
## The three unknowns
1. **Can the runner build an image at all?** The workflows use `services:`, which proves it can *start* containers, but not that a job can run `docker build`. That needs a Docker socket or buildx in the job.
2. **Can it push to Gitea's container registry?** The packages API answers, and lists nothing — the registry has never been used here, so "enabled" is unconfirmed. Pushing also needs a token with package write scope.
3. **Can Portainer pull from it?** Needs registry credentials configured in Portainer, and the Docker daemon on the NAS to trust the certificate `gitea.bermudalamb.synology.me` serves.
## The probe
One throwaway `workflow_dispatch` workflow. No push or pull_request trigger, so it cannot run on its own and nothing existing is touched. Run by hand, once.
It reports the runner's Docker capabilities first, so a failure at any later step is still informative; builds a trivial image to isolate "can it build and push" from "does the real build work"; then builds the **real** Dockerfile and times it, because runner cost is a live concern and the answer shapes the design.
Bounded by `timeout-minutes`, following the reasoning already in `backend-integration.yml`: a hang should cost minutes of runner time rather than hours.
Then, by hand: add the registry in Portainer and pull the pushed tag.
## Why the timing step matters
`backend-integration.yml` is `workflow_dispatch` because on 2026-08-18 a job held the runner for 3h12m and kept everything else queued behind it. Any design that builds an image on every merge to `main` puts that work on the same single runner. Knowing what a real build costs there is part of deciding whether this is worth doing.
## Afterwards
The workflow file is deleted whichever way it goes, and any pushed package is deleted. Nothing here is intended to survive.
Follows #235, #233.
Dispatched on the branch rather than main, which also settles a side question: workflow_dispatch works from a non-default branch, so a throwaway spike never has to be merged to be run.
Now established as fact
The runner can build images. The static docker CLI installed cleanly, uname -m resolved the architecture, and docker info succeeded — so the daemon really is reachable through the mounted socket. Question 1's original "no" was only ever about a missing client.
The container registry is enabled.https://gitea.bermudalamb.synology.me/v2/ answered. It had never been exercised, so this was genuinely unknown.
TLS from the runner to the Gitea host works. The failure was an HTTP 401, not an x509 error. That is worth more than it looks: the same certificate is what Portainer's Docker daemon on the NAS would have to trust when pulling, and a self-signed or untrusted cert was one of the three original unknowns.
The remaining blocker
Error response from daemon: Get "https://gitea.bermudalamb.synology.me/v2/": unauthorized
GITEA_TOKEN is present — the "is a token available" step confirms it — but the token Actions injects automatically is not accepted by the package registry. It is scoped for the repository API, not for packages.
This needs a personal access token with write:package (and read:package for the Portainer side), stored as a repo secret and used instead of GITEA_TOKEN. Only Thom can create that; it is not something the workflow can work around.
Still unmeasured
What a real image build costs on this runner. That is the number that decides whether the approach is viable at all, and it is behind the credential.
Recommendation, unchanged and now better supported
Feasible is not the same as worth it. The cost has accreted with each iteration: a CLI install step, a new personal access token to create and rotate, registry credentials configured in Portainer, and a build on a runner where a full CI pass already takes ~21.5 minutes and the queue reached six deep. What it buys is seven characters on an admin screen, when builtAt already answers the question that actually caused this issue.
If the real commit is still wanted, release-triggered builds remain the right shape rather than per-merge: build and push when a version is cut, Portainer pulls that tag. The frequency objection disappears, and it pairs with the sonar.projectVersion bump that already belongs at release time.
Whichever way this goes, .gitea/workflows/spike-registry.yml gets deleted and any spike-* package removed.
## Iteration 2 results — run 667
Dispatched on the branch rather than `main`, which also settles a side question: **`workflow_dispatch` works from a non-default branch**, so a throwaway spike never has to be merged to be run.
### Now established as fact
- **The runner can build images.** The static docker CLI installed cleanly, `uname -m` resolved the architecture, and `docker info` succeeded — so the daemon really is reachable through the mounted socket. Question 1's original "no" was only ever about a missing client.
- **The container registry is enabled.** `https://gitea.bermudalamb.synology.me/v2/` answered. It had never been exercised, so this was genuinely unknown.
- **TLS from the runner to the Gitea host works.** The failure was an HTTP 401, not an x509 error. That is worth more than it looks: the same certificate is what Portainer's Docker daemon on the NAS would have to trust when pulling, and a self-signed or untrusted cert was one of the three original unknowns.
### The remaining blocker
```
Error response from daemon: Get "https://gitea.bermudalamb.synology.me/v2/": unauthorized
```
`GITEA_TOKEN` is present — the "is a token available" step confirms it — but the token Actions injects automatically is not accepted by the package registry. It is scoped for the repository API, not for packages.
This needs a **personal access token with `write:package`** (and `read:package` for the Portainer side), stored as a repo secret and used instead of `GITEA_TOKEN`. Only Thom can create that; it is not something the workflow can work around.
### Still unmeasured
What a real image build costs on this runner. That is the number that decides whether the approach is viable at all, and it is behind the credential.
### Recommendation, unchanged and now better supported
Feasible is not the same as worth it. The cost has accreted with each iteration: a CLI install step, a new personal access token to create and rotate, registry credentials configured in Portainer, and a build on a runner where a full CI pass already takes ~21.5 minutes and the queue reached six deep. What it buys is seven characters on an admin screen, when `builtAt` already answers the question that actually caused this issue.
If the real commit is still wanted, **release-triggered** builds remain the right shape rather than per-merge: build and push when a version is cut, Portainer pulls that tag. The frequency objection disappears, and it pairs with the `sonar.projectVersion` bump that already belongs at release time.
Whichever way this goes, `.gitea/workflows/spike-registry.yml` gets deleted and any `spike-*` package removed.
The spike workflow never needed to reach main — workflow_dispatch fires from a branch, which iteration 2 demonstrated. Merging it would have put a file into main that this issue says gets deleted either way, requiring a second PR to remove it.
Keeping the PR open also had a real cost. Every push to the branch triggered a full pull-request CI pass, so the one-line change swapping GITEA_TOKEN for REGISTRY_TOKEN queued two runs ahead of the spike it was meant to unblock — roughly 21.5 minutes of the single runner, twice, for a workflow-only edit.
That is worth recording as evidence rather than only as an annoyance: it is the same contention this issue is trying to decide about, observed while trying to decide about it.
The branch stays so the workflow can still be dispatched. Both the branch and .gitea/workflows/spike-registry.yml go when this issue is answered.
PR #244 is closed rather than merged.
The spike workflow never needed to reach `main` — `workflow_dispatch` fires from a branch, which iteration 2 demonstrated. Merging it would have put a file into `main` that this issue says gets deleted either way, requiring a second PR to remove it.
Keeping the PR open also had a real cost. Every push to the branch triggered a full pull-request CI pass, so the one-line change swapping `GITEA_TOKEN` for `REGISTRY_TOKEN` queued two runs ahead of the spike it was meant to unblock — roughly 21.5 minutes of the single runner, twice, for a workflow-only edit.
That is worth recording as evidence rather than only as an annoyance: it is the same contention this issue is trying to decide about, observed while trying to decide about it.
The branch stays so the workflow can still be dispatched. Both the branch and `.gitea/workflows/spike-registry.yml` go when this issue is answered.
Iteration 3 — run 672 passed, and the answer is not what the design assumed
All nine steps green. Job 22:06:17 → 22:15:31, 9m14s for checkout, diagnostics, CLI install, login, a trivial build and push, the real image build, its push, and running the result.
Everything mechanical works
The registry accepts pushes.spike-trivial and spike-3085970 are both in the package registry. This was genuinely unknown — it had never been used.
The PAT with write:package authenticates. The auto-injected Actions token does not; that distinction is real and was the blocker in iteration 2.
The runner can build the real image once a docker CLI is present, through the mounted socket.
A CI-built image still reports commit: "unknown".#235 removed COPY .git from the Dockerfile because Portainer's build context has no history and the copy failed every deploy. A Gitea Actions checkout does have .git — but the Dockerfile no longer copies it, so writeBuildInfo finds nothing regardless of where the build runs.
Moving builds to CI does not, on its own, produce the commit. The premise this issue was opened on is false as things stand.
What would actually be needed
A build argument, not a build location. CI knows the SHA and can pass it:
with the Dockerfile accepting ARG GIT_COMMIT and writeBuildInfo preferring it over reading .git. That works in CI, degrades to unknown in Portainer without breaking anything, and needs no .git in any build context — which is the property #235 was bought with.
Note what that implies: the build arg is the whole fix, and it does not require moving builds to CI at all. Portainer cannot supply the SHA, so it would still stamp unknown there — but if a CI-built, registry-pushed image is what gets deployed, the arg carries the commit and the registry migration is only the delivery mechanism.
Cost, for the decision
9m14s of a single serialised runner per build, on top of a ~21.5 minute CI pass, on a machine where the queue reached six deep and this spike waited over two hours across three attempts to run.
That is affordable per release. It is not affordable per merge. If this goes ahead, release-triggered is the only shape that makes sense — which is what I have recommended at every iteration, now with a number behind it.
Cleanup owed either way
.gitea/workflows/spike-registry.yml exists on bothmain (from #238) and spike/237-registry-docker-cli. Both copies go, the branch goes, and the spike-trivial and spike-3085970 packages should be deleted from the registry.
## Iteration 3 — run 672 passed, and the answer is not what the design assumed
All nine steps green. Job 22:06:17 → 22:15:31, **9m14s** for checkout, diagnostics, CLI install, login, a trivial build and push, the real image build, its push, and running the result.
### Everything mechanical works
- **The registry accepts pushes.** `spike-trivial` and `spike-3085970` are both in the package registry. This was genuinely unknown — it had never been used.
- **The PAT with `write:package` authenticates.** The auto-injected Actions token does not; that distinction is real and was the blocker in iteration 2.
- **The runner can build the real image** once a docker CLI is present, through the mounted socket.
### But the thing it was for does not work
```
git says: 3085970
image says:
{
"commit": "unknown",
"builtAt": "2026-08-30T22:12:32Z"
}
```
**A CI-built image still reports `commit: "unknown"`.** #235 removed `COPY .git` from the Dockerfile because Portainer's build context has no history and the copy failed every deploy. A Gitea Actions checkout *does* have `.git` — but the Dockerfile no longer copies it, so `writeBuildInfo` finds nothing regardless of where the build runs.
Moving builds to CI does not, on its own, produce the commit. The premise this issue was opened on is false as things stand.
### What would actually be needed
A build argument, not a build location. CI knows the SHA and can pass it:
```
docker build --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" .
```
with the Dockerfile accepting `ARG GIT_COMMIT` and `writeBuildInfo` preferring it over reading `.git`. That works in CI, degrades to `unknown` in Portainer without breaking anything, and needs no `.git` in any build context — which is the property #235 was bought with.
**Note what that implies: the build arg is the whole fix, and it does not require moving builds to CI at all.** Portainer cannot supply the SHA, so it would still stamp `unknown` there — but if a CI-built, registry-pushed image is what gets deployed, the arg carries the commit and the registry migration is only the delivery mechanism.
### Cost, for the decision
9m14s of a single serialised runner per build, on top of a ~21.5 minute CI pass, on a machine where the queue reached six deep and this spike waited over two hours across three attempts to run.
That is affordable **per release**. It is not affordable per merge. If this goes ahead, release-triggered is the only shape that makes sense — which is what I have recommended at every iteration, now with a number behind it.
### Cleanup owed either way
`.gitea/workflows/spike-registry.yml` exists on **both** `main` (from #238) and `spike/237-registry-docker-cli`. Both copies go, the branch goes, and the `spike-trivial` and `spike-3085970` packages should be deleted from the registry.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Feasibility only. The output is an answer, not code to keep.
#235 left
commitreadingunknownin every environment Portainer builds, because Portainer's build context has no.git. The chosen fix is to move image builds into Gitea Actions, push to Gitea's container registry, and have Portainer pull rather than build — CI knows the commit and can bake it in.That reverses decisions currently documented in both compose files, so it is worth knowing it actually works before designing around it.
The three unknowns
services:, which proves it can start containers, but not that a job can rundocker build. That needs a Docker socket or buildx in the job.gitea.bermudalamb.synology.meserves.The probe
One throwaway
workflow_dispatchworkflow. No push or pull_request trigger, so it cannot run on its own and nothing existing is touched. Run by hand, once.It reports the runner's Docker capabilities first, so a failure at any later step is still informative; builds a trivial image to isolate "can it build and push" from "does the real build work"; then builds the real Dockerfile and times it, because runner cost is a live concern and the answer shapes the design.
Bounded by
timeout-minutes, following the reasoning already inbackend-integration.yml: a hang should cost minutes of runner time rather than hours.Then, by hand: add the registry in Portainer and pull the pushed tag.
Why the timing step matters
backend-integration.ymlisworkflow_dispatchbecause on 2026-08-18 a job held the runner for 3h12m and kept everything else queued behind it. Any design that builds an image on every merge tomainputs that work on the same single runner. Knowing what a real build costs there is part of deciding whether this is worth doing.Afterwards
The workflow file is deleted whichever way it goes, and any pushed package is deleted. Nothing here is intended to survive.
Follows #235, #233.
Iteration 2 results — run 667
Dispatched on the branch rather than
main, which also settles a side question:workflow_dispatchworks from a non-default branch, so a throwaway spike never has to be merged to be run.Now established as fact
uname -mresolved the architecture, anddocker infosucceeded — so the daemon really is reachable through the mounted socket. Question 1's original "no" was only ever about a missing client.https://gitea.bermudalamb.synology.me/v2/answered. It had never been exercised, so this was genuinely unknown.The remaining blocker
GITEA_TOKENis present — the "is a token available" step confirms it — but the token Actions injects automatically is not accepted by the package registry. It is scoped for the repository API, not for packages.This needs a personal access token with
write:package(andread:packagefor the Portainer side), stored as a repo secret and used instead ofGITEA_TOKEN. Only Thom can create that; it is not something the workflow can work around.Still unmeasured
What a real image build costs on this runner. That is the number that decides whether the approach is viable at all, and it is behind the credential.
Recommendation, unchanged and now better supported
Feasible is not the same as worth it. The cost has accreted with each iteration: a CLI install step, a new personal access token to create and rotate, registry credentials configured in Portainer, and a build on a runner where a full CI pass already takes ~21.5 minutes and the queue reached six deep. What it buys is seven characters on an admin screen, when
builtAtalready answers the question that actually caused this issue.If the real commit is still wanted, release-triggered builds remain the right shape rather than per-merge: build and push when a version is cut, Portainer pulls that tag. The frequency objection disappears, and it pairs with the
sonar.projectVersionbump that already belongs at release time.Whichever way this goes,
.gitea/workflows/spike-registry.ymlgets deleted and anyspike-*package removed.PR #244 is closed rather than merged.
The spike workflow never needed to reach
main—workflow_dispatchfires from a branch, which iteration 2 demonstrated. Merging it would have put a file intomainthat this issue says gets deleted either way, requiring a second PR to remove it.Keeping the PR open also had a real cost. Every push to the branch triggered a full pull-request CI pass, so the one-line change swapping
GITEA_TOKENforREGISTRY_TOKENqueued two runs ahead of the spike it was meant to unblock — roughly 21.5 minutes of the single runner, twice, for a workflow-only edit.That is worth recording as evidence rather than only as an annoyance: it is the same contention this issue is trying to decide about, observed while trying to decide about it.
The branch stays so the workflow can still be dispatched. Both the branch and
.gitea/workflows/spike-registry.ymlgo when this issue is answered.Iteration 3 — run 672 passed, and the answer is not what the design assumed
All nine steps green. Job 22:06:17 → 22:15:31, 9m14s for checkout, diagnostics, CLI install, login, a trivial build and push, the real image build, its push, and running the result.
Everything mechanical works
spike-trivialandspike-3085970are both in the package registry. This was genuinely unknown — it had never been used.write:packageauthenticates. The auto-injected Actions token does not; that distinction is real and was the blocker in iteration 2.But the thing it was for does not work
A CI-built image still reports
commit: "unknown". #235 removedCOPY .gitfrom the Dockerfile because Portainer's build context has no history and the copy failed every deploy. A Gitea Actions checkout does have.git— but the Dockerfile no longer copies it, sowriteBuildInfofinds nothing regardless of where the build runs.Moving builds to CI does not, on its own, produce the commit. The premise this issue was opened on is false as things stand.
What would actually be needed
A build argument, not a build location. CI knows the SHA and can pass it:
with the Dockerfile accepting
ARG GIT_COMMITandwriteBuildInfopreferring it over reading.git. That works in CI, degrades tounknownin Portainer without breaking anything, and needs no.gitin any build context — which is the property #235 was bought with.Note what that implies: the build arg is the whole fix, and it does not require moving builds to CI at all. Portainer cannot supply the SHA, so it would still stamp
unknownthere — but if a CI-built, registry-pushed image is what gets deployed, the arg carries the commit and the registry migration is only the delivery mechanism.Cost, for the decision
9m14s of a single serialised runner per build, on top of a ~21.5 minute CI pass, on a machine where the queue reached six deep and this spike waited over two hours across three attempts to run.
That is affordable per release. It is not affordable per merge. If this goes ahead, release-triggered is the only shape that makes sense — which is what I have recommended at every iteration, now with a number behind it.
Cleanup owed either way
.gitea/workflows/spike-registry.ymlexists on bothmain(from #238) andspike/237-registry-docker-cli. Both copies go, the branch goes, and thespike-trivialandspike-3085970packages should be deleted from the registry.