From 0c746eeff99fd9e4dfaa5834d4c97113983d5a72 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 09:54:29 -0700 Subject: [PATCH 1/5] Attest images with provenance and SBOM. --- .github/workflows/publish.yml | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a537e99..9379089 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,9 @@ on: - v* permissions: - contents: read + contents: write # release job creates/updates the GitHub Release + attestations: write # actions/attest-* publish to the repo's attestation store + id-token: write # OIDC token used by buildx provenance and the attest actions env: REGISTRY: docker.io/stellar/stellar-cli @@ -96,12 +98,15 @@ jobs: } >> "$GITHUB_OUTPUT" - name: build and push + id: build uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true platforms: ${{ matrix.platform }} tags: ${{ steps.tag.outputs.image }} + provenance: mode=max + sbom: true build-args: | RUST_VERSION=${{ matrix.rust_version }} RUST_IMAGE_DIGEST=${{ matrix.rust_image_digest }} @@ -110,6 +115,61 @@ jobs: BUILD_DATE=${{ steps.meta.outputs.build_date }} BUILDS_JSON_SHA=${{ steps.meta.outputs.builds_json_sha }} + - name: generate SBOM file + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + image: ${{ steps.tag.outputs.image }}@${{ steps.build.outputs.digest }} + format: spdx-json + output-file: sbom-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_version }}-${{ matrix.arch }}.spdx.json + upload-release-assets: false + upload-artifact: false + + - name: attest build provenance + id: attest-prov + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: false + + - name: attest SBOM + id: attest-sbom + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }} + subject-digest: ${{ steps.build.outputs.digest }} + sbom-path: sbom-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_version }}-${{ matrix.arch }}.spdx.json + push-to-registry: false + + - name: write per-arch metadata + run: | + out="meta-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_version }}-${{ matrix.arch }}.json" + jq -n \ + --arg arch "${{ matrix.arch }}" \ + --arg cli "${{ matrix.stellar_cli_version }}" \ + --arg digest "${{ steps.build.outputs.digest }}" \ + --arg image "${{ steps.tag.outputs.image }}" \ + --arg rust "${{ matrix.rust_version }}" \ + --arg tag "${{ steps.tag.outputs.tag }}" \ + '{arch: $arch, digest: $digest, image: $image, rust_version: $rust, stellar_cli_version: $cli, tag: $tag}' \ + > "$out" + + - name: rename provenance bundle + run: | + cp "${{ steps.attest-prov.outputs.bundle-path }}" \ + "prov-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_version }}-${{ matrix.arch }}.intoto.jsonl" + + - name: upload release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-artifacts-${{ matrix.stellar_cli_version }}-rust${{ matrix.rust_version }}-${{ matrix.arch }} + path: | + sbom-*.spdx.json + prov-*.intoto.jsonl + meta-*.json + retention-days: 7 + if-no-files-found: error + manifest: name: assemble manifest lists needs: [matrix, build] From db6541ee38c50b66c8c407b4c35ed92f1cf97449 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 09:56:55 -0700 Subject: [PATCH 2/5] Publish a GitHub release with SBOM and provenance assets. --- .github/workflows/publish.yml | 31 +++++++++ scripts/release-body.sh | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100755 scripts/release-body.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9379089..5a138b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -252,6 +252,36 @@ jobs: echo "cli $STELLAR_CLI_VERSION is not the newest ($newest_cli); skipping :latest" fi + release: + name: github release with sbom and provenance + if: startsWith(github.ref, 'refs/tags/v') + needs: [matrix, aliases] + runs-on: ubuntu-24.04 + env: + STELLAR_CLI_VERSION: ${{ needs.matrix.outputs.stellar_cli_version }} + steps: + - name: checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: download per-arch release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: release-artifacts + pattern: release-artifacts-* + merge-multiple: true + - name: compose release body + run: | + ./scripts/release-body.sh \ + --stellar-cli-version "$STELLAR_CLI_VERSION" \ + --metadata-dir release-artifacts \ + > release-body.md + - name: create / update the github release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + body_path: release-body.md + files: | + release-artifacts/sbom-*.spdx.json + release-artifacts/prov-*.intoto.jsonl + complete: if: always() needs: @@ -259,6 +289,7 @@ jobs: - build - manifest - aliases + - release runs-on: ubuntu-24.04 steps: - name: check upstream jobs diff --git a/scripts/release-body.sh b/scripts/release-body.sh new file mode 100755 index 0000000..1fd2064 --- /dev/null +++ b/scripts/release-body.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Compose the markdown body for a GitHub Release, given a directory of +# per-arch metadata files (meta--rust-.json) written by +# the publish workflow's build job. +# +# Each metadata file has the shape: +# {"arch": "...", "digest": "sha256:...", "image": "...", "rust_version": "...", +# "stellar_cli_version": "...", "tag": "..."} +# +# Output goes to stdout. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$script_dir/lib/common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/release-body.sh --stellar-cli-version --metadata-dir [--help] + +Required: + --stellar-cli-version The release this body is for (e.g. 26.0.0). + Must match the cli in every metadata file. + --metadata-dir Directory containing meta-*.json files. + +Options: + --help Show this message. + +Prints the release body markdown to stdout. +EOF +} + +main() { + local cli="" metadata_dir="" + + while [ $# -gt 0 ]; do + case "$1" in + --stellar-cli-version) cli="$2"; shift 2;; + --metadata-dir) metadata_dir="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) err "unknown argument: $1"; usage; exit 1;; + esac + done + + test -n "$cli" || { err "--stellar-cli-version is required"; usage; exit 1; } + test -n "$metadata_dir" || { err "--metadata-dir is required"; usage; exit 1; } + test -d "$metadata_dir" || die "$metadata_dir is not a directory" + + preflight_checks jq + + # Aggregate all meta-*.json files under the metadata dir into one JSON array. + local rows + rows="$(find "$metadata_dir" -type f -name 'meta-*.json' -print0 \ + | xargs -0 jq -s --arg cli "$cli" \ + 'map(select(.stellar_cli_version == $cli)) + | sort_by(.rust_version, .arch)')" + test "$(jq 'length' <<<"$rows")" -gt 0 \ + || die "no metadata files for stellar-cli $cli under $metadata_dir" + + emit_body "$cli" "$rows" +} + +emit_body() { + local cli="$1" rows="$2" + + printf '# stellar-cli %s\n\n' "$cli" + + printf 'Trusted, SEP-58-compatible build images for the Stellar CLI.\n\n' + + printf '## Convenience tags\n\n' + printf -- '- `docker.io/stellar/stellar-cli:%s` — multi-arch, default Rust for this release\n' "$cli" + local rust + while IFS= read -r rust; do + printf -- '- `docker.io/stellar/stellar-cli:%s-rust%s` — multi-arch\n' "$cli" "$rust" + done < <(jq -r '. | map(.rust_version) | unique | .[]' <<<"$rows") + + printf '\n## Per-architecture digests (for SEP-58 `bldimg`)\n\n' + printf 'Use the per-architecture digest when recording `bldimg` in your contract metadata. Never use a moving tag like `:latest` or `:%s`.\n\n' "$cli" + + while IFS= read -r rust; do + printf '### Rust %s\n\n' "$rust" + while IFS= read -r row; do + printf -- '- `%s@%s`\n' \ + "$(jq -r '.image' <<<"$row")" \ + "$(jq -r '.digest' <<<"$row")" + done < <(jq -c --arg r "$rust" 'map(select(.rust_version == $r)) | .[]' <<<"$rows") + printf '\n' + done < <(jq -r '. | map(.rust_version) | unique | .[]' <<<"$rows") + + cat <<'EOF' +## Verification + +Each per-architecture image carries two independent attestation chains. + +### GitHub-native (recommended) + +```sh +gh attestation verify oci://@ \ + --repo stellar/stellar-cli-docker +``` + +The repo includes `scripts/verify-image.sh` that wraps this for both provenance and SBOM: + +```sh +./scripts/verify-image.sh --image @ +``` + +### Registry-attached (cosign / docker buildx) + +```sh +cosign verify-attestation --type slsaprovenance @ +docker buildx imagetools inspect @ +``` + +## Assets + +This release attaches one SBOM file (`.spdx.json`) and one provenance bundle (`.intoto.jsonl`) per per-architecture image. +EOF +} + +main "$@" From 011e66b71909c004dcd4f2798fd2d328160232cf Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 09:58:39 -0700 Subject: [PATCH 3/5] Add verify-image.sh for SEP-58 consumers. --- README.md | 1 + scripts/verify-image.sh | 97 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100755 scripts/verify-image.sh diff --git a/README.md b/README.md index 23a9964..0d5d872 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ compare the resulting WASM sha256. | `scripts/validate-json.sh` | Validates every `*.json` for sorted keys and `builds.json` for schema + cross-field constraints. | | `scripts/refresh-rust-digests.sh` | Fills blank `rust_image_digests` entries by inspecting `rust:-slim-bookworm` upstream. Does not touch already-pinned digests unless asked per-version. | | `scripts/refresh-stellar-cli-digests.sh` | Fills blank `stellar_cli_versions[].ref` entries by resolving the matching `v` git tag in `stellar/stellar-cli`. Same per-target opt-in shape as the rust refresher. | +| `scripts/verify-image.sh` | Consumer-facing verifier. Wraps `gh attestation verify` for both the SLSA build provenance and the SPDX SBOM attestations against a per-arch image digest. | | `scripts/lib/common.sh` | Shared helpers sourced by the other scripts. | ## Local development diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh new file mode 100755 index 0000000..8108b3a --- /dev/null +++ b/scripts/verify-image.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Verify that a published stellar-cli image has both attestation chains — +# SLSA build provenance and SPDX SBOM — signed by this repo's GitHub +# Actions OIDC identity. +# +# Intended for SEP-58 verifiers and any consumer about to record a `bldimg` +# digest. Reports cleanly per chain so a partial failure is easy to read. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$script_dir/lib/common.sh" + +REPO=stellar/stellar-cli-docker +PROVENANCE_PREDICATE_TYPE=https://slsa.dev/provenance/v1 +SBOM_PREDICATE_TYPE=https://spdx.dev/Document + +usage() { + cat <<'EOF' +Usage: scripts/verify-image.sh --image [--help] + +Required: + --image Full image reference, pinned to a per-arch digest. + e.g. docker.io/stellar/stellar-cli@sha256:abc... + A tag-only reference (no @sha256:...) is rejected; the + point of verification is to prove a specific digest. + +Options: + --help Show this message. + +Runs two `gh attestation verify` calls against the published image, one for +each predicate type. Both must succeed for the verification to pass. + +Requires the `gh` CLI to be installed and authenticated. +EOF +} + +main() { + local image="" + + while [ $# -gt 0 ]; do + case "$1" in + --image) image="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) err "unknown argument: $1"; usage; exit 1;; + esac + done + + test -n "$image" || { err "--image is required"; usage; exit 1; } + + # Reject tag-only references — verifying a tag is meaningless because the + # tag could be re-pointed. The whole verification flow rests on the digest. + case "$image" in + *@sha256:*) :;; + *) die "image must be pinned to a sha256 digest (e.g. @sha256:...); got '$image'";; + esac + + preflight_checks gh + + local oci_ref="oci://${image}" + local rc=0 + + log "verifying $image against $REPO ..." + + log "" + log "[1/2] SLSA build provenance" + if gh attestation verify "$oci_ref" \ + --repo "$REPO" \ + --predicate-type "$PROVENANCE_PREDICATE_TYPE"; then + log " ok" + else + err " FAILED: build provenance did not verify" + rc=1 + fi + + log "" + log "[2/2] SPDX SBOM" + if gh attestation verify "$oci_ref" \ + --repo "$REPO" \ + --predicate-type "$SBOM_PREDICATE_TYPE"; then + log " ok" + else + err " FAILED: SBOM did not verify" + rc=1 + fi + + log "" + if [ "$rc" -eq 0 ]; then + log "verify-image: $image passed all attestation checks" + else + err "verify-image: $image FAILED one or more attestation checks" + fi + return "$rc" +} + +main "$@" From ac60e03d92a6ca307861d2000f46b9cf5d551e99 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 10:43:06 -0700 Subject: [PATCH 4/5] Validate flag values across all scripts. --- scripts/build-image.sh | 8 ++++---- scripts/lib/common.sh | 10 ++++++++++ scripts/refresh-rust-digests.sh | 2 +- scripts/refresh-stellar-cli-digests.sh | 2 +- scripts/release-body.sh | 4 ++-- scripts/resolve-matrix.sh | 2 +- scripts/smoke-test-image.sh | 6 +++--- scripts/tag-names.sh | 6 +++--- scripts/verify-image.sh | 2 +- 9 files changed, 26 insertions(+), 16 deletions(-) diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 9f151af..fd1dcc5 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -34,10 +34,10 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --stellar-cli-version) cli="$2"; shift 2;; - --rust-version) rust="$2"; shift 2;; - --platform) platform="$2"; shift 2;; - --tag) tag="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; + --rust-version) require_value "$1" "${2:-}"; rust="$2"; shift 2;; + --platform) require_value "$1" "${2:-}"; platform="$2"; shift 2;; + --tag) require_value "$1" "${2:-}"; tag="$2"; shift 2;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; esac diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 3bc6dcb..be768d6 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -35,6 +35,16 @@ require_cmd() { done } +# require_value +# Aborts with a clear error if is empty. Use at the top of each +# --flag case arm: require_value "$1" "${2:-}" +# Prevents the unhelpful "$2: unbound variable" crash that `set -u` +# emits when a user passes a flag with no value (e.g. `--image` at EOL). +require_value() { + local flag="$1" value="${2:-}" + test -n "$value" || die "missing value for $flag" +} + # Minimum bash version this project's scripts rely on. Bump in one place. # 4.3 is what `local -n` (used by the apply_updates helpers) requires; # 4.0 covers `declare -A`. macOS ships bash 3.2 by default. diff --git a/scripts/refresh-rust-digests.sh b/scripts/refresh-rust-digests.sh index 9bc52fc..c05c4eb 100755 --- a/scripts/refresh-rust-digests.sh +++ b/scripts/refresh-rust-digests.sh @@ -48,7 +48,7 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --rust-version) only_version="$2"; shift 2;; + --rust-version) require_value "$1" "${2:-}"; only_version="$2"; shift 2;; --dry-run) dry_run=1; shift;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; diff --git a/scripts/refresh-stellar-cli-digests.sh b/scripts/refresh-stellar-cli-digests.sh index 0a3a2d8..3d75cb5 100755 --- a/scripts/refresh-stellar-cli-digests.sh +++ b/scripts/refresh-stellar-cli-digests.sh @@ -51,7 +51,7 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --stellar-cli-version) only_version="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; only_version="$2"; shift 2;; --dry-run) dry_run=1; shift;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; diff --git a/scripts/release-body.sh b/scripts/release-body.sh index 1fd2064..5ff7659 100755 --- a/scripts/release-body.sh +++ b/scripts/release-body.sh @@ -36,8 +36,8 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --stellar-cli-version) cli="$2"; shift 2;; - --metadata-dir) metadata_dir="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; + --metadata-dir) require_value "$1" "${2:-}"; metadata_dir="$2"; shift 2;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; esac diff --git a/scripts/resolve-matrix.sh b/scripts/resolve-matrix.sh index 8b5f0e9..cf3fe9d 100755 --- a/scripts/resolve-matrix.sh +++ b/scripts/resolve-matrix.sh @@ -43,7 +43,7 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --stellar-cli-version) only_cli="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; only_cli="$2"; shift 2;; --compact) mode="compact"; shift;; --pretty) mode="pretty"; shift;; -h|--help) usage; exit 0;; diff --git a/scripts/smoke-test-image.sh b/scripts/smoke-test-image.sh index 376c751..8429a82 100755 --- a/scripts/smoke-test-image.sh +++ b/scripts/smoke-test-image.sh @@ -44,9 +44,9 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --image) image="$2"; shift 2;; - --stellar-cli-version) cli="$2"; shift 2;; - --rust-version) rust="$2"; shift 2;; + --image) require_value "$1" "${2:-}"; image="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; + --rust-version) require_value "$1" "${2:-}"; rust="$2"; shift 2;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; esac diff --git a/scripts/tag-names.sh b/scripts/tag-names.sh index 7d67c78..0515243 100755 --- a/scripts/tag-names.sh +++ b/scripts/tag-names.sh @@ -49,9 +49,9 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --stellar-cli-version) cli="$2"; shift 2;; - --rust-version) rust="$2"; shift 2;; - --platform) platform="$2"; shift 2;; + --stellar-cli-version) require_value "$1" "${2:-}"; cli="$2"; shift 2;; + --rust-version) require_value "$1" "${2:-}"; rust="$2"; shift 2;; + --platform) require_value "$1" "${2:-}"; platform="$2"; shift 2;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; esac diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh index 8108b3a..1c4fe86 100755 --- a/scripts/verify-image.sh +++ b/scripts/verify-image.sh @@ -41,7 +41,7 @@ main() { while [ $# -gt 0 ]; do case "$1" in - --image) image="$2"; shift 2;; + --image) require_value "$1" "${2:-}"; image="$2"; shift 2;; -h|--help) usage; exit 0;; *) err "unknown argument: $1"; usage; exit 1;; esac From 38e7fc318170d107217ea9af14625278224572ef Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 21 May 2026 10:47:32 -0700 Subject: [PATCH 5/5] Error loudly when metadata files don't match the release cli. --- scripts/release-body.sh | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/release-body.sh b/scripts/release-body.sh index 5ff7659..5868cd4 100755 --- a/scripts/release-body.sh +++ b/scripts/release-body.sh @@ -49,14 +49,28 @@ main() { preflight_checks jq - # Aggregate all meta-*.json files under the metadata dir into one JSON array. + # Aggregate all meta-*.json files under the metadata dir, validating + # each one individually before merging. A mismatched or missing + # stellar_cli_version is a hard error — silently dropping would let a + # misconfigured run produce a release body with arches omitted. + local -a meta_files=() + while IFS= read -r -d '' f; do + meta_files+=("$f") + done < <(find "$metadata_dir" -type f -name 'meta-*.json' -print0) + test "${#meta_files[@]}" -gt 0 \ + || die "no meta-*.json files under $metadata_dir" + + local f entry_cli + for f in "${meta_files[@]}"; do + entry_cli="$(jq -r '.stellar_cli_version // empty' "$f")" + test -n "$entry_cli" \ + || die "metadata file $f is missing the stellar_cli_version field" + test "$entry_cli" = "$cli" \ + || die "metadata file $f has stellar_cli_version='$entry_cli', expected '$cli'" + done + local rows - rows="$(find "$metadata_dir" -type f -name 'meta-*.json' -print0 \ - | xargs -0 jq -s --arg cli "$cli" \ - 'map(select(.stellar_cli_version == $cli)) - | sort_by(.rust_version, .arch)')" - test "$(jq 'length' <<<"$rows")" -gt 0 \ - || die "no metadata files for stellar-cli $cli under $metadata_dir" + rows="$(jq -s 'sort_by(.rust_version, .arch)' "${meta_files[@]}")" emit_body "$cli" "$rows" }