1596 lines
86 KiB
JavaScript
1596 lines
86 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { afterEach, describe, it } from "vitest";
|
|
|
|
import { formatGiteaDeployWorkflowResult, verifyGiteaDeployWorkflow } from "./verify-gitea-deploy-workflow.mjs";
|
|
|
|
const VALID_WORKFLOW = `name: Deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
action:
|
|
description: "stage or promote"
|
|
required: true
|
|
default: "stage"
|
|
staged_sha:
|
|
description: "Full staged commit SHA to promote"
|
|
required: false
|
|
backup_confirmed:
|
|
description: "Set to true only after production backup or restore-point readiness is confirmed for promote"
|
|
required: false
|
|
default: "false"
|
|
|
|
concurrency:
|
|
group: boilerplate-deploy
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
deploy-preflight:
|
|
runs-on: toolctrl-ci
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
- name: Verify deploy wiring
|
|
run: |
|
|
node scripts/verify-gitea-deploy-workflow.mjs
|
|
node scripts/verify-deploy-readiness.mjs
|
|
node scripts/verify-docker-baseline.mjs
|
|
node scripts/verify-docker-non-root.mjs
|
|
node --check scripts/coolify-deploy.mjs
|
|
node --check scripts/coolify-ensure-worker-groups.mjs
|
|
bash -n scripts/coolify-prepull-images.sh
|
|
bash -n scripts/ci/install-cosign.sh
|
|
|
|
stage-or-promote:
|
|
runs-on: toolctrl-ci
|
|
needs: deploy-preflight
|
|
env:
|
|
REGISTRY: git.tool-ctrl.de
|
|
IMAGE_NAMESPACE: toolctrl-labs
|
|
IMAGE_PREFIX: boilerplate
|
|
SHA: \${{ gitea.sha }}
|
|
DISPATCH_ACTION: \${{ gitea.event.inputs.action }}
|
|
DISPATCH_STAGED_SHA: \${{ gitea.event.inputs.staged_sha }}
|
|
DISPATCH_BACKUP_CONFIRMED: \${{ gitea.event.inputs.backup_confirmed }}
|
|
steps:
|
|
- name: Resolve deployment action
|
|
run: |
|
|
REF="\${GITEA_REF:-\${{ gitea.ref }}}"
|
|
EVENT_NAME="\${GITEA_EVENT_NAME:-\${{ gitea.event_name }}}"
|
|
ACTION="\${DISPATCH_ACTION:-stage}"
|
|
STAGED_SHA="\${DISPATCH_STAGED_SHA:-}"
|
|
BACKUP_CONFIRMED="\${DISPATCH_BACKUP_CONFIRMED:-false}"
|
|
|
|
if [ "$EVENT_NAME" = "push" ] && [ "$REF" = "refs/heads/main" ]; then
|
|
DEPLOY_ACTION=stage
|
|
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$ACTION" = "stage" ]; then
|
|
if [ "$REF" != "refs/heads/main" ]; then
|
|
echo "Stage must be dispatched from refs/heads/main"
|
|
exit 1
|
|
fi
|
|
DEPLOY_ACTION=stage
|
|
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$ACTION" = "promote" ]; then
|
|
DEPLOY_ACTION=promote
|
|
if [ "$REF" != "refs/heads/main" ]; then
|
|
echo "Promote must be dispatched from refs/heads/main"
|
|
exit 1
|
|
fi
|
|
if ! printf '%s' "$STAGED_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
|
|
echo "Promote requires staged_sha as the full 40-character staged commit SHA"
|
|
exit 1
|
|
fi
|
|
if [ "$BACKUP_CONFIRMED" != "true" ]; then
|
|
echo "Promote requires backup_confirmed=true after production backup or restore-point readiness is confirmed"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo "Unsupported deployment action: event=$EVENT_NAME ref=$REF action=$ACTION"
|
|
exit 1
|
|
fi
|
|
|
|
if ! printf '%s' "$SHA" | grep -Eq '^[0-9a-f]{40}$'; then
|
|
echo "Gitea SHA must be a full 40-character commit SHA"
|
|
exit 1
|
|
fi
|
|
if [ "$DEPLOY_ACTION" = "promote" ]; then
|
|
IMAGE_TAG="$STAGED_SHA"
|
|
else
|
|
IMAGE_TAG="$SHA"
|
|
fi
|
|
{
|
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
|
echo "DEPLOY_ACTION=$DEPLOY_ACTION"
|
|
} >> "$GITHUB_ENV"
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
- name: Verify checkout revision
|
|
run: |
|
|
CHECKOUT_SHA="$(git rev-parse HEAD)"
|
|
if [ "$CHECKOUT_SHA" != "$SHA" ]; then
|
|
echo "Checked out revision must match the Gitea SHA"
|
|
exit 1
|
|
fi
|
|
- name: Login to Gitea Registry
|
|
run: echo "\${{ secrets.REGISTRY_PAT }}" | docker login "$REGISTRY" --username "\${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
- name: Verify staged promotion provenance
|
|
run: |
|
|
if [ "$DEPLOY_ACTION" != "promote" ]; then
|
|
echo "Staged promotion provenance skipped for staging"
|
|
exit 0
|
|
fi
|
|
git fetch --no-tags origin main
|
|
git cat-file -e "$IMAGE_TAG^{commit}"
|
|
git merge-base --is-ancestor "$IMAGE_TAG" origin/main
|
|
for TARGET in web api worker; do
|
|
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
|
|
SHA_DIGEST="$(docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" --format '{{json .Manifest.Digest}}')"
|
|
STAGING_DIGEST="$(docker buildx imagetools inspect "$IMAGE:staging" --format '{{json .Manifest.Digest}}')"
|
|
if [ -z "$SHA_DIGEST" ] || [ "$SHA_DIGEST" != "$STAGING_DIGEST" ]; then
|
|
echo "Staged image digest for $TARGET does not match $IMAGE_TAG"
|
|
exit 1
|
|
fi
|
|
done
|
|
- name: Build and push staging images
|
|
run: |
|
|
BUILD_PIDS=()
|
|
BUILD_TARGETS=()
|
|
for TARGET in web api worker; do
|
|
(
|
|
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
|
|
CACHE_REF="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/build-cache:$TARGET"
|
|
WEB_BUILD_ARGS=()
|
|
if [ "$TARGET" = "web" ]; then
|
|
WEB_BUILD_ARGS=(--build-arg VITE_SENTRY_DSN_WEB="$VITE_SENTRY_DSN_WEB" --build-arg VITE_SENTRY_RELEASE="$SHA")
|
|
fi
|
|
docker buildx build --push --target "$TARGET" --file Dockerfile --cache-from "type=registry,ref=$CACHE_REF" --cache-to "type=registry,ref=$CACHE_REF,mode=max" --build-arg APP_REVISION="$SHA" "\${WEB_BUILD_ARGS[@]}" --tag "$IMAGE:$IMAGE_TAG" .
|
|
) &
|
|
BUILD_PIDS+=("$!")
|
|
BUILD_TARGETS+=("$TARGET")
|
|
done
|
|
BUILD_FAILED=0
|
|
for INDEX in "\${!BUILD_PIDS[@]}"; do
|
|
if ! wait "\${BUILD_PIDS[$INDEX]}"; then
|
|
echo "Docker build failed for \${BUILD_TARGETS[$INDEX]}"
|
|
BUILD_FAILED=1
|
|
fi
|
|
done
|
|
if [ "$BUILD_FAILED" -ne 0 ]; then
|
|
exit "$BUILD_FAILED"
|
|
fi
|
|
for TARGET in web api worker; do
|
|
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
|
|
docker buildx imagetools create --tag "$IMAGE:staging" "$IMAGE:$IMAGE_TAG"
|
|
done
|
|
- name: Install cosign
|
|
if: env.DEPLOY_ACTION == 'stage'
|
|
run: bash scripts/ci/install-cosign.sh
|
|
- name: Configure Coolify staging migration command
|
|
env:
|
|
COOLIFY_STAGING_API_URL: \${{ vars.COOLIFY_STAGING_API_URL }}
|
|
COOLIFY_STAGING_TOKEN: \${{ secrets.COOLIFY_STAGING_TOKEN }}
|
|
COOLIFY_STAGING_RESOURCE_UUIDS: \${{ vars.COOLIFY_STAGING_RESOURCE_UUIDS }}
|
|
MIGRATION_COMMAND: cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts
|
|
SEED_COMMAND: cd /myapp && NODE_ENV=production node packages/db/dist/production-seed.server.js
|
|
run: |
|
|
WEB_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/web"
|
|
API_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/api"
|
|
WORKER_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/worker"
|
|
WEB_RESOURCE_UUID="$(WEB_IMAGE="$WEB_IMAGE" ALLOWED_RESOURCE_UUIDS="$COOLIFY_STAGING_RESOURCE_UUIDS" node -e 'const apps = [{ uuid: "web", docker_registry_image_name: process.env.WEB_IMAGE, docker_registry_image_tag: process.env.IMAGE_TAG }]; const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.WEB_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");')"
|
|
API_RESOURCE_UUID="$(API_IMAGE="$API_IMAGE" node -e 'process.stdout.write("api")')"
|
|
WORKER_GENERAL_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=general WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-general")')"
|
|
WORKER_MAIL_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=mail WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-mail")')"
|
|
WORKER_IMPORT_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=import WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-import")')"
|
|
WORKER_WEBHOOK_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=webhook WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-webhook")')"
|
|
WORKER_PLUGIN_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=plugin WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-plugin")')"
|
|
WORKER_RESOURCE_UUIDS="$WORKER_GENERAL_RESOURCE_UUID,$WORKER_MAIL_RESOURCE_UUID,$WORKER_IMPORT_RESOURCE_UUID,$WORKER_WEBHOOK_RESOURCE_UUID,$WORKER_PLUGIN_RESOURCE_UUID"
|
|
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
|
|
if ! printf ",%s," "$COOLIFY_STAGING_RESOURCE_UUIDS" | grep -Fq ",$REQUIRED_RESOURCE_UUID,"; then
|
|
echo "Coolify staging deploy resources must include the web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
|
|
exit 1
|
|
fi
|
|
done
|
|
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
|
|
echo "STAGING_WEB_RESOURCE_UUID=$WEB_RESOURCE_UUID"
|
|
echo "STAGING_WORKER_RESOURCE_UUIDS=$WORKER_RESOURCE_UUIDS"
|
|
echo "STAGING_DEPENDENT_RESOURCE_UUIDS=$DEPENDENT_RESOURCE_UUIDS"
|
|
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: process.env.MIGRATION_COMMAND, post_deployment_command: process.env.SEED_COMMAND, docker_registry_image_tag: process.env.IMAGE_TAG }))')"
|
|
curl --fail --silent --show-error --request PATCH \\
|
|
--header "Authorization: Bearer $COOLIFY_STAGING_TOKEN" \\
|
|
--header "Content-Type: application/json" \\
|
|
--data "$MIGRATION_PAYLOAD" \\
|
|
"$COOLIFY_STAGING_API_URL/applications/$WEB_RESOURCE_UUID"
|
|
IMAGE_TAG_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ docker_registry_image_tag: process.env.IMAGE_TAG }))')"
|
|
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
|
|
curl --fail --silent --show-error --request PATCH \\
|
|
--header "Authorization: Bearer $COOLIFY_STAGING_TOKEN" \\
|
|
--header "Content-Type: application/json" \\
|
|
--data "$IMAGE_TAG_PAYLOAD" \\
|
|
"$COOLIFY_STAGING_API_URL/applications/$RESOURCE_UUID"
|
|
done
|
|
- name: Trigger Coolify staging deploy
|
|
env:
|
|
COOLIFY_STAGING_API_URL: \${{ vars.COOLIFY_STAGING_API_URL }}
|
|
COOLIFY_STAGING_TOKEN: \${{ secrets.COOLIFY_STAGING_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_STAGING_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"
|
|
STAGING_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID")"
|
|
echo "STAGING_WEB_DEPLOYMENT_UUID=$STAGING_WEB_DEPLOYMENT_UUID" >> "$GITHUB_ENV"
|
|
- name: Wait for Coolify staging health
|
|
env:
|
|
COOLIFY_STAGING_API_URL: \${{ vars.COOLIFY_STAGING_API_URL }}
|
|
COOLIFY_STAGING_TOKEN: \${{ secrets.COOLIFY_STAGING_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_STAGING_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"
|
|
node scripts/coolify-deploy.mjs wait "$STAGING_WEB_DEPLOYMENT_UUID" "Coolify staging web"
|
|
RESOURCE_INDEX=0
|
|
for RESOURCE_UUID in $(printf "%s" "$STAGING_WEB_RESOURCE_UUID" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
APP_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error \\
|
|
--header "Authorization: Bearer $COOLIFY_STAGING_TOKEN" \\
|
|
"$COOLIFY_STAGING_API_URL/applications/$RESOURCE_UUID" > "$APP_JSON"
|
|
STATUS="$(node -e 'const app = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(app.status || "unknown");' "$APP_JSON")"
|
|
echo "Coolify staging resource #$RESOURCE_INDEX status: $STATUS"
|
|
HEALTH_URL="https://staging-web.example.invalid"
|
|
HEALTH_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$HEALTH_JSON"
|
|
HEALTH_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$HEALTH_JSON")"
|
|
EXPECTED_SERVICE="\${5:-web}"
|
|
HEALTH_SERVICE="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.service || "");' "$HEALTH_JSON")"
|
|
if [ -n "$EXPECTED_SERVICE" ] && [ "$HEALTH_SERVICE" != "$EXPECTED_SERVICE" ]; then
|
|
exit 1
|
|
fi
|
|
if [ "$HEALTH_REVISION" != "$IMAGE_TAG" ]; then
|
|
exit 1
|
|
fi
|
|
test "$STATUS" = "running:healthy"
|
|
done
|
|
- name: Trigger Coolify staging dependent deploys
|
|
env:
|
|
COOLIFY_STAGING_API_URL: \${{ vars.COOLIFY_STAGING_API_URL }}
|
|
COOLIFY_STAGING_TOKEN: \${{ secrets.COOLIFY_STAGING_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_STAGING_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"
|
|
STAGING_DEPENDENT_DEPLOYMENT_UUIDS=""
|
|
for RESOURCE_UUID in $(printf "%s" "$STAGING_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$RESOURCE_UUID")"
|
|
STAGING_DEPENDENT_DEPLOYMENT_UUIDS="\${STAGING_DEPENDENT_DEPLOYMENT_UUIDS:+$STAGING_DEPENDENT_DEPLOYMENT_UUIDS,}$DEPLOYMENT_UUID"
|
|
done
|
|
echo "STAGING_DEPENDENT_DEPLOYMENT_UUIDS=$STAGING_DEPENDENT_DEPLOYMENT_UUIDS" >> "$GITHUB_ENV"
|
|
- name: Wait for Coolify staging dependent health
|
|
env:
|
|
COOLIFY_STAGING_API_URL: \${{ vars.COOLIFY_STAGING_API_URL }}
|
|
COOLIFY_STAGING_TOKEN: \${{ secrets.COOLIFY_STAGING_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_STAGING_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"
|
|
DEPLOYMENT_WAIT_PIDS=""
|
|
for DEPLOYMENT_UUID in $(printf "%s" "$STAGING_DEPENDENT_DEPLOYMENT_UUIDS" | tr ',' ' '); do
|
|
node scripts/coolify-deploy.mjs wait "$DEPLOYMENT_UUID" "Coolify staging dependent" &
|
|
DEPLOYMENT_WAIT_PIDS="$DEPLOYMENT_WAIT_PIDS $!"
|
|
done
|
|
for PID in $DEPLOYMENT_WAIT_PIDS; do
|
|
wait "$PID"
|
|
done
|
|
RESOURCE_INDEX=0
|
|
for RESOURCE_UUID in $(printf "%s" "$STAGING_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
APP_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error \\
|
|
--header "Authorization: Bearer $COOLIFY_STAGING_TOKEN" \\
|
|
"$COOLIFY_STAGING_API_URL/applications/$RESOURCE_UUID" > "$APP_JSON"
|
|
STATUS="$(node -e 'const app = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(app.status || "unknown");' "$APP_JSON")"
|
|
echo "Coolify staging dependent resource #$RESOURCE_INDEX status: $STATUS"
|
|
HEALTH_URL="https://staging-api.example.invalid"
|
|
HTTP_RESOURCE="$(node -e 'process.stdout.write("true")')"
|
|
WORKER_RESOURCE="$(node -e 'process.stdout.write("false")')"
|
|
if printf ",%s," "$STAGING_WORKER_RESOURCE_UUIDS" | grep -Fq ",$RESOURCE_UUID,"; then
|
|
WORKER_RESOURCE="$(node -e 'process.stdout.write("true")')"
|
|
fi
|
|
if [ "$WORKER_RESOURCE" = "true" ]; then
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("staging")')"
|
|
if [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then
|
|
echo "$LABEL worker image tag does not match expected alias"
|
|
exit 1
|
|
fi
|
|
WORKER_CONTAINER="$(remote_ssh "docker ps --filter name=$RESOURCE_UUID --filter status=running --format '{{.Names}}' | head -n1")"
|
|
remote_ssh "docker exec $WORKER_CONTAINER node apps/worker/dist/health.js"
|
|
WORKER_REVISION="$(node -e 'process.stdout.write("abc")')"
|
|
WORKER_ENABLED="$(node -e 'const payload = { workerEnabled: true }; process.stdout.write(payload.workerEnabled === true ? "true" : "false")')"
|
|
WORKER_STATUS="$(node -e 'process.stdout.write("ready")')"
|
|
if [ "$WORKER_ENABLED" != "true" ]; then
|
|
echo "$LABEL worker health reports workerEnabled=false"
|
|
exit 1
|
|
fi
|
|
if [ "$WORKER_STATUS" != "ready" ]; then
|
|
echo "$LABEL worker health status is not ready"
|
|
exit 1
|
|
fi
|
|
WORKER_GROUP="$(node -e 'process.stdout.write("mail")')"
|
|
WORKER_QUEUES="$(node -e 'process.stdout.write("toolctrl.email,toolctrl.notifications,toolctrl.maintenance,toolctrl.file-scan,toolctrl.imports,toolctrl.exports,toolctrl.ai-chatbot,toolctrl.browser-automation,toolctrl.plugin-invoke")')"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.notifications toolctrl.maintenance toolctrl.file-scan"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.email"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.imports toolctrl.exports"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.ai-chatbot toolctrl.browser-automation"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.plugin-invoke"
|
|
for EXPECTED_WORKER_QUEUE in $EXPECTED_WORKER_QUEUES; do
|
|
echo "$EXPECTED_WORKER_QUEUE"
|
|
done
|
|
echo "$LABEL worker group does not match expected queue group"
|
|
echo "$LABEL worker queues do not match expected queue group"
|
|
echo "$LABEL worker live revision matches image tag"
|
|
return
|
|
fi
|
|
if [ -z "$HEALTH_URL" ] && [ "$HTTP_RESOURCE" = "true" ]; then
|
|
echo "$LABEL is an HTTP resource but has no health URL"
|
|
exit 1
|
|
fi
|
|
EXPECTED_IMAGE_ALIAS="\${4:-staging}"
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("staging")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then
|
|
exit 1
|
|
fi
|
|
HEALTH_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$HEALTH_JSON"
|
|
HEALTH_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$HEALTH_JSON")"
|
|
EXPECTED_SERVICE="\${5:-api}"
|
|
HEALTH_SERVICE="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.service || "");' "$HEALTH_JSON")"
|
|
if [ -n "$EXPECTED_SERVICE" ] && [ "$HEALTH_SERVICE" != "$EXPECTED_SERVICE" ]; then
|
|
exit 1
|
|
fi
|
|
if [ "$HEALTH_REVISION" != "$IMAGE_TAG" ]; then
|
|
exit 1
|
|
fi
|
|
test "$STATUS" = "running:healthy"
|
|
done
|
|
check_dependent_resource() {
|
|
RESOURCE_UUID="$1"
|
|
RESOURCE_INDEX="$2"
|
|
test -n "$RESOURCE_UUID"
|
|
}
|
|
RESOURCE_WAIT_PIDS=""
|
|
for RESOURCE_UUID in $(printf "%s" "$STAGING_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
check_dependent_resource "$RESOURCE_UUID" "$RESOURCE_INDEX" &
|
|
RESOURCE_WAIT_PIDS="$RESOURCE_WAIT_PIDS $!"
|
|
done
|
|
for PID in $RESOURCE_WAIT_PIDS; do
|
|
wait "$PID"
|
|
done
|
|
- name: Promote staging images to production aliases
|
|
run: |
|
|
for TARGET in web api worker; do
|
|
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET"
|
|
docker buildx imagetools create --tag "$IMAGE:main" "$IMAGE:$IMAGE_TAG"
|
|
done
|
|
- name: Configure Coolify production migration command
|
|
env:
|
|
COOLIFY_API_URL: \${{ vars.COOLIFY_API_URL }}
|
|
COOLIFY_RESOURCE_UUIDS: \${{ vars.COOLIFY_RESOURCE_UUIDS }}
|
|
COOLIFY_TOKEN: \${{ secrets.COOLIFY_TOKEN }}
|
|
MIGRATION_COMMAND: cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts
|
|
SEED_COMMAND: cd /myapp && NODE_ENV=production node packages/db/dist/production-seed.server.js
|
|
run: |
|
|
WEB_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/web"
|
|
API_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/api"
|
|
WORKER_IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/worker"
|
|
WEB_RESOURCE_UUID="$(WEB_IMAGE="$WEB_IMAGE" ALLOWED_RESOURCE_UUIDS="$COOLIFY_RESOURCE_UUIDS" node -e 'const apps = [{ uuid: "web", docker_registry_image_name: process.env.WEB_IMAGE, docker_registry_image_tag: process.env.IMAGE_TAG }]; const allowed = new Set((process.env.ALLOWED_RESOURCE_UUIDS || "").split(",").filter(Boolean)); const app = apps.find((candidate) => candidate.docker_registry_image_name === process.env.WEB_IMAGE && allowed.has(candidate.uuid)); process.stdout.write(app?.uuid || "");')"
|
|
API_RESOURCE_UUID="$(API_IMAGE="$API_IMAGE" node -e 'process.stdout.write("api")')"
|
|
WORKER_GENERAL_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=general WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-general")')"
|
|
WORKER_MAIL_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=mail WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-mail")')"
|
|
WORKER_IMPORT_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=import WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-import")')"
|
|
WORKER_WEBHOOK_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=webhook WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-webhook")')"
|
|
WORKER_PLUGIN_RESOURCE_UUID="$(WORKER_QUEUE_GROUP=plugin WORKER_IMAGE="$WORKER_IMAGE" node -e 'process.stdout.write("worker-plugin")')"
|
|
WORKER_RESOURCE_UUIDS="$WORKER_GENERAL_RESOURCE_UUID,$WORKER_MAIL_RESOURCE_UUID,$WORKER_IMPORT_RESOURCE_UUID,$WORKER_WEBHOOK_RESOURCE_UUID,$WORKER_PLUGIN_RESOURCE_UUID"
|
|
for REQUIRED_RESOURCE_UUID in "$WEB_RESOURCE_UUID" "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
|
|
if ! printf ",%s," "$COOLIFY_RESOURCE_UUIDS" | grep -Fq ",$REQUIRED_RESOURCE_UUID,"; then
|
|
echo "Coolify production deploy resources must include the web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications"
|
|
exit 1
|
|
fi
|
|
done
|
|
DEPENDENT_RESOURCE_UUIDS="$API_RESOURCE_UUID,$WORKER_RESOURCE_UUIDS"
|
|
echo "PRODUCTION_WEB_RESOURCE_UUID=$WEB_RESOURCE_UUID"
|
|
echo "PRODUCTION_WORKER_RESOURCE_UUIDS=$WORKER_RESOURCE_UUIDS"
|
|
echo "PRODUCTION_DEPENDENT_RESOURCE_UUIDS=$DEPENDENT_RESOURCE_UUIDS"
|
|
MIGRATION_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ pre_deployment_command: process.env.MIGRATION_COMMAND, post_deployment_command: process.env.SEED_COMMAND, docker_registry_image_tag: process.env.IMAGE_TAG }))')"
|
|
curl --fail --silent --show-error --request PATCH \\
|
|
--header "Authorization: Bearer $COOLIFY_TOKEN" \\
|
|
--header "Content-Type: application/json" \\
|
|
--data "$MIGRATION_PAYLOAD" \\
|
|
"$COOLIFY_API_URL/applications/$WEB_RESOURCE_UUID"
|
|
IMAGE_TAG_PAYLOAD="$(node -e 'process.stdout.write(JSON.stringify({ docker_registry_image_tag: process.env.IMAGE_TAG }))')"
|
|
for RESOURCE_UUID in "$API_RESOURCE_UUID" "$WORKER_GENERAL_RESOURCE_UUID" "$WORKER_MAIL_RESOURCE_UUID" "$WORKER_IMPORT_RESOURCE_UUID" "$WORKER_WEBHOOK_RESOURCE_UUID" "$WORKER_PLUGIN_RESOURCE_UUID"; do
|
|
curl --fail --silent --show-error --request PATCH \\
|
|
--header "Authorization: Bearer $COOLIFY_TOKEN" \\
|
|
--header "Content-Type: application/json" \\
|
|
--data "$IMAGE_TAG_PAYLOAD" \\
|
|
"$COOLIFY_API_URL/applications/$RESOURCE_UUID"
|
|
done
|
|
- name: Trigger Coolify production deploy
|
|
env:
|
|
COOLIFY_API_URL: \${{ vars.COOLIFY_API_URL }}
|
|
COOLIFY_TOKEN: \${{ secrets.COOLIFY_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"
|
|
PRODUCTION_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$PRODUCTION_WEB_RESOURCE_UUID")"
|
|
echo "PRODUCTION_WEB_DEPLOYMENT_UUID=$PRODUCTION_WEB_DEPLOYMENT_UUID" >> "$GITHUB_ENV"
|
|
- name: Wait for Coolify production health
|
|
env:
|
|
COOLIFY_API_URL: \${{ vars.COOLIFY_API_URL }}
|
|
COOLIFY_TOKEN: \${{ secrets.COOLIFY_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"
|
|
node scripts/coolify-deploy.mjs wait "$PRODUCTION_WEB_DEPLOYMENT_UUID" "Coolify production web"
|
|
RESOURCE_INDEX=0
|
|
for RESOURCE_UUID in $(printf "%s" "$PRODUCTION_WEB_RESOURCE_UUID" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
APP_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error \\
|
|
--header "Authorization: Bearer $COOLIFY_TOKEN" \\
|
|
"$COOLIFY_API_URL/applications/$RESOURCE_UUID" > "$APP_JSON"
|
|
STATUS="$(node -e 'const app = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(app.status || "unknown");' "$APP_JSON")"
|
|
echo "Coolify production resource #$RESOURCE_INDEX status: $STATUS"
|
|
HEALTH_URL="https://production-web.example.invalid"
|
|
HEALTH_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$HEALTH_JSON"
|
|
HEALTH_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$HEALTH_JSON")"
|
|
EXPECTED_SERVICE="\${5:-web}"
|
|
HEALTH_SERVICE="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.service || "");' "$HEALTH_JSON")"
|
|
if [ -n "$EXPECTED_SERVICE" ] && [ "$HEALTH_SERVICE" != "$EXPECTED_SERVICE" ]; then
|
|
exit 1
|
|
fi
|
|
if [ "$HEALTH_REVISION" != "$IMAGE_TAG" ]; then
|
|
exit 1
|
|
fi
|
|
test "$STATUS" = "running:healthy"
|
|
done
|
|
- name: Prepare production migration status checkout
|
|
run: |
|
|
if [ "$DEPLOY_ACTION" != "promote" ]; then
|
|
echo "Production migration status checkout skipped for staging"
|
|
exit 0
|
|
fi
|
|
git checkout --detach "$IMAGE_TAG"
|
|
CHECKOUT_SHA="$(git rev-parse HEAD)"
|
|
if [ "$CHECKOUT_SHA" != "$IMAGE_TAG" ]; then
|
|
echo "Production migration status checkout must match the staged image tag"
|
|
exit 1
|
|
fi
|
|
corepack enable
|
|
corepack prepare pnpm@10.33.0 --activate
|
|
pnpm config set store-dir .pnpm-store
|
|
- name: Cache production migration pnpm store
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: .pnpm-store
|
|
key: \${{ runner.os }}-pnpm-promote-\${{ hashFiles('pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
\${{ runner.os }}-pnpm-
|
|
- name: Install production migration dependencies
|
|
run: |
|
|
if [ "$DEPLOY_ACTION" != "promote" ]; then
|
|
echo "Production migration dependency install skipped for staging"
|
|
exit 0
|
|
fi
|
|
pnpm install --frozen-lockfile --prefer-offline
|
|
- name: Verify production migration status after web deploy
|
|
env:
|
|
DATABASE_URL: \${{ secrets.DATABASE_URL }}
|
|
run: |
|
|
if [ "$DEPLOY_ACTION" != "promote" ]; then
|
|
echo "Production migration status check skipped for staging"
|
|
exit 0
|
|
fi
|
|
if [ -z "$DATABASE_URL" ]; then
|
|
echo "DATABASE_URL is required for production migration status verification"
|
|
exit 1
|
|
fi
|
|
pnpm run verify:db-migrate-status
|
|
- name: Trigger Coolify production dependent deploys
|
|
env:
|
|
COOLIFY_API_URL: \${{ vars.COOLIFY_API_URL }}
|
|
COOLIFY_TOKEN: \${{ secrets.COOLIFY_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"
|
|
PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS=""
|
|
for RESOURCE_UUID in $(printf "%s" "$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$RESOURCE_UUID")"
|
|
PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS="\${PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS:+$PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS,}$DEPLOYMENT_UUID"
|
|
done
|
|
echo "PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS=$PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS" >> "$GITHUB_ENV"
|
|
- name: Wait for Coolify production dependent health
|
|
env:
|
|
COOLIFY_API_URL: \${{ vars.COOLIFY_API_URL }}
|
|
COOLIFY_TOKEN: \${{ secrets.COOLIFY_TOKEN }}
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="$COOLIFY_API_URL"
|
|
SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"
|
|
DEPLOYMENT_WAIT_PIDS=""
|
|
for DEPLOYMENT_UUID in $(printf "%s" "$PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS" | tr ',' ' '); do
|
|
node scripts/coolify-deploy.mjs wait "$DEPLOYMENT_UUID" "Coolify production dependent" &
|
|
DEPLOYMENT_WAIT_PIDS="$DEPLOYMENT_WAIT_PIDS $!"
|
|
done
|
|
for PID in $DEPLOYMENT_WAIT_PIDS; do
|
|
wait "$PID"
|
|
done
|
|
RESOURCE_INDEX=0
|
|
for RESOURCE_UUID in $(printf "%s" "$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
APP_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error \\
|
|
--header "Authorization: Bearer $COOLIFY_TOKEN" \\
|
|
"$COOLIFY_API_URL/applications/$RESOURCE_UUID" > "$APP_JSON"
|
|
STATUS="$(node -e 'const app = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(app.status || "unknown");' "$APP_JSON")"
|
|
echo "Coolify production dependent resource #$RESOURCE_INDEX status: $STATUS"
|
|
HEALTH_URL="https://production-api.example.invalid"
|
|
HTTP_RESOURCE="$(node -e 'process.stdout.write("true")')"
|
|
WORKER_RESOURCE="$(node -e 'process.stdout.write("false")')"
|
|
if printf ",%s," "$PRODUCTION_WORKER_RESOURCE_UUIDS" | grep -Fq ",$RESOURCE_UUID,"; then
|
|
WORKER_RESOURCE="$(node -e 'process.stdout.write("true")')"
|
|
fi
|
|
if [ "$WORKER_RESOURCE" = "true" ]; then
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("main")')"
|
|
if [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then
|
|
echo "$LABEL worker image tag does not match expected alias"
|
|
exit 1
|
|
fi
|
|
WORKER_CONTAINER="$(remote_ssh "docker ps --filter name=$RESOURCE_UUID --filter status=running --format '{{.Names}}' | head -n1")"
|
|
remote_ssh "docker exec $WORKER_CONTAINER node apps/worker/dist/health.js"
|
|
WORKER_REVISION="$(node -e 'process.stdout.write("abc")')"
|
|
WORKER_ENABLED="$(node -e 'const payload = { workerEnabled: true }; process.stdout.write(payload.workerEnabled === true ? "true" : "false")')"
|
|
WORKER_STATUS="$(node -e 'process.stdout.write("ready")')"
|
|
if [ "$WORKER_ENABLED" != "true" ]; then
|
|
echo "$LABEL worker health reports workerEnabled=false"
|
|
exit 1
|
|
fi
|
|
if [ "$WORKER_STATUS" != "ready" ]; then
|
|
echo "$LABEL worker health status is not ready"
|
|
exit 1
|
|
fi
|
|
WORKER_GROUP="$(node -e 'process.stdout.write("mail")')"
|
|
WORKER_QUEUES="$(node -e 'process.stdout.write("toolctrl.email,toolctrl.notifications,toolctrl.maintenance,toolctrl.file-scan,toolctrl.imports,toolctrl.exports,toolctrl.ai-chatbot,toolctrl.browser-automation,toolctrl.plugin-invoke")')"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.notifications toolctrl.maintenance toolctrl.file-scan"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.email"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.imports toolctrl.exports"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.ai-chatbot toolctrl.browser-automation"
|
|
EXPECTED_WORKER_QUEUES="toolctrl.plugin-invoke"
|
|
for EXPECTED_WORKER_QUEUE in $EXPECTED_WORKER_QUEUES; do
|
|
echo "$EXPECTED_WORKER_QUEUE"
|
|
done
|
|
echo "$LABEL worker group does not match expected queue group"
|
|
echo "$LABEL worker queues do not match expected queue group"
|
|
echo "$LABEL worker live revision matches image tag"
|
|
return
|
|
fi
|
|
if [ -z "$HEALTH_URL" ] && [ "$HTTP_RESOURCE" = "true" ]; then
|
|
echo "$LABEL is an HTTP resource but has no health URL"
|
|
exit 1
|
|
fi
|
|
EXPECTED_IMAGE_ALIAS="\${4:-main}"
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("main")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then
|
|
exit 1
|
|
fi
|
|
HEALTH_JSON="$(mktemp)"
|
|
curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$HEALTH_JSON"
|
|
HEALTH_REVISION="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.revision || "");' "$HEALTH_JSON")"
|
|
EXPECTED_SERVICE="\${5:-api}"
|
|
HEALTH_SERVICE="$(node -e 'const fs = require("node:fs"); const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(payload.service || "");' "$HEALTH_JSON")"
|
|
if [ -n "$EXPECTED_SERVICE" ] && [ "$HEALTH_SERVICE" != "$EXPECTED_SERVICE" ]; then
|
|
exit 1
|
|
fi
|
|
if [ "$HEALTH_REVISION" != "$IMAGE_TAG" ]; then
|
|
exit 1
|
|
fi
|
|
test "$STATUS" = "running:healthy"
|
|
done
|
|
check_dependent_resource() {
|
|
RESOURCE_UUID="$1"
|
|
RESOURCE_INDEX="$2"
|
|
test -n "$RESOURCE_UUID"
|
|
}
|
|
RESOURCE_WAIT_PIDS=""
|
|
for RESOURCE_UUID in $(printf "%s" "$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
RESOURCE_INDEX=$((RESOURCE_INDEX + 1))
|
|
check_dependent_resource "$RESOURCE_UUID" "$RESOURCE_INDEX" &
|
|
RESOURCE_WAIT_PIDS="$RESOURCE_WAIT_PIDS $!"
|
|
done
|
|
for PID in $RESOURCE_WAIT_PIDS; do
|
|
wait "$PID"
|
|
done
|
|
|
|
- name: Mark Sentry deploy
|
|
if: success()
|
|
env:
|
|
SENTRY_AUTH_TOKEN: \${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
SENTRY_ORG: \${{ secrets.SENTRY_ORG }}
|
|
SENTRY_URL: \${{ secrets.SENTRY_URL }}
|
|
run: |
|
|
if [ -z "\${SENTRY_AUTH_TOKEN:-}" ] || [ -z "\${SENTRY_ORG:-}" ] || [ -z "\${SENTRY_URL:-}" ]; then
|
|
echo "Sentry secrets not configured; skipping deploy marker"
|
|
exit 0
|
|
fi
|
|
if [ "$DEPLOY_ACTION" = "promote" ]; then
|
|
SENTRY_ENVIRONMENT=production
|
|
RELEASE_SHA="\${IMAGE_TAG}"
|
|
else
|
|
SENTRY_ENVIRONMENT=staging
|
|
RELEASE_SHA="\${SHA}"
|
|
fi
|
|
for PROJECT in boilerplate-api boilerplate-worker boilerplate-web; do
|
|
bash scripts/ci/sentry-release.sh \
|
|
--project "$PROJECT" \
|
|
--environment "$SENTRY_ENVIRONMENT" \
|
|
--release "$RELEASE_SHA" \
|
|
--sourcemaps none
|
|
done
|
|
`;
|
|
|
|
const fixtures = [];
|
|
const VALID_COSIGN_INSTALLER = `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
COSIGN_VERSION="\${COSIGN_VERSION:-v3.0.6}"
|
|
COSIGN_SHA256="\${COSIGN_SHA256:-c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74}"
|
|
curl --fail --silent --show-error --location "https://github.com/sigstore/cosign/releases/download/\${COSIGN_VERSION}/cosign-linux-amd64" --output /tmp/cosign
|
|
printf '%s %s\\n' "$COSIGN_SHA256" /tmp/cosign | sha256sum --check --status
|
|
install -m 0755 /tmp/cosign /usr/local/bin/cosign
|
|
`;
|
|
|
|
function writeFile(rootDir, relativePath, content) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content);
|
|
}
|
|
|
|
function createFixture(workflowContent = VALID_WORKFLOW) {
|
|
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-deploy-workflow-"));
|
|
fixtures.push(rootDir);
|
|
|
|
if (workflowContent !== null) {
|
|
writeFile(rootDir, ".gitea/workflows/deploy.yml", workflowContent);
|
|
writeFile(rootDir, "scripts/ci/install-cosign.sh", VALID_COSIGN_INSTALLER);
|
|
}
|
|
|
|
return rootDir;
|
|
}
|
|
|
|
afterEach(() => {
|
|
while (fixtures.length > 0) {
|
|
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("verify-gitea-deploy-workflow", () => {
|
|
it("passes for the stage-and-promote Gitea Registry workflow", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture());
|
|
const output = formatGiteaDeployWorkflowResult(result);
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.some((pass) => /runner is "toolctrl-ci"/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight job "deploy-preflight" is defined'));
|
|
assert.ok(result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy job "stage-or-promote" needs "deploy-preflight"'));
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node scripts/verify-gitea-deploy-workflow.mjs"')
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node scripts/verify-deploy-readiness.mjs"')
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node scripts/verify-docker-baseline.mjs"')
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node scripts/verify-docker-non-root.mjs"')
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node --check scripts/coolify-deploy.mjs"')
|
|
);
|
|
assert.ok(
|
|
result.passes.some(
|
|
(pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "node --check scripts/coolify-ensure-worker-groups.mjs"'
|
|
)
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "bash -n scripts/coolify-prepull-images.sh"')
|
|
);
|
|
assert.ok(result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: deploy preflight includes command "bash -n scripts/ci/install-cosign.sh"'));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: cosign install uses scripts/ci/install-cosign.sh"));
|
|
assert.ok(result.passes.some((pass) => pass === "scripts/ci/install-cosign.sh: cosign download is verified by SHA256"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: deploy preflight does not expose production database credentials"));
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: deploy preflight stays lightweight and leaves full CI gates to the CI workflow")
|
|
);
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: registry login uses REGISTRY_USER secret"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: registry login uses REGISTRY_PAT secret"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: main-branch push trigger is defined for staging deployment"));
|
|
assert.ok(
|
|
result.passes.some(
|
|
(pass) => pass === ".gitea/workflows/deploy.yml: workflow_dispatch supports stage and promote actions with explicit staged_sha and backup confirmation"
|
|
)
|
|
);
|
|
assert.ok(
|
|
result.passes.some(
|
|
(pass) =>
|
|
pass ===
|
|
".gitea/workflows/deploy.yml: promote verifies production migration status after web deploy and before dependent deploys with cached pnpm store, secret-free install, and step-scoped DATABASE_URL"
|
|
)
|
|
);
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: deployment concurrency serializes staging and production promotion"));
|
|
assert.ok(result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: builds Docker target "web"'));
|
|
assert.ok(result.passes.some((pass) => pass === '.gitea/workflows/deploy.yml: pushes Docker target "worker"'));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: image paths match existing Coolify boilerplate/{web|api|worker} resources"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: full SHA, staging, and promoted main tag generation is present"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: staging aliases are not pushed from individual target builds"));
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: promote uses explicit full staged SHA image tag with backup confirmation"));
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: promote verifies staged commit ancestry and staging image digests before retagging")
|
|
);
|
|
assert.ok(result.passes.some((pass) => /Coolify pre-deployment migration command is present/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Coolify post-deployment guarded seed command is present/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Coolify guarded seed uses built DB package output/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Docker image publication passes APP_REVISION build arg/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /staging Docker builds use stable registry cache refs per image namespace and target/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /staging Docker image builds run in parallel and collect per-target failures/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Vite build args are scoped to the web Docker target/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Coolify production pre-deployment migration and post-deployment guarded seed command targets the main web application/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /production Sentry deploy marker uses promoted IMAGE_TAG/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Sentry deploy marker covers staging SHA and all runtime projects/.test(pass)));
|
|
assert.ok(
|
|
result.passes.some((pass) =>
|
|
/Coolify staging deploy resources are checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/.test(
|
|
pass
|
|
)
|
|
)
|
|
);
|
|
assert.ok(
|
|
result.passes.some((pass) =>
|
|
/Coolify production deploy resources are checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/.test(
|
|
pass
|
|
)
|
|
)
|
|
);
|
|
assert.ok(result.passes.some((pass) => /Coolify migration command configuration runs before Coolify deploy/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Coolify production migration command configuration runs before production deploy/.test(pass)));
|
|
assert.ok(result.passes.some((pass) => /Coolify staging health wait polls application status/.test(pass)));
|
|
assert.ok(
|
|
result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: promote retags the verified full-SHA images to production aliases without rebuilding")
|
|
);
|
|
assert.match(output, /^PASS Gitea deploy workflow verification/);
|
|
assert.match(output, /Summary: \d+ passed, 0 observed, 0 failed/);
|
|
});
|
|
|
|
it("fails when the workflow file is missing", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(null));
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/deploy\.yml: missing required Gitea deploy workflow file/);
|
|
});
|
|
|
|
it("fails when a hosted runner is used", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" runs-on: toolctrl-ci\n", " runs-on: ubuntu-latest\n")));
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/deploy\.yml:\d+: runs-on must be "toolctrl-ci" but found "ubuntu-latest"/);
|
|
});
|
|
|
|
it("fails when deploy does not depend on the preflight job", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" needs: deploy-preflight\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy job "stage-or-promote" must declare needs: deploy-preflight/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits a required gate", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" node scripts/verify-docker-baseline.mjs\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "node scripts\/verify-docker-baseline\.mjs"/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits deploy readiness consistency checks", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" node scripts/verify-deploy-readiness.mjs\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "node scripts\/verify-deploy-readiness\.mjs"/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits the Coolify helper syntax check", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" node --check scripts/coolify-deploy.mjs\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "node --check scripts\/coolify-deploy\.mjs"/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits the Coolify worker provisioner syntax check", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" node --check scripts/coolify-ensure-worker-groups.mjs\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "node --check scripts\/coolify-ensure-worker-groups\.mjs"/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits the pre-pull script syntax check", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" bash -n scripts/coolify-prepull-images.sh\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "bash -n scripts\/coolify-prepull-images\.sh"/);
|
|
});
|
|
|
|
it("fails when the deploy preflight omits the cosign installer syntax check", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" bash -n scripts/ci/install-cosign.sh\n", "")));
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight missing command "bash -n scripts\/ci\/install-cosign\.sh"/);
|
|
});
|
|
|
|
it("fails when cosign is installed from a mutable latest URL", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
"run: bash scripts/ci/install-cosign.sh",
|
|
"run: curl -sSL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 -o /usr/local/bin/cosign"
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /cosign installer must not use mutable releases\/latest download URLs/);
|
|
assert.match(result.failures.join("\n"), /cosign install must use scripts\/ci\/install-cosign\.sh/);
|
|
});
|
|
|
|
it("fails when the pinned cosign installer omits checksum verification", () => {
|
|
const rootDir = createFixture();
|
|
writeFile(rootDir, "scripts/ci/install-cosign.sh", VALID_COSIGN_INSTALLER.replace(" | sha256sum --check --status", ""));
|
|
|
|
const result = verifyGiteaDeployWorkflow(rootDir);
|
|
|
|
assert.match(result.failures.join("\n"), /scripts\/ci\/install-cosign\.sh: must verify the cosign download with a pinned SHA256/);
|
|
});
|
|
|
|
it("fails when deploy preflight reintroduces full CI checks", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" bash -n scripts/coolify-prepull-images.sh\n",
|
|
" bash -n scripts/coolify-prepull-images.sh\n pnpm run verify:i18n\n pnpm run typecheck\n pnpm run lint\n pnpm run build\n pnpm test\n"
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/deploy preflight must stay lightweight and not run heavy CI commands: .*pnpm run verify:i18n.*pnpm run typecheck.*pnpm run lint.*pnpm run test.*pnpm run build/
|
|
);
|
|
});
|
|
|
|
it("fails when deploy preflight uses shorthand heavy pnpm commands", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" bash -n scripts/coolify-prepull-images.sh\n",
|
|
" bash -n scripts/coolify-prepull-images.sh\n pnpm typecheck\n pnpm lint\n pnpm build\n"
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/deploy preflight must stay lightweight and not run heavy CI commands: .*pnpm run typecheck.*pnpm run lint.*pnpm run build/
|
|
);
|
|
});
|
|
|
|
it("fails when deploy preflight exposes the production database secret at job scope", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(" runs-on: toolctrl-ci\n", " runs-on: toolctrl-ci\n env:\n DATABASE_URL: ${{ secrets.DATABASE_URL }}\n")
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /deploy preflight must not expose secrets\.DATABASE_URL at job scope/);
|
|
});
|
|
|
|
it("fails when a secret-backed token environment variable is printed", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
' SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"\n',
|
|
' SELECTED_COOLIFY_TOKEN="$COOLIFY_TOKEN"\n echo "$COOLIFY_TOKEN"\n'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /workflow must not print or query-string secret-backed environment variables/);
|
|
});
|
|
|
|
it("passes when deploy preflight has no database URL", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture());
|
|
|
|
assert.deepEqual(result.failures, []);
|
|
assert.ok(result.passes.some((pass) => pass === ".gitea/workflows/deploy.yml: deploy preflight does not expose production database credentials"));
|
|
});
|
|
|
|
it("fails when a Coolify selected environment variable is assigned twice in one run block", () => {
|
|
const duplicateSelectedAssignment = VALID_WORKFLOW.replace(
|
|
' SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"\n STAGING_WEB_DEPLOYMENT_UUID=',
|
|
' SELECTED_COOLIFY_TOKEN="$COOLIFY_STAGING_TOKEN"\n SELECTED_COOLIFY_API_URL="$COOLIFY_STAGING_API_URL"\n STAGING_WEB_DEPLOYMENT_UUID='
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(duplicateSelectedAssignment));
|
|
|
|
assert.match(result.failures.join("\n"), /SELECTED_COOLIFY_API_URL is assigned 2 times in one run block/);
|
|
});
|
|
|
|
it("fails when promote migration status omits the pnpm store cache", () => {
|
|
const withoutPromoteCache = VALID_WORKFLOW.replace(
|
|
/ - name: Cache production migration pnpm store[\s\S]*? - name: Verify production migration status after web deploy/s,
|
|
" - name: Verify production migration status after web deploy"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutPromoteCache));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/promote must check out IMAGE_TAG, cache \.pnpm-store, install dependencies without DATABASE_URL, and run pnpm run verify:db-migrate-status/
|
|
);
|
|
});
|
|
|
|
it("fails when a staging branch trigger is used", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" branches: [main]", " branches: [main, staging]")));
|
|
|
|
assert.match(result.failures.join("\n"), /staging branch trigger is forbidden; staging must deploy from main artifacts/);
|
|
});
|
|
|
|
it("fails when workflow_dispatch does not support promote", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace("DEPLOY_ACTION=promote", "DEPLOY_ACTION=stage")));
|
|
|
|
assert.match(result.failures.join("\n"), /workflow_dispatch must support stage and promote actions with explicit staged_sha and backup confirmation/);
|
|
});
|
|
|
|
it("fails when workflow_dispatch stage is not restricted to main", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
` if [ "$REF" != "refs/heads/main" ]; then
|
|
echo "Stage must be dispatched from refs/heads/main"
|
|
exit 1
|
|
fi
|
|
`,
|
|
""
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /workflow_dispatch stage must be restricted to refs\/heads\/main/);
|
|
});
|
|
|
|
it("fails when workflow_dispatch lacks the staged_sha input", () => {
|
|
const withoutStagedShaInput = VALID_WORKFLOW.replace(
|
|
` staged_sha:
|
|
description: "Full staged commit SHA to promote"
|
|
required: false
|
|
`,
|
|
""
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutStagedShaInput));
|
|
|
|
assert.match(result.failures.join("\n"), /workflow_dispatch must support stage and promote actions with explicit staged_sha and backup confirmation/);
|
|
});
|
|
|
|
it("fails when promote uses the dispatch SHA instead of the staged SHA", () => {
|
|
const unsafePromoteWorkflow = VALID_WORKFLOW.replace('IMAGE_TAG="$STAGED_SHA"', 'IMAGE_TAG="$SHA"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(unsafePromoteWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /promote must require staged_sha, backup confirmation, and use staged_sha as the full image tag/);
|
|
});
|
|
|
|
it("fails when promote does not verify staged commit ancestry", () => {
|
|
const unsafePromoteWorkflow = VALID_WORKFLOW.replace(' git merge-base --is-ancestor "$IMAGE_TAG" origin/main\n', "");
|
|
const result = verifyGiteaDeployWorkflow(createFixture(unsafePromoteWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /promote must verify staged commit ancestry and staging image digests before retagging/);
|
|
});
|
|
|
|
it("fails when promote does not inspect source images before retagging", () => {
|
|
const unsafePromoteWorkflow = VALID_WORKFLOW.replace(
|
|
' SHA_DIGEST="$(docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" --format \'{{json .Manifest.Digest}}\')"\n',
|
|
""
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(unsafePromoteWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /promote must verify staged commit ancestry and staging image digests before retagging/);
|
|
});
|
|
|
|
it("fails when SHA tag generation is missing", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(/ SHA: \$\{\{ gitea\.sha \}\}\n/g, "")));
|
|
|
|
assert.match(result.failures.join("\n"), /missing full SHA image tag generation/);
|
|
});
|
|
|
|
it("fails when checkout revision is not verified before image publication", () => {
|
|
const withoutCheckoutGuard = VALID_WORKFLOW.replace(
|
|
/ - name: Verify checkout revision[\s\S]*? - name: Login to Gitea Registry/s,
|
|
" - name: Login to Gitea Registry"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutCheckoutGuard));
|
|
|
|
assert.match(result.failures.join("\n"), /must verify git rev-parse HEAD matches SHA before image publication/);
|
|
});
|
|
|
|
it("fails when the immutable image tag is shortened", () => {
|
|
const shortenedWorkflow = VALID_WORKFLOW.replace('IMAGE_TAG="$SHA"', 'IMAGE_TAG="${SHA:0:7}"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(shortenedWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /missing full SHA image tag generation/);
|
|
});
|
|
|
|
it("fails when legacy generic registry variables are used", () => {
|
|
const legacyWorkflow = VALID_WORKFLOW.replace(" IMAGE_NAMESPACE: toolctrl-labs\n", " CONTAINER_REGISTRY_NAMESPACE: toolctrl-labs\n");
|
|
const result = verifyGiteaDeployWorkflow(createFixture(legacyWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /legacy generic registry variables are forbidden/);
|
|
});
|
|
|
|
it("fails when latest is generated", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace('--tag "$IMAGE:staging"', '--tag "$IMAGE:latest"')));
|
|
|
|
assert.match(result.failures.join("\n"), /mutable latest tag is forbidden/);
|
|
});
|
|
|
|
it("fails when staging aliases are pushed during individual target builds", () => {
|
|
const unsafeWorkflow = VALID_WORKFLOW.replace('--tag "$IMAGE:$IMAGE_TAG" .', '--tag "$IMAGE:$IMAGE_TAG" --tag "$IMAGE:staging" .');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(unsafeWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /staging aliases must be retagged only after all staging target builds succeed/);
|
|
});
|
|
|
|
it("fails when a forbidden E2E command is present", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(`${VALID_WORKFLOW} - name: E2E\n run: pnpm exec playwright test\n`));
|
|
|
|
assert.match(result.failures.join("\n"), /forbidden E2E\/browser command found: run: pnpm exec playwright test/);
|
|
});
|
|
|
|
it("fails when registry login is softened at step level", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Login to Gitea Registry\n run:",
|
|
" - name: Login to Gitea Registry\n continue-on-error: true\n run:"
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /required registry login step must not use continue-on-error or always\(\)/);
|
|
});
|
|
|
|
it("fails when image publication disables errexit", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
" - name: Build and push staging images\n run: |\n BUILD_PIDS=()\n",
|
|
" - name: Build and push staging images\n run: |\n set +e\n BUILD_PIDS=()\n"
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /required Docker image publication step must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when staging Docker builds omit registry cache refs", () => {
|
|
const withoutBuildCache = VALID_WORKFLOW.replace(' CACHE_REF="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/build-cache:$TARGET"\n', "").replace(
|
|
' --cache-from "type=registry,ref=$CACHE_REF" --cache-to "type=registry,ref=$CACHE_REF,mode=max"',
|
|
""
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutBuildCache));
|
|
|
|
assert.match(result.failures.join("\n"), /staging Docker builds must use --cache-from\/--cache-to with a stable registry cache ref/);
|
|
});
|
|
|
|
it("fails when staging Docker builds are not tracked as parallel jobs", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace(" BUILD_PIDS=()\n", "")));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/staging Docker image builds must run web\/api\/worker in parallel and collect per-target failures before tagging/
|
|
);
|
|
});
|
|
|
|
it("fails when Vite build args are not scoped to the web Docker target", () => {
|
|
const withoutWebScopedViteArgs = VALID_WORKFLOW.replace(
|
|
/ WEB_BUILD_ARGS=\(\)[\s\S]*? docker buildx build --push --target "\$TARGET"/,
|
|
' docker buildx build --push --target "$TARGET"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutWebScopedViteArgs));
|
|
|
|
assert.match(result.failures.join("\n"), /Vite build args must be scoped to the web Docker target/);
|
|
});
|
|
|
|
it("fails when the Coolify migration command configuration step is missing", () => {
|
|
const withoutMigration = VALID_WORKFLOW.replace(
|
|
/ - name: Configure Coolify staging migration command[\s\S]*? - name: Trigger Coolify staging deploy/s,
|
|
" - name: Trigger Coolify staging deploy"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutMigration));
|
|
|
|
assert.match(result.failures.join("\n"), /missing Coolify staging migration command configuration step/);
|
|
});
|
|
|
|
it("fails when the Coolify migration command is not patched through the application API", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(VALID_WORKFLOW.replace("--request PATCH", "--request GET")));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging migration command must be patched through the application API/);
|
|
});
|
|
|
|
it("fails when the Coolify seed is combined into the pre-deployment command slot", () => {
|
|
const combinedSeedWorkflow = VALID_WORKFLOW
|
|
.replace(/post_deployment_command: process\.env\.SEED_COMMAND, /g, "")
|
|
.replace(
|
|
/MIGRATION_COMMAND: cd \/myapp && pnpm exec prisma migrate deploy --config prisma\.config\.ts/g,
|
|
"$& && NODE_ENV=production node packages/db/dist/production-seed.server.js"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(combinedSeedWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /must set post_deployment_command to the guarded seed/);
|
|
});
|
|
|
|
it("fails when the Coolify migration command configuration runs after Coolify deploy", () => {
|
|
const migrationMatch = VALID_WORKFLOW.match(
|
|
/ - name: Configure Coolify staging migration command[\s\S]*?(?= - name: Trigger Coolify staging deploy)/
|
|
);
|
|
assert.ok(migrationMatch);
|
|
const migrationStep = migrationMatch[0];
|
|
const withoutMigration = VALID_WORKFLOW.replace(migrationStep, "");
|
|
const afterCoolify = withoutMigration.replace(
|
|
" - name: Promote staging images to production aliases\n",
|
|
`${migrationStep} - name: Promote staging images to production aliases\n`
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(afterCoolify));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify migration command configuration must run before Coolify deploy/);
|
|
});
|
|
|
|
it("fails when the Coolify staging health wait step is missing", () => {
|
|
const withoutWait = VALID_WORKFLOW.replace(
|
|
/ - name: Wait for Coolify staging health[\s\S]*? - name: Promote staging images to production aliases/s,
|
|
" - name: Promote staging images to production aliases"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutWait));
|
|
|
|
assert.match(result.failures.join("\n"), /missing Coolify staging health wait step/);
|
|
});
|
|
|
|
it("fails when staging health verification pass-opens a missing health URL", () => {
|
|
const passOpen = VALID_WORKFLOW.replace(
|
|
' HEALTH_URL="https://staging-web.example.invalid"',
|
|
' if [ -z "$HEALTH_URL" ]; then\n echo "$LABEL has no health URL; live revision probe skipped"\n return\n fi\n HEALTH_URL="https://staging-web.example.invalid"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(passOpen));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging health wait must not pass-open missing health URLs for HTTP resources/);
|
|
});
|
|
|
|
it("fails when production health verification pass-opens a missing health URL", () => {
|
|
const passOpen = VALID_WORKFLOW.replace(
|
|
' HEALTH_URL="https://production-web.example.invalid"',
|
|
' if [ -z "$HEALTH_URL" ]; then\n echo "$LABEL has no health URL; live revision probe skipped"\n return\n fi\n HEALTH_URL="https://production-web.example.invalid"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(passOpen));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify production health wait must not pass-open missing health URLs for HTTP resources/);
|
|
});
|
|
|
|
it("fails when staging health revision is not parsed from the live health response", () => {
|
|
const staleRevision = VALID_WORKFLOW.replace(
|
|
/HEALTH_REVISION="\$\(node -e 'const fs = require\("node:fs"\); const payload = JSON\.parse\(fs\.readFileSync\(process\.argv\[1\], "utf8"\)\); process\.stdout\.write\(payload\.revision \|\| ""\);' "\$HEALTH_JSON"\)"/,
|
|
'HEALTH_REVISION="$IMAGE_TAG"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleRevision));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging health wait must verify \/healthz revision against IMAGE_TAG/);
|
|
});
|
|
|
|
it("fails when production health revision is not parsed from the live health response", () => {
|
|
const staleRevision = VALID_WORKFLOW.replace(
|
|
/HEALTH_URL="https:\/\/production-web\.example\.invalid"[\s\S]*?if \[ "\$HEALTH_REVISION" != "\$IMAGE_TAG" \]; then\n exit 1\n fi/,
|
|
'HEALTH_URL="https://production-web.example.invalid"\n curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$APP_JSON"\n HEALTH_REVISION="$IMAGE_TAG"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleRevision));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify production health wait must verify \/healthz revision against IMAGE_TAG/);
|
|
});
|
|
|
|
it("fails when staging dependent health revision is not parsed from the live health response", () => {
|
|
const staleRevision = VALID_WORKFLOW.replace(
|
|
/HEALTH_URL="https:\/\/staging-api\.example\.invalid"[\s\S]*?if \[ "\$HEALTH_REVISION" != "\$IMAGE_TAG" \]; then\n exit 1\n fi/,
|
|
'HEALTH_URL="https://staging-api.example.invalid"\n curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$APP_JSON"\n HEALTH_REVISION="$IMAGE_TAG"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleRevision));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging dependent health wait must verify \/healthz revision against IMAGE_TAG/);
|
|
});
|
|
|
|
it("fails when production dependent health revision is not parsed from the live health response", () => {
|
|
const staleRevision = VALID_WORKFLOW.replace(
|
|
/HEALTH_URL="https:\/\/production-api\.example\.invalid"[\s\S]*?if \[ "\$HEALTH_REVISION" != "\$IMAGE_TAG" \]; then\n exit 1\n fi/,
|
|
'HEALTH_URL="https://production-api.example.invalid"\n curl --fail --silent --show-error "$HEALTH_URL/healthz" > "$APP_JSON"\n HEALTH_REVISION="$IMAGE_TAG"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleRevision));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify production dependent health wait must verify \/healthz revision against IMAGE_TAG/);
|
|
});
|
|
|
|
it("fails when staging web health does not verify service identity", () => {
|
|
const missingServiceCheck = VALID_WORKFLOW.replace(' EXPECTED_SERVICE="${5:-web}"\n', "");
|
|
const result = verifyGiteaDeployWorkflow(createFixture(missingServiceCheck));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging web health wait must verify service=web/);
|
|
});
|
|
|
|
it("fails when staging dependent health does not verify API service identity", () => {
|
|
const missingServiceCheck = VALID_WORKFLOW.replace(' EXPECTED_SERVICE="${5:-api}"\n', "");
|
|
const result = verifyGiteaDeployWorkflow(createFixture(missingServiceCheck));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging dependent health wait must verify service=api/);
|
|
});
|
|
|
|
it("fails when staging dependent non-HTTP resources compare image tags to the full SHA", () => {
|
|
const staleAliasCheck = VALID_WORKFLOW.replace(
|
|
`EXPECTED_IMAGE_ALIAS="\${4:-staging}"
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("staging")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then`,
|
|
`CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write(process.env.IMAGE_TAG || "")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$IMAGE_TAG" ]; then`
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleAliasCheck));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when staging dependent non-HTTP resources expect the production alias", () => {
|
|
const staleAlias = VALID_WORKFLOW.replace('EXPECTED_IMAGE_ALIAS="\${4:-staging}"', 'EXPECTED_IMAGE_ALIAS="\${4:-main}"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleAlias));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when production dependent non-HTTP resources compare image tags to the full SHA", () => {
|
|
const staleAliasCheck = VALID_WORKFLOW.replace(
|
|
`EXPECTED_IMAGE_ALIAS="\${4:-main}"
|
|
CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write("main")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$EXPECTED_IMAGE_ALIAS" ]; then`,
|
|
`CONFIGURED_IMAGE_TAG="$(node -e 'process.stdout.write(process.env.IMAGE_TAG || "")')"
|
|
if [ -z "$HEALTH_URL" ] && [ "$CONFIGURED_IMAGE_TAG" != "$IMAGE_TAG" ]; then`
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleAliasCheck));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify production dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when production dependent non-HTTP resources expect the staging alias", () => {
|
|
const staleAlias = VALID_WORKFLOW.replace('EXPECTED_IMAGE_ALIAS="\${4:-main}"', 'EXPECTED_IMAGE_ALIAS="\${4:-staging}"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleAlias));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify production dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when dependent health wait can HTTP-probe worker resources", () => {
|
|
const workerProbeRegression = VALID_WORKFLOW.replace(
|
|
/ WORKER_RESOURCE="\$\(node -e 'process\.stdout\.write\("false"\)'\)"[\s\S]*? if \[ -z "\$HEALTH_URL" \] && \[ "\$HTTP_RESOURCE" = "true" \]; then/,
|
|
' if [ -z "$HEALTH_URL" ] && [ "$HTTP_RESOURCE" = "true" ]; then'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(workerProbeRegression));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when dependent health wait omits a worker queue-group mapping", () => {
|
|
const missingPluginQueueMapping = VALID_WORKFLOW.replace(' EXPECTED_WORKER_QUEUES="toolctrl.plugin-invoke"\n', "");
|
|
const result = verifyGiteaDeployWorkflow(createFixture(missingPluginQueueMapping));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging dependent health wait must fail HTTP resources without health URLs, require worker execute health status=ready and workerEnabled=true, and compare alias tags for non-HTTP resources/
|
|
);
|
|
});
|
|
|
|
it("fails when dependent deployment waits do not aggregate background failures", () => {
|
|
const missingWaitAggregation = VALID_WORKFLOW.replace(
|
|
' for PID in $DEPLOYMENT_WAIT_PIDS; do\n wait "$PID"\n done',
|
|
' echo "deployment waits started"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(missingWaitAggregation));
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging dependent deployment waits must fan out with PID aggregation/);
|
|
});
|
|
|
|
it("fails when dependent resource health checks do not aggregate background failures", () => {
|
|
const missingResourceAggregation = VALID_WORKFLOW.replace(
|
|
' for PID in $RESOURCE_WAIT_PIDS; do\n wait "$PID"\n done',
|
|
' echo "resource health checks started"'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(missingResourceAggregation));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging dependent health wait must fan out STAGING_DEPENDENT_RESOURCE_UUIDS one UUID per worker and aggregate failures/
|
|
);
|
|
});
|
|
|
|
it("fails when a Coolify trigger is pass-opened with shell control operators", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
'STAGING_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID")"',
|
|
'STAGING_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID")" || true'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify deploy trigger must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a later Coolify trigger is pass-opened", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
'PRODUCTION_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$PRODUCTION_WEB_RESOURCE_UUID")"',
|
|
'PRODUCTION_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$PRODUCTION_WEB_RESOURCE_UUID")" || true'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify deploy trigger must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a Coolify trigger helper is removed", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
'STAGING_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID")"',
|
|
'STAGING_WEB_DEPLOYMENT_UUID="$STAGING_WEB_RESOURCE_UUID"'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging deploy must trigger only STAGING_WEB_RESOURCE_UUID first/);
|
|
});
|
|
|
|
it("fails when a Coolify trigger prints the raw response instead of capturing the deployment UUID", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
'STAGING_WEB_DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID")"',
|
|
'node scripts/coolify-deploy.mjs trigger "$STAGING_WEB_RESOURCE_UUID"'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify deploy trigger must suppress raw response output/);
|
|
});
|
|
|
|
it("fails when staging Coolify configuration falls back to production credentials", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(`${VALID_WORKFLOW}
|
|
- name: Unsafe staging fallback
|
|
run: |
|
|
SELECTED_COOLIFY_API_URL="\${COOLIFY_STAGING_API_URL:-$COOLIFY_API_URL}"
|
|
SELECTED_COOLIFY_TOKEN="\${COOLIFY_STAGING_TOKEN:-$COOLIFY_TOKEN}"
|
|
`)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /staging Coolify configuration must not fall back to production Coolify URL or token/);
|
|
});
|
|
|
|
it("fails when a staging Coolify step directly references production variables", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
"COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}",
|
|
"COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}\n COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }}"
|
|
).replace('"$COOLIFY_STAGING_API_URL/deploy?uuid=$STAGING_WEB_RESOURCE_UUID"', '"$COOLIFY_API_URL/deploy?uuid=$STAGING_WEB_RESOURCE_UUID"')
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /staging Coolify steps must not reference production Coolify URL or token variables/);
|
|
});
|
|
|
|
it("fails when a production Coolify step directly references staging variables", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
"COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }}",
|
|
"COOLIFY_API_URL: ${{ vars.COOLIFY_API_URL }}\n COOLIFY_STAGING_API_URL: ${{ vars.COOLIFY_STAGING_API_URL }}"
|
|
).replace('"$COOLIFY_API_URL/deploy?uuid=$PRODUCTION_WEB_RESOURCE_UUID"', '"$COOLIFY_STAGING_API_URL/deploy?uuid=$PRODUCTION_WEB_RESOURCE_UUID"')
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /production Coolify steps must not reference staging Coolify URL or token variables/);
|
|
});
|
|
|
|
it("fails when staging dependent Coolify resources are deployed as a comma-valued UUID", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
` for RESOURCE_UUID in $(printf "%s" "$STAGING_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$RESOURCE_UUID")"
|
|
STAGING_DEPENDENT_DEPLOYMENT_UUIDS="\${STAGING_DEPENDENT_DEPLOYMENT_UUIDS:+$STAGING_DEPENDENT_DEPLOYMENT_UUIDS,}$DEPLOYMENT_UUID"
|
|
done`,
|
|
` DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$STAGING_DEPENDENT_RESOURCE_UUIDS")"
|
|
STAGING_DEPENDENT_DEPLOYMENT_UUIDS="$DEPLOYMENT_UUID"`
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify staging dependent resources must deploy one UUID per request/);
|
|
});
|
|
|
|
it("fails when production dependent Coolify resources are deployed as a comma-valued UUID", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
` for RESOURCE_UUID in $(printf "%s" "$PRODUCTION_DEPENDENT_RESOURCE_UUIDS" | tr ',' ' '); do
|
|
DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$RESOURCE_UUID")"
|
|
PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS="\${PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS:+$PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS,}$DEPLOYMENT_UUID"
|
|
done`,
|
|
` DEPLOYMENT_UUID="$(node scripts/coolify-deploy.mjs trigger "$PRODUCTION_DEPENDENT_RESOURCE_UUIDS")"
|
|
PRODUCTION_DEPENDENT_DEPLOYMENT_UUIDS="$DEPLOYMENT_UUID"`
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /Coolify production dependent resources must deploy one UUID per request/);
|
|
});
|
|
|
|
it("fails when production image promotion ends with an unconditional true", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
VALID_WORKFLOW.replace(
|
|
' docker buildx imagetools create --tag "$IMAGE:main" "$IMAGE:$IMAGE_TAG"\n done\n',
|
|
' docker buildx imagetools create --tag "$IMAGE:main" "$IMAGE:$IMAGE_TAG"\n done\n true\n'
|
|
)
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /required production image promotion step must not use shell success masking/);
|
|
});
|
|
|
|
it("fails when a Coolify trigger is missing", () => {
|
|
const withoutCoolify = VALID_WORKFLOW.replace(
|
|
/ - name: Trigger Coolify staging deploy[\s\S]*? - name: Promote staging images/s,
|
|
" - name: Promote staging images"
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutCoolify));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify trigger must use production and staging Coolify configuration slots|missing Coolify staging health wait step/
|
|
);
|
|
});
|
|
|
|
it("fails when api and worker Coolify resource coverage is missing", () => {
|
|
const webOnlyWorkflow = VALID_WORKFLOW.replace(/ API_IMAGE="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_IMAGE="[^"]+"\n/g, "")
|
|
.replace(/ API_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_GENERAL_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_MAIL_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_IMPORT_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_WEBHOOK_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(/ WORKER_PLUGIN_RESOURCE_UUID="[^"]+"\n/g, "")
|
|
.replace(
|
|
/ WORKER_RESOURCE_UUIDS="\$WORKER_GENERAL_RESOURCE_UUID,\$WORKER_MAIL_RESOURCE_UUID,\$WORKER_IMPORT_RESOURCE_UUID,\$WORKER_WEBHOOK_RESOURCE_UUID,\$WORKER_PLUGIN_RESOURCE_UUID"\n/g,
|
|
""
|
|
)
|
|
.replace(/web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/g, "web application")
|
|
.replace(/ DEPENDENT_RESOURCE_UUIDS="\$API_RESOURCE_UUID,\$WORKER_RESOURCE_UUIDS"\n/g, ' DEPENDENT_RESOURCE_UUIDS=""\n');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(webOnlyWorkflow));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/
|
|
);
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify production deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/
|
|
);
|
|
});
|
|
|
|
it("fails when the mail worker Coolify resource is missing", () => {
|
|
const withoutMailWorker = VALID_WORKFLOW.replace(/WORKER_MAIL_RESOURCE_UUID/g, "WORKER_REMOVED_RESOURCE_UUID")
|
|
.replace(
|
|
/\$WORKER_GENERAL_RESOURCE_UUID,\$WORKER_REMOVED_RESOURCE_UUID,\$WORKER_IMPORT_RESOURCE_UUID,\$WORKER_WEBHOOK_RESOURCE_UUID,\$WORKER_PLUGIN_RESOURCE_UUID/g,
|
|
"$WORKER_GENERAL_RESOURCE_UUID,$WORKER_IMPORT_RESOURCE_UUID,$WORKER_WEBHOOK_RESOURCE_UUID,$WORKER_PLUGIN_RESOURCE_UUID"
|
|
)
|
|
.replace(/"\$WORKER_GENERAL_RESOURCE_UUID" "\$WORKER_REMOVED_RESOURCE_UUID"/g, '"$WORKER_GENERAL_RESOURCE_UUID"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(withoutMailWorker));
|
|
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify staging deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/
|
|
);
|
|
assert.match(
|
|
result.failures.join("\n"),
|
|
/Coolify production deploy resources must be checked for web, api, worker-general, worker-mail, worker-import, worker-webhook, and worker-plugin applications/
|
|
);
|
|
});
|
|
|
|
it("fails when a literal Coolify URL is present", () => {
|
|
const literalUrlWorkflow = `${VALID_WORKFLOW}
|
|
- name: Unsafe literal Coolify URL
|
|
run: curl https://coolify.invalid/api/v1/applications
|
|
`;
|
|
const result = verifyGiteaDeployWorkflow(createFixture(literalUrlWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /literal Coolify\/webhook URL is forbidden/);
|
|
});
|
|
|
|
it("fails when promote rebuilds instead of retagging staging images", () => {
|
|
const rebuildWorkflow = VALID_WORKFLOW.replace(
|
|
'docker buildx imagetools create --tag "$IMAGE:main" "$IMAGE:$IMAGE_TAG"',
|
|
'docker buildx build --push --target "$TARGET" --tag "$IMAGE:main" .'
|
|
);
|
|
const result = verifyGiteaDeployWorkflow(createFixture(rebuildWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /promote must retag the verified full-SHA images to production aliases without rebuilding/);
|
|
});
|
|
|
|
it("fails when the production Sentry deploy marker uses the dispatcher SHA instead of the promoted image tag", () => {
|
|
const staleSentryWorkflow = VALID_WORKFLOW.replace('RELEASE_SHA="${IMAGE_TAG}"', 'RELEASE_SHA="${PROMOTE_SHA:-${SHA}}"');
|
|
const result = verifyGiteaDeployWorkflow(createFixture(staleSentryWorkflow));
|
|
|
|
assert.match(result.failures.join("\n"), /production Sentry deploy marker must release IMAGE_TAG for promote/);
|
|
});
|
|
|
|
it("fails when a literal secret-shaped value is present", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(`${VALID_WORKFLOW} - name: Bad token\n env:\n TOKEN: ghp_1234567890abcdef1234567890abcdef\n`)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /\.gitea\/workflows\/deploy\.yml:\d+: literal secret-shaped value is forbidden/);
|
|
});
|
|
|
|
it("fails when the internal host override uses the reserved GITEA_ prefix", () => {
|
|
const result = verifyGiteaDeployWorkflow(createFixture(`env:\n GITEA_HOST_IP: \${{ vars.GITEA_HOST_IP }}\n${VALID_WORKFLOW}`));
|
|
|
|
assert.match(result.failures.join("\n"), /GITEA_HOST_IP is forbidden because Gitea reserves the GITEA_ variable prefix/);
|
|
});
|
|
|
|
it("fails when the internal host override is written without token validation", () => {
|
|
const result = verifyGiteaDeployWorkflow(
|
|
createFixture(
|
|
`${VALID_WORKFLOW}\n host-smoke:\n runs-on: toolctrl-ci\n steps:\n - name: Resolve internal host\n run: |\n if [ -n \"\${TOOLCTRL_GITEA_HOST_IP:-}\" ]; then\n printf '%s git.tool-ctrl.de\\n' \"$TOOLCTRL_GITEA_HOST_IP\" >> /etc/hosts\n fi\n`
|
|
)
|
|
);
|
|
|
|
assert.match(result.failures.join("\n"), /TOOLCTRL_GITEA_HOST_IP must be validated before writing to \/etc\/hosts/);
|
|
});
|
|
|
|
it("CLI exits non-zero and prints path-specific output for an invalid workflow", () => {
|
|
const rootDir = createFixture(null);
|
|
const scriptPath = path.resolve("scripts/verify-gitea-deploy-workflow.mjs");
|
|
|
|
assert.throws(
|
|
() => execFileSync(process.execPath, [scriptPath], { cwd: rootDir, encoding: "utf8", stdio: "pipe" }),
|
|
(error) => {
|
|
assert.equal(error.status, 1);
|
|
assert.match(error.stderr, /^FAIL Gitea deploy workflow verification/);
|
|
assert.match(error.stderr, /FAIL \.gitea\/workflows\/deploy\.yml: missing required Gitea deploy workflow file/);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
});
|