From 8cea26d748586b811ebc65e6166e1e8b1b136e66 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 13:27:52 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20delete=20orphan=20workflows=20during=20?= =?UTF-8?q?sync=20=E2=80=94=20preserve=20custom-*=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_sync.php --delete-orphans now removes workflows from repos that are not in the platform template. Protected from deletion: - Workflows matching template names (synced normally) - Workflows with custom- prefix (repo-specific convention) - The custom/ subdirectory (future: subfolder discovery) --- cli/workflow_sync.php | 143 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/cli/workflow_sync.php b/cli/workflow_sync.php index 1d1090c..94e4452 100644 --- a/cli/workflow_sync.php +++ b/cli/workflow_sync.php @@ -42,9 +42,16 @@ class WorkflowSyncCli extends CliFramework 'joomla' => ['deploy-manual.yml'], ]; + /** Prefix for custom workflows preserved during orphan cleanup. */ + private const CUSTOM_PREFIX = 'custom-'; + + /** Subdirectory name for custom workflows preserved during orphan cleanup. */ + private const CUSTOM_DIR = 'custom'; + private int $updated = 0; private int $created = 0; private int $skipped = 0; + private int $deleted = 0; private int $errors = 0; protected function configure(): void @@ -56,6 +63,7 @@ class WorkflowSyncCli extends CliFramework $this->addArgument('--branch', 'Target branch (default: main)', 'main'); $this->addArgument('--phase', 'Phase to run: all, templates, repos (default: all)', 'all'); $this->addArgument('--platform-filter', 'Only sync repos matching this platform', ''); + $this->addArgument('--delete-orphans', 'Delete workflows not in template (preserves custom-* and custom/)', false); } protected function run(): int @@ -114,7 +122,7 @@ class WorkflowSyncCli extends CliFramework echo "\n"; $this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, " - . "{$this->skipped} skipped, {$this->errors} error(s)."); + . "{$this->deleted} deleted, {$this->skipped} skipped, {$this->errors} error(s)."); return $this->errors > 0 ? 1 : 0; } @@ -303,6 +311,14 @@ class WorkflowSyncCli extends CliFramework $destPath, $sourceContent, $branch, $commitMsg, $label ); } + + // Delete orphan workflows if enabled + if ($this->getArgument('--delete-orphans', false)) { + $templateNames = array_map(fn($w) => $w['name'], $workflows); + $this->deleteOrphanWorkflows( + $giteaUrl, $token, $org, $repoName, $branch, $templateNames + ); + } } echo "\n"; @@ -406,6 +422,131 @@ class WorkflowSyncCli extends CliFramework } } + /** + * Delete workflows in a repo that are NOT in the template and NOT custom. + * + * Protected from deletion: + * - Files matching template workflow names + * - Files with `custom-` prefix (convention for repo-specific workflows) + * - Directories named `custom` (future: subfolder discovery) + * - Platform-excluded workflows + */ + private function deleteOrphanWorkflows( + string $giteaUrl, + string $token, + string $org, + string $repoName, + string $branch, + array $templateNames + ): void { + $repoWorkflows = $this->listWorkflows($giteaUrl, $token, $org, $repoName, $branch); + if ($repoWorkflows === null) { + return; + } + + // Also list directories so we can preserve custom/ + $allEntries = $this->listWorkflowEntries($giteaUrl, $token, $org, $repoName, $branch); + + foreach ($repoWorkflows as $workflow) { + $name = $workflow['name']; + + // Keep if it's in the template + if (in_array($name, $templateNames, true)) { + continue; + } + + // Keep if it has the custom- prefix + if (str_starts_with($name, self::CUSTOM_PREFIX)) { + $label = "{$org}/{$repoName}/{$name}"; + fprintf(STDERR, "%-45s | %s\n", $label, 'KEPT (custom)'); + continue; + } + + // Delete orphan + $filePath = '.mokogitea/workflows/' . $name; + $label = "{$org}/{$repoName}/{$name}"; + + if ($this->dryRun) { + fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD DELETE'); + $this->deleted++; + continue; + } + + $deleted = $this->deleteFile($giteaUrl, $token, $org, $repoName, $filePath, $branch); + if ($deleted) { + fprintf(STDERR, "%-45s | %s\n", $label, 'DELETED'); + $this->deleted++; + } else { + fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (delete)'); + $this->errors++; + } + } + } + + /** + * Delete a file from a repo via the Gitea Contents API. + */ + private function deleteFile( + string $giteaUrl, + string $token, + string $org, + string $repoName, + string $filePath, + string $branch + ): bool { + // Get SHA first + $existing = $this->apiRequest( + $giteaUrl, $token, 'GET', + "/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}" + ); + + if ($existing['code'] !== 200) { + return false; + } + + $data = json_decode($existing['body'], true); + $sha = $data['sha'] ?? ''; + if ($sha === '') { + return false; + } + + $payload = json_encode([ + 'sha' => $sha, + 'message' => "chore: delete orphan workflow {$filePath} [skip ci]", + 'branch' => $branch, + ]); + + $response = $this->apiRequest( + $giteaUrl, $token, 'DELETE', + "/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}", + $payload + ); + + return $response['code'] === 200; + } + + /** + * List all entries (files + dirs) in .mokogitea/workflows/. + */ + private function listWorkflowEntries( + string $giteaUrl, + string $token, + string $org, + string $repoName, + string $branch + ): array { + $response = $this->apiRequest( + $giteaUrl, $token, 'GET', + "/api/v1/repos/{$org}/{$repoName}/contents/.mokogitea/workflows?ref={$branch}" + ); + + if ($response['code'] !== 200) { + return []; + } + + return json_decode($response['body'], true) ?: []; + } + /** * List workflow files in a repo's .mokogitea/workflows/ directory. */