fix(ci): build dev images without repository write access
This commit is contained in:
@@ -39,16 +39,10 @@ async function promote(image, tags, immutable) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildImages(plan, version, reuseTag) {
|
||||
const stagingTag = `build-${randomUUID()}`;
|
||||
async function buildImages(plan, version, stagingTag = `build-${randomUUID()}`) {
|
||||
const images = [];
|
||||
for (const component of config.images) {
|
||||
const repository = `${config.registry}/${component.name}`;
|
||||
const existing = reuseTag ? imageDigest(`${repository}:${reuseTag}`, true) : null;
|
||||
if (existing) {
|
||||
images.push({ name: component.name, repository, digest: existing });
|
||||
continue;
|
||||
}
|
||||
const metadata = `${outputDir}/${component.name}.json`;
|
||||
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
|
||||
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
|
||||
@@ -71,15 +65,15 @@ try {
|
||||
}
|
||||
const sha = git(['rev-parse', 'HEAD']);
|
||||
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.');
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (!token) throw new Error('CI_TOKEN oder RELEASE_TOKEN als Gitea-Secret konfigurieren (siehe docs/releases.md).');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const api = new Gitea(config.repositoryUrl, token);
|
||||
const stable = ref === `refs/heads/${config.branch}`;
|
||||
unlock = await acquireLock(stable ? {} : { ref: 'refs/heads/automation/dev-build-lock' });
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (stable && !token) throw new Error('Für main einen RELEASE_TOKEN oder CI_TOKEN mit write:repository konfigurieren (siehe docs/releases.md).');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
if (stable) unlock = await acquireLock();
|
||||
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
|
||||
|
||||
if (stable) {
|
||||
const api = new Gitea(config.repositoryUrl, token);
|
||||
const releases = await api.releases();
|
||||
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha);
|
||||
if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
|
||||
@@ -96,10 +90,19 @@ try {
|
||||
}
|
||||
} else {
|
||||
const plan = await createPlan();
|
||||
const images = await buildImages(plan, plan.developmentVersion, `dev-sha-${sha}`);
|
||||
for (const image of images) await promote(image, [`dev-sha-${sha}`], true);
|
||||
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
|
||||
if (remoteDev === sha) for (const image of images) await promote(image, ['dev'], false);
|
||||
// Separate snapshots let overlapping dev runs build without a repository write lock.
|
||||
const snapshotTag = `dev-sha-${sha}-${randomUUID()}`;
|
||||
const images = await buildImages(plan, plan.developmentVersion, snapshotTag);
|
||||
console.log(`Entwicklungsbuild: ${snapshotTag}`);
|
||||
for (const image of images) {
|
||||
// Check each update so an outdated run stops moving the mutable dev channel.
|
||||
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
|
||||
if (remoteDev !== sha) {
|
||||
console.log('Ein neuerer dev-Commit liegt vor; der dev-Kanal wird nicht weiter aktualisiert.');
|
||||
break;
|
||||
}
|
||||
await promote(image, ['dev'], false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
|
||||
@@ -15,6 +15,9 @@ export async function acquireLock({ runGit = git, wait = sleep, attempts = 480,
|
||||
runGit(['push', `--force-with-lease=${ref}:`, 'origin', `${lockSha}:${ref}`]);
|
||||
return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]);
|
||||
} catch (error) {
|
||||
if (/403|forbidden|permission denied|authentication failed/i.test(String(error.stderr || error.message))) {
|
||||
throw new Error('Die main-Release-Sperre benötigt Git-Schreibrechte. RELEASE_TOKEN (Secret oder Variable) oder den bisherigen vars.CI_TOKEN mit write:repository und Zugriff auf automation/release-lock konfigurieren.');
|
||||
}
|
||||
if (!runGit(['ls-remote', 'origin', ref])) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
136
scripts/release/tests/ci-permissions.test.mjs
Normal file
136
scripts/release/tests/ci-permissions.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user