feat(build): pass the commit to the image as a build arg (#248)
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m44s

The admin version stamp has reported commit "unknown" everywhere. #233 read it out of .git during the build and #235 removed that, because Portainer's build context has no repository history and the COPY failed every deploy — the version stamp became the thing that stopped deployments. #237 then established that building in Gitea Actions does not help: the Dockerfile no longer copies .git, so where the build runs is irrelevant.

So the builder hands the commit over rather than the build going to look for it. ARG GIT_COMMIT, empty by default, passed through to writeBuildInfo, which prefers it and still falls back to reading .git so a local build stamps itself with no argument needed.

An empty value is treated as absent rather than stamped. `--build-arg GIT_COMMIT=` is what an unset shell variable expands to, and a blank commit reads as one that happens to be empty rather than one nobody supplied.

Nothing regresses for Portainer. It cannot pass the argument, so its images keep saying "unknown" exactly as today, and they still deploy — the property #235 was bought with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 17:39:24 -05:00
co-authored by Claude Opus 5
parent e19561cde6
commit a02214108a
3 changed files with 75 additions and 6 deletions
+9 -1
View File
@@ -23,7 +23,15 @@ COPY backend/ ./
# #
# writeBuildInfo runs against the compiled output, so it has to follow tsc, and # writeBuildInfo runs against the compiled output, so it has to follow tsc, and
# it warns rather than fails when it finds no .git. # it warns rather than fails when it finds no .git.
RUN npm run build && node dist/writeBuildInfo.js # Passed in rather than discovered. Whoever builds knows the commit; the build
# does not go looking for it, which is what broke every deploy when it did
# (#235) and what building in CI did not fix (#237). Empty by default, so a
# build that does not pass one behaves exactly as before — Portainer cannot
# supply it and still deploys, which is the property that must not regress.
#
# docker build --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" .
ARG GIT_COMMIT=
RUN GIT_COMMIT="$GIT_COMMIT" npm run build && GIT_COMMIT="$GIT_COMMIT" node dist/writeBuildInfo.js
FROM node:20-bookworm-slim FROM node:20-bookworm-slim
WORKDIR /app WORKDIR /app
+29 -5
View File
@@ -37,9 +37,32 @@ function findGitDir(explicit?: string): string | null {
return candidates.find((candidate) => existsSync(candidate)) ?? null; return candidates.find((candidate) => existsSync(candidate)) ?? null;
} }
export function buildStamp(gitDir: string | null): BuildInfo { /**
* A commit passed in by whoever is building, or null.
*
* This is the half that actually works in the environments that matter. The
* Dockerfile deliberately does not copy `.git` — doing so broke every Portainer
* deploy (#235) — and #237 established that building in CI changes nothing,
* because the copy is what was missing rather than the history. So the builder
* has to hand the commit over rather than the build going to look for it (#248).
*
* Empty is treated as absent. A `--build-arg GIT_COMMIT=` with nothing after it
* is what an unset shell variable expands to, and stamping the image with an
* empty string would be worse than saying "unknown" — it reads as a commit that
* happens to be blank rather than as one nobody supplied.
*/
function passedCommit(value: string | undefined): string | null {
return value !== undefined && value.trim() !== '' ? value.trim() : null;
}
export function buildStamp(gitDir: string | null, passed?: string): BuildInfo {
const supplied = passedCommit(passed);
return { return {
commit: gitDir ? resolveCommit(gitSourceAt(gitDir)) : UNKNOWN_COMMIT, // The passed value wins. It is the only one available where this matters,
// and reading .git remains the fallback so a local build still stamps
// itself without anyone having to remember the argument.
commit: supplied ?? (gitDir ? resolveCommit(gitSourceAt(gitDir)) : UNKNOWN_COMMIT),
// Whole seconds: this is read by a person comparing it to when they // Whole seconds: this is read by a person comparing it to when they
// pressed a button, not by anything that needs precision. // pressed a button, not by anything that needs precision.
builtAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') builtAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
@@ -50,15 +73,16 @@ export function buildStamp(gitDir: string | null): BuildInfo {
// deployment — the same reasoning as backfillImageReencode.ts (#231). // deployment — the same reasoning as backfillImageReencode.ts (#231).
if (require.main === module) { if (require.main === module) {
const gitDir = findGitDir(process.argv[2]); const gitDir = findGitDir(process.argv[2]);
const stamp = buildStamp(gitDir); const stamp = buildStamp(gitDir, process.env.GIT_COMMIT);
if (stamp.commit === UNKNOWN_COMMIT) { if (stamp.commit === UNKNOWN_COMMIT) {
// Loud, because a deploy that cannot say what it is defeats the point of // Loud, because a deploy that cannot say what it is defeats the point of
// the stamp — but a warning, not a failure. // the stamp — but a warning, not a failure.
const where = gitDir ? ` at ${gitDir}` : ''; const where = gitDir ? ` at ${gitDir}` : '';
console.warn( console.warn(
`[build-info] no readable .git found${where}` + `[build-info] no GIT_COMMIT passed and no readable .git found${where}` +
`the admin will report the commit as "${UNKNOWN_COMMIT}"` `the admin will report the commit as "${UNKNOWN_COMMIT}". ` +
`Pass --build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" to stamp it.`
); );
} }
+37
View File
@@ -1,4 +1,7 @@
import { resolveCommit, GitSource, UNKNOWN_COMMIT } from '../../src/buildInfo'; import { resolveCommit, GitSource, UNKNOWN_COMMIT } from '../../src/buildInfo';
// Safe to import: writeBuildInfo guards its side effects behind
// require.main === module, so pulling in buildStamp cannot rewrite a stamp.
import { buildStamp } from '../../src/writeBuildInfo';
// A source with nothing in it, so each test states only the files it cares // A source with nothing in it, so each test states only the files it cares
// about. Every field is deliberately explicit — a missing `.git` is a normal // about. Every field is deliberately explicit — a missing `.git` is a normal
@@ -107,3 +110,37 @@ describe('resolveCommit', () => {
expect(resolveCommit(source({ head: ' \n' }))).toBe(UNKNOWN_COMMIT); expect(resolveCommit(source({ head: ' \n' }))).toBe(UNKNOWN_COMMIT);
}); });
}); });
describe('buildStamp with a commit passed in', () => {
// The half that works where it matters. The Dockerfile does not copy .git —
// doing so broke every Portainer deploy (#235) — and building in CI did not
// change that (#237), so the builder has to hand the commit over (#248).
it('prefers a passed commit over reading .git', () => {
expect(buildStamp('/nonexistent/.git', '3085970').commit).toBe('3085970');
});
it('uses a passed commit when there is no .git at all', () => {
expect(buildStamp(null, '3085970').commit).toBe('3085970');
});
it('trims a passed commit', () => {
expect(buildStamp(null, ' 3085970 ').commit).toBe('3085970');
});
// `--build-arg GIT_COMMIT=` with an unset shell variable expands to this.
// Stamping an empty string would read as a commit that happens to be blank
// rather than as one nobody supplied.
it.each(['', ' '])('treats %p as no commit at all', (passed) => {
expect(buildStamp(null, passed).commit).toBe('unknown');
});
it('still says unknown when nothing is passed and there is no .git', () => {
expect(buildStamp(null).commit).toBe('unknown');
});
// The property #235 bought and that must not regress: a build with neither
// still produces a stamp rather than failing.
it('always produces a builtAt', () => {
expect(buildStamp(null).builtAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
});