Files
0pt/.gitea/workflows/release.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

276 lines
13 KiB
YAML

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