Files
redefined-designs/backend/tests/unit/buildInfo.test.ts
T
bermudalambandClaude Opus 5 a02214108a
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Successful in 25m44s
feat(build): pass the commit to the image as a build arg (#248)
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>
2026-09-02 17:39:24 -05:00

147 lines
5.2 KiB
TypeScript

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
// about. Every field is deliberately explicit — a missing `.git` is a normal
// case here, not an edge one, and it has to be as easy to write as the others.
function source(overrides: Partial<GitSource> = {}): GitSource {
return {
head: null,
readRef: () => null,
packedRefs: null,
...overrides
};
}
const SHA = '846646f7ec8ec0087ec1d89bcee8ab1865b57a7a';
describe('resolveCommit', () => {
// What a fresh clone at a specific commit looks like — Portainer checks out
// a ref, and a detached HEAD holds the SHA with no indirection at all.
it('reads a detached HEAD directly', () => {
expect(resolveCommit(source({ head: `${SHA}\n` }))).toBe('846646f');
});
// What a working checkout looks like, including this repository.
it('follows a symbolic HEAD to its ref file', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
readRef: (ref) => (ref === 'refs/heads/main' ? `${SHA}\n` : null)
})
)
).toBe('846646f');
});
// A ref that has been packed has no loose file. Without this the common case
// of a freshly cloned repository resolves to unknown.
it('falls back to packed-refs when the ref file is absent', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
packedRefs: `# pack-refs with: peeled fully-peeled sorted\n${SHA} refs/heads/main\n`
})
)
).toBe('846646f');
});
it('picks the right entry out of a packed-refs with many', () => {
const other = 'ffffffffffffffffffffffffffffffffffffffff';
expect(
resolveCommit(
source({
head: 'ref: refs/heads/main\n',
packedRefs:
`# pack-refs with: peeled fully-peeled sorted\n` +
`${other} refs/heads/other\n` +
`${SHA} refs/heads/main\n` +
`${other} refs/tags/v1\n`
})
)
).toBe('846646f');
});
// Annotated tags write a second `^<sha>` line for the object the tag points
// at. It is not a ref line and must not be mistaken for one.
it('ignores peeled tag lines in packed-refs', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/tags/v1\n',
packedRefs:
`${SHA} refs/tags/v1\n` + `^ffffffffffffffffffffffffffffffffffffffff\n`
})
)
).toBe('846646f');
});
// Every failure below returns UNKNOWN rather than throwing. This runs during
// a Docker build; a version stamp must never be the thing that stops a deploy.
it('is unknown when there is no .git at all', () => {
expect(resolveCommit(source())).toBe(UNKNOWN_COMMIT);
});
it('is unknown when the ref resolves to nothing', () => {
expect(resolveCommit(source({ head: 'ref: refs/heads/main\n' }))).toBe(UNKNOWN_COMMIT);
});
it('is unknown when packed-refs does not mention the ref', () => {
expect(
resolveCommit(
source({
head: 'ref: refs/heads/gone\n',
packedRefs: `${SHA} refs/heads/main\n`
})
)
).toBe(UNKNOWN_COMMIT);
});
// A HEAD that is neither a ref line nor a SHA. Returning it verbatim would
// put arbitrary file contents on the admin screen.
it('is unknown when HEAD is not something it recognises', () => {
expect(resolveCommit(source({ head: 'not a sha and not a ref\n' }))).toBe(UNKNOWN_COMMIT);
});
it('is unknown when HEAD is empty', () => {
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$/);
});
});