feat(release-chain): bygg→push→SBOM→cosign-sign→verify + signed-artifact-gate i cd.yml (kl0ud/st0re-mönstret) #59

Merged
admin merged 3 commits from fix/wire-signed-digest-to-deploy into main 2026-09-15 02:05:12 +00:00
4 changed files with 418 additions and 1 deletions
+112
View File
@@ -23,7 +23,38 @@ jobs:
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
@@ -34,10 +65,91 @@ jobs:
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" \
+275
View File
@@ -0,0 +1,275 @@
name: Release chain (P007 pattern)
# ADDED 2026-09-14: 0pt had NO SBOM/sign/digest chain at all before this file
# (re-confirmed live: no cosign/syft/sbom hit anywhere under .gitea/workflows/,
# only doc mentions in CHANGELOG.md/docs/architecture/ARCHITECTURE.md). This
# is the proven sax3l/cl0ud + sax3l/st0re pattern (originally inlined
# 2026-09-10 from sax3l/siax-standard's release-chain.yml, then hardened
# 2026-09-14), authored directly for 0pt because cross-repo `uses:` clones
# anonymously on this Gitea instance and fails with "repository not found"
# against private repos (the same documented trap that forced cl0ud/st0re to
# inline rather than call out to a shared workflow).
#
# Cosign keypair is 0pt-specific (generated offline 2026-09-14, never
# transmitted anywhere); cosign.pub in the repo root is the corresponding
# public key. Neither this Dockerfile nor h0st's declares
# `ARG GIT_SHA`/`ARG BUILD_TIME`, so this file does not pass them as
# --build-arg (would just produce a harmless-but-noisy "not consumed"
# warning) -- GIT_SHA/DIGEST still go into the evidence JSON below either way.
#
# STATUS NOTE (see .gitea/workflows/cd.yml's own header): 0pt has NO Coolify
# Application provisioned yet -- cd.yml's guard step already fails closed on
# the placeholder COOLIFY_APP_ID for that reason, independent of this file.
# This release chain is deliberately built ahead of that: once the app
# exists and cd.yml's placeholder is replaced with a real UUID, the signed-
# artifact gate added to cd.yml in this same change is already wired and
# ready, instead of being a second follow-up piece of work.
#
# Krävda repo-secrets (NOT YET SET on sax3l/0pt as of this writing -- this
# file will fail closed on its first run until Simon sets them, same
# prerequisite cl0ud/st0re needed before their release.yml went green
# 2026-09-10): REGISTRY_USER/REGISTRY_TOKEN (git.cloud.siax.io push),
# COSIGN_PRIVATE_KEY/COSIGN_PASSWORD (the private half of this repo's
# cosign.pub), SIAX_CI_TOKEN (scoped write:repository token for the
# evidence-commit push -- the runner's ephemeral actions token gets a
# pre-receive 500, GetUserByID(uid=-2), same fleet-wide gotcha as cl0ud/st0re).
# Verification: cosign verify --key cosign.pub git.cloud.siax.io/sax3l/0pt@<digest>
on:
push:
branches: [main]
workflow_dispatch:
jobs:
release:
name: Build, push, SBOM, sign, record evidence
runs-on: ubuntu-latest
permissions:
contents: write
env:
IMAGE_REPO: git.cloud.siax.io/sax3l/0pt
DOCKERFILE: Dockerfile
EVIDENCE_DIR: siax
SERVICE_NAME: "@siax/0pt"
REF_NAME: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
steps:
- uses: https://git.siax.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, mirrored + SHA-pinned (SUP-06, same pin n0d/cl0ud/st0re already use)
with:
fetch-depth: 0
- name: Resolve SHA + tag (never :latest)
id: sha
run: |
set -euo pipefail
full="$(git rev-parse HEAD)"
short="$(git rev-parse --short=12 HEAD)"
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "full=$full" >> "$GITHUB_OUTPUT"
echo "short=$short" >> "$GITHUB_OUTPUT"
echo "built_at=$built_at" >> "$GITHUB_OUTPUT"
echo "image_ref=${IMAGE_REPO}:sha-$short" >> "$GITHUB_OUTPUT"
- name: Docker login (git.cloud.siax.io)
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: Build
env:
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
run: |
set -euo pipefail
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE_REF" \
.
- name: Push (tag sha-<12>, never :latest)
env:
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
run: docker push "$IMAGE_REF"
- name: Read back the digest the registry actually stored (never assumed)
id: digest
env:
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
run: |
set -euo pipefail
# `docker inspect`'s RepoDigests can carry MULTIPLE entries (e.g. one for
# whatever this image was originally pulled/built FROM, plus one per registry
# it has since been pushed to) — index 0 is not reliably "the one we just
# pushed". Filter for the entry whose repo matches IMAGE_REPO instead (same
# filter proven live on cl0ud's P007 build-sandbox: a probe push showed
# RepoDigests holding both an "alpine@sha256:..." entry AND the
# "git.cloud.siax.io/...@sha256:..." entry side by side).
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 match=rd.find(r=>r.startsWith(prefix)); if(!match){console.error('no RepoDigests entry starting with '+prefix+' among: '+JSON.stringify(rd));process.exit(1);} console.log(match);")"
digest="${repo_digest##*@}"
echo "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' || { echo "::error::unexpected digest shape: $digest"; exit 1; }
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "pushed and verified: $IMAGE_REF @ $digest"
# KNOWN RISK, NOT YET FIXED (same open issue as cl0ud's/st0re's/h0st's
# copy of this file, flagged 2026-09-03/09-14): this fleet's runner
# egress is confirmed to reach raw.githubusercontent.com but NOT
# github.com/*/releases/download URLs (curl -sfL -> silent failure).
# The two `curl -fsSL -O https://github.com/.../releases/download/...`
# steps below (syft, cosign) use exactly that broken URL shape, and
# neither tool ships a raw.githubusercontent.com-hosted installer that
# avoids a github.com/releases fetch for the actual binary. Left as
# pinned, checksum-verified curl installs, same as cl0ud/st0re/h0st;
# the first real run of this file on 0pt's own runners is the first
# live evidence for OR against this risk actually manifesting here
# (not simulated in this dry-run pass — see PR description).
- name: Install syft v1.51.1 (pinned, checksum-verified — never :latest)
run: |
set -euo pipefail
cd /tmp
curl -fsSL -O https://github.com/anchore/syft/releases/download/v1.51.1/syft_1.51.1_linux_amd64.tar.gz
curl -fsSL -O https://github.com/anchore/syft/releases/download/v1.51.1/syft_1.51.1_checksums.txt
grep 'syft_1.51.1_linux_amd64.tar.gz' syft_1.51.1_checksums.txt | sha256sum -c -
tar -xzf syft_1.51.1_linux_amd64.tar.gz syft
install -m 0755 syft /usr/local/bin/syft
syft version
- 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: SBOM (syft, spdx-json, against the DIGEST not the tag)
env:
DIGEST: ${{ steps.digest.outputs.digest }}
SHORT: ${{ steps.sha.outputs.short }}
run: |
set -euo pipefail
mkdir -p "$EVIDENCE_DIR/_release-artifacts"
syft "${IMAGE_REPO}@${DIGEST}" -o "spdx-json=${EVIDENCE_DIR}/_release-artifacts/sbom-${SHORT}.spdx.json"
- name: Sign (cosign, key-based — see cosign.pub at repo root)
env:
COSIGN_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
DIGEST: ${{ steps.digest.outputs.digest }}
run: |
set -euo pipefail
echo "$COSIGN_KEY" > /tmp/cosign.key
cosign sign --yes --key /tmp/cosign.key "${IMAGE_REPO}@${DIGEST}"
rm -f /tmp/cosign.key
- name: Verify the signature just written (fail closed if it does not verify)
env:
DIGEST: ${{ steps.digest.outputs.digest }}
run: |
set -euo pipefail
test -f cosign.pub || { echo "::error::cosign.pub not found at repo root — commit the PUBLIC key (not a secret) so anyone can verify without CI access"; exit 1; }
cosign verify --key cosign.pub "${IMAGE_REPO}@${DIGEST}"
- name: Write siax.source-bundle + siax.production-evidence records
env:
SHA_FULL: ${{ steps.sha.outputs.full }}
SHA_SHORT: ${{ steps.sha.outputs.short }}
DIGEST: ${{ steps.digest.outputs.digest }}
IMAGE_REF: ${{ steps.sha.outputs.image_ref }}
run: |
set -euo pipefail
mkdir -p "$EVIDENCE_DIR/source-bundles" "$EVIDENCE_DIR/production-evidence"
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
repo_name="$(basename "$(git rev-parse --show-toplevel)")"
cat > "$EVIDENCE_DIR/source-bundles/release-${SHA_SHORT}.json" <<JSON
{
"id": "sb-release-${SHA_SHORT}",
"repo": "$repo_name",
"commitSha": "$SHA_FULL",
"branch": "$REF_NAME",
"buildId": "${RUN_ID}-${RUN_ATTEMPT}",
"artifactId": "$IMAGE_REF",
"producedAt": "$now"
}
JSON
cat > "$EVIDENCE_DIR/production-evidence/release-${SHA_SHORT}-artifact.json" <<JSON
{
"id": "pe-artifact-${SHA_SHORT}",
"repo": "$repo_name",
"evidenceType": "artifact-digest",
"verifiedAt": "$now",
"verifiedBy": "gitea-ci",
"result": "pass",
"proofRef": "pushed $IMAGE_REF to $IMAGE_REPO",
"tool": "docker",
"sourceSha": "$SHA_FULL",
"digest": "$DIGEST"
}
JSON
cat > "$EVIDENCE_DIR/production-evidence/release-${SHA_SHORT}-sbom.json" <<JSON
{
"id": "pe-sbom-${SHA_SHORT}",
"repo": "$repo_name",
"evidenceType": "sbom",
"verifiedAt": "$now",
"verifiedBy": "gitea-ci",
"result": "pass",
"proofRef": "$EVIDENCE_DIR/_release-artifacts/sbom-${SHA_SHORT}.spdx.json",
"tool": "syft",
"sbomFormat": "spdx-json",
"digest": "$DIGEST"
}
JSON
cat > "$EVIDENCE_DIR/production-evidence/release-${SHA_SHORT}-signature.json" <<JSON
{
"id": "pe-signature-${SHA_SHORT}",
"repo": "$repo_name",
"evidenceType": "signature",
"verifiedAt": "$now",
"verifiedBy": "gitea-ci",
"result": "pass",
"proofRef": "cosign verify --key cosign.pub ${IMAGE_REPO}@${DIGEST} (verified in this same run)",
"tool": "cosign",
"signatureTool": "cosign",
"keyless": false,
"digest": "$DIGEST"
}
JSON
# upload-artifact deliberately OMITTED: act_runner on this fleet has no
# GHES-artifact-API, so upload-artifact@v4 always dies even with
# continue-on-error: true and can abort the whole job before the
# evidence-commit step below runs (proven on cl0ud run 23340, fixed
# there 2026-09-14 by deleting the step outright — applied here from
# the start instead of repeating the same bug). Evidence lands in the
# repo via the commit step below regardless.
- name: Commit evidence back to the calling repo
if: true
env:
SHA_SHORT: ${{ steps.sha.outputs.short }}
SIAX_CI_TOKEN: ${{ secrets.SIAX_CI_TOKEN }}
run: |
set -euo pipefail
git config user.name "siax-ci"
git config user.email "ci@siax.io"
git add "$EVIDENCE_DIR/source-bundles" "$EVIDENCE_DIR/production-evidence"
if git diff --cached --quiet; then
echo "no evidence changes to commit"
else
git commit -m "chore(release-chain): evidence for sha-${SHA_SHORT} [skip ci]"
# Runnerns ephemeral actions-token (uid -2) kan inte pusha:
# pre-receive GetUserByID(-2) → 500. Autentisera som riktig
# Gitea-user via SIAX_CI_TOKEN (samma mönster som cl0ud/st0re/h0st).
git config http.https://git.siax.io/.extraheader "AUTHORIZATION: basic $(printf 'x-access-token:%s' "${SIAX_CI_TOKEN}" | base64 -w0)"
git push origin "HEAD:${REF_NAME}"
fi
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3EIrsPWBhSnH6HL7yD9US1RtXDg4
kHaLp8mDefBhepQRFsFPedyWwNaaqEwA3Pe1G2qBdm/FgAMAFkYjZyvgCg==
-----END PUBLIC KEY-----
+27 -1
View File
@@ -14,10 +14,36 @@
(se backup/restore + incident runbooks).
- [ ] DNS-cutover planerad: `0pt.app` → Coolify-appens ingress (eller Caddy).
## 1b. Signerad-artefakt-grind (tillagd 2026-09-14, samma mönster som cl0ud/st0re/h0st)
`.gitea/workflows/release.yml` (nytt) bygger, SBOM:ar (syft), signerar
(cosign) och evidensbokför varje push till `main`, och pushar till
`git.cloud.siax.io/sax3l/0pt:sha-<12>`. `cd.yml`s `deploy`-jobb har nu ett
extra steg (efter guard-steget i punkt 1 ovan) som kräver att exakt denna
commits avbildning finns i registret och att dess cosign-signatur
verifierar (mot committad `cosign.pub`) innan Coolify överhuvudtaget
anropas.
Detta är byggt FÖRE Coolify-appen finns (se blockerarna i punkt 1) — så att
grinden redan är på plats den dag appen provisioneras, i stället för att bli
ett separat uppföljningsarbete. Samma ärliga begränsning som cl0ud/st0re/
h0st: när appen väl provisioneras som `dockerfile` build pack (så som
`siax.config.json` föreskriver) kör Coolify sin EGEN oberoende
`docker build` från git-källan, inte den signerade avbildningen —
`/api/v1/deploy` saknar digest-parameter helt. Sann digest-pinning skulle
kräva att appen provisioneras som Coolifys separata `dockerimage`-resurstyp
i stället, ett beslut som inte är fattat än. Se `.gitea/workflows/cd.yml`s
"SIGNED-ARTIFACT GATE"-kommentar för den fullständiga skrivningen.
**Inte satt på `sax3l/0pt` ännu:** repo-secrets `REGISTRY_USER`,
`REGISTRY_TOKEN`, `COSIGN_PRIVATE_KEY`, `COSIGN_PASSWORD`, `SIAX_CI_TOKEN` —
`release.yml` fail-closed:ar på sin första körning tills dessa finns.
## 2. Deploy (när appen finns)
1. Push till `main` → `.gitea/workflows/cd.yml` POSTar till
`https://cloud.siax.io/api/v1/deploy?uuid=$COOLIFY_APP_ID&force=false`.
`https://cloud.siax.io/api/v1/deploy?uuid=$COOLIFY_APP_ID&force=false`,
efter att den signerade-artefakt-grinden i punkt 1b gått grön.
2. Följ run i Gitea Actions; verifiera att den slutar grön.
3. Verifiera hälsa: `curl https://0pt.app/api/health` → `ok:true` och
`db: configured` (när Postgres kopplats in).