Compare commits
6 Commits
8473221c13
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e63af1aa2 | |||
| 74c0cf40b3 | |||
| 5ebd2d508c | |||
| f6996c1d7b | |||
| 9128fbb165 | |||
| bb94e73229 |
@@ -30,8 +30,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
# Prefer repository secrets; retain the existing variable during migration.
|
||||
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
# dev uses the normal read-only checkout; only main needs a release writer.
|
||||
token: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || github.token }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
@@ -41,13 +41,14 @@ jobs:
|
||||
with:
|
||||
registry: git.federspiel.tech
|
||||
username: flfeders
|
||||
password: ${{ secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
# Same credential source as the existing image-build workflows.
|
||||
password: ${{ vars.CI_TOKEN || secrets.CI_TOKEN }}
|
||||
- name: Build all images and publish main releases
|
||||
# Gitea 1.25 ignores concurrency. ci.mjs uses an atomic Git ref lock instead.
|
||||
# main uses a Git ref lock. dev only reads Git and writes to the registry.
|
||||
env:
|
||||
CI: 'true'
|
||||
FEDEO_REF: ${{ github.ref }}
|
||||
FEDEO_SHA: ${{ github.sha }}
|
||||
FEDEO_EVENT_NAME: ${{ github.event_name }}
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
|
||||
GITEA_TOKEN: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || '' }}
|
||||
run: npm run release:ci
|
||||
|
||||
@@ -41,7 +41,9 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer`
|
||||
|
||||
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
|
||||
|
||||
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung. Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Den Ausgangscommit nach Einführung nicht ändern.
|
||||
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
|
||||
|
||||
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
|
||||
|
||||
## Versionen und Artefakte
|
||||
|
||||
@@ -49,8 +51,8 @@ Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemein
|
||||
|
||||
- Stabile Images: `…/backend:3.2.0`, `…/backend:v3.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten.
|
||||
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
|
||||
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>`-Images. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
|
||||
- Jeder Build erhält zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
|
||||
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>-<UUID>`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
|
||||
- Release-Builds erhalten zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
|
||||
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh` und `SHA256SUMS`.
|
||||
|
||||
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
|
||||
@@ -71,18 +73,22 @@ Beispiel: `{"product":"FEDEO","version":"3.2.0","revision":"<Commit-ID>"}`. Die
|
||||
|
||||
Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau.
|
||||
|
||||
Unter Repository → Settings → Actions → Secrets:
|
||||
Unter Repository → Settings → Actions → Variables bzw. Secrets:
|
||||
|
||||
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die bisherige Variable `vars.CI_TOKEN` wird als Übergang weiterhin unterstützt; langfristig als Secret hinterlegen.
|
||||
- Optional `RELEASE_TOKEN`: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen der temporären Sperr-Branches. Ohne separates Token wird `CI_TOKEN` dafür verwendet und benötigt dann zusätzlich diese Rechte.
|
||||
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die Registry-Anmeldung verwendet wie die bisherigen Build-Workflows vorrangig `vars.CI_TOKEN`; `secrets.CI_TOKEN` dient als Rückfallwert. Für eine spätere Umstellung auf ein Secret die gleichnamige Variable entfernen.
|
||||
- Optional `RELEASE_TOKEN` als Secret oder Variable: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen des temporären Sperr-Branches `automation/release-lock`. Nur `main` benötigt diese Rechte. Die Reihenfolge für Git-Anmeldung und Release-API ist `secrets.RELEASE_TOKEN`, `vars.RELEASE_TOKEN`, `vars.CI_TOKEN`, `secrets.CI_TOKEN`. Ohne separates Release-Token benötigt der verwendete `CI_TOKEN` zusätzlich diese Rechte.
|
||||
|
||||
Der Token-Benutzer muss die entsprechenden Repository-Rechte haben. Ein Gitea-Standard-Job-Token reicht je nach Server-Konfiguration nicht zum Pushen und zum Registry-Upload. Geschützte Tags `v*` müssen den Release-Benutzer zulassen; die Branches `automation/release-lock` und `automation/dev-build-lock` müssen durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`.
|
||||
Auf `dev` nutzt der Checkout wie die bisherigen Workflows den normalen Job-Token zum Lesen. Entwicklungsbuilds pushen keine Git-Refs und rufen die Release-API nicht auf; der Registry-Token benötigt dafür keine Repository-Schreibrechte.
|
||||
|
||||
Der Release-Token-Benutzer muss die entsprechenden Repository-Rechte haben. Geschützte Tags `v*` müssen ihn zulassen; `automation/release-lock` muss durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`. Ein 403 beim Anlegen der Main-Sperre führt vor dem Image-Build zum Abbruch mit einem Hinweis auf die fehlenden Rechte.
|
||||
|
||||
`dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet.
|
||||
|
||||
## Veröffentlichungsreihenfolge und Wiederaufnahme
|
||||
|
||||
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Für Entwicklungsbuilds gibt es eine separate Sperre. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
|
||||
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner für Releases auf `main` atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Release-Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
|
||||
|
||||
Entwicklungsbuilds benötigen keine Git-Sperre. Parallele Läufe bauen unabhängige Snapshot-Tags. Nach dem vollständigen Build wird vor jeder Aktualisierung eines `dev`-Images geprüft, ob der Commit noch der aktuelle Stand von `origin/dev` ist. Ein erkennbar veralteter Lauf beendet die Aktualisierung. Diese Prüfung und die Registry-Aktualisierung sind nicht atomar; für einen festen Stand aller Komponenten denselben vollständigen Snapshot-Tag oder einen stabilen Release verwenden.
|
||||
|
||||
1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen.
|
||||
2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job.
|
||||
@@ -91,7 +97,7 @@ Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Run
|
||||
5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit.
|
||||
6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen.
|
||||
|
||||
Bei einem Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
|
||||
Bei einem Release-Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
|
||||
|
||||
Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen.
|
||||
|
||||
@@ -103,7 +109,7 @@ git ls-remote origin refs/heads/automation/release-lock
|
||||
git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock
|
||||
```
|
||||
|
||||
Für einen abgebrochenen Entwicklungsbuild entsprechend `automation/dev-build-lock` verwenden. Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird.
|
||||
Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird. Entwicklungsbuilds erzeugen keine Sperre und können direkt erneut gestartet werden.
|
||||
|
||||
## Selfhosting und Updates
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"initialVersion": "3.0.0",
|
||||
"bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18",
|
||||
"legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"],
|
||||
"branch": "main",
|
||||
"repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO",
|
||||
"registry": "git.federspiel.tech/flfeders/fedeo",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,7 +5,7 @@ try {
|
||||
if (command === 'check') {
|
||||
const config = readConfig();
|
||||
assertBootstrap(config, 'HEAD', process.cwd());
|
||||
const commits = commitsSince(config.bootstrapCommit);
|
||||
const commits = commitsSince(config.bootstrapCommit, 'HEAD', process.cwd(), config.legacyHistoryHeads);
|
||||
checkCommits(commits);
|
||||
console.log(`${commits.length} Commit-Nachrichten geprüft.`);
|
||||
} else if (command === 'preview') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
@@ -24,9 +24,9 @@ export function validCommit(message) {
|
||||
(/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(message));
|
||||
}
|
||||
|
||||
export function commitsSince(from, to = 'HEAD', cwd = process.cwd()) {
|
||||
export function commitsSince(from, to = 'HEAD', cwd = process.cwd(), excludedHeads = []) {
|
||||
// semantic-release's revert filter expects newest commits first.
|
||||
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`], cwd);
|
||||
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`, ...excludedHeads.map(head => `^${head}`)], cwd);
|
||||
return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : [];
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ export async function releaseType(commits, cwd = process.cwd()) {
|
||||
export function assertBootstrap(config, target, cwd) {
|
||||
try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, target], cwd); }
|
||||
catch { throw new Error('Der Release-Ausgangscommit fehlt in der Historie. Vollständig auschecken (fetch-depth: 0) und dev nach main übernehmen.'); }
|
||||
for (const head of config.legacyHistoryHeads ?? []) {
|
||||
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('legacyHistoryHeads muss feste vollständige Commit-IDs enthalten.');
|
||||
try { git(['rev-parse', '--verify', `${head}^{commit}`], cwd); }
|
||||
catch { throw new Error(`Die alte Release-Historie ${head} fehlt. Alle Branches vollständig laden (fetch-depth: 0).`); }
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) {
|
||||
@@ -53,8 +58,11 @@ export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd)
|
||||
const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha;
|
||||
const previousTag = resume ? tags[1] : lastTag;
|
||||
const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit;
|
||||
const commits = commitsSince(from, sha, cwd);
|
||||
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd));
|
||||
// Old main-only commits are not ancestors of the dev bootstrap. Exclude their
|
||||
// pinned history without dropping new work branched from those old commits.
|
||||
const legacyHeads = config.legacyHistoryHeads ?? [];
|
||||
const commits = commitsSince(from, sha, cwd, legacyHeads);
|
||||
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd, legacyHeads));
|
||||
const bump = await releaseType(commits, cwd);
|
||||
const version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null;
|
||||
if (version && !semver.valid(version)) throw new Error('Ungültige Release-Version.');
|
||||
@@ -67,7 +75,7 @@ export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd)
|
||||
lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {},
|
||||
nextRelease: { version, gitTag: tag, gitHead: sha },
|
||||
}) : '';
|
||||
const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`], cwd);
|
||||
const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`, ...legacyHeads.map(head => `^${head}`)], cwd);
|
||||
return {
|
||||
sha, version, tag, previousTag: previousTag || null, from, bump, resume,
|
||||
developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`,
|
||||
@@ -83,11 +91,33 @@ export function componentVersions(sha, cwd = process.cwd()) {
|
||||
};
|
||||
}
|
||||
|
||||
export function migrationManifest(sha, cwd = process.cwd()) {
|
||||
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => {
|
||||
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd);
|
||||
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({
|
||||
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'),
|
||||
})) : [];
|
||||
function gitBlobSha256(reference, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('git', ['cat-file', 'blob', reference], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const hash = createHash('sha256');
|
||||
let stderr = '';
|
||||
const fail = message => reject(new Error(`Prüfsumme für ${reference} fehlgeschlagen: ${message}`));
|
||||
// Reference-data migrations can exceed 90 MB. Hash raw chunks without
|
||||
// buffering the entire blob or altering whitespace/encoding.
|
||||
child.stdout.on('data', chunk => hash.update(chunk));
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(0, 8192); });
|
||||
child.once('error', error => fail(error.message));
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
stream.once('error', error => { child.kill(); fail(error.message); });
|
||||
}
|
||||
child.once('close', (code, signal) => {
|
||||
if (code !== 0) fail(`git cat-file (Exit ${code ?? signal}): ${stderr.trim()}`);
|
||||
else resolve(hash.digest('hex'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function migrationManifest(sha, cwd = process.cwd()) {
|
||||
const files = git(['ls-tree', '-r', '-z', '--name-only', sha, '--',
|
||||
'backend/db/migrations', 'push-server/packages/db/drizzle'], cwd)
|
||||
.split('\0').filter(path => path.endsWith('.sql'));
|
||||
const manifest = [];
|
||||
for (const path of files) manifest.push({ path, sha256: await gitBlobSha256(`${sha}:${path}`, cwd) });
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ export function assertManifest(manifest, plan, config) {
|
||||
}
|
||||
}
|
||||
|
||||
export function makeManifest(plan, images, config) {
|
||||
export async function makeManifest(plan, images, config) {
|
||||
return {
|
||||
schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha,
|
||||
previousTag: plan.previousTag, repository: config.repositoryUrl,
|
||||
images, components: componentVersions(plan.sha), migrations: migrationManifest(plan.sha),
|
||||
images, components: componentVersions(plan.sha), migrations: await migrationManifest(plan.sha),
|
||||
migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.',
|
||||
};
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export async function publishRelease({ plan, config, api, build, promote, files
|
||||
}
|
||||
if (!manifest) {
|
||||
const images = await build(plan.version);
|
||||
manifest = manifestFactory(plan, images, config);
|
||||
manifest = await manifestFactory(plan, images, config);
|
||||
}
|
||||
assertManifest(manifest, plan, config);
|
||||
if (!release) {
|
||||
|
||||
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);
|
||||
});
|
||||
73
scripts/release/tests/migrations.test.mjs
Normal file
73
scripts/release/tests/migrations.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { git, migrationManifest } from '../lib.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-migrations-test-'));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
const run = args => git(args, cwd, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
run(['init', '-b', 'main']);
|
||||
run(['config', 'user.name', 'Release Test']);
|
||||
run(['config', 'user.email', 'release@example.test']);
|
||||
// Keep CRLF test bytes in Git regardless of the developer's autocrlf setting.
|
||||
writeFileSync(join(cwd, '.gitattributes'), '*.sql -text\n');
|
||||
const write = (path, data) => {
|
||||
mkdirSync(dirname(join(cwd, path)), { recursive: true });
|
||||
writeFileSync(join(cwd, path), data);
|
||||
};
|
||||
const commit = () => {
|
||||
run(['add', '.']);
|
||||
run(['commit', '--allow-empty', '-m', 'test: add migrations']);
|
||||
return run(['rev-parse', 'HEAD']);
|
||||
};
|
||||
return { cwd, run, write, commit };
|
||||
}
|
||||
|
||||
test('large migration hashes use the complete committed bytes beyond process buffer limits', async t => {
|
||||
const f = fixture(t);
|
||||
const path = 'backend/db/migrations/0058_reference_data.sql';
|
||||
f.write(path, '');
|
||||
const expected = createHash('sha256');
|
||||
const chunk = Buffer.alloc(1024 * 1024, Buffer.from(" INSERT INTO reference VALUES ('Grüße');\r\n"));
|
||||
// Exceed both Node's 1 MiB default and the text Git helper's 32 MiB buffer.
|
||||
for (let i = 0; i < 34; i++) {
|
||||
appendFileSync(join(f.cwd, path), chunk);
|
||||
expected.update(chunk);
|
||||
}
|
||||
const sha = f.commit();
|
||||
f.write(path, 'local changes must not affect the release checksum');
|
||||
assert.deepEqual(await migrationManifest(sha, f.cwd), [{ path, sha256: expected.digest('hex') }]);
|
||||
});
|
||||
|
||||
test('both migration folders are hashed byte-for-byte and non-SQL files are excluded', async t => {
|
||||
const f = fixture(t);
|
||||
const files = [
|
||||
['backend/db/migrations/0000_init.sql', Buffer.from(' \r\nSELECT 1;\r\n\t')],
|
||||
['push-server/packages/db/drizzle/0000_init.sql', Buffer.from('SELECT \'äöü\';\n')],
|
||||
];
|
||||
for (const [path, content] of files) f.write(path, content);
|
||||
f.write('backend/db/migrations/meta/snapshot.json', '{}');
|
||||
const sha = f.commit();
|
||||
assert.deepEqual(await migrationManifest(sha, f.cwd), files.map(([path, content]) => ({
|
||||
path, sha256: createHash('sha256').update(content).digest('hex'),
|
||||
})));
|
||||
});
|
||||
|
||||
test('a commit without migration folders produces an empty manifest', async t => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(await migrationManifest(f.commit(), f.cwd), []);
|
||||
});
|
||||
|
||||
test('an unreadable Git blob rejects the manifest instead of recording a partial checksum', async t => {
|
||||
const f = fixture(t);
|
||||
const path = 'backend/db/migrations/0000_missing.sql';
|
||||
f.write(path, 'SELECT 1;');
|
||||
const sha = f.commit();
|
||||
const blob = f.run(['rev-parse', `${sha}:${path}`]);
|
||||
rmSync(join(f.cwd, '.git', 'objects', blob.slice(0, 2), blob.slice(2)));
|
||||
await assert.rejects(async () => migrationManifest(sha, f.cwd), /0000_missing\.sql/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
@@ -17,9 +18,9 @@ function fixture(t) {
|
||||
run(['commit', '--allow-empty', '-m', 'historic baseline']);
|
||||
const config = { bootstrapCommit: run(['rev-parse', 'HEAD']), initialVersion: '1.0.0', repositoryUrl: 'https://git.example.test/owner/fedeo' };
|
||||
let count = 0;
|
||||
const commit = message => {
|
||||
writeFileSync(join(cwd, 'file'), `${++count}\n`);
|
||||
run(['add', 'file']);
|
||||
const commit = (message, file = 'file') => {
|
||||
writeFileSync(join(cwd, file), `${++count}\n`);
|
||||
run(['add', file]);
|
||||
run(['commit', '-m', message]);
|
||||
return run(['rev-parse', 'HEAD']);
|
||||
};
|
||||
@@ -52,6 +53,51 @@ test('first release uses explicit baseline and excludes legacy history', async t
|
||||
assert.doesNotMatch(plan.notes, /historic baseline|compare\/\.\.\./);
|
||||
});
|
||||
|
||||
function divergentHistory(t) {
|
||||
const f = fixture(t);
|
||||
f.run(['branch', 'legacy-main']);
|
||||
f.config.bootstrapCommit = f.commit('chore: versioning baseline');
|
||||
f.commit('feat: current export');
|
||||
f.run(['checkout', 'legacy-main']);
|
||||
f.commit('.gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert', 'legacy-template');
|
||||
const legacyHead = f.commit('feat: historic main feature', 'legacy-feature');
|
||||
f.config.legacyHistoryHeads = [legacyHead];
|
||||
return f;
|
||||
}
|
||||
|
||||
test('the first main merge excludes pinned legacy branch history from validation and notes', async t => {
|
||||
const f = divergentHistory(t);
|
||||
f.run(['checkout', 'main']);
|
||||
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge historic main']);
|
||||
const plan = await f.plan();
|
||||
assert.equal(plan.version, '1.0.0');
|
||||
assert.match(plan.notes, /current export/);
|
||||
assert.doesNotMatch(plan.notes, /historic main feature|ISSUE_TEMPLATE/);
|
||||
writeFileSync(join(f.cwd, 'release.config.json'), JSON.stringify(f.config));
|
||||
const output = execFileSync(process.execPath, [resolve('scripts/release/cli.mjs'), 'check'], { cwd: f.cwd, encoding: 'utf8' });
|
||||
assert.match(output, /1 Commit-Nachrichten geprüft/);
|
||||
});
|
||||
|
||||
test('new commits on a legacy branch still affect versions and must use Conventional Commits', async t => {
|
||||
const f = divergentHistory(t);
|
||||
f.run(['checkout', 'main']);
|
||||
f.run(['tag', 'v1.0.0']);
|
||||
f.run(['checkout', 'legacy-main']);
|
||||
f.commit('feat(api)!: replace exported API', 'new-api');
|
||||
f.run(['checkout', 'main']);
|
||||
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge new API']);
|
||||
const plan = await f.plan();
|
||||
assert.equal(plan.version, '2.0.0');
|
||||
assert.match(plan.notes, /replace exported API/);
|
||||
assert.doesNotMatch(plan.notes, /historic main feature/);
|
||||
|
||||
f.run(['checkout', 'legacy-main']);
|
||||
f.commit('Unclassified new change', 'another-change');
|
||||
f.run(['checkout', 'main']);
|
||||
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge another change']);
|
||||
await assert.rejects(f.plan(), /Conventional Commits fehlen:[\s\S]*Unclassified new change/);
|
||||
});
|
||||
|
||||
test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => {
|
||||
const f = fixture(t);
|
||||
f.run(['tag', 'v1.9.0']);
|
||||
@@ -104,6 +150,14 @@ test('an unknown baseline fails instead of releasing unrelated history', async t
|
||||
await assert.rejects(f.plan(), /Ausgangscommit/);
|
||||
});
|
||||
|
||||
test('missing or mutable legacy history heads fail instead of silently skipping validation', async t => {
|
||||
const f = fixture(t);
|
||||
f.config.legacyHistoryHeads = ['f'.repeat(40)];
|
||||
await assert.rejects(f.plan(), /alte Release-Historie .* fehlt/);
|
||||
f.config.legacyHistoryHeads = ['main'];
|
||||
await assert.rejects(f.plan(), /feste vollständige Commit-IDs/);
|
||||
});
|
||||
|
||||
test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => {
|
||||
const f = fixture(t);
|
||||
const remote = join(f.cwd, 'remote.git');
|
||||
|
||||
@@ -58,6 +58,26 @@ test('failed build never creates a draft or changes release channels', async ()
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('asynchronous manifest generation finishes before creating the release draft', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.manifestFactory = async () => {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
f.events.push('manifest');
|
||||
return f.manifest;
|
||||
};
|
||||
await publishRelease(f.dependencies);
|
||||
assert.deepEqual(f.events.slice(0, 3), ['build', 'manifest', 'draft']);
|
||||
assert.equal(f.getRelease().draft, false);
|
||||
});
|
||||
|
||||
test('failed migration hashing never creates a draft or promotes images', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.manifestFactory = async () => { throw new Error('migration hashing failed'); };
|
||||
await assert.rejects(publishRelease(f.dependencies), /migration hashing failed/);
|
||||
assert.deepEqual(f.events, ['build']);
|
||||
assert.equal(f.getRelease(), null);
|
||||
});
|
||||
|
||||
test('retry a draft uses saved digests and original notes without rebuilding', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.promote = async () => { throw new Error('registry interrupted'); };
|
||||
|
||||
Reference in New Issue
Block a user