ci: harden release pipeline (SBOM, smoke-test, conditional cosign signing) #144

Merged
goern merged 0 commits from refs/pull/144/head into main 2026-05-24 10:58:45 +00:00
goern commented 2026-05-24 10:43:48 +00:00 (Migrated from codeberg.org)

Summary

Step 3 of CI hardening (see beads forgejo-mcp-51l). Adds supply-chain primitives to the release pipeline:

  • SBOM — one CycloneDX JSON SBOM per release archive (linux/darwin × amd64/arm64), generated by syft via goreleaser's native sboms: directive.
  • Checksums — sha256 checksums.txt (was previously disabled).
  • Smoke-test — boot the freshly-built linux/amd64 binary, grep --help output for the expected version banner. Catches LDFLAGS-injection regressions and dead binaries before .mcpb packaging ships them.
  • Cosign signing (gated) — sign checksums.txt with cosign sign-blob using a key loaded from COSIGN_PRIVATE_KEY + COSIGN_PASSWORD secrets. Skipped with a warning if the secret is unset, so releases keep working until the secret is provisioned. Signing one file covers all artifacts transitively via the checksum chain.

Commits

  1. ci: 🚀 enable checksums and per-archive CycloneDX SBOMs in goreleaser
  2. ci: 🚀 release.yml: syft + cosign install, smoke-test, conditional sign
  3. ci: 🔒️ add cosign-keygen.sh producing SOPS-encrypted k8s Secret

scripts/cosign-keygen.sh

Generates a cosign keypair and emits a SOPS-encrypted Kubernetes Secret manifest. Defenses applied to satisfy "never store unencrypted on disk, never log":

  • Keypair generated inside /dev/shm (tmpfs) when available; falls back to /tmp + shred -uz on exit. Trap on EXIT|INT|TERM|HUP.
  • Password via read -rs (no echo). Minimum 12 chars. unset after Secret YAML is built.
  • SOPS encrypts cosign.key + cosign.password in place via --encrypted-regex. cosign.pub stays plaintext (intended for commit).
  • Neither cosign nor sops echo key material on success paths. set -x never enabled.
  • Refuses to overwrite existing output file.

