feat: add new release process (#12357)

This commit is contained in:
🏔️ Peak authored and GitHub committed 2026-11-02 00:03:41 +01:00
commit babd21d51c
61 files changed
+1871 -4432

No files matched your search

+4 -1
View File
@@ -17,7 +17,10 @@ indent_size = 2
[docker-compose.yml]
indent_size = 4
[scripts/clean-manifests.sh]
[{release.json,upstream.json}]
indent_style = tab
[scripts/{clean-manifests,plan-version,release,sync-cdn,undo-release}.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=
+1
View File
@@ -5,6 +5,7 @@
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md merge=union
/.github export-ignore
CHANGELOG.md export-ignore
+111
View File
@@ -0,0 +1,111 @@
name: Build
on:
schedule:
- cron: "50 7 * * 1"
workflow_dispatch:
workflow_call:
inputs:
ref:
description: The commit to build, the commit of the run when empty
type: string
default: ""
coolify:
description: The Coolify version to build, the version release.json sets for the branch when empty
type: string
default: ""
permissions: {}
cache-mode: none
concurrency:
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
plan:
name: Plan build
permissions:
contents: read
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
coolify: ${{ inputs.coolify || steps.plan.outputs.coolify }}
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Plan the Coolify version
id: plan
env:
BRANCH: ${{ github.base_ref || github.ref_name }}
PULL_REQUEST: ${{ github.event.pull_request.number }}
run: |
coolify="$(scripts/plan-version.sh --build)"
echo "coolify=$coolify" >> "$GITHUB_OUTPUT"
build:
name: Build image (${{ matrix.platform }})
needs: plan
permissions:
contents: read
runs-on: ${{ matrix.runner }} # https://github.com/actions/runner-images
timeout-minutes: 60
strategy:
matrix:
platform: [linux/amd64, linux/arm64]
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-26.04
- platform: linux/arm64
arch: arm64
runner: ubuntu-26.04-arm
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Read the commit date and hash
id: args
run: |
{
echo "epoch=$(git log -1 --format=%ct)"
echo "sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # https://github.com/docker/setup-buildx-action/releases/tag/v4.4.1
with:
cache-binary: false
- name: Build the image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # https://github.com/docker/build-push-action/releases/tag/v7.4.0
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
tags: coollabsio/coolify:test
outputs: type=oci,dest=${{ runner.temp }}/image,tar=false,rewrite-timestamp=true
build-args: |
COOLIFY_VERSION=${{ needs.plan.outputs.coolify }}
SOURCE_DATE_EPOCH=${{ steps.args.outputs.epoch }}
GIT_SHA=${{ steps.args.outputs.sha }}
- name: Upload the image
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # https://github.com/actions/upload-artifact/releases/tag/v7.0.2
with:
name: image-${{ matrix.arch }}
path: ${{ runner.temp }}/image
if-no-files-found: error
retention-days: 1
+1 -1
View File
@@ -17,7 +17,7 @@ defaults:
jobs:
clean:
name: Clean
name: Clean manifests
if: ${{ github.repository == 'coollabsio/coolify' }}
permissions:
contents: read
-122
View File
@@ -1,122 +0,0 @@
name: Build Coolify Main
on:
push:
branches: ["main"]
permissions:
contents: read
packages: write
concurrency:
group: coolify-main-build
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve internal version
id: version
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ steps.version.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:main-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:main-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:main-amd64" \
"${IMAGE}:main-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:main"
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:main-amd64" \
"${IMAGE}:main-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:main"
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
-154
View File
@@ -1,154 +0,0 @@
name: Build Coolify Next
on:
push:
branches: [next]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
concurrency:
group: coolify-next-build
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
rc_version: ${{ steps.version.outputs.rc_version }}
short_sha: ${{ steps.version.outputs.short_sha }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve next version
id: version
run: |
RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Invalid next RC version: ${RC_VERSION}"
exit 1
fi
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${RC_VERSION}.${SHORT_SHA}"
echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push next image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
publish:
needs: [prepare, build]
runs-on: ubuntu-24.04
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
-304
View File
@@ -1,304 +0,0 @@
name: Release Coolify RC
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft prerelease tag (for example, v4.4-rc.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-rc-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside next
if: ${{ github.ref != 'refs/heads/next' }}
run: |
echo "RC releases must run from the next branch, not ${{ github.ref }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Unsupported RC tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft prerelease
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (!release.prerelease) {
core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push RC image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
revalidate:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Revalidate draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
} catch (error) {
if (error.status !== 404) throw error;
}
publish:
needs: [validate, build, revalidate]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish reviewed draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
draft: false,
});
-260
View File
@@ -1,260 +0,0 @@
name: Release Coolify Stable
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft release tag (for example, v4.3.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-fix-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside the production branch
# tmp: allow the 4.4.7 security release from fix/v4.4.7
if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'fix/v') }}
run: |
echo "Stable releases must run from main, not ${{ github.ref_name }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported fix release tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft release
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft release for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (release.prerelease) {
core.setFailed(`Fix release ${process.env.TAG_NAME} cannot be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push release image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
publish:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish version and latest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish version and latest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:latest"
- name: Publish reviewed draft release
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || release.prerelease) {
core.setFailed(`Draft release ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft release ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
draft: false,
});
-40
View File
@@ -1,40 +0,0 @@
name: Generate Changelog
on:
push:
branches: [ main ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:
contents: write
jobs:
changelog:
name: Generate changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}
- name: Commit
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git HEAD:${GITHUB_REF_NAME}
+88
View File
@@ -0,0 +1,88 @@
name: Main
on:
schedule:
- cron: "50 9,21 * * *"
workflow_dispatch:
permissions: {}
cache-mode: none
concurrency:
group: main
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
plan:
name: Plan build
if: ${{ github.repository == 'coollabsio/coolify' }}
permissions:
contents: read
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
build: ${{ steps.plan.outputs.build }}
steps:
- name: Check out main
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Plan the main build
id: plan
env:
BRANCH: main
run: |
labels="$(docker buildx imagetools inspect ghcr.io/coollabsio/coolify:main --format '{{ json (index .Image "linux/amd64").Config.Labels }}')" || labels='{}'
MAIN_VERSION="$(jq -r '.["org.opencontainers.image.version"] // empty' <<< "$labels")"
MAIN_REVISION="$(jq -r '.["org.opencontainers.image.revision"] // empty' <<< "$labels")"
export MAIN_VERSION MAIN_REVISION
build="$(scripts/plan-version.sh --main)"
echo "build=$build" | tee -a "$GITHUB_OUTPUT"
build:
name: Build
needs: plan
if: ${{ needs.plan.outputs.build != '' }}
permissions:
contents: read
uses: $/.github/workflows/build.yaml
with:
ref: ${{ fromJSON(needs.plan.outputs.build).revision }}
coolify: ${{ fromJSON(needs.plan.outputs.build).image }}
publish:
name: Publish image
needs: build
permissions:
packages: write # push the built main to ghcr.io and move the main tag
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 30
steps:
- name: Download the built images
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
with:
pattern: image-*
path: ${{ runner.temp }}/images
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push the built main
run: |
sources=()
for index in "$RUNNER_TEMP"/images/*/index.json; do
sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
done
docker buildx imagetools create --tag ghcr.io/coollabsio/coolify:main "${sources[@]}"
+93
View File
@@ -0,0 +1,93 @@
name: Merge forward
on:
workflow_dispatch:
workflow_call:
permissions: {}
cache-mode: none
concurrency:
group: merge-forward
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
merge:
name: Merge forward
permissions:
contents: write # push the merges
pull-requests: write # open a pull request when a merge conflicts
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Merge main into the release branches and next, and each release branch into next
env:
GH_TOKEN: ${{ github.token }}
run: |
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
push() {
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$1")" ]; then
awk 'NR > 1 && /^#/ && previous != "" { print "" } { print; previous = $0 }' CHANGELOG.md > "$RUNNER_TEMP/CHANGELOG.md"
cp "$RUNNER_TEMP/CHANGELOG.md" CHANGELOG.md
git diff --quiet || git commit --quiet --amend --no-edit --all
fi
git push --quiet origin "HEAD:$2"
}
merge() {
local head="chore/merge-${1//\//-}-into-${2//\//-}" tag title conflicts pull_request
git switch --quiet --detach "origin/$2"
if git merge-base --is-ancestor "origin/$1" HEAD; then
echo "$2 contains $1 already"
return
fi
tag="$(git tag --points-at "origin/$1" | sed -n 1p)"
title="chore: merge ${tag:-$1} into $2"
if git merge --quiet --no-edit --message "$title" "origin/$1"; then
push "$1" "$2"
return
fi
conflicts="$(git diff --name-only --diff-filter=U)"
git merge --abort
if [ "$conflicts" = release.json ] && git merge --quiet --no-edit --strategy-option ours --message "$title" "origin/$1"; then
push "$1" "$2"
return
fi
if git rev-parse --verify --quiet "origin/$head" > /dev/null && ! git merge-base --is-ancestor "origin/$head" "origin/$1"; then
echo "::notice::$head has commits of its own, $1 follows once its pull request is merged"
return
fi
git push --quiet origin "origin/$1:refs/heads/$head"
pull_request="$(gh pr list --head "$head" --base "$2" --json url --jq '.[0].url')"
if [ -n "$pull_request" ]; then
gh pr edit "$pull_request" --title "$title"
return
fi
cat > "$RUNNER_TEMP/body.md" <<BODY
$1 and $2 have conflicts. Resolve them and merge this PR.
> [!IMPORTANT]
> Merge this PR with a merge commit, not a squash merge, so the next merge forward does not conflict again.
BODY
gh pr create --base "$2" --head "$head" --title "$title" --body-file "$RUNNER_TEMP/body.md"
}
releases="$(git branch --remotes --list 'origin/release/*' --format='%(refname:lstrip=3)' | grep -vE '\.0$' | sort -V || true)"
for release in $releases; do
merge main "$release"
done
for release in $releases; do
merge "$release" next
done
merge main next
+134
View File
@@ -0,0 +1,134 @@
name: Nightly
on:
schedule:
- cron: "10 9,21 * * *"
workflow_dispatch:
permissions: {}
cache-mode: none
concurrency:
group: nightly
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
plan:
name: Plan nightly
if: ${{ github.repository == 'coollabsio/coolify' }}
permissions:
contents: read
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
nightly: ${{ steps.plan.outputs.nightly }}
steps:
- name: Check out next
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
ref: next
fetch-depth: 0
persist-credentials: false
- name: Plan the nightly
id: plan
env:
BRANCH: next
run: |
labels="$(docker buildx imagetools inspect ghcr.io/coollabsio/coolify:nightly --format '{{ json (index .Image "linux/amd64").Config.Labels }}')" || labels='{}'
NIGHTLY_VERSION="$(jq -r '.["org.opencontainers.image.version"] // empty' <<< "$labels")"
NIGHTLY_REVISION="$(jq -r '.["org.opencontainers.image.revision"] // empty' <<< "$labels")"
export NIGHTLY_VERSION NIGHTLY_REVISION
nightly="$(scripts/plan-version.sh --nightly)"
echo "nightly=$nightly" | tee -a "$GITHUB_OUTPUT"
build:
name: Build
needs: plan
if: ${{ needs.plan.outputs.nightly != '' }}
permissions:
contents: read
uses: $/.github/workflows/build.yaml
with:
ref: ${{ fromJSON(needs.plan.outputs.nightly).revision }}
coolify: ${{ fromJSON(needs.plan.outputs.nightly).image }}
publish:
name: Publish nightly (${{ matrix.line.tag }})
needs: [plan, build]
permissions:
packages: write # push the built nightly to ghcr.io and move the nightly tag
environment:
name: release
deployment: false
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 30
strategy:
matrix:
line: ${{ fromJSON(needs.plan.outputs.nightly).lines }}
steps:
- name: Download the built images
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
with:
pattern: image-*
path: ${{ runner.temp }}/images
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to docker.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push the built nightly
env:
TAG: ${{ matrix.line.tag }}
LINE: ${{ matrix.line.name }}
run: |
sources=()
for index in "$RUNNER_TEMP"/images/*/index.json; do
sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
done
for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
tags=(--tag "$repository:${TAG#v}")
[ "$LINE" != latest ] || tags+=(--tag "$repository:nightly")
docker buildx imagetools create "${tags[@]}" "${sources[@]}"
done
cdn:
name: Sync CDN
needs: [plan, publish]
permissions:
contents: read
environment:
name: release
deployment: false
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
steps:
- name: Check out the nightly
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
ref: ${{ fromJSON(needs.plan.outputs.nightly).revision }}
persist-credentials: false
- name: Sync the nightly channel of the CDN
env:
GH_TOKEN: ${{ secrets.CDN_TOKEN }}
IMAGE_VERSION: ${{ fromJSON(needs.plan.outputs.nightly).image }}
run: |
gh auth setup-git
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
scripts/sync-cdn.sh --nightly "$IMAGE_VERSION"
+84
View File
@@ -0,0 +1,84 @@
name: Pull request
on:
pull_request:
permissions: {}
cache-mode: none
concurrency:
group: pull-request-${{ github.event.pull_request.number }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
detect:
name: Detect release push
permissions:
contents: read
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
release: ${{ steps.detect.outputs.release }}
steps:
- name: Check whether the push releases
id: detect
env:
GH_TOKEN: ${{ github.token }}
ACTION: ${{ github.event.action }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_BRANCH: ${{ github.head_ref }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
run: |
release=false
if [ "$ACTION" = synchronize ] && [ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] \
&& [[ "$HEAD_BRANCH" =~ ^(main|next|v[0-9]+(\.[0-9]+)?\.x|release/v[0-9]+\.[0-9]+)$ ]]; then
files="$(gh api "repos/$GITHUB_REPOSITORY/compare/$BEFORE...$AFTER" --jq '.files[].filename')"
! grep -qxE 'release\.json' <<< "$files" || release=true
fi
echo "release=$release" | tee -a "$GITHUB_OUTPUT"
build:
name: Build
needs: detect
if: ${{ needs.detect.outputs.release == 'false' }}
permissions:
contents: read
uses: $/.github/workflows/build.yaml
publish:
name: Publish image
needs: build
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }}
permissions:
packages: write # push the image of the pull request to ghcr.io
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 30
steps:
- name: Download the built images
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
with:
pattern: image-*
path: ${{ runner.temp }}/images
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push the image of the pull request
env:
TAG: pr-${{ github.event.pull_request.number }}
run: |
sources=()
for index in "$RUNNER_TEMP"/images/*/index.json; do
sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
done
docker buildx imagetools create --tag "ghcr.io/coollabsio/coolify:$TAG" "${sources[@]}"
+217
View File
@@ -0,0 +1,217 @@
name: Release
on:
push:
branches:
- main
- next
- v[0-9]+.x
- v[0-9]+.[0-9]+.x
- release/v[0-9]+.[0-9]+
paths:
- release.json
workflow_dispatch:
permissions: {}
cache-mode: none
concurrency:
group: release
cancel-in-progress: false
queue: max
defaults:
run:
shell: bash
jobs:
plan:
name: Plan releases
if: ${{ github.repository == 'coollabsio/coolify' }}
permissions:
contents: read
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
releases: ${{ steps.plan.outputs.releases }}
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Plan the releases
id: plan
env:
BRANCH: ${{ github.ref_name }}
run: |
releases="$(scripts/plan-version.sh --release)"
echo "releases=$releases" | tee -a "$GITHUB_OUTPUT"
build:
name: Build
needs: plan
if: ${{ needs.plan.outputs.releases != '[]' }}
permissions:
contents: read
uses: $/.github/workflows/build.yaml
publish:
name: Publish release (${{ matrix.release.tag }})
needs: [plan, build]
permissions:
contents: write # create the GitHub release and its tag
packages: write # push the built images to ghcr.io
environment:
name: release
deployment: false
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 30
strategy:
matrix:
release: ${{ fromJSON(needs.plan.outputs.releases) }}
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
persist-credentials: false
- name: Download the built images
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
with:
pattern: image-*
path: ${{ runner.temp }}/images
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to docker.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push the built images
env:
TAG: ${{ matrix.release.tag }}
run: |
sources=()
for index in "$RUNNER_TEMP"/images/*/index.json; do
sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
done
for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
docker buildx imagetools create --tag "$repository:${TAG#v}" "${sources[@]}"
done
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ matrix.release.tag }}
IMAGE_VERSION: ${{ matrix.release.image }}
run: |
args=(--title "$TAG" --target "$GITHUB_SHA" --notes-file "$RUNNER_TEMP/notes.md" --latest=false)
heading="## [v$IMAGE_VERSION]"
[[ "$IMAGE_VERSION" != *-* ]] || { args+=(--prerelease); heading="## [Unreleased]"; }
awk -v heading="$heading" 'index($0, heading) == 1 { inside = 1; next } /^## / { inside = 0 } inside { sub(/^### /, "## "); print }' CHANGELOG.md > "$RUNNER_TEMP/notes.md"
gh release create "$TAG" "${args[@]}"
tags:
name: Move tags
needs: publish
permissions:
contents: write # mark the GitHub release that latest points at as the latest release
packages: write # move the tags on ghcr.io
environment:
name: release
deployment: false
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
outputs:
tags: ${{ steps.plan.outputs.tags }}
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Plan the moving tags
id: plan
run: |
tags="$(scripts/plan-version.sh --tags)"
echo "tags=$tags" | tee -a "$GITHUB_OUTPUT"
- name: Log in to ghcr.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to docker.io
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Move the tags on ghcr.io and docker.io
env:
TAGS: ${{ steps.plan.outputs.tags }}
run: |
jq -r 'to_entries[] | "\(.key) \(.value)"' <<< "$TAGS" | while read -r tag release; do
for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
docker buildx imagetools create --tag "$repository:${tag#v}" "$repository:${release#v}"
done
done
- name: Mark the latest GitHub release
if: ${{ fromJSON(steps.plan.outputs.tags).latest }}
env:
GH_TOKEN: ${{ github.token }}
LATEST: ${{ fromJSON(steps.plan.outputs.tags).latest }}
run: gh release edit "$LATEST" --latest
cdn:
name: Sync CDN
needs: tags
if: ${{ needs.tags.outputs.tags != '{}' }}
permissions:
contents: read
environment:
name: release
deployment: false
runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Sync the channels of the moved tags
env:
GH_TOKEN: ${{ secrets.CDN_TOKEN }}
TAGS: ${{ needs.tags.outputs.tags }}
run: |
gh auth setup-git
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
jq -r '{stable: .latest, preview: .preview} | to_entries[] | select(.value) | "\(.key) \(.value)"' <<< "$TAGS" | while read -r channel release; do
git switch --quiet --detach "$release"
scripts/sync-cdn.sh "--$channel" "${release#v}"
done
merge:
name: Merge forward
needs: publish
permissions:
contents: write # push the merges
pull-requests: write # open a pull request when a merge conflicts
uses: $/.github/workflows/merge-forward.yaml
-65
View File
@@ -1,65 +0,0 @@
name: Sync main to next
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-next
cancel-in-progress: false
jobs:
sync:
name: Merge main into next
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Merge main into next
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch origin main next
git checkout --quiet -B next origin/next
if git merge --no-edit origin/main; then
git push origin HEAD:refs/heads/next
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
git merge --abort
if [ -z "$conflicts" ]; then
echo '::error title=main to next sync failed::The merge failed without conflicts, so no pull request was created.'
exit 1
fi
sync_branch='automation/sync-main-to-next'
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
if [ -n "$existing_pr" ]; then
# The conflict was already reported when this pull request was opened.
# Stay green until it is merged instead of failing every scheduled run.
echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr"
exit 0
fi
git push --force origin origin/main:"refs/heads/$sync_branch"
gh pr create \
--base next \
--head "$sync_branch" \
--title 'chore: merge main into next' \
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.'
exit 1
@@ -1,159 +0,0 @@
# Temporary: releases the 4.0 to 4.3 security fixes from the v4.0.x to v4.3.x branches without moving latest.
# Delete this workflow once 4.5 ships.
name: TMP Release Coolify Hotfix
run-name: tmp ${{ inputs.branch }}
on:
workflow_dispatch:
inputs:
branch:
description: Release line branch
required: true
type: choice
options:
- v4.3.x
- v4.2.x
- v4.1.x
- v4.0.x
permissions: {}
concurrency:
group: tmp-coolify-hotfix-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
sha: ${{ steps.version.outputs.sha }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.branch }}
persist-credentials: false
- name: Resolve version
id: version
env:
BRANCH: ${{ inputs.branch }}
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
if [[ "${VERSION}" != "${BRANCH:1:3}."* || ! "${VERSION}" =~ ^4\.[0-3]\.[0-9]+$ ]]; then
echo "Version ${VERSION} does not belong to ${BRANCH}."
exit 1
fi
if gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then
echo "Release v${VERSION} already exists."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.validate.outputs.sha }}
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
labels: |
org.opencontainers.image.revision=${{ needs.validate.outputs.sha }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }}
publish:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish the version tag
env:
VERSION: ${{ needs.validate.outputs.version }}
SHA: ${{ needs.validate.outputs.sha }}
run: |
for REGISTRY in "${GITHUB_REGISTRY}" "${DOCKER_REGISTRY}"; do
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
"${IMAGE}:release-${VERSION}-${SHA}-amd64" \
"${IMAGE}:release-${VERSION}-${SHA}-aarch64" \
--tag "${IMAGE}:${VERSION}"
done
- name: Publish the GitHub release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.validate.outputs.version }}
SHA: ${{ needs.validate.outputs.sha }}
run: |
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--target "${SHA}" \
--title "v${VERSION}" \
--latest=false \
--notes "Security fix release for Coolify ${VERSION%.*}. Apply it with: curl -fsSL https://cdn.coollabs.io/coolify/patch.sh | sudo bash"
-1
View File
@@ -36,7 +36,6 @@ scripts/load-test/*
.env.dusk.local
docker/coolify-terminal/node_modules
.DS_Store
CHANGELOG.md
/.workspaces
/.superpowers/
/docs/superpowers/plans/
+54
View File
@@ -0,0 +1,54 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Publish prereleases from `next` and the release branches, tagged like `4.6.0-beta.1`, and the moving `preview` tag
- Publish a nightly version twice a day, tagged like `4.6.0-alpha.0.20261026.1`, with the moving `nightly` tag and the nightly channel of the CDN, from `next` or from the release branch while one is in progress
- Build `main` twice a day when it changed and move the `main` tag on ghcr.io to it, versioned like `4.5.0.20261028.1`, to test fixes before their patch release
- Publish the image of every pull request from a branch of this repository as `ghcr.io/coollabsio/coolify:pr-<number>`, so a change can be tried before it is merged
- Add OCI labels with the version and the commit to the image
### Changed
- Replace the release process, `RELEASE.md` describes it
- Keep the Coolify versions in `release.json`, `stable.coolify` for `main` and the maintenance branches and `preview.coolify` for `next` and the release branches
- Pin the helper, Sentinel and Traefik versions in `upstream.json` instead of `versions.json`, the CDN and the image write `versions.json` from it
- Release from `main`, `next`, release branches such as `release/v4.6` and maintenance branches such as `v4.x` and `v4.4.x`
- Select the stage and the version with `scripts/release.sh`, which offers the versions the branch allows and preselects the bump the commits need
- Take the release commit back before the push with `scripts/undo-release.sh`
- Plan the version, the releases, the moving tags and the nightlies with `scripts/plan-version.sh`, which also reports what a push publishes
- Publish the version a push of `release.json` sets, unless it is published already, instead of a reviewed draft release
- Move `latest` and `preview` to their place among all published tags, so a release of an older version never moves a tag back
- Merge `main` forward into `next` and the release branches only after a release, instead of every 10 minutes, which cluttered the commit history of `next` with merge commits
- Rework the CI workflows for the new release process
- Split the build and release workflows, the release, nightly, main and pull request workflows call the build workflow
- Build each platform once and publish exactly that build, not a second build
- Write the notes of the GitHub release from `CHANGELOG.md` instead of git-cliff
- Mark the GitHub release that `latest` points at as the latest release
- Skip the build of a push to a pull request from `main`, `next`, a release branch or a patch branch that changes `release.json`, the release workflow builds it
- Run every step with bash, so a failing command in a pipe fails the step
- Queue release runs instead of cancelling them, so no two runs publish or move tags at the same time
- Streamline the CDN
- Sync it with `scripts/sync-cdn.sh` instead of `php artisan sync:cdn`, from the release workflow when `latest` or `preview` moves and from the nightly workflow
- Open a pull request for the stable channel and push the preview and nightly channels directly
- Add a preview channel for the betas and release candidates
- Sync every channel with the install and upgrade scripts and the compose files of the commit it releases, instead of the copies in `other/nightly`
- Clear the Cloudflare cache of the CDN with `scripts/sync-cdn.sh --purge`
- List the Coolify version of every channel in `/coolify/release.json`
- Take the Coolify version from the image alone, the upgrade script removes `COOLIFY_VERSION` from `.env`
### Removed
- Remove the git-cliff workflow that generated `CHANGELOG.md`, it is written by hand
- Remove the temporary hotfix release workflow, patch branches release hotfixes
- Remove the `next`, `sha-<commit>` and `<version>-rc.N.<commit>` image tags, prereleases and nightlies take their place, and the `main` tag on docker.io, it moves on ghcr.io alone
### Fixed
- Push nothing but the release index, its images and their attestations, the old workflows pushed every platform image with an index of its own, which stayed behind unused next to the release index on ghcr.io and docker.io
+175 -65
View File
@@ -1,78 +1,188 @@
# Coolify Release Guide
# Releases
## Branches
Every release is tagged `v<version>`, such as `v4.6.0-beta.1`, and publishes the image with the tag `4.6.0-beta.1` on ghcr.io and Docker Hub.
| Branch | Purpose |
| --- | --- |
| `main` | Latest production source |
| `next` | Feature integration and RC releases for the next version |
| `feature/*` | New features based on and merged into `next` |
| `hotfix/X.Y.Z` | Production fixes based on `main` |
> [!IMPORTANT]
> A push that changes `release.json` releases the version it sets, unless that version is released already. Run `scripts/plan-version.sh` first to see what a push releases.
`next` is the only long-lived development branch. Do not create `release/v*` branches.
## Rules
Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`.
| Branch | Takes | Releases |
| -------------- | ---------------------------------------------------------- | -------------------------------------- |
| `main` | fixes, and `next` or a release branch for a new version | stable versions |
| `next` | features | prereleases of the next minor or major |
| `release/v4.6` | the minor 4.6, split off `next` so `next` can go on to 4.7 | prereleases of `4.6.0` |
| `release/v5.0` | the next major, cut from `next` | prereleases of `5.0.0` |
| `v4.x` | backports, once `main` is on v5 | stable `4.x` versions |
| `v4.4.x` | backports and hotfixes, cut from the newest tag of v4.4 | stable `4.4.x` versions |
## Where changes go
- `main` takes fixes only. A new minor or major reaches it as a merge of `next` or a release branch
- `next` and the release branches publish prereleases only, their stable versions are released on `main`
- Name a branch after what it changes: `fix/<topic>`, `feat/<topic>`, and `fix/<topic>-v4.4.8` for a backport, after the version that releases it
- Squash merge pull requests from contributors and pull requests with a single change, such as dependency updates
- Merge everything else with a merge commit: team pull requests with several commits, backports, `next`, the release branches and `v4.5.x` into `main`
- The merge forward workflow merges `main` into `release/v4.6` and `next`, and `release/v4.6` into `next`
- While `main` is on 4.5, `v4.3.x` and `v4.4.x` are never merged into another branch
- Delete `release/v4.6` once `main` has released `4.6.0`
- Delete `v4.5.x` once it is merged into `main`
- Delete `v4.3.x` and `v4.4.x` once their release is published, the next backport cuts them again from the newest tag
- Fixes, security updates, and small improvements target `main`.
- New features and larger changes target `next`.
- **Sync main to next** merges `main` into `next` every 10 minutes, so every production fix is included in the next release. If the merge conflicts, it opens a `chore: merge main into next` pull request to resolve.
- Do not merge `next` into `main` until an RC is approved for a stable release.
> [!CAUTION]
> Never squash merge `next` or a release branch. Their commits stay unmerged and come back as conflicts in every later merge.
## Feature and RC flow
## Create a release
```text
feature/* → next → RC
> [!NOTE]
> `next` publishes prereleases only. For its stable version, merge `next` into `main` with a merge commit and create the release on `main`.
1. Describe the changes under `## [Unreleased]` in `CHANGELOG.md`, they become the release notes
2. Check what the branch releases:
```sh
scripts/plan-version.sh
```
3. Select the stage and the version, the script commits them and asks to push:
```sh
scripts/release.sh
```
4. The pushed commit changes `release.json`, so it runs the release workflow, which builds and publishes the release and moves its tags
5. After a stable release that moves `latest`, merge the pull request of the stable channel on `coollabsio/coollabs-cdn`, see [CDN](#cdn)
> [!TIP]
> To take the release commit back before the push:
>
> ```sh
> scripts/undo-release.sh
> ```
## Backport a fix
`main` is on 4.5, and 4.3 and 4.4 still get fixes. A fix reaches each of them through a patch branch and a pull request of its own:
1. Cut the patch branch from the newest tag of the minor, unless it exists:
```sh
git switch -c v4.4.x v4.4.7 && git push -u origin v4.4.x
```
2. Put the fix on a branch named after the version that releases it, cherry-picked from `main` when it is there already:
```sh
git switch -c fix/<topic>-v4.4.8 origin/v4.4.x && git cherry-pick -x <commit>
```
3. Merge a pull request from `fix/<topic>-v4.4.8` into `v4.4.x` with a merge commit
4. Create the release on your checkout of `v4.4.x`:
```sh
git switch v4.4.x && git pull && scripts/release.sh
```
5. Repeat with `v4.3.x` and `fix/<topic>-v4.3.25`. 4.2 gets no backports
> [!IMPORTANT]
> A hotfix for 4.5 while `main` holds unreleased changes goes through `v4.5.x` the same way. Merge `v4.5.x` into `main` with a merge commit afterwards and delete it. `v4.3.x` and `v4.4.x` are never merged, their fixes come from `main`.
## Release a major version
1. Cut `release/v5.0` from `next` and create its prereleases there with `scripts/release.sh`:
```sh
git switch -c release/v5.0 origin/next && git push -u origin release/v5.0
```
2. Merge `main` into `release/v5.0`, so v5 has every fix of v4 and the pull request into `main` has no conflicts:
```sh
git switch release/v5.0 && git pull && git merge origin/main && git push
```
3. Keep v4 on a branch of its own:
```sh
git switch -c v4.x origin/main && git push -u origin v4.x
```
4. Merge a pull request from `release/v5.0` into `main` with a merge commit
5. Create the release on `main` and select `5.0.0`:
```sh
git switch main && git pull && scripts/release.sh
```
6. Delete `release/v5.0`
> [!IMPORTANT]
> Cut `v4.x` from `main`, or bring an existing one up to date with `main`, before `release/v5.0` is merged into `main`. Afterwards `main` is v5 and v4 lives on `v4.x` alone.
## Nightlies
The nightly workflow publishes a nightly twice a day when its branch has a new commit, versioned `<base>.<YYYYMMDD>.<N>`:
| State | Nightly |
| ------------------------------ | -------------------------- |
| nothing bumped since 5.0.4 | `5.1.0-alpha.0.20261014.1` |
| after 5.1.0-alpha.1 | `5.1.0-alpha.1.20261014.1` |
| after 5.1.0-beta.1 | `5.1.0-beta.1.20261014.1` |
| second nightly on the same day | `5.1.0-beta.1.20261014.2` |
| after 5.1.0-rc.1 | `5.1.0-rc.1.20261015.1` |
| after 5.1.0 | `5.2.0-alpha.0.20261016.1` |
- It comes from `next`, or from the lowest release branch while one is in progress: `release/v5.1` takes it over once it is cut and hands it back once `5.1.0` is released
- The base is the `preview` version of that branch in `release.json` while it is ahead of the newest stable version, a plain one with `-alpha.0`. Otherwise it is the next version with `-alpha.0`, which is never released. A pull request takes the same base
- N counts up per day and base
- No git tag and no GitHub release, only its image tag, the moving `nightly` tag and the nightly channel of the CDN
Print what the next run publishes with `scripts/plan-version.sh --nightly`.
## Main builds
The main workflow builds `main` twice a day when it changed since the last build and moves `ghcr.io/coollabsio/coolify:main` to it, to test fixes before their patch release. A main build is versioned `<stable version>.<YYYYMMDD>.<N>`, `4.5.0.20261028.1`, which sorts after `4.5.0` and before `4.5.1`.
- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN
Print what the next run builds with `scripts/plan-version.sh --main`.
## Pull requests
The pull request workflow builds every pull request and publishes the image of a pull request from a branch of this repository as `ghcr.io/coollabsio/coolify:pr-<number>`. Its version is `<base>-pr.<number>`, with the base a nightly of its base branch takes, or the stable version for `main`:
- Into `next`: `4.6.0-beta.1-pr.1234` after `4.6.0-beta.1`, or `4.7.0-alpha.0-pr.1234` before the first alpha of 4.7.0
- Into `main`: `4.5.3-pr.1234`
- PHP's `version_compare` sorts it after its base and the nightlies of the base and before the next prerelease or release, so an instance on it updates to the release that ships it
- Pull requests from forks and Dependabot are built, not published
- A push to a pull request from `main`, `next`, a release branch or a patch branch that changes `release.json` is not built, the release workflow builds it
- Pull request builds are published to ghcr.io only, as the Docker Hub credentials are in the `release` environment, which no pull request run can use
- Pull request builds create no git tag and no GitHub release
## CDN
`scripts/sync-cdn.sh` syncs a channel of `coollabsio/coollabs-cdn` from the commit of the version it syncs: the install and upgrade scripts, the compose files, the environment file, the service templates, the GitHub releases and `versions.json`.
| Channel | Path | Synced by |
| --------- | ------------------- | --------------------------------------------------------- |
| `stable` | `/coolify/` | the release workflow when `latest` moves, a pull request |
| `preview` | `/coolify/preview/` | the release workflow when `preview` moves, pushed to main |
| `nightly` | `/coolify/nightly/` | the nightly workflow after every nightly, pushed to main |
- The preview channel takes betas and release candidates, alphas reach the nightly channel only
- `CDN_TOKEN` in the environment `release` is a fine-grained token for `coollabsio/coollabs-cdn` alone, with read and write access to contents and pull requests
- `versions.json` of a channel names its Coolify version and the helper, Sentinel and Traefik versions of `upstream.json`
- `/coolify/release.json` lists the Coolify version of every channel, each sync sets the version of its channel
Cloudflare keeps a file of the CDN for up to 10 minutes. To clear the cache sooner, for example once the pull request of the stable channel is merged and deployed, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` in `.env` or the environment, with a token that can purge the cache of the zone:
```sh
scripts/sync-cdn.sh --purge
```
1. Merge feature branches into `next`.
2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`.
3. Every push to `next` runs **Build Coolify Next**. It publishes `sha-<short-sha>`, `4.5-rc.1.<short-sha>`, and the moving `next` tag. It never publishes the exact `4.5-rc.1` tag.
4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease.
5. Run **Release Coolify RC** manually from `next` and enter `v4.5-rc.1`.
6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease.
7. Advance `coolify.nightly.version` to the next intended RC version.
## Update the helper, Sentinel or Traefik
## Stable release flow
`upstream.json` pins them. A change of it releases nothing, every Coolify instance reads the versions from the CDN. To ship it before the next release, sync the stable channel on `main`:
```text
next → main → stable release
```sh
scripts/sync-cdn.sh --stable <latest version>
```
1. Temporarily stop merging features into `next`.
2. Change the version on `next` from the approved RC to the stable version, such as `4.5.0`.
3. Merge `next` into `main`.
4. Create a reviewed draft GitHub Release named `v4.5.0`.
5. Run the stable release workflow from `main`.
6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft.
7. Update the CDN only after the release is approved.
8. Advance `next` to the next development version, such as `4.6-rc.1`.
## Hotfix flow
```text
main → hotfix/X.Y.Z → main → next
```
1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead.
2. Set the intended patch version.
3. Implement and test the fix. SHA images report `X.Y.Z-dev.<short-sha>`.
4. Merge the fix into `main`.
5. Create a reviewed draft GitHub Release named `vX.Y.Z`.
6. Run the stable release workflow from `main`.
7. **Sync main to next** merges `main` into `next`. If the version conflicts, resolve it in favor of the next intended RC in the sync pull request. Delete the hotfix branch if one was used.
8. Update the CDN only after the release is approved.
## Image tags
| Tag | Meaning |
| --- | --- |
| `latest` | Latest stable release |
| `next` | Latest successful `next` build or RC release |
| `X.Y-rc.N.<short-sha>` | Exact `next` commit build |
| `X.Y.Z` | Exact stable release |
| `X.Y-rc.N` | Exact RC release |
| `sha-<commit>` | Exact commit build |
Git tags use the `v` prefix, such as `v4.5.0`. Docker image tags do not.
-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());
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
// To prevent github actions from failing
function env()
{
return null;
}
$version = include 'config/constants.php';
echo $version['coolify']['version'] ?: 'unknown';
-84
View File
@@ -1,84 +0,0 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# template for the changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
+5 -7
View File
@@ -1,12 +1,15 @@
<?php
$release = json_decode(file_get_contents(dirname(__DIR__).'/release.json'), true);
$upstream = json_decode(file_get_contents(dirname(__DIR__).'/upstream.json'), true);
// 0 or an invalid value would remove the time limit of every remote command.
$sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600);
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.5.4',
'helper_version' => '1.0.19',
'version' => env('COOLIFY_VERSION') ?: $release['stable']['coolify'],
'helper_version' => $upstream['helper']['version'],
'railpack_version' => '0.43.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@@ -139,11 +142,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
+8 -2
View File
@@ -197,8 +197,9 @@ COPY --chown=www-data:www-data resources/views ./resources/views
COPY --chown=www-data:www-data artisan artisan
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
COPY --chown=www-data:www-data changelogs/ ./changelogs/
# Version data for Traefik, Sentinel, and helper checks until CheckForUpdatesJob refreshes it
COPY --chown=www-data:www-data versions.json ./versions.json
COPY --chown=www-data:www-data release.json upstream.json ./
RUN jq --arg version "$COOLIFY_VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > versions.json \
&& chown www-data:www-data versions.json
RUN composer dump-autoload
@@ -219,5 +220,10 @@ RUN mkdir -p /etc/nginx/conf.d && \
COPY --from=minio-client /usr/bin/mc /usr/bin/mc
RUN chmod +x /usr/bin/mc
ARG GIT_SHA
LABEL org.opencontainers.image.version="${COOLIFY_VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}"
# Switch to non-root user
USER www-data
-17
View File
@@ -1,17 +0,0 @@
APP_ID=
APP_NAME=Coolify
APP_KEY=
DB_USERNAME=coolify
DB_PASSWORD=
REDIS_PASSWORD=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
ROOT_USERNAME=
ROOT_USER_EMAIL=
ROOT_USER_PASSWORD=
REGISTRY_URL=docker.io
-89
View File
@@ -1,89 +0,0 @@
services:
# Placeholder for "soketi" overrides in docker-compose.custom.yml from versions before 4.4.
# Realtime runs inside the coolify container now. Without this entry, such an override is an
# incomplete service and every upgrade fails. Zero replicas means it never starts, and
# "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it).
# It uses the coolify image, so install and upgrade do not pull another image.
soketi:
image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
deploy:
replicas: 0
coolify:
image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
volumes:
- type: bind
source: /data/coolify/source/.env
target: /var/www/html/.env
read_only: true
- /data/coolify/ssh:/var/www/html/storage/app/ssh
- /data/coolify/applications:/var/www/html/storage/app/applications
- /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups
- /data/coolify/images:/var/www/html/storage/app/images
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
- PHP_FPM_PM_CONTROL=${PHP_FPM_PM_CONTROL:-dynamic}
- PHP_FPM_PM_START_SERVERS=${PHP_FPM_PM_START_SERVERS:-1}
- PHP_FPM_PM_MIN_SPARE_SERVERS=${PHP_FPM_PM_MIN_SPARE_SERVERS:-1}
- PHP_FPM_PM_MAX_SPARE_SERVERS=${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}
- PUSHER_BACKEND_PORT=${PUSHER_BACKEND_PORT:-6001}
env_file:
- /data/coolify/source/.env
ports:
- "${APP_PORT:-8000}:8080"
- "${REVERB_PORT:-${SOKETI_PORT:-6001}}:6001"
- "6002:6002"
expose:
- "${APP_PORT:-8000}"
- "6001"
- "6002"
networks:
coolify:
# Realtime ran in the coolify-realtime container before 4.4; keep the name for existing proxy upstreams.
aliases:
- coolify-realtime
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 24
start_period: 1m
timeout: 2s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
volumes:
- coolify-db:/var/lib/postgresql/data
environment:
POSTGRES_USER: "${DB_USERNAME}"
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
interval: 5s
retries: 10
timeout: 2s
redis:
command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD}"
volumes:
- coolify-redis:/data
healthcheck:
test: redis-cli ping
interval: 5s
retries: 10
timeout: 2s
volumes:
coolify-db:
name: coolify-db
coolify-redis:
name: coolify-redis
networks:
coolify:
external: true
-110
View File
@@ -1,110 +0,0 @@
services:
coolify-testing-host:
init: true
image: "docker.io/coollabsio/coolify-testing-host:latest"
pull_policy: always
container_name: coolify-testing-host
volumes:
- //var/run/docker.sock://var/run/docker.sock
- ./:/data/coolify
coolify:
image: "docker.io/coollabsio/coolify:latest"
pull_policy: always
container_name: coolify
restart: always
working_dir: /var/www/html
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- type: bind
source: .env
target: /var/www/html/.env
read_only: true
- ./ssh:/var/www/html/storage/app/ssh
- ./applications:/var/www/html/storage/app/applications
- ./databases:/var/www/html/storage/app/databases
- ./services:/var/www/html/storage/app/services
- ./backups:/var/www/html/storage/app/backups
- ./images:/var/www/html/storage/app/images
env_file:
- .env
environment:
- APP_ID
- APP_ENV=production
- APP_NAME
- APP_KEY
- DB_PASSWORD
- REDIS_PASSWORD
- SSL_MODE=off
- PHP_PM_CONTROL=dynamic
- PHP_PM_START_SERVERS=1
- PHP_PM_MIN_SPARE_SERVERS=1
- PHP_PM_MAX_SPARE_SERVERS=10
- PUSHER_APP_ID
- PUSHER_APP_KEY
- PUSHER_APP_SECRET
- PUSHER_PORT
- PUSHER_BACKEND_PORT
- AUTOUPDATE=true
- SELF_HOSTED=true
- SSH_MUX_ENABLED=false
- IS_WINDOWS_DOCKER_DESKTOP=true
ports:
- "${APP_PORT:-8000}:8080"
- "${REVERB_PORT:-${SOKETI_PORT:-6001}}:6001"
- "6002:6002"
expose:
- "${APP_PORT:-8000}"
- "6001"
- "6002"
healthcheck:
test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
interval: 5s
retries: 10
timeout: 2s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:15-alpine
pull_policy: always
container_name: coolify-db
restart: always
env_file:
- .env
volumes:
- coolify-db:/var/lib/postgresql/data
environment:
POSTGRES_USER: "${DB_USERNAME}"
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
interval: 5s
retries: 10
timeout: 2s
redis:
image: redis:7-alpine
pull_policy: always
container_name: coolify-redis
restart: always
command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
env_file:
- .env
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD}"
volumes:
- coolify-redis:/data
healthcheck:
test: redis-cli ping
interval: 5s
retries: 10
timeout: 2s
volumes:
coolify-db:
name: coolify-db
coolify-redis:
name: coolify-redis
-32
View File
@@ -1,32 +0,0 @@
services:
coolify:
container_name: coolify
restart: always
working_dir: /var/www/html
extra_hosts:
- host.docker.internal:host-gateway
expose:
- "6001"
- "6002"
networks:
- coolify
depends_on:
- postgres
- redis
postgres:
image: postgres:15-alpine
container_name: coolify-db
restart: always
networks:
- coolify
redis:
image: redis:7-alpine
container_name: coolify-redis
restart: always
networks:
- coolify
networks:
coolify:
name: coolify
driver: bridge
external: false
File diff suppressed because it is too large. Load diff
-405
View File
@@ -1,405 +0,0 @@
#!/bin/bash
## Explicit Coolify internal PostgreSQL major-version migrator.
## This script is intentionally not run by upgrade.sh automatically.
set -Eeuo pipefail
SOURCE_DIR="/data/coolify/source"
ENV_FILE="${SOURCE_DIR}/.env"
BACKUP_DIR="/data/coolify/backups/internal-postgres"
OVERRIDE_FILE="${SOURCE_DIR}/docker-compose.postgres-upgrade.yml"
ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
LOGFILE="${SOURCE_DIR}/postgres-upgrade-${DATE}.log"
COMMAND="${1:-upgrade}"
TARGET_MAJOR="${1:-18}"
if [ "$COMMAND" = "rollback" ]; then
TARGET_MAJOR=""
else
COMMAND="upgrade"
fi
TARGET_IMAGE="${COOLIFY_POSTGRES_TARGET_IMAGE:-postgres:${TARGET_MAJOR}-alpine}"
TARGET_VOLUME="${COOLIFY_POSTGRES_TARGET_VOLUME:-coolify-db-pg${TARGET_MAJOR}}"
TEMP_CONTAINER="coolify-db-pg${TARGET_MAJOR:-rollback}-restore-${DATE}"
DUMP_FILE="${BACKUP_DIR}/postgres-upgrade-${DATE}.sql.gz"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
}
fail() {
log "ERROR: $1"
exit 1
}
usage() {
cat <<EOF
Usage:
$0 <target-major>
$0 rollback
Examples:
$0 18
$0 rollback
Environment overrides:
COOLIFY_POSTGRES_TARGET_IMAGE=postgres:18-alpine
COOLIFY_POSTGRES_TARGET_VOLUME=coolify-db-pg18
EOF
}
cleanup() {
docker rm -f "$TEMP_CONTAINER" >/dev/null 2>&1 || true
}
trap cleanup EXIT
get_env_var() {
local key="$1"
local fallback="${2:-}"
local value
value=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
if [ -z "$value" ]; then
printf '%s' "$fallback"
else
printf '%s' "$value"
fi
}
wait_for_postgres() {
local container="$1"
local user="$2"
local database="$3"
local attempts=60
for _ in $(seq 1 "$attempts"); do
if docker exec "$container" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
compose_files() {
printf -- '-f %s/docker-compose.yml -f %s/docker-compose.prod.yml ' "$SOURCE_DIR" "$SOURCE_DIR"
if [ -f "${SOURCE_DIR}/docker-compose.custom.yml" ]; then
printf -- '-f %s/docker-compose.custom.yml ' "$SOURCE_DIR"
fi
if [ -f "$OVERRIDE_FILE" ]; then
printf -- '-f %s ' "$OVERRIDE_FILE"
fi
}
validate_target_major() {
case "$TARGET_MAJOR" in
''|*[!0-9]*)
usage
fail "Target major version must be numeric. Example: $0 18"
;;
esac
if [ "$TARGET_MAJOR" -lt 10 ]; then
fail "Target major version must be 10 or higher."
fi
}
mount_path_for_major() {
local major="$1"
if [ "$major" -ge 18 ]; then
printf '%s' '/var/lib/postgresql'
else
printf '%s' '/var/lib/postgresql/data'
fi
}
current_postgres_mount_name() {
docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Name}}{{end}}{{end}}' 2>/dev/null
}
current_postgres_mount_path() {
docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Destination}}{{end}}{{end}}' 2>/dev/null
}
current_postgres_image() {
docker inspect coolify-db --format '{{.Config.Image}}' 2>/dev/null
}
current_coolify_image_tag() {
local image
local image_without_digest
local last_segment
image=$(docker inspect coolify --format '{{.Config.Image}}' 2>/dev/null || true)
image_without_digest="${image%@*}"
last_segment="${image_without_digest##*/}"
if [[ "$last_segment" == *:* ]]; then
printf '%s' "${last_segment##*:}"
fi
}
write_override_file() {
local image="$1"
local volume="$2"
local mount_path="$3"
cat > "$OVERRIDE_FILE" <<YAML
services:
postgres:
image: "${image}"
volumes:
- coolify-db:${mount_path}
volumes:
coolify-db:
name: "${volume}"
external: true
YAML
}
write_rollback_file() {
local previous_image="$1"
local previous_volume="$2"
local previous_mount_path="$3"
local previous_override_present="$4"
local upgraded_image="$5"
local upgraded_volume="$6"
cat > "$ROLLBACK_FILE" <<EOF
PREVIOUS_IMAGE='${previous_image}'
PREVIOUS_VOLUME='${previous_volume}'
PREVIOUS_MOUNT_PATH='${previous_mount_path}'
PREVIOUS_OVERRIDE_PRESENT='${previous_override_present}'
UPGRADED_IMAGE='${upgraded_image}'
UPGRADED_VOLUME='${upgraded_volume}'
CREATED_AT='${DATE}'
EOF
chmod 600 "$ROLLBACK_FILE"
}
start_stack() {
local coolify_image_tag="${1:-${LATEST_IMAGE:-latest}}"
local files
files=$(compose_files)
# shellcheck disable=SC2086
LATEST_IMAGE="$coolify_image_tag" docker compose --env-file "$ENV_FILE" $files up -d --remove-orphans --wait --wait-timeout 120
}
print_rollback_instructions() {
cat <<EOF | tee -a "$LOGFILE"
Rollback command if the upgraded database does not work:
${SOURCE_DIR}/upgrade-postgres.sh rollback
Rollback metadata was saved to:
${ROLLBACK_FILE}
The previous active Docker volume was '${PREVIOUS_VOLUME}'.
The new Docker volume is '${TARGET_VOLUME}'.
The dump file is '${DUMP_FILE}'.
EOF
}
validate_common_requirements() {
mkdir -p "$BACKUP_DIR"
touch "$LOGFILE"
chmod 700 "$BACKUP_DIR"
[ -f "$ENV_FILE" ] || fail "Missing ${ENV_FILE}. Run this on a self-hosted Coolify server."
command -v docker >/dev/null 2>&1 || fail "Docker is required."
docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable."
}
rollback_postgres() {
validate_common_requirements
[ -f "$ROLLBACK_FILE" ] || fail "Missing rollback metadata file: ${ROLLBACK_FILE}"
# shellcheck disable=SC1090
. "$ROLLBACK_FILE"
[ -n "${PREVIOUS_IMAGE:-}" ] || fail "Rollback metadata is missing PREVIOUS_IMAGE."
[ -n "${PREVIOUS_VOLUME:-}" ] || fail "Rollback metadata is missing PREVIOUS_VOLUME."
[ -n "${PREVIOUS_MOUNT_PATH:-}" ] || fail "Rollback metadata is missing PREVIOUS_MOUNT_PATH."
[ -n "${PREVIOUS_OVERRIDE_PRESENT:-}" ] || fail "Rollback metadata is missing PREVIOUS_OVERRIDE_PRESENT."
CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
log "Rolling back Coolify internal PostgreSQL."
log "Previous image: ${PREVIOUS_IMAGE}"
log "Previous volume: ${PREVIOUS_VOLUME}"
log "Previous mount path: ${PREVIOUS_MOUNT_PATH}"
log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
docker volume inspect "$PREVIOUS_VOLUME" >/dev/null 2>&1 || fail "Previous volume '${PREVIOUS_VOLUME}' does not exist."
log "Stopping Coolify application container before rollback."
docker stop coolify >>"$LOGFILE" 2>&1 || true
log "Removing current coolify-db container. Current upgraded volume is kept untouched."
docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
if [ "$PREVIOUS_OVERRIDE_PRESENT" = "true" ]; then
log "Restoring previous PostgreSQL compose override."
write_override_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH"
else
log "Removing PostgreSQL compose override to restore base compose configuration."
rm -f "$OVERRIDE_FILE"
fi
log "Starting Coolify stack with rollback database volume."
start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack after rollback. See ${LOGFILE}."
log "Rollback completed successfully."
cat <<EOF | tee -a "$LOGFILE"
Rollback completed.
Current active PostgreSQL volume should be '${PREVIOUS_VOLUME}'.
The upgraded volume '${UPGRADED_VOLUME:-unknown}' was left untouched for inspection or manual cleanup.
EOF
}
upgrade_postgres() {
validate_target_major
TARGET_MOUNT_PATH=$(mount_path_for_major "$TARGET_MAJOR")
validate_common_requirements
log "Starting Coolify internal PostgreSQL major upgrade."
log "Target major: ${TARGET_MAJOR}"
log "Target image: ${TARGET_IMAGE}"
log "Target volume: ${TARGET_VOLUME}"
log "Target mount path: ${TARGET_MOUNT_PATH}"
DB_USERNAME=$(get_env_var DB_USERNAME coolify)
DB_DATABASE=$(get_env_var DB_DATABASE coolify)
if ! docker ps -a --format '{{.Names}}' | grep -qx 'coolify-db'; then
fail "Container 'coolify-db' was not found. Start Coolify before running this script."
fi
if ! docker ps --format '{{.Names}}' | grep -qx 'coolify-db'; then
log "Starting existing coolify-db container for version detection and dump."
docker start coolify-db >>"$LOGFILE" 2>&1 || fail "Could not start coolify-db."
fi
wait_for_postgres coolify-db "$DB_USERNAME" "$DB_DATABASE" || fail "Existing coolify-db is not ready."
SERVER_VERSION_NUM=$(docker exec coolify-db psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SHOW server_version_num;' | tr -d '[:space:]')
CURRENT_MAJOR=$((SERVER_VERSION_NUM / 10000))
PREVIOUS_VOLUME=$(current_postgres_mount_name)
PREVIOUS_MOUNT_PATH=$(current_postgres_mount_path)
PREVIOUS_IMAGE=$(current_postgres_image)
CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
[ -n "$PREVIOUS_VOLUME" ] || fail "Could not detect current PostgreSQL Docker volume."
[ -n "$PREVIOUS_MOUNT_PATH" ] || fail "Could not detect current PostgreSQL mount path."
[ -n "$PREVIOUS_IMAGE" ] || fail "Could not detect current PostgreSQL image."
if [ -f "$OVERRIDE_FILE" ]; then
PREVIOUS_OVERRIDE_PRESENT=true
else
PREVIOUS_OVERRIDE_PRESENT=false
fi
log "Current PostgreSQL major: ${CURRENT_MAJOR}"
log "Current active volume: ${PREVIOUS_VOLUME}"
log "Current image: ${PREVIOUS_IMAGE}"
log "Current mount path: ${PREVIOUS_MOUNT_PATH}"
log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
if [ "$CURRENT_MAJOR" -eq "$TARGET_MAJOR" ]; then
log "PostgreSQL is already on major ${TARGET_MAJOR}. Nothing to do."
exit 0
fi
if [ "$CURRENT_MAJOR" -gt "$TARGET_MAJOR" ]; then
fail "Downgrade from ${CURRENT_MAJOR} to ${TARGET_MAJOR} is not supported. Use '$0 rollback' to restore the previous upgrade state."
fi
if docker volume inspect "$TARGET_VOLUME" >/dev/null 2>&1; then
fail "Target volume '${TARGET_VOLUME}' already exists. Set COOLIFY_POSTGRES_TARGET_VOLUME to a new name or remove the old failed target volume."
fi
log "Stopping Coolify application container to prevent writes during dump."
docker stop coolify >>"$LOGFILE" 2>&1 || true
log "Creating compressed dump at ${DUMP_FILE}."
docker exec coolify-db pg_dumpall -U "$DB_USERNAME" | gzip -c > "$DUMP_FILE"
chmod 600 "$DUMP_FILE"
if [ ! -s "$DUMP_FILE" ]; then
fail "Dump file is empty. Aborting."
fi
log "Creating target Docker volume '${TARGET_VOLUME}'."
docker volume create "$TARGET_VOLUME" >>"$LOGFILE" 2>&1
log "Pulling ${TARGET_IMAGE}."
docker pull "$TARGET_IMAGE" >>"$LOGFILE" 2>&1
log "Starting temporary PostgreSQL ${TARGET_MAJOR} container."
docker run -d \
--name "$TEMP_CONTAINER" \
--network coolify \
-e POSTGRES_HOST_AUTH_METHOD=trust \
-v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}" \
"$TARGET_IMAGE" >>"$LOGFILE" 2>&1
wait_for_postgres "$TEMP_CONTAINER" postgres postgres || fail "Temporary PostgreSQL ${TARGET_MAJOR} container did not become ready."
log "Restoring dump into target volume."
gunzip -c "$DUMP_FILE" | docker exec -i "$TEMP_CONTAINER" psql -U postgres -d postgres >>"$LOGFILE" 2>&1
log "Smoke-checking restored Coolify database."
docker exec "$TEMP_CONTAINER" psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SELECT 1;' | grep -qx '1' || fail "Restored database smoke check failed."
log "Saving rollback metadata to ${ROLLBACK_FILE}."
write_rollback_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH" "$PREVIOUS_OVERRIDE_PRESENT" "$TARGET_IMAGE" "$TARGET_VOLUME"
log "Writing Docker Compose override to ${OVERRIDE_FILE}."
write_override_file "$TARGET_IMAGE" "$TARGET_VOLUME" "$TARGET_MOUNT_PATH"
log "Stopping temporary restore container."
docker rm -f "$TEMP_CONTAINER" >>"$LOGFILE" 2>&1 || true
log "Stopping old coolify-db container. Previous volume '${PREVIOUS_VOLUME}' will be kept for rollback."
docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
log "Starting Coolify stack with PostgreSQL ${TARGET_MAJOR}."
start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack with upgraded PostgreSQL. See ${LOGFILE}."
log "Coolify internal PostgreSQL upgrade completed successfully."
print_rollback_instructions
}
if [ "${COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY:-false}" = "true" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then
return 0
fi
case "$COMMAND" in
rollback)
rollback_postgres
;;
upgrade)
upgrade_postgres
;;
-h|--help|help)
usage
;;
*)
usage
fail "Unknown command: ${COMMAND}"
;;
esac
-427
View File
@@ -1,427 +0,0 @@
#!/bin/bash
## Do not modify this file. You will lose the ability to autoupdate!
CDN="https://cdn.coollabs.io/coolify-nightly"
LATEST_IMAGE=${1:-latest}
LATEST_HELPER_VERSION=${2:-latest}
ENV_FILE="/data/coolify/source/.env"
if [ -n "${3+x}" ]; then
REGISTRY_URL="$3"
elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1)
else
REGISTRY_URL="docker.io"
fi
SKIP_BACKUP=${4:-false}
STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
LOGFILE="/data/coolify/source/upgrade-${DATE}.log"
# Helper function to log with timestamp
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
}
# Helper function to log section headers
log_section() {
echo "" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
}
# Helper function to write upgrade status for API polling
write_status() {
local step="$1"
local message="$2"
echo "${step}|${message}|$(date -Iseconds)" > "$STATUS_FILE"
}
# Print the first gateway of a network that Docker reports with a prefix length (fd00::1/64).
# Docker Engine 27.0 to 28.0.0 does this for IPv6 until the daemon restarts (https://github.com/moby/moby/pull/49520).
# The Docker CLI and Compose in the helper image fail on such a network with a ParseAddr error.
network_gateway_with_prefix() {
docker network inspect "$1" --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>&1 | grep -oE '[0-9A-Fa-f:.]+/[0-9]+' | head -n1
}
# Make sure the coolify network exists and the helper image can read it.
# It is created when starting Coolify with docker compose
ensure_coolify_network() {
log "Checking Docker network 'coolify'..."
if ! docker network inspect coolify >/dev/null 2>&1; then
log "Network 'coolify' does not exist, creating..."
if ! docker network create --attachable --ipv6 coolify 2>/dev/null; then
log "Failed to create network with IPv6, trying without IPv6..."
docker network create --attachable coolify 2>/dev/null
log "Network 'coolify' created without IPv6"
elif [ -n "$(network_gateway_with_prefix coolify)" ]; then
# No container uses the new network yet, so it is safe to recreate it.
log "Docker reports the IPv6 gateway of network 'coolify' with a prefix length, recreating it without IPv6..."
docker network rm coolify >/dev/null 2>&1
docker network create --attachable coolify 2>/dev/null
log "Network 'coolify' created without IPv6"
else
log "Network 'coolify' created with IPv6 support"
fi
return 0
fi
log "Network 'coolify' already exists"
local gateway
gateway=$(network_gateway_with_prefix coolify)
if [ -n "$gateway" ]; then
local message="Docker Engine $(docker version --format '{{.Server.Version}}' 2>/dev/null) reports the gateway of the 'coolify' network as ${gateway}, which Coolify cannot read (Docker Engine bug, fixed in 28.0.1). Run 'systemctl restart docker' (this restarts all containers) or upgrade Docker Engine, then upgrade Coolify again."
log "ERROR: ${message}"
write_status "error" "$message"
echo " ERROR: ${message}"
return 1
fi
}
# Pull an image. Retry with backoff, because the registry can rate limit many instances that pull at the same time.
pull_image() {
local image="$1"
local attempt=1
local max_attempts=5
local delay=15
until docker pull "$image" >>"$LOGFILE" 2>&1; do
if [ "$attempt" -ge "$max_attempts" ]; then
return 1
fi
log "Pull of $image failed (attempt $attempt/$max_attempts). Retrying in ${delay}s..."
echo " Pull failed (attempt $attempt/$max_attempts). Retrying in ${delay}s..."
sleep "$((delay + RANDOM % 10))"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
echo ""
echo "=========================================="
echo " Coolify Upgrade - ${DATE}"
echo "=========================================="
echo ""
# Initialize log file with header
echo "============================================================" >>"$LOGFILE"
echo "Coolify Upgrade Log" >>"$LOGFILE"
echo "Started: $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOGFILE"
echo "Target Version: ${LATEST_IMAGE}" >>"$LOGFILE"
echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE"
echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
log_section "Step 1/6: Downloading configuration files"
write_status "1" "Downloading configuration files"
echo "1/6 Downloading latest configuration files..."
log "Downloading docker-compose.yml from ${CDN}/docker-compose.yml"
curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml"
curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
log "Downloading .env.production from ${CDN}/.env.production"
curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
log "Downloading upgrade-postgres.sh from ${CDN}/upgrade-postgres.sh"
curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh
chmod +x /data/coolify/source/upgrade-postgres.sh
log "Configuration files downloaded successfully"
echo " Done."
# Extract all images from docker-compose configuration
log "Extracting all images from docker-compose configuration..."
COMPOSE_FILES="-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml"
# Check if custom compose file exists
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml"
log "Including custom docker-compose.yml in image extraction"
fi
# Check if PostgreSQL upgrade override exists
if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml"
log "Including PostgreSQL upgrade compose override in image extraction"
fi
# Get all unique images from docker compose config
# LATEST_IMAGE env var is needed for image substitution in compose files
IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
if [ -z "$IMAGES" ]; then
log "ERROR: Failed to extract images from docker-compose files"
write_status "error" "Failed to parse docker-compose configuration"
echo " ERROR: Failed to parse docker-compose configuration. Aborting upgrade."
exit 1
fi
log "Images to pull:"
echo "$IMAGES" | while read img; do log " - $img"; done
# Backup existing .env file before making any changes
if [ "$SKIP_BACKUP" != "true" ]; then
if [ -f "$ENV_FILE" ]; then
echo " Creating backup of .env file..."
log "Creating backup of .env file to .env-$DATE"
cp "$ENV_FILE" "$ENV_FILE-$DATE"
log "Backup created: ${ENV_FILE}-${DATE}"
else
log "WARNING: No existing .env file found to backup"
fi
fi
log_section "Step 2/6: Updating environment configuration"
write_status "2" "Updating environment configuration"
echo ""
echo "2/6 Updating environment configuration..."
log "Merging .env.production values into .env"
awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
log "Environment file merged successfully"
update_env_var() {
local key="$1"
local value="$2"
# If variable "key=" exists but has no value, update the value of the existing line
if grep -q "^${key}=$" "$ENV_FILE"; then
sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE"
log "Updated ${key} (was empty)"
# If variable "key=" doesn't exist, append it to the file with value
elif ! grep -q "^${key}=" "$ENV_FILE"; then
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
log "Added ${key} (was missing)"
fi
}
set_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
log "Updated ${key}"
else
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
log "Added ${key}"
fi
}
# Quote a value so that Docker Compose and phpdotenv both read it back unchanged (same as install.sh).
quote_env_value() {
local value="$1"
case "$value" in
*\'*)
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//\$/\\\$}
printf '"%s"' "$value"
;;
*)
printf "'%s'" "$value"
;;
esac
}
# Installers before 4.5.1 (and manual edits) wrote root user credentials with spaces without quotes.
# phpdotenv cannot parse such a line, so the recreated Coolify container returns HTTP 500 for every page.
# Only quote lines that phpdotenv rejects: unquoted values that contain whitespace but no comment.
quote_root_user_env_values() {
local key value
for key in ROOT_USERNAME ROOT_USER_EMAIL ROOT_USER_PASSWORD; do
value=$(grep -m1 "^${key}=" "$ENV_FILE" | cut -d '=' -f2-) || continue
# phpdotenv ignores whitespace around an unquoted value (also the \r of CRLF line endings)
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
case "$value" in
\"* | \'* | *[[:space:]]\#*) continue ;;
*[[:space:]]*) ;;
*) continue ;;
esac
# awk reads the value from the environment, so characters such as | & \ in it stay literal
KEY="$key" VALUE="$(quote_env_value "$value")" awk '!done && index($0, ENVIRON["KEY"] "=") == 1 { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"]; done = 1 } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
log "Quoted ${key} (its value has spaces)"
done
}
log "Checking environment variables..."
quote_root_user_env_values
set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
update_env_var "PUSHER_BACKEND_PORT" "6001"
# The realtime container no longer exists; point older installs at the bundled Reverb server
if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then
set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"
fi
log "Environment variables check complete"
echo " Done."
ensure_coolify_network || exit 1
mkdir -p /data/coolify/images/{avatars,project-icons}
chown -R 9999:root /data/coolify/images
chmod -R 700 /data/coolify/images
# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
# Only changes owner — preserves existing group to respect custom setups
SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
if [ "$SSH_OWNER" != "9999" ]; then
log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
chown -R 9999 /data/coolify/ssh
chmod -R 700 /data/coolify/ssh
fi
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json"
log "Docker config mount enabled: /root/.docker/config.json"
fi
log_section "Step 3/6: Pulling Docker images"
write_status "3" "Pulling Docker images"
echo ""
echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade)
HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE"
if pull_image "$HELPER_IMAGE"; then
log "Successfully pulled $HELPER_IMAGE"
else
log "ERROR: Failed to pull $HELPER_IMAGE"
write_status "error" "Failed to pull $HELPER_IMAGE"
echo " ERROR: Failed to pull $HELPER_IMAGE. Aborting upgrade."
exit 1
fi
# Pull all images from compose config
# Using a for loop to avoid subshell issues with exit
for IMAGE in $IMAGES; do
if [ -n "$IMAGE" ]; then
echo " - Pulling $IMAGE..."
log "Pulling image: $IMAGE"
if pull_image "$IMAGE"; then
log "Successfully pulled $IMAGE"
else
log "ERROR: Failed to pull $IMAGE"
write_status "error" "Failed to pull $IMAGE"
echo " ERROR: Failed to pull $IMAGE. Aborting upgrade."
exit 1
fi
fi
done
log "All images pulled successfully"
echo " All images pulled successfully."
set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"
set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"
log_section "Step 4/6: Stopping and restarting containers"
write_status "4" "Stopping containers"
echo ""
echo "4/6 Stopping containers and starting new ones..."
echo " This step will restart all Coolify containers."
echo " Check the log file for details: ${LOGFILE}"
# From this point forward, we need to ensure the script continues even if
# the SSH connection is lost (which happens when coolify container stops)
# We use a subshell with nohup to ensure completion
log "Starting container restart sequence (detached)..."
nohup bash -c "
LOGFILE='$LOGFILE'
STATUS_FILE='$STATUS_FILE'
DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT'
REGISTRY_URL='$REGISTRY_URL'
LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION'
LATEST_IMAGE='$LATEST_IMAGE'
log() {
echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] \$1\" >>\"\$LOGFILE\"
}
write_status() {
echo \"\$1|\$2|\$(date -Iseconds)\" > \"\$STATUS_FILE\"
}
# Stop and remove containers
# coolify-realtime is kept for upgrades from versions that still ran the separate realtime container
for container in coolify coolify-db coolify-redis coolify-realtime; do
if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
log \"Stopping container: \${container}\"
docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
log \"Removing container: \${container}\"
docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
log \"Container \${container} stopped and removed\"
else
log \"Container \${container} not found (skipping)\"
fi
done
log \"Container cleanup complete\"
# Start new containers
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
log 'Step 5/6: Starting new containers'
echo '============================================================' >>\"\$LOGFILE\"
write_status '5' 'Starting new containers'
COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml'
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
log 'Using custom docker-compose.yml'
COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\"
fi
if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
log 'Using PostgreSQL upgrade compose override'
COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\"
fi
log 'Running docker compose up...'
if docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1; then
log 'Docker compose up completed'
elif [ \"\$(docker inspect --format '{{.State.Running}}' coolify 2>/dev/null)\" = 'true' ]; then
# A slow first start (for example long migrations) can pass the wait timeout. Coolify is still starting.
log 'Docker compose up did not finish waiting, but the coolify container is running'
else
log 'ERROR: Docker compose up failed and the coolify container is not running'
write_status 'error' \"Coolify did not start. Check the upgrade log: \$LOGFILE\"
exit 1
fi
# Final log entry
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
log 'Step 6/6: Upgrade complete'
echo '============================================================' >>\"\$LOGFILE\"
write_status '6' 'Upgrade complete'
log 'Coolify upgrade completed successfully'
log \"Version: \${LATEST_IMAGE}\"
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
echo \"Upgrade completed: \$(date '+%Y-%m-%d %H:%M:%S')\" >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
# Clean up status file after a short delay to allow frontend to read completion
sleep 10
rm -f \"\$STATUS_FILE\"
log 'Status file cleaned up'
" >>"$LOGFILE" 2>&1 &
# Give the background process a moment to start
sleep 2
log "Container restart sequence started in background (PID: $!)"
echo ""
echo "5/6 Containers are being restarted in the background..."
echo "6/6 Upgrade process initiated!"
echo ""
echo "=========================================="
echo " Coolify upgrade to ${LATEST_IMAGE} in progress"
echo "=========================================="
echo ""
echo " The upgrade will continue in the background."
echo " Coolify will be available again shortly."
echo " Log file: ${LOGFILE}"
-31
View File
@@ -1,31 +0,0 @@
{
"coolify": {
"v4": {
"version": "4.5.4"
},
"nightly": {
"version": "4.6-rc.1"
},
"helper": {
"version": "1.0.19"
},
"sentinel": {
"version": "1.1.3"
},
"sentinel-host": {
"version": "1.1.3",
"digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
}
},
"traefik": {
"v3.7": "3.7.16",
"v3.6": "3.6.25",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.60"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"stable": {
"coolify": "4.5.3"
},
"preview": {
"coolify": "4.5.3"
}
}
+2 -2
View File
@@ -519,8 +519,8 @@ fi
# Fetch versions.json once and parse all values from it
VERSIONS_JSON=$(curl -L --silent $CDN/versions.json)
LATEST_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $2}' | tr -d ',')
LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $6}' | tr -d ',')
LATEST_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"v4"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1)
LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"helper"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1)
if [ -z "$LATEST_HELPER_VERSION" ]; then
LATEST_HELPER_VERSION=latest
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env bash
set -euo pipefail
OPTION="$*"
BRANCH="${BRANCH:-}"
PULL_REQUEST="${PULL_REQUEST:-}"
NIGHTLY_VERSION="${NIGHTLY_VERSION:-}"
NIGHTLY_REVISION="${NIGHTLY_REVISION:-}"
MAIN_VERSION="${MAIN_VERSION:-}"
MAIN_REVISION="${MAIN_REVISION:-}"
VERSION_FILE=release.json
ARTIFACT=coolify
UPSTREAM_FILE=upstream.json
UPSTREAM=
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' RESET=$'\033[0m'
info() { [ -n "$OPTION" ] || printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*" >&2; }
ok() { [ -n "$OPTION" ] || printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*" >&2; }
warn() { [ -n "$OPTION" ] || printf ' %s⚠%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
die() {
printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
cd "$(dirname "$0")/.."
main() {
[[ "$OPTION" =~ ^(--build|--release|--tags|--nightly|--main)?$ ]] || die "'$OPTION' is not an option, expected --build, --release, --tags, --nightly, --main or none"
read_version
print_build_version
fetch_origin
print_tags
print_next_build
check_branch
check_version
select_releases
check_unreleased
print_upcoming_versions
print_releases
}
read_version() {
branch="${BRANCH:-$(git branch --show-current)}"
channel=stable
[[ ! "$branch" =~ ^(next|release/v[0-9]+\.[0-9]+)$ ]] || channel=preview
version="$(artifact_version "$channel")"
[ -n "$version" ] || die "$VERSION_FILE has no $channel version of $ARTIFACT"
}
print_build_version() {
[ "$OPTION" = --build ] || return 0
if [ -n "$PULL_REQUEST" ] && [ "$channel" = preview ]; then
tags="$(git ls-remote --tags --refs origin | sed 's|.*refs/tags/||' | filter_release_tags | sort -V)" || die "could not list the tags of origin"
line="${branch#release/v}"
[ "$line" != "$branch" ] || line=""
revision=HEAD
select_newest_stable
select_preview_base
version="$base"
fi
echo "$version${PULL_REQUEST:+-pr.$PULL_REQUEST}"
exit 0
}
fetch_origin() {
local current behind
[ "$(git rev-parse --is-shallow-repository)" = false ] || die "the clone is shallow, run git fetch --unshallow first"
git fetch --quiet --prune --tags origin || die "could not fetch origin"
current="$(git branch --show-current)"
if [ -n "$current" ] && behind="$(git rev-list --count "HEAD..origin/$current" 2> /dev/null)"; then
[ "$behind" = 0 ] || die "$current is $behind commit(s) behind origin/$current, pull first"
fi
tags="$(release_tags | sort -V)"
}
print_tags() {
local released
[ "$OPTION" = --tags ] || return 0
released="$(release_tags --points-at HEAD)"
moved_tags "$(grep -vxF -f <(echo "$released") <<< "$tags" || true)" "$tags" \
| jq -Rnc '[inputs | split(" ") | {(.[0]): .[1]}] | add // {}'
exit 0
}
print_next_build() {
local build current_version current_revision
[[ "$OPTION" =~ ^--(nightly|main)$ ]] || return 0
if [ "$OPTION" = --main ]; then
branch=main
revision="$(git rev-parse origin/main)"
current_version="$MAIN_VERSION" current_revision="$MAIN_REVISION"
else
select_nightly_branch
current_version="$NIGHTLY_VERSION" current_revision="$NIGHTLY_REVISION"
fi
[ "$revision" != "$current_revision" ] || exit 0
if [ "$OPTION" = --main ]; then
base="$(artifact_version stable <(git show "$revision:$VERSION_FILE" 2> /dev/null))"
[ -n "$base" ] || die "main has no stable version of $ARTIFACT in $VERSION_FILE, expected the release process there"
else
select_preview_base
fi
[ -z "$UPSTREAM" ] || current_version="${current_version#*-v}"
build="$base.$(date -u +%Y%m%d)"
if [[ "$current_version" == "$build".* ]]; then
build+=".$((${current_version##*.} + 1))"
else
build+=".1"
fi
upstream_lines <(git show "$revision:$UPSTREAM_FILE") | while read -r name upstream; do
jq -nc --arg name "$name" --arg version "$upstream" --arg tag "v${upstream:+$upstream-v}$build" '{$name, $version, $tag}'
done | jq -sc --arg branch "$branch" --arg revision "$revision" --arg image "$build" '{$branch, $revision, $image, lines: .}'
exit 0
}
select_nightly_branch() {
select_newest_stable
line="$(git for-each-ref --format='%(refname:lstrip=4)' 'refs/remotes/origin/release/v*' | sed -n 's/^v\([0-9]*\.[0-9]*\)$/\1/p' | sort -V \
| while read -r release; do ! newer "$release.0" "$stable" || { echo "$release" && break; }; done)"
branch="${line:+release/v$line}"
branch="${branch:-next}"
revision="$(git rev-parse "origin/$branch")"
}
check_branch() {
[[ "$branch" =~ ^(main|next|v([0-9]+(\.[0-9]+)?)\.x|release/v([0-9]+\.[0-9]+))$ ]] || die "${branch:-a detached HEAD} releases nothing"
line="${BASH_REMATCH[2]}${BASH_REMATCH[4]}"
case "$branch" in
main) ok "main releases stable versions" ;;
next) ok "next releases prereleases" ;;
release/*) ok "$branch releases prereleases of $line.0" ;;
*) ok "$branch releases stable $line.x versions" ;;
esac
}
check_version() {
local from
if [ "$channel" = stable ]; then
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "$VERSION_FILE sets '$version', expected major.minor.patch"
[ -z "$line" ] || [[ "$version" == "$line".* ]] || die "$VERSION_FILE sets $version, expected a $line.x version"
else
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]] \
|| die "$VERSION_FILE sets '$version', expected major.minor.patch with an optional -alpha.N, -beta.N or -rc.N"
[ -z "$line" ] || [ "${version%%-*}" = "$line.0" ] || die "$VERSION_FILE sets $version, expected a prerelease of $line.0"
fi
last="$(release_tags --merged HEAD | tag_versions | awk -v channel="$channel" 'channel == "preview" || !/-/' | newest)"
earlier="$(with_version "$version" <<< "$tags" | tail -n 1)"
publish=false
if newer "$last" "$version"; then
info "$VERSION_FILE sets $version, $branch released $last already"
elif [ "$version" = 0.0.0 ]; then
info "$VERSION_FILE sets 0.0.0, $branch releases nothing before scripts/release.sh sets the first version"
elif [ "$channel" = preview ] && [[ ! "$version" =~ -[a-z]+\.[1-9] ]]; then
info "$VERSION_FILE sets $version, $branch publishes numbered prereleases only"
elif [ "$channel" = preview ] && [ -n "$earlier" ]; then
ok "$VERSION_FILE sets $BOLD$version$RESET"
info "$earlier is published, $branch publishes a prerelease once"
else
from="${last:+$last → }"
[ "$version" != "$last" ] || from=
ok "$VERSION_FILE sets $from$BOLD$version$RESET"
publish=true
fi
if [ "$publish" = true ] && taken "${version%%-*}"; then
die "$VERSION_FILE sets $version, ${version%%-*} is taken by $holder"
fi
if [ "$publish" = true ] && in_progress "${version%%-*}"; then
warn "${version%%-*} is in progress, held by $holder"
fi
}
select_releases() {
local name sqlite tag previous older moves listed added=""
releases=
[ "$publish" = true ] || return 0
while read -r name sqlite; do
tag="v${sqlite:+$sqlite-v}$version"
if grep -qxF "$tag" <<< "$tags"; then
info "$tag is published"
continue
fi
previous=
while read -r older; do
! newer "$sqlite" "$older" || previous="$older"
done < <(with_version "$version" <<< "$tags" | sed -nE 's/^v(.*)-v.*/\1/p')
added+="$tag"$'\n'
releases+="$(jq -nc --arg name "$name" --arg version "$sqlite" --arg image "$version" --arg tag "$tag" --arg previous "$previous" \
'{$name, $version, $image, $tag, $previous}')"$'\n'
done < <(upstream_lines)
moves="$(moved_tags "$tags" "$tags"$'\n'"$added")"
for tag in $added; do
listed="$(moved_to "$tag")"
ok "$BOLD$tag$RESET is new, a push publishes it${listed:+ and moves $listed}"
done
awk 'NF { print $2 }' <<< "$moves" | sort -u | while read -r tag; do
grep -qxF "$tag" <<< "$added" || info "a push moves $(moved_to "$tag") to $tag"
done
}
check_unreleased() {
local count
[ -n "$releases" ] && [ -n "$earlier" ] || return 0
count="$(git rev-list --no-merges "$earlier..HEAD" -- src | grep -cvxF -f <(git rev-list "$earlier..HEAD" -- "$UPSTREAM_FILE") || true)"
[ "$count" = 0 ] || die "$count commit(s) since $earlier change src/ and would ship as $version, release them first with scripts/release.sh"
}
print_upcoming_versions() {
local base stable upcoming
[ -z "$releases" ] || return 0
if [ "$channel" = stable ]; then
base="$version"
! newer "$last" "$base" || base="$last"
case "$branch" in
main) upcoming="$(next_version "$base" patch), $(next_version "$base" minor) or $(next_version "$base" major)" ;;
v*.*.x) upcoming="$(next_version "$base" patch)" ;;
*) upcoming="$(next_version "$base" patch) or $(next_version "$base" minor)" ;;
esac
[ "$base" != 0.0.0 ] || upcoming="$(next_version "$base" minor) or $(next_version "$base" major)"
elif [ -n "$line" ]; then
upcoming="a prerelease of $line.0"
else
stable="$(release_tags --merged HEAD | tag_versions | awk '!/-/' | newest)"
stable="${stable:-$(artifact_version stable)}"
upcoming="a prerelease of $(next_version "$stable" minor) or $(next_version "$stable" major)"
fi
info "the upcoming version on $branch is $upcoming, scripts/release.sh sets it"
}
print_releases() {
[ "$OPTION" = --release ] || return 0
jq -sc . <<< "$releases"
}
select_newest_stable() {
stable="$(tag_versions <<< "$tags" | awk '!/-/' | newest)"
stable="${stable:-$(artifact_version stable)}"
}
select_preview_base() {
version="$(artifact_version preview <(git show "$revision:$VERSION_FILE"))"
if [ -n "$line" ]; then
base="$line.0-alpha.0"
[[ "$version" != "$line.0-"* ]] || base="$version"
elif newer "$version" "$stable"; then
base="$version"
[[ "$version" == *-* ]] || base="$version-alpha.0"
else
base="$(next_version "$stable" minor)-alpha.0"
fi
}
artifact_version() {
jq -r --arg channel "$1" --arg artifact "$ARTIFACT" '.[$channel][$artifact] // empty' "${2:-$VERSION_FILE}"
}
upstream_lines() {
if [ -z "$UPSTREAM" ]; then
echo latest
return
fi
jq -r --arg upstream "$UPSTREAM" '.[$upstream] | to_entries[] | "\(.key) \(.value.version)"' "${1:-$UPSTREAM_FILE}"
}
next_version() {
local major minor patch
IFS=. read -r major minor patch <<< "${1%%-*}"
while true; do
case "$2" in
patch) patch=$((patch + 1)) ;;
minor) minor=$((minor + 1)) patch=0 ;;
major) major=$((major + 1)) minor=0 patch=0 ;;
esac
taken "$major.$minor.$patch" || break
done
echo "$major.$minor.$patch"
}
taken() {
local tag
for tag in $(release_tags "*v$1"); do
holder="$tag, which $branch does not contain"
git merge-base --is-ancestor "$tag" HEAD || return 0
done
[ "$channel" = preview ] && in_progress "$1"
}
in_progress() {
local tag release="release/v${1%.0}"
if [ "$release" != "$branch" ] && git rev-parse --quiet --verify "origin/$release" > /dev/null; then
holder="$release"
[ "$channel" = stable ] || return 0
git merge-base --is-ancestor "origin/$release" HEAD || return 0
fi
for tag in $(release_tags "*v$1-*"); do
holder="$tag, which $branch does not contain"
git merge-base --is-ancestor "$tag" HEAD || return 0
done
return 1
}
moved_tags() {
moving_tags <<< "$2" | grep -vxF -f <(moving_tags <<< "$1") || true
}
moving_tags() {
local tags releases latest preview base image sqlite prefix seen=" "
tags="$(cat)"
releases="$(sed -nE 's/^v((.*)-v)?([^-]*)$/\3 \2/p' <<< "$tags" | sort -k1,1rV -k2,2rV)"
latest="$(awk 'NR == 1 && NF { print "v" ($2 == "" ? "" : $2 "-v") $1 }' <<< "$releases")"
preview="$latest"
while read -r base; do
if newer "$base" "${latest##*v}"; then
preview="$(tag_versions <<< "$tags" | grep "^$base-" | newest)"
preview="$(with_version "$preview" <<< "$tags" | sort -V | tail -n 1)"
break
fi
done < <(tag_versions <<< "$tags" | sed -n 's/-.*//p' | sort -uV)
[ -z "$latest" ] || echo "latest $latest"
[ -z "$preview" ] || echo "preview $preview"
while read -r image sqlite; do
prefix="$sqlite"
while [ -n "$prefix" ] && [[ "$seen" != *" $prefix "* ]]; do
echo "$prefix v$sqlite-v$image"
seen+="$prefix "
[[ "$prefix" == *.* ]] || break
prefix="${prefix%.*}"
done
done <<< "$releases"
}
release_tags() {
git tag --list "$@" | filter_release_tags
}
filter_release_tags() {
grep -E "^v${UPSTREAM:+[^-]+-v}[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" || true
}
tag_versions() {
sed 's/.*v//'
}
with_version() {
awk -v version="$1" '{ tagged = $0; sub(/.*v/, "", tagged) } tagged == version'
}
moved_to() {
awk -v tag="$1" '$2 == tag { print $1 }' <<< "$moves" | paste -sd , - | sed 's/,/, /g; s/\(.*\), /\1 and /'
}
newer() {
[ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | newest)" = "$1" ]
}
newest() {
sed 's/-/~/' | sort -V | sed 's/~/-/' | tail -n 1
}
main
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
set -euo pipefail
OPTION="$*"
VERSION_FILE=release.json
ARTIFACT=coolify
UPSTREAM=
BREAKING_CHANGE='^([a-z]+(\([^)]*\))?!|BREAKING[ -]CHANGE): '
WORK_DIR="$(mktemp -d)"
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; }
info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
warn() { printf ' %s⚠%s %s\n' "$YELLOW" "$RESET" "$*"; }
die() {
printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
confirm() {
local answer
printf '\n %s [y/N] ' "$*"
read -r answer
[[ "$answer" =~ ^[Yy]$ ]]
}
hint() { printf ' %-26s%s%s%s\n' "$1" "$DIM" "$2" "$RESET"; }
cd "$(dirname "$0")/.."
trap 'rm -rf "$WORK_DIR"; tput cnorm 2> /dev/null || true' EXIT
main() {
[ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
check_repository
read_versions
read_changes
select_stage
select_version
heading "Releasing v$new_version"
update_changelog
update_version
commit
push
}
check_repository() {
local counts behind ahead entries
heading "Checking the repository"
git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
branch="$(git symbolic-ref --quiet --short HEAD)" || die "HEAD is detached, expected a branch"
[[ "$branch" =~ ^(main|next|v[0-9]+(\.[0-9]+)?\.x|release/v([0-9]+\.[0-9]+))$ ]] \
|| die "$branch releases nothing"
line="${BASH_REMATCH[3]}"
channel=stable
[[ ! "$branch" =~ ^(next|release/) ]] || channel=preview
[ -z "$(git status --porcelain)" ] || die "the working tree is not clean, commit or stash first"
[ "$(git rev-parse --is-shallow-repository)" = false ] || die "the clone is shallow, run git fetch --unshallow first"
git fetch --quiet --prune --tags origin || die "could not fetch origin"
counts="$(git rev-list --left-right --count "origin/$branch...HEAD" 2> /dev/null)" || die "origin has no branch $branch, push it first"
read -r behind ahead <<< "$counts"
[ "$behind$ahead" = 00 ] || die "$branch is $ahead commit(s) ahead of and $behind behind origin/$branch, expected it in sync"
ok "$branch is clean and in sync with origin/$branch"
entries="$(awk '/^## \[Unreleased\]$/ { inside = 1; next } /^## / { inside = 0 } inside && /^- / { count++ } END { print count + 0 }' CHANGELOG.md)"
[ "$entries" != 0 ] || die "CHANGELOG.md has no entries under ## [Unreleased], describe the changes first"
ok "CHANGELOG.md lists $BOLD$entries$RESET change(s) under [Unreleased], the notes of this release"
}
read_versions() {
local released
version="$(artifact_version "$channel")"
released="$(artifact_version stable)"
[ -n "$version" ] && [ -n "$released" ] || die "$VERSION_FILE has no versions of $ARTIFACT"
stable="$(release_tags --merged HEAD | tag_versions | awk '!/-/' | sort -V | tail -n 1)"
stable="${stable:-$released}"
! newer "$released" "$stable" || die "$VERSION_FILE sets the stable version $released, $branch released $stable last, publish $released first"
ok "$VERSION_FILE sets the $channel version $BOLD$version$RESET"
}
read_changes() {
local tag range hash subject
tag="$(git describe --tags --abbrev=0 --match "v${UPSTREAM:+*-v}$stable" 2> /dev/null)" || true
range="${tag:+$tag..}HEAD"
required="patch"
if [ -n "$(git log -E --grep="$BREAKING_CHANGE" --format=%h "$range")" ]; then
required=major
[ "${stable%%.*}" != 0 ] || required=minor
elif git log --format=%s "$range" | awk '/^feat(\([^)]*\))?!?: / && !/^feat\(release\): bump / { found = 1 } END { exit !found }'; then
required=minor
fi
info "the commits since ${tag:-the first commit} need a $BOLD$required$RESET release"
git log -E --grep="$BREAKING_CHANGE" --format='%h %s' "$range" | while read -r hash subject; do
warn "$DIM$hash$RESET $subject is a breaking change"
done
}
select_stage() {
local stages=(alpha beta rc) selected=0 i
stage=
[ "$channel" = preview ] || return 0
for i in "${!stages[@]}"; do
[[ "$version" != *-"${stages[$i]}".* ]] || selected="$i"
done
heading "Select the stage"
stage="${stages[$(menu "$selected" "${stages[@]}")]}"
}
select_version() {
local bases=() base candidate options=() selected=0
if [ -n "$line" ]; then
bases=("$line.0")
elif [ "$channel" = preview ]; then
bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
[ "$required" != major ] || selected=1
if [[ "$version" == *-* ]] && newer "${version%%-*}" "$stable"; then
if taken "${version%%-*}"; then
info "${version%%-*} is taken by $holder"
else
bases=("${version%%-*}" "${bases[@]}")
selected=0
fi
fi
elif [ "$stable" = 0.0.0 ]; then
bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
else
bases=("$(next_version "$stable" patch)")
[[ "$branch" == v*.*.x ]] || bases+=("$(next_version "$stable" minor)")
[ "$branch" != main ] || bases+=("$(next_version "$stable" major)")
case "$required" in
minor) selected=1 ;;
major) selected=2 ;;
esac
fi
for base in "${bases[@]}"; do
candidate="$base"
if [ -n "$stage" ]; then
candidate="$base-$stage.1"
[ "$base-$stage" != "${version%.*}" ] || candidate="$base-$stage.$((${version##*.} + 1))"
fi
if newer "$candidate" "$version" && [[ " ${options[*]:-} " != *" $candidate "* ]]; then
options+=("$candidate")
! in_progress "$base" || warn "$base is in progress, held by $holder"
fi
done
[ -n "${options[*]:-}" ] || die "$branch is at $version, no $stage prerelease follows it"
[ "$selected" -lt "${#options[@]}" ] || selected=$((${#options[@]} - 1))
heading "Select the version"
new_version="${options[$(menu "$selected" "${options[@]/#/v}")]}"
}
update_changelog() {
local today
if [ "$channel" = preview ]; then
info "CHANGELOG.md keeps the entries under [Unreleased] until the stable release dates them"
return
fi
today="$(date +%Y-%m-%d)"
awk -v heading="## [v$new_version] - $today" '
/^#/ && NR > 1 && previous != "" { print "" }
{ print; previous = $0 }
/^## \[Unreleased\]$/ { print ""; print heading; previous = heading }
' CHANGELOG.md > "$WORK_DIR/CHANGELOG.md"
cp "$WORK_DIR/CHANGELOG.md" CHANGELOG.md
ok "CHANGELOG.md dates the unreleased entries as ${BOLD}v$new_version$RESET, $today"
}
update_version() {
jq --tab --arg channel "$channel" --arg artifact "$ARTIFACT" --arg version "$new_version" '.[$channel][$artifact] = $version' "$VERSION_FILE" > "$WORK_DIR/release.json"
cp "$WORK_DIR/release.json" "$VERSION_FILE"
ok "$VERSION_FILE sets the $channel version $version → $BOLD$new_version$RESET"
}
commit() {
git add "$VERSION_FILE" CHANGELOG.md
git commit --quiet -S --message "feat(release): bump $ARTIFACT version to v$new_version"
ok "signed commit $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
}
push() {
local runs
if confirm "Push to origin/$branch?"; then
git push --quiet origin "$branch"
ok "pushed ${BOLD}v$new_version$RESET to origin/$branch"
runs="$(git remote get-url origin | sed -E 's#^(ssh://)?git@github\.com[:/]#https://github.com/#; s#\.git$##')/actions/workflows/release.yaml?query=branch%3A$branch"
info "the release workflow runs at $runs"
else
info "not pushed"
heading "Next"
hint "git push" "push the release commit later"
hint "scripts/undo-release.sh" "undo the release commit"
fi
}
artifact_version() {
jq -r --arg channel "$1" --arg artifact "$ARTIFACT" '.[$channel][$artifact] // empty' "$VERSION_FILE"
}
release_tags() {
git tag --list "$@" | grep -E "^v${UPSTREAM:+[^-]+-v}[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" || true
}
tag_versions() {
sed 's/.*v//'
}
next_version() {
local major minor patch
IFS=. read -r major minor patch <<< "${1%%-*}"
while true; do
case "$2" in
patch) patch=$((patch + 1)) ;;
minor) minor=$((minor + 1)) patch=0 ;;
major) major=$((major + 1)) minor=0 patch=0 ;;
esac
taken "$major.$minor.$patch" || break
done
echo "$major.$minor.$patch"
}
taken() {
local tag
for tag in $(release_tags "*v$1"); do
holder="$tag, which $branch does not contain"
git merge-base --is-ancestor "$tag" HEAD || return 0
done
[ "$channel" = preview ] && in_progress "$1"
}
in_progress() {
local tag release="release/v${1%.0}"
if [ "$release" != "$branch" ] && git rev-parse --quiet --verify "origin/$release" > /dev/null; then
holder="$release"
[ "$channel" = stable ] || return 0
git merge-base --is-ancestor "origin/$release" HEAD || return 0
fi
for tag in $(release_tags "*v$1-*"); do
holder="$tag, which $branch does not contain"
git merge-base --is-ancestor "$tag" HEAD || return 0
done
return 1
}
newer() {
[ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sed 's/-/~/' | sort -V | tail -n 1 | sed 's/~/-/')" = "$1" ]
}
menu() {
local selected="$1" options key i
shift
options=("$@")
tput civis >&2 2> /dev/null || true
while true; do
for i in "${!options[@]}"; do
if [ "$i" = "$selected" ]; then
printf ' %s❯ %s%s\n' "$GREEN" "${options[$i]}" "$RESET" >&2
else
printf ' %s %s%s\n' "$DIM" "${options[$i]}" "$RESET" >&2
fi
done
IFS= read -rsn1 key
if [ "$key" = $'\x1b' ]; then
IFS= read -rsn2 key
if [ "$key" = '[A' ] && [ "$selected" -gt 0 ]; then
selected=$((selected - 1))
elif [ "$key" = '[B' ] && [ "$selected" -lt $((${#options[@]} - 1)) ]; then
selected=$((selected + 1))
fi
elif [ -z "$key" ]; then
break
fi
printf '\033[%dA\033[J' "${#options[@]}" >&2
done
tput cnorm >&2 2> /dev/null || true
echo "$selected"
}
main
-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]+)?|--nightly [0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+\.[0-9]{8}\.[0-9]+|--purge)$ ]] \
|| die "'$OPTION' is not an option, expected --stable, --preview or --nightly 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 reaches the nightly channel only"
exit 0
fi
info "syncing $channel_url to Coolify $BOLD$VERSION$RESET"
}
write_versions() {
jq --indent 4 --arg version "$VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > "$WORK_DIR/versions.json"
ok "versions.json names Coolify $VERSION and the helper, Sentinel and Traefik versions of upstream.json"
}
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, nightly} | 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
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
OPTION="$*"
VERSION_FILE=release.json
BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; }
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
}
confirm() {
local answer
printf '\n %s [y/N] ' "$*"
read -r answer
[[ "$answer" =~ ^[Yy]$ ]]
}
cd "$(dirname "$0")/.."
main() {
[ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
check_repository
heading "Undoing $(git log -1 --format='%h %s')"
info "resets the commit and restores $VERSION_FILE and CHANGELOG.md"
confirm "Continue?" || die "aborted, nothing changed"
undo_commit
}
check_repository() {
local branch
git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
branch="$(git rev-parse --abbrev-ref HEAD)"
git diff --quiet HEAD~1 HEAD -- . ":(exclude)$VERSION_FILE" ":(exclude)CHANGELOG.md" || die "the last commit changes more than $VERSION_FILE and CHANGELOG.md, expected a release commit"
[ "$(versions_at HEAD~1)" != "$(versions_at HEAD)" ] || die "the last commit keeps the versions in $VERSION_FILE at $(versions_at HEAD), expected a release commit"
[ -z "$(git status --porcelain -- "$VERSION_FILE" CHANGELOG.md)" ] || die "$VERSION_FILE or CHANGELOG.md has uncommitted changes, commit or stash them first"
git fetch --quiet origin || die "could not fetch origin"
[ "$(git rev-list --count "origin/$branch..HEAD")" -ge 1 ] || die "the release commit is on origin/$branch already, nothing to undo locally"
}
undo_commit() {
git reset --quiet --soft HEAD~1
git restore --staged --worktree "$VERSION_FILE" CHANGELOG.md
ok "reset to $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
ok "$VERSION_FILE is back to $BOLD$(versions_at HEAD)$RESET, the entries are back under [Unreleased]"
}
versions_at() {
git show "$1:$VERSION_FILE" | jq -r '[paths(scalars) as $path | "\($path | join(".")) \(getpath($path))"] | join(", ")'
}
main
+1 -1
View File
@@ -318,7 +318,7 @@ log "All images pulled successfully"
echo " All images pulled successfully."
set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"
set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"
sed -i '/^COOLIFY_VERSION=/d' "$ENV_FILE"
log_section "Step 4/6: Stopping and restarting containers"
write_status "4" "Stopping containers"
@@ -55,8 +55,8 @@ it('notification settings have traefik_outdated fields', function () {
expect($team->webhookNotificationSettings)->toHaveKey('traefik_outdated_webhook_notifications');
});
it('versions.json contains traefik branches with patch versions', function () {
$versionsPath = base_path('versions.json');
it('upstream.json contains traefik branches with patch versions', function () {
$versionsPath = base_path('upstream.json');
expect(File::exists($versionsPath))->toBeTrue();
$versions = json_decode(File::get($versionsPath), true);
@@ -14,7 +14,6 @@ it('persists images in stable and nightly production compose files', function (s
->toContain('/data/coolify/images:/var/www/html/storage/app/images');
})->with([
'stable' => 'docker-compose.prod.yml',
'nightly' => 'other/nightly/docker-compose.prod.yml',
]);
it('persists images in the Windows compose file', function () {
@@ -49,9 +49,6 @@ it('detects the current Coolify image tag from the running container image', fun
expect($exitCode)->toBe(0)
->and($output)->toBe($expectedTag);
})->with([
'nightly standard registry image' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
'nightly registry with port' => ['other/nightly/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
'nightly digest suffix' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
'scripts standard registry image' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
'scripts registry with port' => ['scripts/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
'scripts digest suffix' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
@@ -66,6 +63,5 @@ it('passes the preserved Coolify image tag to docker compose when starting the s
->and($output)->toBe('')
->and($latestImage)->toBe('4.0.0-beta.420');
})->with([
'nightly script' => 'other/nightly/upgrade-postgres.sh',
'release script' => 'scripts/upgrade-postgres.sh',
]);
@@ -70,7 +70,6 @@ it('rewrites the legacy realtime backend host during upgrades', function (string
->toContain('set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('includes Reverb but not the terminal server in the Coolify container healthcheck', function (string $composeFile) {
@@ -82,9 +81,7 @@ it('includes Reverb but not the terminal server in the Coolify container healthc
->not->toContain('6002/ready');
})->with([
'production compose' => ['docker-compose.prod.yml'],
'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
'windows compose' => ['docker-compose.windows.yml'],
'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('removes the legacy realtime container during upgrades', function (string $script) {
@@ -92,7 +89,6 @@ it('removes the legacy realtime container during upgrades', function (string $sc
->toContain('for container in coolify coolify-db coolify-redis coolify-realtime; do');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('runs Reverb and terminal websocket services inside the Coolify containers', function (string $dockerfile, string $dependencyService) {
@@ -195,11 +191,6 @@ it('uses Pusher environment keys for self-hosted Reverb compatibility', function
'docker-compose.windows.yml',
'scripts/install.sh',
'scripts/upgrade.sh',
'other/nightly/.env.production',
'other/nightly/docker-compose.prod.yml',
'other/nightly/docker-compose.windows.yml',
'other/nightly/install.sh',
'other/nightly/upgrade.sh',
];
foreach ($files as $file) {
@@ -230,9 +221,7 @@ it('does not use the browser websocket port as the Docker host port', function (
->not->toContain('"${PUSHER_PORT:-6001}:6001"');
})->with([
'production compose' => ['docker-compose.prod.yml'],
'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
'windows compose' => ['docker-compose.windows.yml'],
'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('preserves existing public websocket port overrides during install and upgrade', function (string $script) {
@@ -244,28 +233,22 @@ it('preserves existing public websocket port overrides during install and upgrad
})->with([
'install script' => ['scripts/install.sh'],
'upgrade script' => ['scripts/upgrade.sh'],
'nightly install script' => ['other/nightly/install.sh'],
'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('stops publishing or preserving the obsolete realtime image', function () {
$productionInstallScript = file_get_contents(base_path('scripts/install.sh'));
$nightlyInstallScript = file_get_contents(base_path('other/nightly/install.sh'));
expect(file_get_contents(config_path('constants.php')))
->not->toContain('realtime_version')
->not->toContain('realtime_image')
->and(file_get_contents(app_path('Actions/Server/CleanupDocker.php')))
->not->toContain('realtimeImage')
->and(file_get_contents(base_path('versions.json')))
->and(file_get_contents(base_path('upstream.json')))
->not->toContain('"realtime"')
->and(is_dir(base_path('docker/coolify-realtime')))->toBeFalse()
->and(file_exists(base_path('.github/workflows/coolify-realtime.yml')))->toBeFalse()
->and(file_exists(base_path('.github/workflows/coolify-realtime-next.yml')))->toBeFalse()
->and(file_get_contents(base_path('.github/workflows/coolify-next-build.yml')))->not->toContain('coolify-realtime')
->and($productionInstallScript)->not->toContain('LATEST_REALTIME_VERSION')
->not->toContain('| Realtime')
->and($nightlyInstallScript)->not->toContain('LATEST_REALTIME_VERSION')
->not->toContain('| Realtime');
});
@@ -279,7 +262,6 @@ it('keeps 4.3.23 realtime setups working with the production compose files', fun
->and(implode("\n", [...$coolify['ports'], ...$coolify['expose']]))->not->toContain('TERMINAL_PORT');
})->with([
'production compose' => ['docker-compose.prod.yml'],
'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
]);
it('keeps TERMINAL_PORT browser-only in the Windows compose files', function (string $composeFile) {
@@ -289,7 +271,6 @@ it('keeps TERMINAL_PORT browser-only in the Windows compose files', function (st
->and(implode("\n", [...$coolify['ports'], ...$coolify['expose']]))->not->toContain('TERMINAL_PORT');
})->with([
'windows compose' => ['docker-compose.windows.yml'],
'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('resolves the browser websocket port like 4.3.23', function (string $pageUrl, ?string $pusherPort, ?string $expectedPort) {
@@ -336,7 +317,6 @@ it('accepts a soketi override from 4.3.23 in docker-compose.custom.yml with zero
})->with([
'production compose, partial override' => ['docker-compose.prod.yml', "services:\n soketi:\n environment:\n SOKETI_DEBUG: 'true'\n ports:\n - '6001:6001'\n"],
'production compose, full service' => ['docker-compose.prod.yml', "services:\n soketi:\n image: quay.io/soketi/soketi:1.6-16-alpine\n container_name: coolify-realtime\n ports:\n - '6001:6001'\n"],
'nightly production compose, partial override' => ['other/nightly/docker-compose.prod.yml', "services:\n soketi:\n environment:\n SOKETI_DEBUG: 'true'\n"],
]);
it('does not pull an extra image for the soketi placeholder', function (string $composeFile) {
@@ -363,5 +343,4 @@ it('does not pull an extra image for the soketi placeholder', function (string $
->toBe(['postgres:15-alpine', 'redis:7-alpine', 'registry.example.com/coollabsio/coolify:4.5.1']);
})->with([
'production compose' => ['docker-compose.prod.yml'],
'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
]);
-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']]]],
]);
@@ -38,5 +38,4 @@ it('removes only previous Coolify keys from authorized_keys', function (string $
exec('rm -rf '.escapeshellarg($home));
})->with([
'stable install script' => ['scripts/install.sh'],
'nightly install script' => ['other/nightly/install.sh'],
]);
@@ -46,7 +46,6 @@ function installScriptFileMode(string $path): string
$installScripts = [
'stable install' => ['scripts/install.sh', ['source', 'ssh', 'images']],
'nightly install' => ['other/nightly/install.sh', ['source', 'ssh', 'images']],
];
$resourceDirectories = ['applications', 'databases', 'backups', 'services', 'proxy', 'sentinel'];
@@ -56,7 +56,7 @@ function runInstallScriptDockerDaemonStep(string $scriptPath, ?string $daemonJso
return ['config' => $config, 'restarted' => str_contains($output, 'RESTARTED'), 'output' => $output];
}
$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh'];
$installScripts = ['stable install' => 'scripts/install.sh'];
it('does not create daemon.json or restart Docker when containers are running', function (string $scriptPath) {
$result = runInstallScriptDockerDaemonStep($scriptPath, null, runningContainers: 30);
@@ -22,7 +22,3 @@ function expectRockyInstallScriptToUseRhelRepo(string $path): void
it('uses the rocky linux documented docker install flow in the stable install script', function () {
expectRockyInstallScriptToUseRhelRepo('scripts/install.sh');
});
it('uses the rocky linux documented docker install flow in the nightly install script', function () {
expectRockyInstallScriptToUseRhelRepo('other/nightly/install.sh');
});
+1 -1
View File
@@ -41,7 +41,7 @@ function writeRootUserCredentialsWithInstallScript(string $scriptPath, array $cr
return Dotenv::parse($contents);
}
$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh'];
$installScripts = ['stable install' => 'scripts/install.sh'];
$credentials = [
'username with a space' => ['ROOT_USERNAME', 'Coolify Lab'],
@@ -25,7 +25,6 @@ it('shows every installer step with the terminal UI and sends command output to
->and(preg_match_all('/^step_done(?!\()/m', $script))->toBe(9);
})->with([
'stable install script' => ['scripts/install.sh'],
'nightly install script' => ['other/nightly/install.sh'],
]);
it('renders aligned step lines, warnings and failures without a terminal', function (string $path) {
@@ -68,5 +67,4 @@ it('renders aligned step lines, warnings and failures without a terminal', funct
->and(array_map(fn (string $line) => mb_strlen($line), $stepLines))->each->toBe(80);
})->with([
'stable install script' => ['scripts/install.sh'],
'nightly install script' => ['other/nightly/install.sh'],
]);
@@ -11,5 +11,4 @@ it('allows Coolify enough time to start before counting healthcheck failures', f
->retries->toBe(24);
})->with([
'stable' => 'docker-compose.prod.yml',
'nightly' => 'other/nightly/docker-compose.prod.yml',
]);
+10 -156
View File
@@ -1,72 +1,12 @@
<?php
it('publishes main builds under rolling and commit sha tags with a traceable internal version', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-main-build.yml');
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile');
$constants = file_get_contents(dirname(__DIR__, 2).'/config/constants.php');
$versions = json_decode(file_get_contents(dirname(__DIR__, 2).'/versions.json'), true, flags: JSON_THROW_ON_ERROR);
$nightlyVersions = json_decode(file_get_contents(dirname(__DIR__, 2).'/other/nightly/versions.json'), true, flags: JSON_THROW_ON_ERROR);
it('reads the Coolify and helper versions by key from the versions.json of the CDN in install.sh', function () {
$versions = shell_exec("jq --indent 4 --arg version 4.6.0-beta.1 '{coolify: ({v4: {\$version}} + del(.traefik)), traefik}' ".escapeshellarg(dirname(__DIR__, 2).'/upstream.json'));
$parse = implode("\n", preg_grep('/^LATEST_(HELPER_)?VERSION=\$\(echo "\$VERSIONS_JSON"/', file(dirname(__DIR__, 2).'/scripts/install.sh')));
$output = shell_exec('VERSIONS_JSON='.escapeshellarg($versions).' bash -c '.escapeshellarg($parse."\necho \"\$LATEST_VERSION \$LATEST_HELPER_VERSION\""));
$helper = json_decode(file_get_contents(dirname(__DIR__, 2).'/upstream.json'), true)['helper']['version'];
expect($workflow)
->toContain('name: Build Coolify Main')
->toContain('branches: ["main"]')
->not->toContain('v4.x')
->toContain('group: coolify-main-build')
->toContain('cancel-in-progress: false')
->toContain('IMAGE_NAME }}:main-${{ matrix.arch }}')
->toContain('--tag "${IMAGE}:main"')
->toContain('short_sha=${GITHUB_SHA::7}')
->toContain('SHA: ${{ needs.build-push.outputs.short_sha }}')
->toContain('--tag "${IMAGE}:sha-${SHA}"')
->not->toContain('sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}')
->toContain('org.opencontainers.image.revision=${{ github.sha }}')
->toContain('php bootstrap/getVersion.php')
->toContain('version=${BASE_VERSION}-dev.${GITHUB_SHA::9}')
->toContain('COOLIFY_VERSION=${{ steps.version.outputs.version }}')
->toContain('sarisia/actions-status-discord@v1')
->toContain('webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}')
->not->toContain('IMAGE_NAME }}:latest')
->and($dockerfile)
->toContain('ARG COOLIFY_VERSION')
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
->and($constants)
->toContain("'version' => env('COOLIFY_VERSION') ?: '{$versions['coolify']['v4']['version']}'")
->and($versions['coolify']['nightly']['version'])->toMatch('/^\d+\.\d+-rc\.\d+$/')
->and(version_compare($versions['coolify']['v4']['version'], $versions['coolify']['nightly']['version'], '<'))->toBeTrue()
->and($nightlyVersions)->toBe($versions);
});
it('orders a maintenance development build before its stable release', function () {
expect(version_compare('4.3.2-dev.d64cbda3e', '4.3.2', '<'))->toBeTrue()
->and(version_compare('4.3.2', '4.3.2-dev.d64cbda3e', '>'))->toBeTrue();
});
it('orders rolling and exact release candidates before the stable release', function () {
expect(version_compare('4.4-rc.1.d64cbda', '4.4-rc.1', '<'))->toBeTrue()
->and(version_compare('4.4-rc.1', '4.4-rc.2', '<'))->toBeTrue()
->and(version_compare('4.4-rc.2', '4.4.0', '<'))->toBeTrue();
});
it('requires a reviewed draft release before building a stable version', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
expect($workflow)
->toContain('name: Release Coolify Stable')
->toContain('run-name: ${{ inputs.tag }}')
->toContain('workflow_dispatch:')
->toContain('tag:')
->toContain("github.ref_name != 'main'")
->toContain('github.paginate(github.rest.repos.listReleases')
->toContain('release.draft')
->toContain('release.prerelease')
->toContain('release.body?.trim()')
->toContain('bootstrap/getVersion.php')
->toContain('target_commitish: context.sha')
->toContain('tag_name: process.env.TAG_NAME')
->toContain('actions/github-script@v8')
->not->toContain('actions/github-script@v7')
->not->toContain('environment: production-release')
->not->toContain('generate-notes');
expect(trim($output))->toBe("4.6.0-beta.1 {$helper}");
});
it('runs support image workflows from main', function (string $workflowFile) {
@@ -112,93 +52,6 @@ it('publishes the testing host only to Docker Hub', function () {
->toContain('dockerfile: ./docker/testing-host/Dockerfile');
});
it('generates the production changelog from main', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml');
expect($workflow)
->toContain('branches: [ main ]')
->not->toContain('v4.x');
});
it('publishes traceable rolling builds from next without creating an exact rc tag', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-next-build.yml');
expect($workflow)
->toContain('name: Build Coolify Next')
->toContain('branches: [next]')
->toContain('group: coolify-next-build')
->toContain("jq -r '.coolify.nightly.version' versions.json")
->toContain('VERSION="${RC_VERSION}.${SHORT_SHA}"')
->toContain('COOLIFY_VERSION=${{ needs.prepare.outputs.version }}')
->toContain('--tag "${IMAGE}:sha-${SHA}"')
->toContain('--tag "${IMAGE}:${VERSION}"')
->toContain('--tag "${IMAGE}:next"')
->toContain('sarisia/actions-status-discord@v1')
->toContain('webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}')
->not->toContain('--tag "${IMAGE}:${RC_VERSION}"')
->not->toContain('--tag "${IMAGE}:latest"');
});
it('requires a reviewed draft prerelease before publishing an exact rc', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-rc-release.yml');
expect($workflow)
->toContain('name: Release Coolify RC')
->toContain('run-name: ${{ inputs.tag }}')
->toContain("github.ref != 'refs/heads/next'")
->toContain('group: coolify-rc-release')
->toContain('^v[0-9]+\\.[0-9]+-rc\\.[0-9]+$')
->toContain("jq -r '.coolify.nightly.version' versions.json")
->toContain('release.draft')
->toContain('!release.prerelease')
->toContain('release.body?.trim()')
->toContain('revalidate:')
->toMatch('/revalidate:.*?permissions:\s+contents: write/s')
->toContain('needs: [validate, build, revalidate]')
->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
->toContain('--tag "${IMAGE}:${VERSION}"')
->toContain('--tag "${IMAGE}:next"')
->toContain('prerelease: true')
->toContain('actions/github-script@v8')
->not->toContain('--tag "${IMAGE}:latest"')
->not->toContain('environment:');
});
it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () {
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
expect($workflow)
->toContain('docker/build-push-action@v6')
->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
->toContain('release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}')
->toContain('--tag "${IMAGE}:${VERSION}"')
->toContain('--tag "${IMAGE}:latest"')
->toContain('github.rest.repos.getRelease')
->toContain('release.target_commitish !== context.sha')
->toContain('release_id: Number(process.env.RELEASE_ID)')
->toContain('draft: false')
->toContain('permissions: {}')
->not->toContain('sarisia/actions-status-discord@v1')
->not->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"');
});
it('documents the production, rc, and hotfix release flows', function () {
$releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
expect($releaseGuide)
->toContain('| `main` | Latest production source |')
->toContain('| `next` | Feature integration and RC releases |')
->toContain('| `hotfix/X.Y.Z` | Production fixes based on `main` |')
->toContain('feature/* → next → RC')
->toContain('next → main → stable release')
->toContain('main → hotfix/X.Y.Z → main → next')
->toContain('reviewed draft GitHub Release')
->toContain('workflows never edit or commit versions')
->toContain('Update the CDN only after the release is approved')
->not->toContain('`edge`')
->not->toContain('promotes the existing SHA image');
});
it('documents pull request targets for fixes and features', function () {
$contributingGuide = file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md');
@@ -223,12 +76,13 @@ it('guides issue authors to the correct contribution branch', function () {
->toContain('Small fixes should target `main`; larger changes should target `next`');
});
it('ships versions.json in the production image so version checks work right after an upgrade', function () {
it('writes versions.json in the production image from upstream.json so version checks work right after an upgrade', function () {
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile');
$dockerignore = file_get_contents(dirname(__DIR__, 2).'/.dockerignore');
expect($dockerfile)
->toContain('COPY --chown=www-data:www-data versions.json ./versions.json')
->toContain('COPY --chown=www-data:www-data release.json upstream.json ./')
->toContain('upstream.json > versions.json')
->and($dockerignore)
->not->toMatch('/^\/?versions\.json$/m');
->not->toMatch('/^\/?(release|upstream)\.json$/m');
});
+2 -10
View File
@@ -26,7 +26,6 @@ function assertBashSyntaxIsValid(string $path): void
it('ships postgres upgrade scripts with valid bash syntax', function () {
assertBashSyntaxIsValid('scripts/upgrade-postgres.sh');
assertBashSyntaxIsValid('other/nightly/upgrade-postgres.sh');
});
it('downloads postgres upgrade script during install and upgrade without auto-running it', function (string $path) {
@@ -39,9 +38,7 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
->not->toContain('bash /data/coolify/source/upgrade-postgres.sh');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
@@ -50,7 +47,6 @@ it('uses the selected registry url when extracting upgrade images', function (st
expect($script)->toContain('IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('persists the selected registry url during upgrades', function (string $path) {
@@ -59,10 +55,9 @@ it('persists the selected registry url during upgrades', function (string $path)
expect($script)->toContain('set_env_var "REGISTRY_URL" "$REGISTRY_URL"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('persists the target image and runtime version before recreating containers', function (string $path) {
it('persists the target image and lets the image set its version before recreating containers', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
if ($script === false) {
throw new RuntimeException("Unable to read {$path}");
@@ -78,7 +73,7 @@ it('persists the target image and runtime version before recreating containers',
};
$latestImagePosition = $position('set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"');
$coolifyVersionPosition = $position('set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"');
$coolifyVersionPosition = $position('sed -i \'/^COOLIFY_VERSION=/d\' "$ENV_FILE"');
$imagesPulledPosition = $position('log "All images pulled successfully"');
$composeUpPosition = $position('docker compose --env-file /data/coolify/source/.env');
@@ -88,7 +83,6 @@ it('persists the target image and runtime version before recreating containers',
->and($coolifyVersionPosition)->toBeLessThan($composeUpPosition);
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) {
@@ -102,7 +96,6 @@ it('uses the existing env registry url when old callers do not pass a registry a
->toContain('REGISTRY_URL="docker.io"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
@@ -114,7 +107,6 @@ it('keeps postgres upgrade compose override in future upgrade compose commands',
->toContain('Using PostgreSQL upgrade compose override');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses postgres 18 compatible mount path in generated override and restore container', function () {
@@ -46,18 +46,18 @@ it('reports an error when the new Coolify containers do not start', function (st
expect($result['exitCode'])->toBe(1)
->and($result['status'])->toStartWith('error|')
->toContain('upgrade log');
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('completes the upgrade when the coolify container runs but is not healthy within the wait timeout', function (string $script) {
$result = runUpgradeScriptContainerStart($script, composeExitCode: 1, coolifyRunning: true);
expect($result['exitCode'])->toBe(0)
->and($result['status'])->toBe('');
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('completes the upgrade when docker compose up succeeds', function (string $script) {
$result = runUpgradeScriptContainerStart($script, composeExitCode: 0, coolifyRunning: true);
expect($result['exitCode'])->toBe(0)
->and($result['status'])->toBe('');
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
@@ -64,19 +64,19 @@ it('stops the upgrade before any container changes when the coolify network has
->and($result['status'])->toStartWith('error|')
->toContain('fd00:1::1/64')
->toContain('systemctl restart docker');
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('keeps a readable coolify network', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: '172.19.0.1 fd00:1::1 ', createdIpv6Gateway: 'unused');
expect($result)->toBe(['exitCode' => 0, 'calls' => [], 'status' => '']);
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('creates the coolify network with IPv6', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: null, createdIpv6Gateway: 'fd00:1::1');
expect($result)->toBe(['exitCode' => 0, 'calls' => ['network create --attachable --ipv6 coolify'], 'status' => '']);
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('recreates a new coolify network without IPv6 when Docker reports its gateway with a prefix length', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: null, createdIpv6Gateway: 'fd00:1::1/64');
@@ -90,4 +90,4 @@ it('recreates a new coolify network without IPv6 when Docker reports its gateway
],
'status' => '',
]);
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
+2 -2
View File
@@ -35,8 +35,8 @@ function runUpgradeScriptPullImage(string $script, int $failures): array
it('retries a rate limited image pull until it succeeds', function (string $script) {
expect(runUpgradeScriptPullImage($script, failures: 2))->toBe(['exitCode' => 0, 'pulls' => 3]);
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
it('fails the image pull after five attempts', function (string $script) {
expect(runUpgradeScriptPullImage($script, failures: 10))->toBe(['exitCode' => 1, 'pulls' => 5]);
})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
})->with(['scripts/upgrade.sh']);
+1 -1
View File
@@ -33,7 +33,7 @@ function repairRootUserEnvWithUpgradeScript(string $scriptPath, string $initialE
return $contents;
}
$upgradeScripts = ['stable upgrade' => 'scripts/upgrade.sh', 'nightly upgrade' => 'other/nightly/upgrade.sh'];
$upgradeScripts = ['stable upgrade' => 'scripts/upgrade.sh'];
it('quotes unquoted root user credentials with spaces so that .env parses again', function (string $scriptPath) {
$env = repairRootUserEnvWithUpgradeScript($scriptPath, implode("\n", [
+23
View File
@@ -0,0 +1,23 @@
{
"helper": {
"version": "1.0.19"
},
"sentinel": {
"version": "1.1.3"
},
"sentinel-host": {
"version": "1.1.3",
"digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
},
"traefik": {
"v3.7": "3.7.16",
"v3.6": "3.6.25",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.60"
}
}
-31
View File
@@ -1,31 +0,0 @@
{
"coolify": {
"v4": {
"version": "4.5.4"
},
"nightly": {
"version": "4.6-rc.1"
},
"helper": {
"version": "1.0.19"
},
"sentinel": {
"version": "1.1.3"
},
"sentinel-host": {
"version": "1.1.3",
"digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
}
},
"traefik": {
"v3.7": "3.7.16",
"v3.6": "3.6.25",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.60"
}
}