fix(ci): build dev images without repository write access
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s

This commit is contained in:
2026-09-20 18:22:17 +02:00
parent 68c0a224e5
commit bb94e73229
5 changed files with 177 additions and 30 deletions

View File

@@ -0,0 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
const ciScript = resolve('scripts/release/ci.mjs');
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-ci-permissions-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
const git = args => execFileSync(realGit, args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
git(['init', '-b', 'main']);
git(['config', 'user.name', 'Release Test']);
git(['config', 'user.email', 'release@example.test']);
git(['commit', '--allow-empty', '-m', 'chore: baseline']);
const baseline = git(['rev-parse', 'HEAD']);
const remote = join(cwd, 'remote.git');
git(['init', '--bare', remote]);
git(['remote', 'add', 'origin', remote]);
git(['push', 'origin', 'main']);
git(['checkout', '-b', 'dev']);
writeFileSync(join(cwd, 'release.config.json'), JSON.stringify({
initialVersion: '3.0.0', bootstrapCommit: baseline, branch: 'main',
repositoryUrl: 'https://git.example.test/owner/repo', registry: 'registry.example.test/fedeo',
images: [{ name: 'backend', context: 'backend', dockerfile: 'backend/Dockerfile' }],
}));
git(['add', 'release.config.json']);
git(['commit', '-m', 'feat: add release configuration']);
git(['push', 'origin', 'dev']);
const sha = git(['rev-parse', 'HEAD']);
const bin = join(cwd, 'bin');
mkdirSync(bin);
const gitTrace = join(cwd, 'git-calls.jsonl');
const dockerTrace = join(cwd, 'docker-calls.jsonl');
const registryFile = join(cwd, 'registry.json');
writeFileSync(gitTrace, '');
writeFileSync(dockerTrace, '');
writeFileSync(registryFile, '{}');
// All Git operations are real and local, except that the simulated CI user cannot push.
writeFileSync(join(bin, 'git'), `#!/usr/bin/env node
const { appendFileSync } = require('node:fs');
const { spawnSync } = require('node:child_process');
const args = process.argv.slice(2);
appendFileSync(process.env.TEST_GIT_TRACE, JSON.stringify(args) + '\\n');
if (args[0] === 'push') {
process.stderr.write('remote: Forbidden\\nfatal: requested URL returned error: 403\\n');
process.exit(128);
}
if (process.env.TEST_STALE_DEV === 'true' && args[0] === 'ls-remote' && args[2] === 'refs/heads/dev') {
process.stdout.write('b'.repeat(40) + '\\trefs/heads/dev\\n');
process.exit(0);
}
const result = spawnSync(process.env.TEST_REAL_GIT, args, { stdio: 'inherit' });
process.exit(result.status ?? 1);
`, { mode: 0o755 });
// Simulate only Docker's registry boundary; no external service or credential is used.
writeFileSync(join(bin, 'docker'), `#!/usr/bin/env node
const { appendFileSync, readFileSync, writeFileSync } = require('node:fs');
const { createHash } = require('node:crypto');
const args = process.argv.slice(2);
appendFileSync(process.env.TEST_DOCKER_TRACE, JSON.stringify(args) + '\\n');
const registry = JSON.parse(readFileSync(process.env.TEST_REGISTRY_FILE, 'utf8'));
if (args[1] === 'build') {
const tag = args[args.indexOf('--tag') + 1];
const digest = 'sha256:' + createHash('sha256').update(tag).digest('hex');
registry[tag] = digest;
registry[tag.slice(0, tag.lastIndexOf(':')) + '@' + digest] = digest;
writeFileSync(args[args.indexOf('--metadata-file') + 1], JSON.stringify({ 'containerimage.digest': digest }));
} else if (args[1] === 'imagetools' && args[2] === 'create') {
const digest = registry[args.at(-1)];
if (!digest) process.exit(1);
registry[args[args.indexOf('--tag') + 1]] = digest;
} else if (args[1] === 'imagetools' && args[2] === 'inspect') {
const digest = registry[args[3]];
if (!digest) { process.stderr.write('manifest unknown\\n'); process.exit(1); }
process.stdout.write(JSON.stringify({ digest }));
} else { process.exit(1); }
writeFileSync(process.env.TEST_REGISTRY_FILE, JSON.stringify(registry));
`, { mode: 0o755 });
const run = (extra = {}) => spawnSync(process.execPath, [ciScript], {
cwd, encoding: 'utf8', timeout: 30_000,
env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, CI: 'true',
FEDEO_REF: 'refs/heads/dev', FEDEO_SHA: sha, FEDEO_EVENT_NAME: 'push', GITEA_TOKEN: '',
TEST_REAL_GIT: realGit, TEST_GIT_TRACE: gitTrace, TEST_DOCKER_TRACE: dockerTrace,
TEST_REGISTRY_FILE: registryFile, ...extra,
},
});
const calls = path => readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
return { sha, run, gitCalls: () => calls(gitTrace), dockerCalls: () => calls(dockerTrace), registry: () => JSON.parse(readFileSync(registryFile, 'utf8')) };
}
test('dev builds and retries succeed without a release token or Git push permissions', t => {
const f = fixture(t);
for (let attempt = 0; attempt < 2; attempt++) {
const result = f.run();
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
}
assert.ok(f.gitCalls().every(args => !['push', 'commit-tree'].includes(args[0])));
const snapshots = Object.keys(f.registry()).filter(tag => tag.includes(`:dev-sha-${f.sha}-`));
assert.equal(snapshots.length, 2, 'Reruns must have separate snapshots, even for the same commit');
assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], f.registry()[snapshots[1]]);
});
test('an outdated dev run keeps its snapshot without advancing the dev channel', t => {
const f = fixture(t);
const result = f.run({ TEST_STALE_DEV: 'true' });
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], undefined);
assert.match(result.stdout, /neuerer dev-Commit/);
assert.ok(f.dockerCalls().some(args => args[1] === 'build'));
});
test('main still requires a release credential before building or publishing', t => {
const f = fixture(t);
const result = f.run({ FEDEO_REF: 'refs/heads/main' });
assert.equal(result.status, 1);
assert.match(result.stderr, /RELEASE_TOKEN.*write:repository/);
assert.equal(f.dockerCalls().length, 0);
});
test('a forbidden main lock fails with an actionable message and never builds unlocked', t => {
const f = fixture(t);
const result = f.run({ FEDEO_REF: 'refs/heads/main', GITEA_TOKEN: 'synthetic-test-token' });
assert.equal(result.status, 1);
assert.match(result.stderr, /main-Release-Sperre benötigt Git-Schreibrechte/);
assert.doesNotMatch(result.stderr, /synthetic-test-token/);
assert.ok(f.gitCalls().some(args => args[0] === 'push'));
assert.equal(f.dockerCalls().length, 0);
});