.gitignore updated: secrets/* ignored, only *.enc.yaml, *.enc.yml, cosign.pub allowed through. secrets/.gitkeep anchors the directory.

Activation steps (post-merge)

  1. Ensure .sops.yaml has a working recipient (age key, KMS, etc.). If not, configure it first.
  2. Run scripts/cosign-keygen.sh — produces secrets/cosign-signing-key.enc.yaml + secrets/cosign.pub.
  3. Commit both files.
  4. Mirror the two encrypted fields into Codeberg Actions repo secrets:
    sops --decrypt secrets/cosign-signing-key.enc.yaml | yq -r '.stringData["cosign.key"]'      → COSIGN_PRIVATE_KEY
    sops --decrypt secrets/cosign-signing-key.enc.yaml | yq -r '.stringData["cosign.password"]' → COSIGN_PASSWORD
    
    (Do this interactively; never redirect to disk.)
  5. Next v* tag push will produce a signed release.

Consumers verify with:

cosign verify-blob \
  --key https://codeberg.org/goern/forgejo-mcp/raw/branch/main/secrets/cosign.pub \
  --signature forgejo-mcp_<version>_checksums.txt.sig \
  forgejo-mcp_<version>_checksums.txt

Design tradeoffs

  • Why key-based and not keyless Sigstore? Codeberg's Forgejo OIDC issuer is not in Fulcio's default trust root, so keyless signatures would not be verifiable by stock cosign without custom trust setup. Key-based is the pragmatic standard.
  • Why sign checksums, not each archive? Goreleaser's recommended pattern; one signature anchors the whole release via the sha256 chain. Simpler verification, smaller attack surface.
  • Why gated on secret? Avoids breaking the release pipeline at merge-time. Trade: signatures don't exist until secret is set — that's an explicit follow-up step, not a silent regression.

Test plan

  • Tag a pre-release (e.g. v2.22.0-rc1) and verify:
    • checksums.txt uploaded
    • *.sbom.cdx.json uploaded per archive
    • Smoke-test step passes (banner matches version)
    • Sign step prints skip warning when secret unset
  • After secret provisioning, re-tag and verify checksums.txt.sig uploaded + cosign verify-blob succeeds
  • Local dry-run of scripts/cosign-keygen.sh confirms tmpfs + shred + encrypted output

Follow-ups

  • Verify .sops.yaml exists/is correct before first cosign-keygen.sh run.
  • Add SBOM signing too (currently only checksums signed).
  • Add container image signing once forgejo-mcp-zuh lands.

Refs forgejo-mcp-51l.

## Summary Step 3 of CI hardening (see beads `forgejo-mcp-51l`). Adds supply-chain primitives to the release pipeline: - **SBOM** — one CycloneDX JSON SBOM per release archive (linux/darwin × amd64/arm64), generated by syft via goreleaser's native `sboms:` directive. - **Checksums** — sha256 `checksums.txt` (was previously disabled). - **Smoke-test** — boot the freshly-built `linux/amd64` binary, grep `--help` output for the expected version banner. Catches LDFLAGS-injection regressions and dead binaries before `.mcpb` packaging ships them. - **Cosign signing** (gated) — sign `checksums.txt` with `cosign sign-blob` using a key loaded from `COSIGN_PRIVATE_KEY` + `COSIGN_PASSWORD` secrets. Skipped with a warning if the secret is unset, so releases keep working until the secret is provisioned. Signing one file covers all artifacts transitively via the checksum chain. ## Commits 1. `ci: 🚀 enable checksums and per-archive CycloneDX SBOMs in goreleaser` 2. `ci: 🚀 release.yml: syft + cosign install, smoke-test, conditional sign` 3. `ci: 🔒️ add cosign-keygen.sh producing SOPS-encrypted k8s Secret` ## scripts/cosign-keygen.sh Generates a cosign keypair and emits a SOPS-encrypted Kubernetes `Secret` manifest. Defenses applied to satisfy "never store unencrypted on disk, never log": - Keypair generated inside `/dev/shm` (tmpfs) when available; falls back to `/tmp` + `shred -uz` on exit. Trap on `EXIT|INT|TERM|HUP`. - Password via `read -rs` (no echo). Minimum 12 chars. `unset` after Secret YAML is built. - SOPS encrypts `cosign.key` + `cosign.password` in place via `--encrypted-regex`. `cosign.pub` stays plaintext (intended for commit). - Neither cosign nor sops echo key material on success paths. `set -x` never enabled. - Refuses to overwrite existing output file. `.gitignore` updated: `secrets/*` ignored, only `*.enc.yaml`, `*.enc.yml`, `cosign.pub` allowed through. `secrets/.gitkeep` anchors the directory. ## Activation steps (post-merge) 1. Ensure `.sops.yaml` has a working recipient (age key, KMS, etc.). If not, configure it first. 2. Run `scripts/cosign-keygen.sh` — produces `secrets/cosign-signing-key.enc.yaml` + `secrets/cosign.pub`. 3. Commit both files. 4. Mirror the two encrypted fields into Codeberg Actions repo secrets: ``` sops --decrypt secrets/cosign-signing-key.enc.yaml | yq -r '.stringData["cosign.key"]' → COSIGN_PRIVATE_KEY sops --decrypt secrets/cosign-signing-key.enc.yaml | yq -r '.stringData["cosign.password"]' → COSIGN_PASSWORD ``` (Do this interactively; never redirect to disk.) 5. Next `v*` tag push will produce a signed release. Consumers verify with: ``` cosign verify-blob \ --key https://codeberg.org/goern/forgejo-mcp/raw/branch/main/secrets/cosign.pub \ --signature forgejo-mcp_<version>_checksums.txt.sig \ forgejo-mcp_<version>_checksums.txt ``` ## Design tradeoffs - **Why key-based and not keyless Sigstore?** Codeberg's Forgejo OIDC issuer is not in Fulcio's default trust root, so keyless signatures would not be verifiable by stock cosign without custom trust setup. Key-based is the pragmatic standard. - **Why sign checksums, not each archive?** Goreleaser's recommended pattern; one signature anchors the whole release via the sha256 chain. Simpler verification, smaller attack surface. - **Why gated on secret?** Avoids breaking the release pipeline at merge-time. Trade: signatures don't exist until secret is set — that's an explicit follow-up step, not a silent regression. ## Test plan - [ ] Tag a pre-release (e.g. `v2.22.0-rc1`) and verify: - [ ] `checksums.txt` uploaded - [ ] `*.sbom.cdx.json` uploaded per archive - [ ] Smoke-test step passes (banner matches version) - [ ] Sign step prints skip warning when secret unset - [ ] After secret provisioning, re-tag and verify `checksums.txt.sig` uploaded + `cosign verify-blob` succeeds - [ ] Local dry-run of `scripts/cosign-keygen.sh` confirms tmpfs + shred + encrypted output ## Follow-ups - Verify `.sops.yaml` exists/is correct before first `cosign-keygen.sh` run. - Add SBOM signing too (currently only checksums signed). - Add container image signing once `forgejo-mcp-zuh` lands. Refs forgejo-mcp-51l.
op1st-gitops commented 2026-05-24 10:43:54 +00:00 (Migrated from codeberg.org)

op1st Pipelines as Code/on-pull-request-hcpvp is running.

Starting Pipelinerun on-pull-request-hcpvp in namespace op1st-pipelines

You can monitor the execution using the op1st Pipelines as Code PipelineRun viewer or through the command line by
using the tkn CLI with the following command:

tkn pr logs -n op1st-pipelines on-pull-request-hcpvp -f

op1st Pipelines as Code/on-pull-request-hcpvp is running. Starting Pipelinerun <b>[on-pull-request-hcpvp](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/on-pull-request-hcpvp)</b> in namespace <b>op1st-pipelines</b> You can monitor the execution using the [op1st Pipelines as Code](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/on-pull-request-hcpvp) PipelineRun viewer or through the command line by using the [tkn](https://tekton.dev/docs/cli/#installation) CLI with the following command: <code>tkn pr logs -n op1st-pipelines on-pull-request-hcpvp -f</code>
op1st-gitops commented 2026-05-24 10:43:54 +00:00 (Migrated from codeberg.org)

op1st Pipelines as Code/code-scans-z4bsm is running.

Starting Pipelinerun code-scans-z4bsm in namespace op1st-pipelines

You can monitor the execution using the op1st Pipelines as Code PipelineRun viewer or through the command line by
using the tkn CLI with the following command:

tkn pr logs -n op1st-pipelines code-scans-z4bsm -f

op1st Pipelines as Code/code-scans-z4bsm is running. Starting Pipelinerun <b>[code-scans-z4bsm](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm)</b> in namespace <b>op1st-pipelines</b> You can monitor the execution using the [op1st Pipelines as Code](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm) PipelineRun viewer or through the command line by using the [tkn](https://tekton.dev/docs/cli/#installation) CLI with the following command: <code>tkn pr logs -n op1st-pipelines code-scans-z4bsm -f</code>
op1st-gitops commented 2026-05-24 10:44:14 +00:00 (Migrated from codeberg.org)

op1st Pipelines as Code/code-scans-z4bsm has successfully validated your commit.


Task Statuses:

StatusDurationName
Succeeded 11 seconds

fetch-source

Succeeded 8 seconds

gitleaks-version

Succeeded 7 seconds

gitleaks

op1st Pipelines as Code/code-scans-z4bsm has <b>successfully</b> validated your commit. <ul> <li><b>Namespace</b>: <a href="https://detailurl.setting.custom-console-url-namespace.is.not.configured">op1st-pipelines</a></li> <li><b>PipelineRun:</b> <a href="https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm">code-scans-z4bsm</a></li> </ul> <hr> <h4>Task Statuses:</h4> <table> <tr><th>Status</th><th>Duration</th><th>Name</th></tr> <tr> <td>Succeeded</td> <td>11 seconds</td><td> [fetch-source](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm/logs/fetch-source) </td></tr> <tr> <td>Succeeded</td> <td>8 seconds</td><td> [gitleaks-version](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm/logs/gitleaks-version) </td></tr> <tr> <td>Succeeded</td> <td>7 seconds</td><td> [gitleaks](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/code-scans-z4bsm/logs/gitleaks) </td></tr> </table>
op1st-gitops commented 2026-05-24 10:45:59 +00:00 (Migrated from codeberg.org)

op1st Pipelines as Code/on-pull-request-hcpvp has successfully validated your commit.


Task Statuses:

StatusDurationName
Succeeded 11 seconds

fetch-source

Succeeded 1 minute

build-and-test

op1st Pipelines as Code/on-pull-request-hcpvp has <b>successfully</b> validated your commit. <ul> <li><b>Namespace</b>: <a href="https://detailurl.setting.custom-console-url-namespace.is.not.configured">op1st-pipelines</a></li> <li><b>PipelineRun:</b> <a href="https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/on-pull-request-hcpvp">on-pull-request-hcpvp</a></li> </ul> <hr> <h4>Task Statuses:</h4> <table> <tr><th>Status</th><th>Duration</th><th>Name</th></tr> <tr> <td>Succeeded</td> <td>11 seconds</td><td> [fetch-source](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/on-pull-request-hcpvp/logs/fetch-source) </td></tr> <tr> <td>Succeeded</td> <td>1 minute</td><td> [build-and-test](https://console-openshift-console.apps.nostromo.erdgeschoss.b4mad.emea.operate-first.cloud/k8s/ns/op1st-pipelines/tekton.dev~v1~PipelineRun/on-pull-request-hcpvp/logs/build-and-test) </td></tr> </table>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
agentic-forges/forgejo-mcp!144
No description provided.