masterplan-lock / check (push) Successful in 5s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 15s
CI (SIAX Cloud) / integration (push) Successful in 25s
CI (SIAX Cloud) / quality (push) Successful in 34s
masterplan-lock / check (pull_request) Successful in 7s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / integration (pull_request) Successful in 25s
CI (SIAX Cloud) / quality (pull_request) Successful in 35s
181 lines
9.6 KiB
YAML
181 lines
9.6 KiB
YAML
name: Deploy to Coolify
|
|
|
|
# Follows the fleet-wide pattern used by sax3l/n0d and sax3l/st0re: push to
|
|
# main triggers a POST to Coolify's deploy endpoint for this app's UUID,
|
|
# authenticated with the COOLIFY_TOKEN repo secret. Coolify already has the
|
|
# Dockerfile (build_pack: dockerfile) -- this workflow only fires the
|
|
# rebuild+redeploy, it doesn't build or push an image itself.
|
|
#
|
|
# STATUS (2026-08-09): no Coolify application has been provisioned for 0pt
|
|
# yet -- siax.config.json's `deploy.target` was flipped from "vercel" to
|
|
# "coolify" to state the intended platform, but there is no app UUID to
|
|
# point at. COOLIFY_APP_ID below is a literal placeholder. The guard step
|
|
# fails the job loudly (rather than silently POSTing to a bogus/empty URL)
|
|
# until both the Coolify app exists and its real UUID replaces the
|
|
# placeholder here. See docs/architecture/ARCHITECTURE.md §5/§8 for the
|
|
# open "Vercel or fleet" question this resolves the config side of.
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
COOLIFY_APP_ID: ${{ vars.COOLIFY_APP_ID || 'TODO-no-coolify-app-provisioned-yet' }}
|
|
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
|
|
steps:
|
|
# -----------------------------------------------------------------
|
|
# SIGNED-ARTIFACT GATE (added 2026-09-14, same pattern as
|
|
# sax3l/cl0ud + sax3l/st0re's fix/wire-signed-digest-to-deploy): once
|
|
# the guard step below stops failing (i.e. once a real Coolify app
|
|
# exists for 0pt and COOLIFY_APP_ID is filled in), this job will
|
|
# REFUSE to call Coolify at all unless a cosign-verified image exists
|
|
# in the registry for the EXACT commit being deployed.
|
|
# `.gitea/workflows/release.yml` (added in this same change) is the
|
|
# piece that builds, SBOMs, signs, and pushes that image.
|
|
#
|
|
# HONEST LIMITATION (identical to cl0ud/st0re/h0st's writeup):
|
|
# `siax.config.json`'s `deploy.target: "coolify"` and this file's own
|
|
# `/api/v1/deploy` call assume a `dockerfile` build-pack Application
|
|
# once provisioned -- same as cl0ud/st0re/h0st, Coolify would clone
|
|
# this repo and run its OWN independent `docker build`, never pulling
|
|
# the image release.yml pushed and signed, and `/api/v1/deploy` has no
|
|
# digest parameter. True digest-pinning would need 0pt's eventual
|
|
# Coolify app migrated to the separate "Docker Image" resource type --
|
|
# a production infrastructure decision that hasn't been made yet
|
|
# (0pt isn't even provisioned on Coolify at all as of this writing;
|
|
# see the guard step below and docs/runbook/deploy.md §1), not
|
|
# something this CI-YAML change can or should decide.
|
|
#
|
|
# So: what this DOES guarantee, from the day 0pt's Coolify app exists
|
|
# -- Coolify is only ever told to rebuild+redeploy a commit whose
|
|
# release-chain artifact was built, SBOM'd, signed, and had its
|
|
# signature RE-verified in THIS job. What it does NOT guarantee --
|
|
# bit-identical bytes between the signed image and whatever Coolify's
|
|
# own rebuild produces from the same source.
|
|
# -----------------------------------------------------------------
|
|
- name: Guard against unset token / placeholder app id
|
|
run: |
|
|
if [ -z "${{ secrets.COOLIFY_TOKEN }}" ]; then
|
|
echo "::error::COOLIFY_TOKEN secret is not set on this repo -- cannot redeploy."
|
|
exit 1
|
|
fi
|
|
if [ "$COOLIFY_APP_ID" = "TODO-no-coolify-app-provisioned-yet" ]; then
|
|
echo "::error::No Coolify app has been provisioned for 0pt yet. Create the app in the Coolify UI (cloud.siax.io), copy its UUID into COOLIFY_APP_ID in this file, and set the COOLIFY_TOKEN repo secret before this workflow can deploy anything."
|
|
exit 1
|
|
fi
|
|
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve this commit's release-chain tag (same scheme as release.yml)
|
|
id: sha
|
|
run: |
|
|
set -euo pipefail
|
|
short="$(git rev-parse --short=12 HEAD)"
|
|
echo "short=$short" >> "$GITHUB_OUTPUT"
|
|
echo "image_ref=${IMAGE_REPO}:sha-$short" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Docker login (git.cloud.siax.io) — read access to check release.yml's output
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
registry_host="$(echo "$IMAGE_REPO" | cut -d/ -f1)"
|
|
echo "$REGISTRY_TOKEN" | docker login "$registry_host" -u "$REGISTRY_USER" --password-stdin
|
|
|
|
- name: Install cosign v3.1.3 (pinned, checksum-verified — never :latest)
|
|
run: |
|
|
set -euo pipefail
|
|
cd /tmp
|
|
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
|
|
curl -fsSL -O https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign_checksums.txt
|
|
grep 'cosign-linux-amd64$' cosign_checksums.txt | sha256sum -c -
|
|
install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
|
|
cosign version
|
|
|
|
- name: Wait for release.yml's signed artifact for THIS commit, then verify (fail closed)
|
|
id: verify
|
|
env:
|
|
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
|
|
SHORT: ${{ steps.sha.outputs.short }}
|
|
run: |
|
|
set -euo pipefail
|
|
test -f cosign.pub || { echo "::error::cosign.pub not found at repo root — cannot verify, refusing to deploy"; exit 1; }
|
|
|
|
# release.yml runs as a SEPARATE workflow triggered by the same push
|
|
# (Gitea/GitHub Actions has no cross-workflow-file `needs:`), so this
|
|
# job cannot depend on that job directly — it polls the registry for
|
|
# the artifact release.yml produces instead. Bounded: a genuinely
|
|
# broken/skipped release run fails this job loudly rather than
|
|
# hanging forever.
|
|
#
|
|
# The loop retries TWO distinct not-ready states (measured live on
|
|
# run 2265dbb 2026-09-15: deploy pulled the image at 02:36:45 and
|
|
# failed verify at 02:37:03 with "no signatures found" while the
|
|
# release run attached the signature at 02:37:11 — an 8-second race
|
|
# that the pull-only retry missed):
|
|
# 1. tag not pushed yet (release still building)
|
|
# 2. tag pushed but signature not attached yet (release between
|
|
# push and cosign sign)
|
|
attempt=0
|
|
max_attempts=40
|
|
digest=""
|
|
while true; do
|
|
attempt=$((attempt + 1))
|
|
if [ "$attempt" -ge "$max_attempts" ]; then
|
|
echo "::error::no signed image found at $IMAGE_REF after $max_attempts attempts (10 min) — release.yml has not produced a signed artifact for sha-$SHORT. Refusing to deploy an unverified rebuild."
|
|
cat /tmp/pull.log 2>/dev/null || true
|
|
cat /tmp/verify.log 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
if ! docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; then
|
|
echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..."
|
|
sleep 15
|
|
continue
|
|
fi
|
|
|
|
# Read back the digest the registry actually stored for this tag —
|
|
# same filter release.yml itself uses (RepoDigests can hold multiple
|
|
# entries; never trust index 0) — and verify THAT digest, never the
|
|
# mutable tag alone, so a tag reassigned after signing can't slip past.
|
|
repo_digest="$(docker inspect --format='{{json .RepoDigests}}' "$IMAGE_REF" \
|
|
| node -e "const rd=JSON.parse(require('fs').readFileSync(0,'utf8')); const prefix=process.env.IMAGE_REPO+'@'; const m=rd.find(r=>r.startsWith(prefix)); if(!m){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(m);")"
|
|
digest="${repo_digest##*@}"
|
|
echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; }
|
|
|
|
echo "verifying cosign signature on ${IMAGE_REPO}@${digest} ..."
|
|
if cosign verify --key cosign.pub "${IMAGE_REPO}@${digest}" >/tmp/verify.log 2>&1; then
|
|
break
|
|
fi
|
|
if grep -q "no signatures found" /tmp/verify.log; then
|
|
echo "image present but signature not attached yet (attempt $attempt/$max_attempts) — waiting for release.yml's cosign sign..."
|
|
sleep 15
|
|
continue
|
|
fi
|
|
echo "::error::cosign verify FAILED for ${IMAGE_REPO}@${digest} (not a not-ready state — refusing to deploy)"
|
|
cat /tmp/verify.log
|
|
exit 1
|
|
done
|
|
|
|
echo "digest=$digest" >> "$GITHUB_OUTPUT"
|
|
echo "SIGNED ARTIFACT VERIFIED: ${IMAGE_REPO}@${digest} — authorizing Coolify redeploy of sha-$SHORT"
|
|
|
|
- name: Trigger Coolify redeploy
|
|
env:
|
|
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
|
VERIFIED_DIGEST: ${{ steps.verify.outputs.digest }}
|
|
run: |
|
|
# See the SIGNED-ARTIFACT GATE comment above: Coolify's /deploy has
|
|
# no digest/image param to pass, so $VERIFIED_DIGEST is not (and
|
|
# cannot be) sent to Coolify — it is recorded here as the
|
|
# provenance proof that authorized this specific redeploy call.
|
|
echo "authorized by verified digest: $VERIFIED_DIGEST"
|
|
curl -fsS -X POST \
|
|
"https://cloud.siax.io/api/v1/deploy?uuid=${COOLIFY_APP_ID}&force=false" \
|
|
-H "Authorization: Bearer $COOLIFY_TOKEN" \
|
|
-o /dev/null -w 'coolify deploy HTTP %{http_code}\n'
|