spike: can the runner build and push an image, and can Portainer pull it? #237

Closed
opened 2026-08-29 18:29:42 -05:00 by bermudalamb · 3 comments
Owner

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.

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.
Author
Owner

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.

## 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.
Author
Owner

PR #244 is closed rather than merged.

The spike workflow never needed to reach mainworkflow_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.
Author
Owner

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.

## 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.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#237