Reproducibility means two things here: the same commit builds the same image,
and the running container can be asked which commit it is.
- Base images are pinned by digest, not by tag. A tag moves; two builds of one
commit could otherwise differ. These are manifest-list digests, so buildx
still picks the right architecture.
- scripts/build-image.sh also writes ped-ai-local:<revision>, an immutable
name a deploy can refer to instead of chasing :latest. Its summary goes to
stderr so stdout stays the Compose invocation.
- Compose takes the image from PED_AI_IMAGE, so a deploy runs a specific
revision-tagged image while a local build still uses the local tag.
- scripts/deploy.sh pins that image in the file Compose interpolates from,
waits for health, then asks /api/build which revision is actually serving
and rolls back to the previous image if it does not match. Healthy is not
the same as running what you asked for. The rollback path was exercised.
- The entrypoint applies migrations before the app starts, so code and schema
arrive together. node-pg-migrate takes an advisory lock; losing it is not an
error, it waits and looks again, so a rolling restart does not fail. A real
migration failure stops the container rather than serving on a schema that
does not match the build. RUN_MIGRATIONS=false opts out.
- The Forgejo workflow builds through that same script, tags by full revision,
and has an opt-in deploy job. It refuses to run if the deploy directory has
uncommitted work rather than resetting over it.
The running image was labelled revision=unknown, and /api/build said "unknown",
because `docker compose up --build` never passes GIT_REVISION. That is exactly
the hole this closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
101 lines
5.1 KiB
JavaScript
101 lines
5.1 KiB
JavaScript
'use strict';
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { getBuildId, isGitRevision } = require('../src/utils/buildId');
|
|
|
|
test('build ID resolves real Git checkouts, packed refs, worktrees, baked images and unknown sources', t => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ped-build-id-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const repo = path.join(root, 'repo');
|
|
fs.mkdirSync(repo);
|
|
const git = (...args) => execFileSync('git', ['-C', repo, ...args], {
|
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
git('init');
|
|
git('-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
|
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'test');
|
|
const sha = git('rev-parse', 'HEAD');
|
|
assert.equal(sha.length, 40);
|
|
assert.equal(getBuildId(repo), sha);
|
|
|
|
git('pack-refs', '--all', '--prune');
|
|
assert.equal(fs.existsSync(path.join(repo, '.git', git('symbolic-ref', 'HEAD'))), false);
|
|
assert.equal(getBuildId(repo), sha);
|
|
|
|
const worktree = path.join(root, 'worktree');
|
|
git('worktree', 'add', '--detach', worktree, 'HEAD');
|
|
assert.equal(fs.statSync(path.join(worktree, '.git')).isFile(), true);
|
|
assert.equal(getBuildId(worktree), sha);
|
|
git('checkout', '--detach', 'HEAD');
|
|
assert.equal(getBuildId(repo), sha);
|
|
|
|
// No accidental ancestor Git discovery for an unversioned source directory.
|
|
const plain = path.join(repo, 'plain');
|
|
fs.mkdirSync(plain);
|
|
assert.equal(getBuildId(plain), 'unknown');
|
|
const baked = 'b'.repeat(40);
|
|
fs.writeFileSync(path.join(plain, 'BUILD_ID'), baked + '\n');
|
|
assert.equal(getBuildId(plain), baked);
|
|
fs.writeFileSync(path.join(worktree, 'BUILD_ID'), baked);
|
|
assert.equal(getBuildId(worktree), baked, 'baked metadata takes precedence');
|
|
for (const invalid of ['unknown', '', 'abc1234', 'z'.repeat(40), '<script>', sha + '\r\nInjected: yes']) {
|
|
fs.writeFileSync(path.join(worktree, 'BUILD_ID'), invalid);
|
|
assert.equal(getBuildId(worktree), 'unknown');
|
|
assert.equal(isGitRevision(invalid), false);
|
|
}
|
|
assert.equal(isGitRevision(sha + '\n'), false);
|
|
assert.equal(isGitRevision(sha.toUpperCase()), false);
|
|
fs.writeFileSync(path.join(plain, '.git'), 'gitdir: /nonexistent/ped-build-test\n');
|
|
fs.unlinkSync(path.join(plain, 'BUILD_ID'));
|
|
assert.equal(getBuildId(plain), 'unknown');
|
|
});
|
|
|
|
test('manual build script passes the full revision to Compose without starting services', t => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ped-build-script-'));
|
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
fs.mkdirSync(path.join(root, 'scripts'));
|
|
fs.copyFileSync(path.join(__dirname, '../scripts/build-image.sh'), path.join(root, 'scripts/build-image.sh'));
|
|
const bin = path.join(root, 'bin');
|
|
fs.mkdirSync(bin);
|
|
fs.writeFileSync(path.join(bin, 'docker'), '#!/bin/sh\nprintf "%s\\n" "$GIT_REVISION" "$@"\n', { mode: 0o755 });
|
|
const build = (env = {}) => execFileSync('/bin/sh', [path.join(root, 'scripts/build-image.sh'), '--no-cache'], {
|
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PATH: bin + path.delimiter + process.env.PATH, GIT_REVISION: 'bad-override', ...env },
|
|
}).trim().split('\n');
|
|
// No revision means no immutable tag to write, so Compose is the only call.
|
|
assert.deepEqual(build(), ['unknown', 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
|
const git = (...args) => execFileSync('git', ['-C', root, ...args], {
|
|
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
git('init');
|
|
git('-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
|
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'test');
|
|
const sha = git('rev-parse', 'HEAD');
|
|
// With a revision the image is also tagged immutably, so a deploy can name a
|
|
// build rather than chasing whatever :latest happens to point at.
|
|
assert.deepEqual(build(), [
|
|
sha, 'compose', 'build', '--no-cache', 'pediatric-scribe',
|
|
sha, 'tag', 'ped-ai-local:latest', 'ped-ai-local:' + sha,
|
|
]);
|
|
|
|
const foreign = path.join(root, 'foreign');
|
|
git('init', foreign);
|
|
git('-C', foreign, '-c', 'user.name=Build Test', '-c', 'user.email=build@example.invalid',
|
|
'-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'foreign');
|
|
assert.notEqual(git('-C', foreign, 'rev-parse', 'HEAD'), sha);
|
|
const controls = { GIT_DIR: path.join(foreign, '.git'), GIT_WORK_TREE: foreign,
|
|
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' };
|
|
assert.deepEqual(build(controls), [
|
|
sha, 'compose', 'build', '--no-cache', 'pediatric-scribe',
|
|
sha, 'tag', 'ped-ai-local:latest', 'ped-ai-local:' + sha,
|
|
]);
|
|
// A Docker-only source archive still works without Git installed.
|
|
fs.rmSync(path.join(root, '.git'), { recursive: true });
|
|
fs.symlinkSync('/usr/bin/dirname', path.join(bin, 'dirname'));
|
|
assert.deepEqual(build({ ...controls, PATH: bin }), ['unknown', 'compose', 'build', '--no-cache', 'pediatric-scribe']);
|
|
});
|