Files
0pt/.gitea/workflows/cd.yml
T
admin ea40a67940
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
docs(deploy): runbook and workflow headers match the live pipeline
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.
2026-11-02 18:57:45 +01:00

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'