feat(release): add sync-cdn.sh to sync a release to the CDN

- Replaces php artisan sync:cdn and its sync:cdn task in scripts/run
- --purge clears the Cloudflare cache sooner than the 10 minutes it keeps a file, with the token and zone from the environment or .env
This commit is contained in:
peaklabs-dev committed 2026-11-01 15:13:43 +01:00
1 parent 42382d8a4c
commit 7560d0745d
7 files changed
+144 -623

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ indent_size = 2
[docker-compose.yml]
indent_size = 4
[scripts/clean-manifests.sh]
[scripts/{clean-manifests,sync-cdn}.sh]
indent_style = tab
binary_next_line = true
switch_case_indent = true
+1 -2
View File
@@ -58,7 +58,6 @@ VITE_PORT=5173
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444
# Special Keys for Andras
# For Cloudflare cache purging (token needs Zone > Cache Purge)
# For scripts/sync-cdn.sh --purge, a Cloudflare token that can purge the cache of the zone (Zone > Cache Purge)
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
-399
View File
@@ -1,399 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\select;
class SyncCdn extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'sync:cdn {--purge : Purge the Cloudflare cache for the CDN files}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sync CDN files to the coollabs-cdn GitHub repository';
protected function removeTemporaryDirectory(string $tmpDir): void
{
$temporaryRoot = realpath(sys_get_temp_dir());
$temporaryDirectory = realpath($tmpDir);
if ($temporaryRoot === false || $temporaryDirectory === false) {
return;
}
$expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-';
if (! str_starts_with($temporaryDirectory, $expectedPrefix)) {
return;
}
File::deleteDirectory($temporaryDirectory);
}
/**
* Purge the given CDN URLs from the Cloudflare cache.
*
* @param array<int, string> $urls
*/
private function purgeCloudflareCache(array $urls): bool
{
$apiToken = config('constants.cloudflare.api_token');
$zoneId = config('constants.cloudflare.zone_id');
if (blank($apiToken) || blank($zoneId)) {
$this->error('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID must be set to purge the Cloudflare cache.');
return false;
}
foreach ($urls as $url) {
$this->info('Purging: '.$url);
}
try {
$response = Http::withToken($apiToken)
->timeout(30)
->post("https://api.cloudflare.com/client/v4/zones/{$zoneId}/purge_cache", [
'files' => array_values($urls),
]);
} catch (\Throwable $e) {
$this->error('Failed to purge Cloudflare cache: '.$e->getMessage());
return false;
}
if (! $response->successful() || $response->json('success') !== true) {
$this->error('Failed to purge Cloudflare cache: '.$response->body());
return false;
}
$this->info('Cloudflare cache purged.');
return true;
}
/**
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
{
$this->info('Fetching releases from GitHub...');
try {
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30, // Fetch more releases for better changelog
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
$this->error('Failed to create temporary releases.json.');
return false;
}
$files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
try {
return $this->syncFilesToGitHubRepo($files, $nightly);
} finally {
@unlink($releasesFile);
}
} catch (\Throwable $e) {
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
}
/**
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
*/
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
{
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("Syncing $envLabel files to GitHub repository...");
try {
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coollabs-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Copy each file to its target path in the CDN repo
$copiedFiles = [];
foreach ($files as $sourceFile => $targetPath) {
if (! file_exists($sourceFile)) {
$this->warn("Source file not found, skipping: $sourceFile");
continue;
}
$destPath = "$tmpDir/$targetPath";
$destDir = dirname($destPath);
if (! is_dir($destDir)) {
if (! mkdir($destDir, 0755, true)) {
$this->error("Failed to create directory: $destDir");
$this->removeTemporaryDirectory($tmpDir);
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
$this->removeTemporaryDirectory($tmpDir);
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
$this->removeTemporaryDirectory($tmpDir);
return true;
}
// Stage all copied files
$this->info('Staging changes...');
$output = [];
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
exec($stageCmd, $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Check for changes
$this->info('Checking for changes...');
$changedFiles = [];
exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check changed files: '.implode("\n", $changedFiles));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
$changedFiles = array_values(array_filter($changedFiles));
if (empty($changedFiles)) {
$this->info('All files are already up to date. No changes to commit.');
$this->removeTemporaryDirectory($tmpDir);
return true;
}
// Commit changes
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
$fileList = implode("\n- ", $changedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
$this->removeTemporaryDirectory($tmpDir);
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($changedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Execute the console command.
*/
public function handle()
{
$nightly = select(
label: 'Which environment would you like to sync?',
options: [
'production' => 'Production',
'nightly' => 'Nightly',
],
default: 'production',
) === 'nightly';
$cdn = 'https://cdn.coollabs.io';
$parent_dir = realpath(dirname(__FILE__).'/../../..');
$compose_file = 'docker-compose.yml';
$compose_file_prod = 'docker-compose.prod.yml';
$install_script = 'install.sh';
$upgrade_script = 'upgrade.sh';
$upgrade_postgres_script = 'upgrade-postgres.sh';
$production_env = '.env.production';
$service_template = config('constants.services.file_name');
$versions = 'versions.json';
$compose_file_location = "$parent_dir/$compose_file";
$compose_file_prod_location = "$parent_dir/$compose_file_prod";
$install_script_location = "$parent_dir/scripts/install.sh";
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
$patch_script_location = "$parent_dir/scripts/patch.sh";
$production_env_location = "$parent_dir/.env.production";
$service_template_location = "$parent_dir/templates/$service_template";
$versions_location = "$parent_dir/$versions";
try {
if ($nightly) {
$compose_file_location = "$parent_dir/other/nightly/$compose_file";
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
$production_env_location = "$parent_dir/other/nightly/$production_env";
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
$upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
if ($nightly) {
$files = [
$versions_location => 'json/coolify/nightly/versions.json',
$compose_file_location => 'json/coolify/nightly/docker-compose.yml',
$compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
$production_env_location => 'json/coolify/nightly/.env.production',
$install_script_location => 'json/coolify/nightly/install.sh',
$upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
$upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
$service_template_location => 'json/coolify/nightly/service-templates-latest.json',
];
} else {
$files = [
$versions_location => 'json/coolify/versions.json',
$compose_file_location => 'json/coolify/docker-compose.yml',
$compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
$production_env_location => 'json/coolify/.env.production',
$install_script_location => 'json/coolify/install.sh',
$upgrade_script_location => 'json/coolify/upgrade.sh',
$upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
$patch_script_location => 'json/coolify/patch.sh',
$service_template_location => 'json/coolify/service-templates-latest.json',
];
}
$releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
if ($this->option('purge')) {
$urls = array_map(
fn (string $targetPath) => $cdn.'/'.substr($targetPath, strlen('json/')),
[$releasesTarget, ...array_values($files)],
);
if ($nightly) {
// Nightly scripts still download from the legacy /coolify-nightly/ path, which Cloudflare caches separately.
$urls = [...$urls, ...str_replace('/coolify/nightly/', '/coolify-nightly/', $urls)];
}
return $this->purgeCloudflareCache($urls) ? self::SUCCESS : self::FAILURE;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
$options = [$releasesTarget, ...array_values($files)];
$selectedFiles = multiselect(
label: 'Which files would you like to sync?',
options: $options,
default: $options,
required: true,
scroll: count($options),
);
$includeReleases = in_array($releasesTarget, $selectedFiles, true);
$files = array_filter(
$files,
fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
);
$synced = $includeReleases
? $this->syncReleasesToGitHubRepo($files, $nightly)
: $this->syncFilesToGitHubRepo($files, $nightly);
if ($synced) {
$this->info('After the CDN pull request is merged and deployed, run "php artisan sync:cdn --purge" to purge the Cloudflare cache.');
}
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
}
}
-5
View File
@@ -139,11 +139,6 @@ return [
'dev_webhook' => env('SERVEO_URL'),
],
'cloudflare' => [
'api_token' => env('CLOUDFLARE_API_TOKEN'),
'zone_id' => env('CLOUDFLARE_ZONE_ID'),
],
'server_checks' => [
// Notification delay configuration for parallel server checks
// Used for Traefik version checks and other future server check jobs
-4
View File
@@ -27,10 +27,6 @@ function test {
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify php artisan test --testsuite=Feature -p
}
function sync:cdn {
php artisan sync:cdn --env=secrets
}
function db:reset {
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -u www-data coolify php artisan migrate:fresh --seed
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
OPTION="$*"
CHANNEL="${1:-}"
VERSION="${2:-}"
CDN_REPOSITORY=coollabsio/coollabs-cdn
CDN_URL=https://cdn.coollabs.io
FILES=versions.json,releases.json,docker-compose.yml,docker-compose.prod.yml,.env.production,install.sh,upgrade.sh,upgrade-postgres.sh,patch.sh,service-templates-latest.json
WORK_DIR="$(mktemp -d)"
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' RESET=$'\033[0m'
info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
die() {
printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
cd "$(dirname "$0")/.."
trap 'rm -rf "$WORK_DIR"' EXIT
main() {
[[ "$OPTION" =~ ^(--stable [0-9]+\.[0-9]+\.[0-9]+|--preview [0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?|--purge)$ ]] \
|| die "'$OPTION' is not an option, expected --stable or --preview with the version of the channel, or --purge"
if [ "$OPTION" = --purge ]; then
purge_cache
return
fi
select_channel
write_versions
fetch_releases
clone_cdn
write_release
commit_channel
publish_channel
}
select_channel() {
channel="${CHANNEL#--}"
channel_path=json/coolify channel_url="$CDN_URL/coolify"
if [ "$channel" != stable ]; then
channel_path+="/$channel" channel_url+="/$channel"
fi
if [[ "$channel:$VERSION" == preview:*-alpha.* ]]; then
info "the preview channel takes no alphas, Coolify $VERSION is not synced"
exit 0
fi
info "syncing $channel_url to Coolify $BOLD$VERSION$RESET"
}
write_versions() {
jq --indent 4 --arg version "$VERSION" '.coolify.v4.version = $version' versions.json > "$WORK_DIR/versions.json"
ok "versions.json names Coolify $VERSION and the helper, Sentinel and Traefik versions of this commit"
}
fetch_releases() {
gh api "repos/coollabsio/coolify/releases?per_page=30" | jq . > "$WORK_DIR/releases.json"
ok "releases.json lists the $(jq length "$WORK_DIR/releases.json") newest GitHub releases"
}
clone_cdn() {
gh repo clone "$CDN_REPOSITORY" "$WORK_DIR/cdn" -- --quiet --depth 1
ok "cloned $CDN_REPOSITORY"
}
write_release() {
local release="$WORK_DIR/cdn/json/coolify/release.json"
[ -f "$release" ] || echo '{}' > "$release"
jq --tab --arg channel "$channel" --arg version "$VERSION" '.[$channel].coolify = $version | {stable, preview} | with_entries(select(.value))' \
"$release" > "$WORK_DIR/release.json"
cp "$WORK_DIR/release.json" "$release"
ok "release.json sets the $channel version $BOLD$VERSION$RESET"
}
commit_channel() {
local name count
mkdir -p "$WORK_DIR/cdn/$channel_path"
for name in ${FILES//,/ }; do
channel_file "$name" > "$WORK_DIR/cdn/$channel_path/$name"
done
cdn add "$channel_path" json/coolify/release.json
if cdn diff --cached --quiet; then
ok "$channel_path on $CDN_REPOSITORY is up to date"
exit 0
fi
title="chore: sync the $channel channel to Coolify $VERSION"
count="$(cdn diff --cached --name-only | wc -l | tr -d ' ')"
cdn commit --quiet --message "$title"
ok "committed $count changed file(s) of $channel_path"
}
publish_channel() {
local previous from branch pull_request
if [ "$channel" != stable ]; then
cdn push --quiet origin HEAD:main
ok "pushed the $channel channel to main of $CDN_REPOSITORY"
return
fi
previous="$(cdn show HEAD~1:json/coolify/versions.json 2> /dev/null | jq -r '.coolify.v4.version // empty' || true)"
[ "$previous" = "$VERSION" ] || from="${previous:+from Coolify $previous to }"
branch="sync/stable-$(date -u +%Y%m%d%H%M%S)"
cdn push --quiet origin "HEAD:refs/heads/$branch"
pull_request="$(gh pr create --repo "$CDN_REPOSITORY" --base main --head "$branch" --title "$title" \
--body "Syncs the stable channel ${from:-to Coolify }$VERSION, see the [release notes](https://github.com/coollabsio/coolify/releases/tag/v$VERSION).")"
ok "opened $pull_request"
info "Cloudflare keeps the old files for up to 10 minutes, scripts/sync-cdn.sh --purge clears them once this pull request is merged and deployed"
}
purge_cache() {
local token zone
token="${CLOUDFLARE_API_TOKEN:-$(env_value CLOUDFLARE_API_TOKEN)}"
zone="${CLOUDFLARE_ZONE_ID:-$(env_value CLOUDFLARE_ZONE_ID)}"
[ -n "$token" ] && [ -n "$zone" ] || die "CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are set neither in the environment nor in .env, expected a token that can purge the cache of the zone"
clone_cdn
cdn ls-files json/coolify | sed "s|^json/|$CDN_URL/|" | xargs -n 30 | while read -r urls; do
curl --proto '=https' --tlsv1.2 -sSf --retry 3 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
--data "$(tr ' ' '\n' <<< "$urls" | jq -Rnc '{files: [inputs]}')" "https://api.cloudflare.com/client/v4/zones/$zone/purge_cache" \
| jq -e .success > /dev/null || die "Cloudflare did not purge the files of $CDN_URL/coolify"
done
ok "purged every file of $CDN_URL/coolify from the Cloudflare cache"
}
channel_file() {
case "$1" in
versions.json | releases.json) cat "$WORK_DIR/$1" ;;
service-templates-latest.json) cat "templates/$1" ;;
*.sh) sed "s|^CDN=\"$CDN_URL/coolify\"$|CDN=\"$channel_url\"|" "scripts/$1" ;;
*) cat "$1" ;;
esac
}
cdn() {
git -C "$WORK_DIR/cdn" "$@"
}
env_value() {
[ ! -f .env ] || sed -n "s/^$1=//p" .env | tail -n 1 | tr -d "\"'"
}
main
-212
View File
@@ -1,212 +0,0 @@
<?php
use App\Console\Commands\SyncCdn;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
function createFakeSyncCdnBinary(string $binDir, string $name, string $contents): void
{
file_put_contents("{$binDir}/{$name}", $contents);
chmod("{$binDir}/{$name}", 0755);
}
it('does not expose the removed sync options', function () {
$definition = Artisan::all()['sync:cdn']->getDefinition();
expect($definition->hasOption('bunny'))->toBeFalse()
->and($definition->hasOption('github-releases'))->toBeFalse()
->and($definition->hasOption('release'))->toBeFalse()
->and($definition->hasOption('nightly'))->toBeFalse()
->and($definition->hasOption('templates'))->toBeFalse();
});
it('loads service templates from the Coollabs CDN', function () {
expect(config('constants.services.official'))
->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
});
it('only removes validated Coolify CDN temporary directories', function () {
$command = new class extends SyncCdn
{
public function removeDirectory(string $path): void
{
$this->removeTemporaryDirectory($path);
}
};
$invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid();
$validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid();
mkdir($invalidDirectory);
mkdir($validDirectory);
$command->removeDirectory('');
$command->removeDirectory($invalidDirectory);
$command->removeDirectory($validDirectory);
expect($invalidDirectory)->toBeDirectory()
->and($validDirectory)->not->toBeDirectory();
rmdir($invalidDirectory);
});
it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
Http::fake([
'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
]);
$binDir = sys_get_temp_dir().'/sync-cdn-bin-'.uniqid();
$logFile = sys_get_temp_dir().'/sync-cdn-'.uniqid().'.log';
mkdir($binDir, 0755, true);
createFakeSyncCdnBinary($binDir, 'gh', <<<'SH'
#!/bin/sh
printf 'gh %s\n' "$*" >> "$SYNC_CDN_TEST_LOG"
if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
mkdir -p "$4"
fi
exit 0
SH);
createFakeSyncCdnBinary($binDir, 'git', <<<'SH'
#!/bin/sh
printf 'git %s\n' "$*" >> "$SYNC_CDN_TEST_LOG"
if [ "$1" = "status" ]; then
printf 'M json/releases.json\n'
fi
if [ "$1" = "diff" ]; then
if [ -f json/coolify/nightly/releases.json ]; then
printf 'json/coolify/nightly/releases.json\n'
else
printf 'json/coolify/releases.json\n'
fi
fi
exit 0
SH);
$originalPath = getenv('PATH') ?: '';
putenv("PATH={$binDir}:{$originalPath}");
putenv("SYNC_CDN_TEST_LOG={$logFile}");
$allBasenames = [
'releases.json',
'versions.json',
'docker-compose.yml',
'docker-compose.prod.yml',
'.env.production',
'install.sh',
'upgrade.sh',
'upgrade-postgres.sh',
...($environment === 'production' ? ['patch.sh'] : []),
'service-templates-latest.json',
];
$allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames);
$selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames);
try {
$this->artisan('sync:cdn')
->expectsChoice('Which environment would you like to sync?', $environment, [
'production' => 'Production',
'nightly' => 'Nightly',
])
->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets)
->assertExitCode(0);
} finally {
putenv("PATH={$originalPath}");
putenv('SYNC_CDN_TEST_LOG');
}
$log = file_get_contents($logFile);
expect($log)
->toContain('gh pr create --repo coollabsio/coollabs-cdn')
->not->toContain('coollabsio/coolify-cdn');
foreach ($selectedTargets as $selectedTarget) {
expect($log)->toContain($selectedTarget);
}
foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) {
expect($log)->not->toContain($unselectedTarget);
}
$pullRequestCommand = substr($log, strrpos($log, 'gh pr create'));
expect($pullRequestCommand)
->toContain("$targetDirectory/releases.json")
->not->toContain("$targetDirectory/versions.json");
Http::assertSentCount(1);
})->with([
'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']],
'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [
'releases.json',
'versions.json',
'docker-compose.yml',
'docker-compose.prod.yml',
'.env.production',
'install.sh',
'upgrade.sh',
'upgrade-postgres.sh',
'service-templates-latest.json',
]],
]);
it('purges the CDN files from the Cloudflare cache', function (string $environment, array $cdnDirectories) {
config()->set('constants.cloudflare.api_token', 'test-token');
config()->set('constants.cloudflare.zone_id', 'test-zone');
Http::fake([
'https://api.cloudflare.com/*' => Http::response(['success' => true], 200),
]);
$this->artisan('sync:cdn --purge')
->expectsChoice('Which environment would you like to sync?', $environment, [
'production' => 'Production',
'nightly' => 'Nightly',
])
->assertExitCode(0);
Http::assertSentCount(1);
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['files'] === collect($cdnDirectories)->flatMap(fn (string $cdnDirectory) => array_map(fn (string $file) => "https://cdn.coollabs.io/$cdnDirectory/$file", [
'releases.json',
'versions.json',
'docker-compose.yml',
'docker-compose.prod.yml',
'.env.production',
'install.sh',
'upgrade.sh',
'upgrade-postgres.sh',
...($environment === 'production' ? ['patch.sh'] : []),
'service-templates-latest.json',
]))->all());
})->with([
'production' => ['production', ['coolify']],
'nightly with legacy path' => ['nightly', ['coolify/nightly', 'coolify-nightly']],
]);
it('fails the Cloudflare purge without credentials or on an API error', function (?string $apiToken, array $response) {
config()->set('constants.cloudflare.api_token', $apiToken);
config()->set('constants.cloudflare.zone_id', 'test-zone');
Http::fake([
'https://api.cloudflare.com/*' => Http::response($response, 400),
]);
$this->artisan('sync:cdn --purge')
->expectsChoice('Which environment would you like to sync?', 'production', [
'production' => 'Production',
'nightly' => 'Nightly',
])
->assertExitCode(1);
if ($apiToken === null) {
Http::assertNothingSent();
}
})->with([
'missing token' => [null, []],
'api error' => ['test-token', ['success' => false, 'errors' => [['code' => 10000, 'message' => 'Authentication error']]]],
]);