ci: enforce GitFlow and publish branch images

This commit is contained in:
nessar committed 2026-10-08 00:09:29 +02:00
1 parent 06adeb12d6
commit 2a53bb6cb4
4 files changed
+281 -7

No files matched your search

+58 -6
View File
@@ -1,5 +1,17 @@
image: oven/bun:latest
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
when: never
- if: $CI_PIPELINE_SOURCE == "push"
default:
interruptible: true
stages:
- validate
- test
@@ -10,6 +22,7 @@ stages:
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ''
AST_ENABLE_MR_PIPELINES: 'true'
cache:
key:
@@ -19,11 +32,24 @@ cache:
- .bun/
.node_setup:
rules:
- when: on_success
before_script:
- bun install --frozen-lockfile
# -- Validate Stage --
ci-policy:
image: node:24.21.0
stage: validate
variables:
GIT_DEPTH: '0'
rules:
- when: on_success
script:
- node scripts/ci-policy.test.mjs
- node scripts/ci-policy.mjs
lint:
extends: .node_setup
stage: validate
@@ -52,7 +78,10 @@ test:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- bun run test --watch=false --coverage
- bun scripts/generate-langs.js
- bunx vitest run --coverage --maxWorkers=2 --exclude='src/app/benchmark/**'
# Keep the complete benchmark outside coverage instrumentation and worker contention.
- bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
artifacts:
when: always
paths:
@@ -90,6 +119,8 @@ trivy_scan:
name: aquasec/trivy:latest
entrypoint: ['']
before_script: [] # override global before_script
rules:
- when: on_success
script:
# Trivy will scan the project directory
- trivy fs --format sarif --output trivy-results.sarif .
@@ -100,16 +131,37 @@ trivy_scan:
# -- Docker Stage --
docker-build:
.docker:
stage: docker
image: docker:29.8.2
services:
- docker:29.8.2-dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
- printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
cache: []
dependencies: []
docker-build:
extends: .docker
resource_group: image-$CI_COMMIT_REF_SLUG
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
# ponytail: GitLab slugs truncate at 63 bytes; add a branch-name hash if colliding names are needed.
- docker build -t "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG"
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "detached" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID
docker-release:
extends: .docker
interruptible: false
resource_group: stable-image
script:
- docker pull "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
+35 -1
View File
@@ -242,7 +242,41 @@ GitLab CI runs independent validation, test, build, security, and image stages:
- Vitest with coverage artifacts;
- an optimized production build;
- GitLab SAST and Trivy filesystem scanning;
- Docker image publication from the default branch.
- GitFlow, Conventional Commit, and release-version checks;
- Docker image publication for every successful branch push, including branches with an open MR.
Pipelines switch to MR pipelines when a branch has an open MR, avoiding duplicate builds. Docker publication from MR pipelines is limited to detached pipelines within this project. Tag pipelines promote the already-built commit image instead of rebuilding it.
The test job runs Vitest directly with at most two workers and coverage. It then runs the complete benchmark protocol separately, with one worker and no coverage instrumentation, preserving its assertions and existing timeout. This avoids the benchmark's observed 90-second timeout under coverage while keeping it mandatory.
### GitFlow and releases
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their MR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both MRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Use Conventional Commits for new commits and MR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. GitLab generates merge messages from the validated MR title. Legacy history is not rewritten.
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<GitLab ref slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` publishes `branch-feature-calendar`; integration publishes `branch-develop`. GitLab normalizes ref slugs to lowercase and truncates them to 63 bytes, so choose branch names with distinct slugs. Only valid release tags can update `latest`.
After the MR into `main` and its full pipeline have succeeded, tag the published main commit:
```bash
git fetch origin
git tag -a v1.6.0 origin/main -m "Release 1.6.0"
git push origin v1.6.0
```
The tag must match the checked-out `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main pipeline before tagging: the release job requires its `sha-<commit>` image. Tags do not increment the version automatically.
GitLab project settings must use `develop` as the default branch, the **Merge commit** method, squashing disabled, and **Pipelines must succeed** with skipped pipelines disallowed. Protect `main` and `develop` with no direct or forced pushes and Maintainer-only merges. Set the merge commit template to `%{title}` followed by the MR reference, preserving Conventional Commit subjects. The CI permits only `release/*` and `hotfix/*` into `main`; working MRs target `develop`. Rebase checks validate the target head at pipeline time: if `develop` advances afterward, rebase and rerun the MR pipeline before merging.
Run the policy integration check locally with `node scripts/ci-policy.test.mjs`.
## Domain and design references
+85
View File
@@ -0,0 +1,85 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
const ancestor = (base, commit) =>
spawnSync('git', ['merge-base', '--is-ancestor', base, commit]).status === 0;
const conventional = /^[a-z]+(?:\([^()\r\n]+\))?!?: \S.*$/i;
const requirePolicy = (condition, message) => {
if (!condition) throw new Error(message);
};
try {
const commit = env.CI_COMMIT_SHA;
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/main:refs/remotes/origin/main',
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_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(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
: 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.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
'Only release/* and hotfix/* may merge into main.',
);
if (target === 'develop' && branch !== 'main' && !/^(release|hotfix)\//.test(branch)) {
requirePolicy(
ancestor('origin/develop', commit),
'Rebase the working branch onto origin/develop before merging.',
);
requirePolicy(
!git('rev-list', '--merges', `origin/develop..${commit}`),
'Working branches must use rebase, not merge commits.',
);
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
for (const line of subjects ? subjects.split('\n') : []) {
requirePolicy(
conventional.test(line.slice(line.indexOf(' ') + 1)),
`Commit must use Conventional Commits: ${line}`,
);
}
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, 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-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 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,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
};
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',
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI policy test');
git('config', 'user.email', 'ci-policy@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'Legacy history is allowed');
git('branch', 'develop');
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);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
git('rebase', 'develop');
check(mr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
git('switch', '-c', 'release/1.6.0');
writeFileSync(join(directory, 'package.json'), '{"version":"1.6.0"}\n');
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',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: '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' });
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',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: '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);
console.log('CI policy integration checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}