ci: migrate to Gitea Actions
CI / policy (push) Successful in 48s
CI / validate (push) Successful in 1m25s
CI / test (push) Successful in 3m43s
CI / build (push) Successful in 1m20s
CI / security (push) Successful in 1m31s
CI / benchmark (push) Failing after 5m52s
CI / images (push) Failing after 1m2s
CI / required (push) Failing after 1s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 3m30s
CI / benchmark (pull_request) Canceled after 3s
CI / build (pull_request) Successful in 1m5s
CI / security (pull_request) Successful in 1m27s
CI / images (pull_request) Successful in 2m24s
CI / required (pull_request) Successful in 5s

This commit is contained in:
nessar committed 2026-10-13 19:43:08 +02:00
1 parent 7d60157863
commit d74dfb9938
14 files changed
+662 -262

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
// Gitea exposes its event payload through the GitHub-compatible runner variables.
export function readCiContext() {
const env = process.env;
const eventName = env.GITHUB_EVENT_NAME;
if (!['push', 'pull_request', 'workflow_dispatch'].includes(eventName)) {
throw new Error('Only push, pull_request and workflow_dispatch events are supported.');
}
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
const commit = env.GITHUB_SHA;
if (!/^[a-f0-9]{40}$/.test(commit ?? '')) throw new Error('A commit SHA is required.');
const pullRequest = eventName === 'pull_request' ? event.pull_request : undefined;
if (eventName === 'pull_request' && pullRequest?.head?.sha !== commit) {
throw new Error('CI must validate the pull request head commit.');
}
const tag = env.GITHUB_REF?.startsWith('refs/tags/') ? env.GITHUB_REF.slice(10) : '';
const branch = pullRequest
? pullRequest.head.ref
: env.GITHUB_REF?.startsWith('refs/heads/')
? env.GITHUB_REF.slice(11)
: '';
if (!tag && !branch) throw new Error('A branch or tag ref is required.');
const slug = branch
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.slice(0, 63)
.replace(/^-+|-+$/g, '');
const publish =
eventName === 'push' ||
(eventName === 'pull_request' &&
Boolean(env.GITHUB_REPOSITORY) &&
pullRequest.head.repo?.full_name === env.GITHUB_REPOSITORY);
return { commit, tag, branch, slug, publish, pullRequest, before: event.before };
}
+40
View File
@@ -0,0 +1,40 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readCiContext } from './ci-context.mjs';
try {
const { commit, tag, slug, publish } = readCiContext();
if (!publish) throw new Error('Images can only be published by pushes or same-repository PRs.');
execFileSync(process.execPath, [fileURLToPath(new URL('./ci-policy.mjs', import.meta.url))]);
const env = process.env;
if (!env.REGISTRY_USERNAME || !env.REGISTRY_PASSWORD) {
throw new Error('REGISTRY_USERNAME and REGISTRY_PASSWORD are required for publication.');
}
const registry = new URL(env.GITHUB_SERVER_URL).host;
const image = `${registry}/${env.GITHUB_REPOSITORY.toLowerCase()}`;
const shaImage = `${image}:sha-${commit}`;
const docker = (...args) => execFileSync('docker', args, { stdio: 'inherit' });
execFileSync(
'docker',
['login', registry, '--username', env.REGISTRY_USERNAME, '--password-stdin'],
{
input: env.REGISTRY_PASSWORD,
stdio: ['pipe', 'inherit', 'inherit'],
},
);
if (tag) {
docker('pull', shaImage);
docker('tag', shaImage, `${image}:${tag}`);
docker('push', `${image}:${tag}`);
docker('tag', shaImage, `${image}:latest`);
docker('push', `${image}:latest`);
} else {
const branchImage = `${image}:branch-${slug}`;
docker('build', '-t', shaImage, '-t', branchImage, '.');
docker('push', shaImage);
docker('push', branchImage);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-image-'));
const script = fileURLToPath(new URL('./ci-image.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const image = 'gitea.nessar.fr/nessar/vac-optimizer';
const commandsPath = join(directory, 'docker-commands.jsonl');
const bin = join(directory, 'bin');
const run = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: 'refs/heads/feature/calendar';
const event = { ref };
if (eventName === 'pull_request') {
event.pull_request = {
title: 'feat: calendar',
head: {
sha: commit,
ref: 'feature/calendar',
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: 'develop' },
};
}
writeFileSync(join(directory, 'event.json'), JSON.stringify(event));
writeFileSync(commandsPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: `${bin}:${process.env.PATH}`,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: join(directory, 'event.json'),
GITHUB_SHA: commit,
GITHUB_REF: ref,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_SERVER_URL: 'https://gitea.nessar.fr',
REGISTRY_USERNAME: 'publisher',
REGISTRY_PASSWORD: variables.registryPassword ?? 'test-token',
DOCKER_COMMANDS: commandsPath,
MISSING_IMAGE: variables.missingImage ? 'true' : '',
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(commandsPath, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI image test');
git('config', 'user.email', 'ci-image@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'chore: initial release');
git('branch', 'develop');
git('switch', '-c', 'feature/calendar');
git('commit', '--allow-empty', '-m', 'feat: calendar');
mkdirSync(bin);
const docker = join(bin, 'docker');
writeFileSync(
docker,
`#!/usr/bin/env node
import { appendFileSync, readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
appendFileSync(process.env.DOCKER_COMMANDS, JSON.stringify({ args, input }) + '\\n');
if (args[0] === 'pull' && process.env.MISSING_IMAGE === 'true') process.exit(1);
`,
);
chmodSync(docker, 0o755);
const commit = git('rev-parse', 'HEAD');
const commands = run();
assert.deepEqual(commands, [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{
args: [
'build',
'-t',
`${image}:sha-${commit}`,
'-t',
`${image}:branch-feature-calendar`,
'.',
],
input: '',
},
{ args: ['push', `${image}:sha-${commit}`], input: '' },
{ args: ['push', `${image}:branch-feature-calendar`], input: '' },
]);
assert.deepEqual(run({ eventName: 'pull_request' }), commands);
assert.deepEqual(
run({ eventName: 'pull_request', headRepository: 'contributor/vac-optimizer' }, 1),
[],
);
assert.deepEqual(run({ eventName: 'workflow_dispatch' }, 1), []);
assert.deepEqual(run({ registryPassword: '' }, 1), []);
assert.deepEqual(run({ tag: 'v1.5.0' }, 1), []);
git('switch', 'main');
git('tag', '-a', 'v1.5.0', '-m', 'Release 1.5.0');
const releaseCommit = git('rev-parse', 'HEAD');
assert.deepEqual(run({ tag: 'v1.5.0' }), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:v1.5.0`], input: '' },
{ args: ['push', `${image}:v1.5.0`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:latest`], input: '' },
{ args: ['push', `${image}:latest`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0', missingImage: true }, 1), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0-rc.1' }, 1), []);
assert.deepEqual(run({ tag: 'v1.6.0' }, 1), []);
console.log('CI image publication checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}
+17 -15
View File
@@ -1,5 +1,6 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { appendFileSync, readFileSync } from 'node:fs';
import { readCiContext } from './ci-context.mjs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
@@ -11,7 +12,7 @@ const requirePolicy = (condition, message) => {
};
try {
const commit = env.CI_COMMIT_SHA;
const { commit, tag, branch, slug, pullRequest, before } = readCiContext();
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
@@ -21,33 +22,32 @@ try {
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
if (tag) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
requirePolicy(tag === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
const target = pullRequest
? pullRequest.base?.ref
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
git('check-ref-format', `refs/heads/${branch}`);
requirePolicy(['main', 'develop'].includes(target), 'PRs must target main or develop.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
if (pullRequest) {
const title = pullRequest.title?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
'PR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
@@ -65,9 +65,8 @@ try {
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
!pullRequest && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
@@ -78,6 +77,9 @@ try {
);
}
}
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, `slug=${slug}\ntag=${tag}\n`);
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
+65 -38
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,26 +8,48 @@ import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const check = (variables, expected = 0) => {
const check = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const branch = variables.branch ?? 'feature/test';
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: `refs/heads/${branch}`;
const event = { ref, before: variables.before };
if (eventName === 'pull_request') {
event.pull_request = {
title: variables.title ?? 'feat(test): add policy checks',
head: {
ref: branch,
sha: variables.headSha ?? commit,
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: variables.target ?? 'develop' },
};
}
const eventPath = join(directory, 'event.json');
const outputPath = join(directory, 'outputs');
writeFileSync(eventPath, JSON.stringify(event));
writeFileSync(outputPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push',
CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
CI_COMMIT_BRANCH: 'feature/test',
...variables,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: eventPath,
GITHUB_REF: ref,
GITHUB_SHA: variables.sha ?? commit,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_OUTPUT: outputPath,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(outputPath, 'utf8');
};
const mr = {
CI_PIPELINE_SOURCE: 'merge_request_event',
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
};
const pr = { eventName: 'pull_request' };
try {
git('init', '--initial-branch=main');
@@ -41,26 +63,31 @@ try {
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
check(mr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
check(pr);
check({ ...pr, headRepository: 'contributor/vac-optimizer' });
check({ eventName: 'workflow_dispatch' });
assert.match(check({ branch: 'Feature/My_calendar' }), /slug=feature-my-calendar\n/);
check({ ...pr, headSha: '0'.repeat(40) }, 1);
check({ eventName: 'pull_request_target' }, 1);
check({ ...pr, title: 'Draft: feat: test policy' });
check({ ...pr, title: '(Draft) feat: test policy' });
check({ ...pr, target: 'main' }, 1);
check({ ...pr, title: 'Add checks' }, 1);
check({ tag: 'v1.5.0' }, 1);
check({ sha: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
check(pr, 1);
git('rebase', 'develop');
check(mr);
check(pr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
check({ before: before }, 1);
check(pr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
check({ before: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
@@ -69,33 +96,33 @@ try {
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'release/1.6.0',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
check({ ...pr, branch: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
check({ tag: 'v1.6.0' });
check({ tag: 'v1.5.0' }, 1);
check({ tag: 'v01.6.0' }, 1);
check({ tag: 'v1.6.0-rc.1' }, 1);
check({ ...pr, branch: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'hotfix/1.6.1',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
check({ ...pr, branch: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1);
check(pr, 1);
console.log('CI policy integration checks passed.');
} finally {