2 Commits
Author SHA1 Message Date
Kenzo fdd3793ee1 Merge pull request 'fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build' (#5) from fix/publish-git-enoent into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #5
2026-08-11 09:51:59 +02:00
KenzoandClaude Opus 5 88ad16c86f fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
Publish schlug bei jedem Versuch mit "spawn git ENOENT" fehl, obwohl git im
Container installiert ist. Node meldet ENOENT auch dann, wenn das cwd des
Kindprozesses nicht existiert: initialize() setzte simple-git auf den
Workspace, loeschte diesen per rm -rf und startete den Clone anschliessend
aus dem geloeschten Verzeichnis heraus.

Nebeneffekt davon: da Uploads unter GIT_WORKSPACE_DIR/public/images liegen,
hat jeder fehlgeschlagene Publish die hochgeladenen Bilder mitgeloescht.

git.service.ts:
- Clone laeuft aus dem Elternverzeichnis statt aus dem geloeschten Ziel
- Re-Clone nur noch wenn kein brauchbares Repo vorhanden ist
- Uploads werden ueber den Re-Clone hinweg gesichert (Repo-Stand gewinnt)
- commitAndPush scheitert nicht mehr, wenn es nichts zu committen gibt
- reset() nur bei echtem Repo, mit Ausnahme fuer public/images
- Token wird in Fehlermeldungen maskiert

Dockerfile:
- COPY von src/db/migrations entfernt. Das Verzeichnis war nie im Git und
  liess den Image-Build scheitern. Zur Laufzeit wird es nicht gebraucht,
  das Schema legt initDatabase() selbst an.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 09:40:29 +02:00
2 changed files with 74 additions and 16 deletions
+2 -1
View File
@@ -39,8 +39,9 @@ RUN npm ci --omit=dev || npm install --production && \
RUN apk del python3 make g++ vips-dev RUN apk del python3 make g++ vips-dev
# Copy built files from builder # Copy built files from builder
# Hinweis: Drizzle-Migrationen werden nicht kopiert - das Schema wird zur
# Laufzeit von initDatabase() angelegt, src/db/migrations existiert nicht.
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
# Copy migration script and migrated images # Copy migration script and migrated images
COPY --from=builder /app/migrate-production.js ./migrate-production.js COPY --from=builder /app/migrate-production.js ./migrate-production.js
+72 -15
View File
@@ -1,43 +1,84 @@
import simpleGit, { SimpleGit } from 'simple-git'; import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm } from 'fs/promises'; import { mkdir, rm, cp } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
export class GitService { export class GitService {
private git: SimpleGit; private git: SimpleGit;
private workspaceDir: string; private workspaceDir: string;
private parentDir: string;
private repoUrl: string; private repoUrl: string;
private token: string; private token: string;
constructor() { constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR; this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.parentDir = path.dirname(this.workspaceDir);
this.repoUrl = env.GIT_REPO_URL; this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN; this.token = env.GIT_TOKEN;
this.git = simpleGit(); this.git = simpleGit();
} }
async initialize() { async initialize() {
// Ensure workspace directory exists // Elternverzeichnis muss existieren - simple-git startet git von dort aus
await mkdir(this.workspaceDir, { recursive: true }); await mkdir(this.parentDir, { recursive: true });
// Add token to repo URL for authentication // Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace( const authenticatedUrl = this.repoUrl.replace(
'https://', 'https://',
`https://oauth2:${this.token}@` `https://oauth2:${encodeURIComponent(this.token)}@`
); );
try { let usable = false;
// Check if repo already exists
await this.git.cwd(this.workspaceDir); if (existsSync(path.join(this.workspaceDir, '.git'))) {
await this.git.status(); try {
console.log('Repository already exists, pulling latest...'); this.git = simpleGit(this.workspaceDir);
await this.git.pull(); await this.git.status();
} catch { console.log('Repository already exists, pulling latest...');
// Clone if doesn't exist await this.git.pull();
usable = true;
} catch (error) {
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
const msg = error instanceof Error ? error.message : String(error);
console.warn(
'Existing workspace unusable, re-cloning:',
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
);
}
}
if (!usable) {
console.log('Cloning repository...'); console.log('Cloning repository...');
// Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const imagesDir = path.join(this.workspaceDir, 'public', 'images');
const backupDir = path.join(this.parentDir, '.workspace-images-backup');
const hasImages = existsSync(imagesDir);
await rm(backupDir, { recursive: true, force: true });
if (hasImages) {
await cp(imagesDir, backupDir, { recursive: true });
}
await rm(this.workspaceDir, { recursive: true, force: true }); await rm(this.workspaceDir, { recursive: true, force: true });
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
this.git = simpleGit(this.parentDir);
await this.git.clone(authenticatedUrl, this.workspaceDir); await this.git.clone(authenticatedUrl, this.workspaceDir);
await this.git.cwd(this.workspaceDir); this.git = simpleGit(this.workspaceDir);
if (hasImages) {
// force: false -> was schon im Repo liegt, bleibt unangetastet
await cp(backupDir, imagesDir, {
recursive: true,
force: false,
errorOnExist: false,
});
await rm(backupDir, { recursive: true, force: true });
}
} }
// Configure git user // Configure git user
@@ -47,6 +88,14 @@ export class GitService {
async commitAndPush(message: string): Promise<string> { async commitAndPush(message: string): Promise<string> {
await this.git.add('.'); await this.git.add('.');
const status = await this.git.status();
if (status.isClean()) {
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
const current = await this.git.log({ maxCount: 1 });
return current.latest?.hash || '';
}
await this.git.commit(message); await this.git.commit(message);
await this.git.push('origin', 'main'); await this.git.push('origin', 'main');
@@ -59,7 +108,15 @@ export class GitService {
} }
async reset() { async reset() {
await this.git.reset(['--hard', 'HEAD']); // Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
await this.git.clean('f', ['-d']); // im Prozess-Arbeitsverzeichnis und raeumt dort auf
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
return;
}
const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
await git.clean('f', ['-d', '-e', 'public/images']);
} }
} }