Completes the OpenSpec lifecycle for the change merged in #189 (tasks.md 3.2): archive the change and sync the live release-tools-image spec. Code already landed under forgejo-mcp-aa6 (b2619fc); this is spec-sync + proof only. - Live spec "SBOM attached as registry artifact" now describes a SIGNED cosign attest --type cyclonedx and a verify-attestation retrieval contract, replacing the removed/unsigned cosign attach sbom / download sbom path. - Add showboat demo: real syft -> cosign attest --type cyclonedx -> verify-attestation roundtrip (ephemeral ttl.sh registry, throwaway key), plus a real probe of the published image documenting the current gap (no post-migration tag cut yet). - Change moved to changes/archive/2026-06-02-signed-sbom-attestation. Closes forgejo-mcp-3y1.
12 KiB
release-tools-image Specification
Purpose
The release-tools-image capability publishes a signed, SBOM-attested container image bundling Go, goreleaser, syft, cosign, jq, curl, and Node tooling for use by the forgejo-mcp release pipeline and other tag-driven workflows. The image lives in its own source tree under image/release-tools/ and is built and published via dedicated Tekton PipelineRuns under .tekton/release-tools/, isolating its lifecycle from the consuming forgejo-mcp Go release.
Requirements
Requirement: Source tree isolation
All artifacts introduced by this capability SHALL live under exactly two top-level paths:
image/release-tools/— Containerfile, build scripts, version manifest, README, Renovate config..tekton/release-tools/— PR-build and tag-publish PipelineRun definitions plus any release-tools-only Tasks.
No file under any other path SHALL be added, modified, or deleted by this capability. Migrating the capability to a separate repository MUST be achievable via git mv of exactly these two trees plus a registry-path string change.
Scenario: Lift-out to separate project
The lift is NOT a one-step git mv. The full operator procedure is:
- WHEN a maintainer
git mvsimage/release-tools/and.tekton/release-tools/to a new repository - AND registers the new repository as a PaC
RepositoryCR in theop1st-pipelinesnamespace (separate manifest inop1st-emea-b4mad) - AND ensures the new namespace has read access to
cosign-signing-key— either by keeping PipelineRuns inop1st-pipelinesitself, or by extending the emberstack reflector ruleset to mirror the Secret into the new namespace - AND updates the registry path string in
on-tag-publish.yaml(if the new project ships to a different registry) - AND edits ADR cross-references in this source repo's
docs/design/release-pipeline-migration.mdto point at the new location - THEN the build and publish pipelines SHALL function unchanged, producing the same signed image at the new registry path
Scenario: Forgejo-mcp Go change does not touch image tree
- WHEN a PR modifies any file under
operation/,pkg/,cmd/, ormain.go - AND does not modify any file under
image/release-tools/or.tekton/release-tools/ - THEN the image PR build pipeline SHALL NOT trigger
Requirement: Base image is Hummingbird
The Containerfile SHALL use registry.access.redhat.com/hi/go:<pinned-version>-builder as the base for both the build stage and the final stage. The pinned version SHALL be recorded in image/release-tools/VERSIONS.md and referenced via build ARG in the Containerfile.
The final stage MUST retain bash and dnf availability so that the consuming Tekton Tasks (which use script: blocks requiring /bin/sh) execute successfully.
Substituting a different base image (e.g. docker.io/library/golang, chainguard/go) constitutes a MAJOR version bump and requires explicit ADR amendment.
Scenario: Image carries a shell
- WHEN the image is run via
podman run --rm <image-ref> /bin/sh -c 'echo ok' - THEN the command SHALL exit 0 with stdout
ok
Scenario: Image declares its Hummingbird lineage
- WHEN
podman inspect <image-ref>is invoked - THEN
LABEL org.opencontainers.image.base.nameSHALL equal the exact pinned Hummingbird tag recorded inVERSIONS.md(e.g.registry.access.redhat.com/hi/go:1.25-builder), not merely contain a substring — substring matching can be faked by a malicious base swap that retains the original label string
Requirement: Bundled tools with pinned versions
The image SHALL provide the following tools on $PATH for any user, with versions pinned via build ARGs sourced from VERSIONS.md:
| Tool | Source | Use |
|---|---|---|
go |
Hummingbird hi/go:<ver>-builder |
release-time go build invoked by goreleaser |
syft |
anchore release | per-archive CycloneDX SBOMs |
goreleaser |
go install from pinned tag |
release orchestration |
cosign |
sigstore release | sign-blob + verify |
node |
dnf | runtime for npx -y @anthropic-ai/mcpb |
npm |
dnf (with node) | bootstrap mcpb |
jq |
dnf | Codeberg API JSON parsing |
curl |
dnf | downloads + Codeberg API uploads |
ca-certificates |
dnf | TLS trust |
The Containerfile MUST include a final-stage RUN step that exercises each tool's --version (or equivalent) command. Failure of that step SHALL fail the build.
Scenario: All bundled tools report a version
- WHEN
podman run --rm <image-ref> sh -c 'go version && syft version && goreleaser --version && cosign version && jq --version && curl --version && node --version' - THEN the command SHALL exit 0 with each tool printing a non-empty version string
Scenario: npx can resolve @anthropic-ai/mcpb without network
- WHEN
podman run --rm --network=none <image-ref> sh -c 'npx -y @anthropic-ai/mcpb --version' - THEN the command SHALL exit 0 (npm cache prewarmed in build stage)
Scenario: @anthropic-ai/mcpb installs from a pinned lockfile, not from the live npm registry
The build stage SHALL install @anthropic-ai/mcpb via npm ci --ignore-scripts against a committed image/release-tools/npm/package-lock.json that pins the package and every transitive dependency by integrity hash. The tarball SHA256 SHALL also be recorded in VERSIONS.md so a manual integrity check can be performed without running npm.
- WHEN an attacker publishes a malicious new version of
@anthropic-ai/mcpb(or any of its transitive deps) to the npm registry - AND the image is rebuilt from the same git revision
- THEN the build SHALL produce an image bit-identical to the previous build, because
npm ciresolves only what the lockfile records - AND uplift to the new package version SHALL require an explicit commit that updates the lockfile (Renovate-managed, human-reviewed)
Requirement: Image is cosign-signed at publish time
The tag-publish pipeline SHALL sign the published image manifest with cosign using the cosign-signing-key Secret in the op1st-pipelines namespace. The signature SHALL be attached to the image registry such that cosign verify --key <public-key-url> <image-ref> succeeds for any consumer.
The public key URL referenced in the README SHALL be identical to the URL referenced for forgejo-mcp release artifact verification (single source of truth).
Signing posture diverges from the forgejo-mcp release pipeline. The release pipeline mounts cosign-signing-key with optional: true (fail-open: unsigned release still ships if the Secret is absent). The image-publish pipeline mounts the same Secret with optional: false (fail-closed: missing key → Failed PipelineRun → image not advertised). Rationale: an unsigned release-tools image is a supply-chain regression because downstream Tasks execute its contents with signing-key access; an unsigned checksums.txt for a forgejo-mcp release is a UX regression only. The asymmetry is intentional and recorded in the ADR addendum.
Scenario: Published image verifies against the documented key
- WHEN the tag-publish pipeline completes for
release-tools/v1.0.0 - AND a consumer runs
cosign verify --key <op1st-pub-url> codeberg.org/operate-first/release-tools:v1.0.0 - THEN the command SHALL exit 0 with
Verification for ... successful
Scenario: Image without signature does not pass cutover
- WHEN a publish run completes but cosign-sign Step fails
- THEN the PipelineRun status SHALL be Failed
- AND the image SHALL NOT be advertised to consumers via tag promotion
Scenario: Sign-fail does not leave an unsigned tag in the registry (TOCTOU)
The publish pipeline SHALL push to the registry by digest only (no human-readable tag) BEFORE the cosign-sign Step. The human-readable vMAJOR.MINOR.PATCH tag SHALL be promoted (via crane tag or equivalent) ONLY after cosign-sign succeeds. Registry tag mutability SHOULD be disabled.
- WHEN the publish pipeline pushes a manifest by digest and the subsequent cosign-sign Step fails
- THEN no
vMAJOR.MINOR.PATCHtag SHALL resolve to that digest in the registry - AND the previous good
vMAJOR.MINOR.PATCH(if any) SHALL still resolve to its prior signed digest
Requirement: SBOM attached as registry artifact
The publish pipeline SHALL emit a CycloneDX SBOM via syft <image-ref> and bind it to the published image manifest as a signed in-toto attestation using cosign attest --predicate <sbom> --type cyclonedx --key <cosign-key>. The attestation SHALL be signed with the same cosign key used to sign the image manifest. The pipeline SHALL NOT use cosign attach sbom, which is deprecated (sigstore/cosign#2755) and pushes the SBOM unsigned. Consumers SHALL be able to verify and retrieve the SBOM via cosign verify-attestation --type cyclonedx --key <cosign.pub> <image-ref> (or cosign download attestation <image-ref> for the raw DSSE envelope), or via the OCI referrers API.
Scenario: Signed SBOM attestation verifiable alongside the image
- WHEN a consumer runs
cosign verify-attestation --type cyclonedx --key cosign-images.pub codeberg.org/operate-first/release-tools:v1.0.0 - THEN the command SHALL succeed, confirming the attestation signature against the public key
- AND the verified attestation payload SHALL carry a CycloneDX 1.x JSON document as its predicate
Scenario: Deprecated unsigned attach path is not used
- WHEN the publish pipeline binds the SBOM to the published image
- THEN it SHALL use
cosign attest(signed) - AND it SHALL NOT use
cosign attach sbom(deprecated, unsigned)
Requirement: PR build pipeline CEL-gated to release-tools paths
The PaC PipelineRun at .tekton/release-tools/on-pull-request-build.yaml SHALL include an on-cel-expression annotation that fires when ANY changed file matches ^(image/release-tools/|\.tekton/release-tools/).*. The expression SHALL use files.any.exists, NOT files.all.exists — the latter requires every changed file to match, so a hybrid PR (image change + unrelated typo fix) would silently fail to fire the build.
Isolation (image-tree changes MUST NOT be mixed with Go code changes in the same PR) is enforced by code review, not by the CEL gate. The CEL stays permissive about firing the build.
Scenario: PR touching forgejo-mcp Go code does not fire image build
- WHEN a PR modifies
operation/issues/create.go - AND does not modify any file under
image/release-tools/or.tekton/release-tools/ - THEN no
on-pull-request-buildPipelineRun SHALL appear inop1st-pipelinesfor that PR — observed viatkn pr list -n op1st-pipelinesfiltered by repository and PR commit SHA, expected to return zero matching runs
Scenario: PR touching the Containerfile fires the image build
- WHEN a PR modifies
image/release-tools/Containerfile - THEN the
on-pull-request-buildPipelineRun SHALL execute for that PR
Requirement: Tag-publish pipeline CEL-gated to release-tools tag prefix
The PaC PipelineRun at .tekton/release-tools/on-tag-publish.yaml SHALL trigger only on push of tags matching refs/tags/release-tools/v*. A regular forgejo-mcp release tag (v2.X.Y) MUST NOT cause this pipeline to fire.
Scenario: forgejo-mcp release tag does not fire image publish
- WHEN a tag
v2.24.3is pushed to main - THEN the
on-tag-publishPipelineRun for release-tools SHALL NOT execute
Scenario: release-tools tag fires the publish pipeline
- WHEN a tag
release-tools/v1.0.0is pushed - THEN the
on-tag-publishPipelineRun SHALL execute and produce a signed image at the agreed registry path
Requirement: Forgejo-mcp release pipeline unchanged by this capability
This change SHALL NOT modify .tekton/tasks/goreleaser-release.yaml, .tekton/tasks/cosign-sign-release.yaml, .tekton/tasks/mcpb-pack.yaml, or .tekton/on-tag-push-release.yaml. Switching those Tasks to consume the published image is a separate change (release-pipeline-use-release-tools-image) and is out of scope here.
Scenario: Consuming Tasks remain untouched
- WHEN the PR introducing this capability is reviewed
- THEN
git diffSHALL show no modifications under.tekton/tasks/or.tekton/on-tag-push-release.yaml