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
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>
276 lines
13 KiB
YAML
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
|