apiVersion: tekton.dev/v1 kind: Task metadata: name: release-tools-cosign-sign-image-by-digest annotations: tekton.dev/displayName: "cosign sign release-tools image manifest by digest" spec: description: | Signs the image manifest identified by digest using cosign. Pushes the signature object to the registry alongside the image. A3 asymmetry (intentional, per spec): - This task mounts cosign-signing-key-images with optional: false (fail-closed). A missing key causes a Failed PipelineRun; the unsigned image is NOT promoted to a human-readable tag. - The forgejo-mcp release pipeline uses optional: true (fail-open). The asymmetry is intentional: an unsigned release-tools image is a supply-chain regression; an unsigned checksums.txt is a UX regression only. Image choice: uses ubi9-minimal + curl-installed cosign rather than the release-tools image itself. Reasons: 1. Avoids circular reference at v1.0.0 (the image being built does not yet exist when this task runs the first time). 2. ubi9-minimal has /bin/sh so Tekton script: blocks execute. 3. Single-step install pattern (proven in .tekton/tasks/goreleaser-release.yaml after the v2.24.x lessons). cosign needs registry write auth to push the signature object. Mounts the same registry-credentials workspace as push-image-by-digest / promote-image-tag. params: - name: IMAGE_REPO_DIGEST description: Full digest reference (e.g. codeberg.org/operate-first/release-tools@sha256:abc123). type: string - name: COSIGN_VERSION description: cosign CLI version pinned for this Task. type: string default: v2.4.1 workspaces: - name: registry-credentials mountPath: /workspace/registry-creds optional: false description: | Dockerconfigjson Secret with write auth on the release-tools registry path. Reuses codeberg-pusher Secret in op1st-pipelines (Path A decision — see docs/design.md D4). volumes: - name: cosign-signing-key-images secret: # A3 fail-closed: optional: false ensures a missing key fails the PipelineRun # rather than silently promoting an unsigned image. secretName: cosign-signing-key-images optional: false steps: - name: sign image: registry.access.redhat.com/ubi9/ubi-minimal:latest volumeMounts: - name: cosign-signing-key-images mountPath: /workspace/cosign-key readOnly: true env: - name: COSIGN_KEY_PATH value: /workspace/cosign-key/cosign.key - name: COSIGN_PASSWORD valueFrom: secretKeyRef: name: cosign-signing-key-images # Real Secret has keys: cosign.key, cosign.password, cosign.pub # (verified by inspection on nostromo op1st-pipelines namespace). key: cosign.password optional: false - name: REGISTRY_AUTH_FILE value: /workspace/registry-creds/.dockerconfigjson script: | #!/usr/bin/env sh set -eu # curl-minimal is pre-installed in ubi9-minimal; nothing extra needed. curl -sSfLo /tmp/cosign-linux-amd64 \ "https://github.com/sigstore/cosign/releases/download/$(params.COSIGN_VERSION)/cosign-linux-amd64" curl -sSfLo /tmp/cosign-checksums.txt \ "https://github.com/sigstore/cosign/releases/download/$(params.COSIGN_VERSION)/cosign_checksums.txt" cd /tmp && grep " cosign-linux-amd64$" cosign-checksums.txt | sha256sum -c - mv /tmp/cosign-linux-amd64 /usr/local/bin/cosign rm /tmp/cosign-checksums.txt chmod +x /usr/local/bin/cosign cosign version # Some registries publish config.json instead of .dockerconfigjson. if [ ! -f "${REGISTRY_AUTH_FILE}" ]; then REGISTRY_AUTH_FILE=/workspace/registry-creds/config.json fi if [ ! -f "${REGISTRY_AUTH_FILE}" ]; then echo "ERROR: no registry auth file found in /workspace/registry-creds/" >&2 ls -la /workspace/registry-creds/ >&2 exit 1 fi export REGISTRY_AUTH_FILE IMAGE_DIGEST_REF="$(params.IMAGE_REPO_DIGEST)" echo "Signing: ${IMAGE_DIGEST_REF}" # --- credential resolution shim ------------------------------------ # cosign / go-containerregistry resolve registry auth by HOST only # (e.g. "codeberg.org"), whereas skopeo/buildah longest-prefix-match # path-scoped entries (e.g. "codeberg.org/goern"). The codeberg-pusher # Secret carries BOTH: a host-level auth (op1st-gitops, no goern # package-write) and a path-scoped goern auth (write:package on # goern/*). push-image-by-digest (skopeo) picks goern by prefix and # succeeds; cosign picks the host entry and gets # "401 Unauthorized: reqPackageAccess". # # Replicate skopeo's behaviour: pick the longest auths key that is a # path-prefix of this image's repo, then re-key it under the bare host # so cosign's host-only lookup finds the right token. No-op for # operate-first/release-tools, whose longest match IS the host key. # jq is fetched as a static binary (curl + sha256, same supply-chain # pattern as cosign/syft/goreleaser) because the step runs non-root and # cannot microdnf-install. JQ_VERSION=jq-1.7.1 JQ_SHA256=5942c9b0934e510ee61eb3e30273f1b3fe2590df93933a93d7c58b81d19c8ff5 curl -sSfLo /tmp/jq \ "https://github.com/jqlang/jq/releases/download/${JQ_VERSION}/jq-linux-amd64" echo "${JQ_SHA256} /tmp/jq" | sha256sum -c - chmod +x /tmp/jq REPO="${IMAGE_DIGEST_REF%@*}" # strip @sha256:... -> codeberg.org/ns/img HOST="${REPO%%/*}" # codeberg.org # Longest auths key that equals REPO or is a "/" path-prefix of it. # Done in shell (not jq) to avoid jq's "." rebinding inside a pipe. BEST_KEY="" for k in $(/tmp/jq -r '.auths | keys[]' "${REGISTRY_AUTH_FILE}"); do if [ "${REPO}" = "${k}" ] || [ "${REPO#"${k}"/}" != "${REPO}" ]; then [ ${#k} -gt ${#BEST_KEY} ] && BEST_KEY="${k}" fi done if [ -z "${BEST_KEY}" ]; then echo "ERROR: no auth entry in ${REGISTRY_AUTH_FILE} matches ${REPO}" >&2 /tmp/jq -r '.auths | keys[]' "${REGISTRY_AUTH_FILE}" >&2 || true exit 1 fi echo "Auth entry '${BEST_KEY}' re-keyed under host '${HOST}' for cosign" mkdir -p /tmp/cosign-docker /tmp/jq -c --arg key "${BEST_KEY}" --arg host "${HOST}" \ '{auths: {($host): .auths[$key]}}' "${REGISTRY_AUTH_FILE}" \ > /tmp/cosign-docker/config.json export DOCKER_CONFIG=/tmp/cosign-docker export REGISTRY_AUTH_FILE=/tmp/cosign-docker/config.json # ------------------------------------------------------------------- cosign sign \ --key="${COSIGN_KEY_PATH}" \ --yes \ "${IMAGE_DIGEST_REF}" echo "cosign sign completed for ${IMAGE_DIGEST_REF}"