CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 5s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m49s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m1s
The runbook still described Vercel and an unprovisioned Coolify app. It now documents the verified chain (ci -> release attestation -> cd -> Coolify), the proven manual dispatch, the verification endpoints and the rollback.
207 lines
11 KiB
YAML
207 lines
11 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 (verified 2026-11-02): LIVE. The Coolify application exists
|
|
# (dockerfile build pack, git.siax.io/sax3l/0pt, branch main) and its UUID is
|
|
# supplied by the repository variable COOLIFY_APP_ID; COOLIFY_TOKEN and the
|
|
# registry/cosign secrets are set. A push to main, or a manual
|
|
# workflow_dispatch of this file, has been proven to verify the signed
|
|
# attestation for the exact commit and then return HTTP 200 from Coolify.
|
|
# The placeholder guard below remains as a fail-closed guard, not as a TODO.
|
|
# Operational procedure and verification live in docs/runbook/deploy.md.
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
|
|
# Never let two deploys race the same Coolify app. Unlike CI we do NOT cancel an
|
|
# in-flight deploy (it may already be restarting the container); the newest
|
|
# commit still wins because Coolify builds from the branch head.
|
|
concurrency:
|
|
group: deploy-0pt-main
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
deploy:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
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; this
|
|
# job's own wait-for-signed-artifact polling logic below predates and
|
|
# is independent of the 2026-09-15 G17 migration, and is KEPT as-is
|
|
# here -- it is a real, working, 0pt-specific mechanism, not part of
|
|
# what that migration delegates to siax-templates): 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 verified attestation exists in the registry
|
|
# for the EXACT commit being deployed. `.gitea/workflows/release.yml`
|
|
# is the piece that builds, SBOMs, scans, attests, and pushes that
|
|
# image (rewritten 2026-09-15 to delegate to sax3l/siax-templates'
|
|
# G17 reference hook -- see `.siax/hooks/README.md`).
|
|
#
|
|
# VERIFY STEP CHANGED 2026-09-15 (part of the G17 migration): the old
|
|
# release.yml called `cosign sign` on the bare digest, so this job's
|
|
# gate used `cosign verify`. The new release.yml calls `cosign attest`
|
|
# instead -- a full DSSE-signed in-toto Statement binding digest +
|
|
# source SHA + build identity + environment + SBOM hash (ADR-W16-003
|
|
# bar items 4/5), not just a bare-digest signature. `cosign verify`
|
|
# against a `cosign attest`-only artifact fails with "no matching
|
|
# signatures" (attestations and signatures are stored/verified
|
|
# separately in cosign's model) -- so this step now calls
|
|
# `cosign verify-attestation --type <predicate-type>` instead, the
|
|
# same command `.siax/hooks/G17.sh` itself already runs and requires
|
|
# to PASS before it will submit evidence to AUD0.
|
|
#
|
|
# 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 }}
|
|
PREDICATE_TYPE: https://siax.io/attestations/g17-release-evidence/v1
|
|
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, under the OLD bare-signature gate — the
|
|
# same race applies to attestation-attach timing today): tag not
|
|
# pushed yet (release still building) vs. tag pushed but the
|
|
# attestation not attached yet (release between push and
|
|
# `cosign attest`).
|
|
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 attestation on ${IMAGE_REPO}@${digest} ..."
|
|
if cosign verify-attestation --key cosign.pub --type "$PREDICATE_TYPE" "${IMAGE_REPO}@${digest}" >/tmp/verify.log 2>&1; then
|
|
break
|
|
fi
|
|
if grep -qE "no matching (attestations|signatures)( found)?" /tmp/verify.log; then
|
|
echo "image present but attestation not attached yet (attempt $attempt/$max_attempts) — waiting for release.yml's cosign attest..."
|
|
sleep 15
|
|
continue
|
|
fi
|
|
echo "::error::cosign verify-attestation 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'
|