Merge this to unblock deploys. One line removed from the Dockerfile.
COPY .git ./.git, which I added in #233, fails in Portainer's build context:
failed to solve: failed to compute cache key:
failed to calculate checksum of ref ...: "/.git": not found
What went wrong
This is precisely the outcome #233 set out to prevent. That issue says a version stamp must never be the thing that stops a deploy, and the resolution code honours it — every unreadable-.git path returns unknown and warns rather than throwing.
The guard was in the wrong layer.COPY fails at image-build time, long before any application code runs. Graceful degradation buys nothing once the Dockerfile has already refused to build.
The assumption came from the local build context, where there is no .dockerignore so .git is present. I verified it with a local docker build, which passed, and never against the only environment that actually deploys.
Verified properly this time
Rather than reasoning about it, I reproduced the failing case: built from git archive HEAD, a context containing exactly the tracked files and no history — what a clean checkout gives Portainer.
$ cd <archive of HEAD, no .git>
$ docker build -t redefined-nogit-check .
... DONE
$ docker run --rm redefined-nogit-check cat dist/buildInfo.json
{ "commit": "unknown", "builtAt": "2026-08-29T20:50:24Z" }
The build succeeds and stamps a real build time. Locally nothing changes — writeBuildInfo reads ../.git directly, outside Docker, and still resolves a real commit:
$ node dist/writeBuildInfo.js
[build-info] a6121db built 2026-08-29T20:51:28Z
$ git rev-parse --short HEAD
a6121db
296 unit tests pass; no application code changed.
What you get after this
commit reads unknown wherever Portainer builds. builtAt is real.
That is a partial delivery of what you asked for, and worth being straight about — but the timestamp is the more useful half here. Portainer already tells you which commit it cloned; what it cannot tell you is whether the running container is actually that build. A build time can, and a stale one is exactly what this morning's QA container would have shown.
Getting the real commit
Needs a different mechanism. #235 records the three candidates — a build arg from a stack variable, a version file written by Gitea Actions, or building in CI and having Portainer pull rather than build — with their costs. I would rather decide that deliberately than guess at a fourth, having just guessed wrong at the third.
The lesson is recorded there too: a passing local docker build proves nothing about the environment that deploys.
**Merge this to unblock deploys.** One line removed from the Dockerfile.
`COPY .git ./.git`, which I added in #233, fails in Portainer's build context:
```
failed to solve: failed to compute cache key:
failed to calculate checksum of ref ...: "/.git": not found
```
## What went wrong
This is precisely the outcome #233 set out to prevent. That issue says a version stamp must never be the thing that stops a deploy, and the resolution code honours it — every unreadable-`.git` path returns `unknown` and warns rather than throwing.
**The guard was in the wrong layer.** `COPY` fails at image-build time, long before any application code runs. Graceful degradation buys nothing once the Dockerfile has already refused to build.
The assumption came from the local build context, where there is no `.dockerignore` so `.git` is present. I verified it with a local `docker build`, which passed, and never against the only environment that actually deploys.
## Verified properly this time
Rather than reasoning about it, I reproduced the failing case: built from `git archive HEAD`, a context containing exactly the tracked files and no history — what a clean checkout gives Portainer.
```
$ cd <archive of HEAD, no .git>
$ docker build -t redefined-nogit-check .
... DONE
$ docker run --rm redefined-nogit-check cat dist/buildInfo.json
{ "commit": "unknown", "builtAt": "2026-08-29T20:50:24Z" }
```
The build succeeds and stamps a real build time. Locally nothing changes — `writeBuildInfo` reads `../.git` directly, outside Docker, and still resolves a real commit:
```
$ node dist/writeBuildInfo.js
[build-info] a6121db built 2026-08-29T20:51:28Z
$ git rev-parse --short HEAD
a6121db
```
296 unit tests pass; no application code changed.
## What you get after this
`commit` reads **`unknown`** wherever Portainer builds. `builtAt` is real.
That is a partial delivery of what you asked for, and worth being straight about — but the timestamp is the more useful half here. Portainer already tells you which commit it cloned; what it cannot tell you is whether the running container is actually that build. A build time can, and a stale one is exactly what this morning's QA container would have shown.
## Getting the real commit
Needs a different mechanism. #235 records the three candidates — a build arg from a stack variable, a version file written by Gitea Actions, or building in CI and having Portainer pull rather than build — with their costs. I would rather decide that deliberately than guess at a fourth, having just guessed wrong at the third.
The lesson is recorded there too: a passing local `docker build` proves nothing about the environment that deploys.
Closes #235
`COPY .git ./.git`, added in #233, fails with `"/.git": not found` in Portainer's build context, so every stack deploy died before anything else ran.
This is the exact outcome #233 set out to prevent. That issue states that a version stamp must never be the thing that stops a deploy, and the resolution code honours it — every unreadable-.git path returns "unknown" and warns. The guard was simply in the wrong layer: COPY fails at image-build time, long before any of that code executes. Graceful degradation in the application buys nothing once the Dockerfile has refused to build.
The assumption came from the local build context, where there is no .dockerignore and .git is therefore present. It was checked with a local `docker build`, which passed, and never against the only environment that actually deploys.
Verified properly this time, by building from `git archive HEAD` — a context containing exactly the tracked files and no history, which is what a clean checkout gives. That build now succeeds and stamps `commit: "unknown"` with a real `builtAt`. The failing case is reproduced and fixed rather than reasoned about.
`commit` will read "unknown" wherever Portainer builds. `builtAt` is still real, and is the half that matters most: Portainer already reports which commit it cloned, but cannot tell you whether the running container is that build. A build time can, and a stale one is exactly what the QA incident earlier today would have shown. Locally nothing changes — writeBuildInfo reads ../.git directly and still resolves a real commit.
Sourcing the real commit inside a Portainer build needs a different mechanism, and #235 records the three candidates rather than guessing at a fourth.
Closes#235
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.
Merge this to unblock deploys. One line removed from the Dockerfile.
COPY .git ./.git, which I added in #233, fails in Portainer's build context:What went wrong
This is precisely the outcome #233 set out to prevent. That issue says a version stamp must never be the thing that stops a deploy, and the resolution code honours it — every unreadable-
.gitpath returnsunknownand warns rather than throwing.The guard was in the wrong layer.
COPYfails at image-build time, long before any application code runs. Graceful degradation buys nothing once the Dockerfile has already refused to build.The assumption came from the local build context, where there is no
.dockerignoreso.gitis present. I verified it with a localdocker build, which passed, and never against the only environment that actually deploys.Verified properly this time
Rather than reasoning about it, I reproduced the failing case: built from
git archive HEAD, a context containing exactly the tracked files and no history — what a clean checkout gives Portainer.The build succeeds and stamps a real build time. Locally nothing changes —
writeBuildInforeads../.gitdirectly, outside Docker, and still resolves a real commit:296 unit tests pass; no application code changed.
What you get after this
commitreadsunknownwherever Portainer builds.builtAtis real.That is a partial delivery of what you asked for, and worth being straight about — but the timestamp is the more useful half here. Portainer already tells you which commit it cloned; what it cannot tell you is whether the running container is actually that build. A build time can, and a stale one is exactly what this morning's QA container would have shown.
Getting the real commit
Needs a different mechanism. #235 records the three candidates — a build arg from a stack variable, a version file written by Gitea Actions, or building in CI and having Portainer pull rather than build — with their costs. I would rather decide that deliberately than guess at a fourth, having just guessed wrong at the third.
The lesson is recorded there too: a passing local
docker buildproves nothing about the environment that deploys.Closes #235