Files
0pt/.gitea/workflows/cd.yml
T
adminandClaude Sonnet 5 5a0429d46b
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 13s
CI (SIAX Cloud) / quality (push) Successful in 32s
Security Scan (SIAX Cloud) / security (push) Successful in 52s
CI (SIAX Cloud) / integration (push) Successful in 1m35s
fix(ci): add SBOM+sign release chain and gate Coolify redeploy on it
0pt had no SBOM/sign/digest chain at all (re-confirmed live before this
change: no cosign/syft/sbom hit anywhere under .gitea/workflows/, only doc
mentions in CHANGELOG.md/docs/architecture/ARCHITECTURE.md). Applies the
same two-part pattern already proven on sax3l/cl0ud and sax3l/st0re
(fix/wire-signed-digest-to-deploy, 2026-09-14):

1. .gitea/workflows/release.yml (new): builds the image, pushes it to
   git.cloud.siax.io/sax3l/0pt:sha-<12>, SBOMs it (syft), signs it (cosign,
   key-based), verifies the signature in the same run, and commits
   source-bundle/production-evidence records back to the repo.
2. .gitea/workflows/cd.yml: the `deploy` job now polls the registry for
   this exact commit's release-chain image and cosign-verifies it (against
   the committed cosign.pub) before calling Coolify's deploy endpoint --
   fails closed (10 min timeout) if release.yml never produced a verifiable
   artifact for this sha. Added AFTER the existing guard step, which still
   fails closed first on 0pt's real, pre-existing blocker: no Coolify
   Application is provisioned for 0pt yet (COOLIFY_APP_ID is a literal
   placeholder). This gate is wired ahead of that so it's already in place
   the day the app is provisioned, instead of a second follow-up change.

Honest limitation carried over from cl0ud/st0re/h0st: once provisioned,
0pt's Coolify Application is expected to use the `dockerfile` build pack
(per siax.config.json), so Coolify would still perform its own independent
docker build rather than pulling the signed image, and /api/v1/deploy has
no digest parameter to pin against -- true digest-pinning needs a
production Coolify resource-type decision, out of CI-YAML scope. This gate
proves a signed, verified build of the exact commit exists before
authorizing any deploy call; it does not prove byte-identical output from
Coolify's own rebuild.

cosign.pub is a fresh, 0pt-specific keypair generated offline for this
change. NOT YET SET on sax3l/0pt (release.yml/cd.yml's gate will fail
closed until present): REGISTRY_USER, REGISTRY_TOKEN, COSIGN_PRIVATE_KEY,
COSIGN_PASSWORD, SIAX_CI_TOKEN repo secrets.

Validated without live Coolify/registry access: YAML-parsed all 2 files,
bash -n syntax-checked all 19 embedded run: blocks, and dry-ran the actual
verification logic -- the RepoDigests filter and bounded poll-retry loop
against mocked scenarios, plus a real cosign sign+verify round-trip
(offline signing-config, local throwaway registry container) proving the
gate's cosign verify command genuinely passes on the correct key/digest and
fails closed on a wrong key and on an unsigned image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:45:53 +02:00

157 lines
8.4 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.
attempt=0
max_attempts=40
until docker pull "$IMAGE_REF" >/tmp/pull.log 2>&1; 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 an artifact for sha-$SHORT. Refusing to deploy an unverified rebuild."
cat /tmp/pull.log
exit 1
fi
echo "image not yet available at $IMAGE_REF (attempt $attempt/$max_attempts) — waiting for release.yml..."
sleep 15
done
# 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} ..."
cosign verify --key cosign.pub "${IMAGE_REPO}@${digest}"
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'