From b9117e6870770a812b7e8e2499b3c40e070a0d30 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sun, 12 Apr 2026 13:24:07 -0400 Subject: [PATCH] feat: sign releases and publish sboms --- .woodpecker.yml | 15 +++- CHANGELOG.md | 2 + README.md | 6 ++ docs/ARCHITECTURE.md | 4 +- docs/INSTALL.md | 29 ++++++ docs/deployment/WOODPECKER_HOSTINGER_SETUP.md | 6 +- docs/release/MVP_PRICING_RELEASE.md | 24 ++++- keys/profitctl-release-cosign.pub | 4 + scripts/release/generate-sboms.sh | 59 +++++++++++++ scripts/release/install-tool.sh | 62 +++++++++++++ scripts/release/publish-github-release.sh | 8 +- scripts/release/sign-release-assets.sh | 64 ++++++++++++++ scripts/release/smoke-published-release.sh | 39 ++------ scripts/release/verify-release-assets.sh | 88 +++++++++++++++++++ 14 files changed, 369 insertions(+), 41 deletions(-) create mode 100644 keys/profitctl-release-cosign.pub create mode 100755 scripts/release/generate-sboms.sh create mode 100755 scripts/release/install-tool.sh create mode 100755 scripts/release/sign-release-assets.sh create mode 100755 scripts/release/verify-release-assets.sh diff --git a/.woodpecker.yml b/.woodpecker.yml index fd2c84f..6cae3b0 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -9,13 +9,17 @@ steps: - name: build-and-package image: golang:1.24 commands: - - apt-get update && apt-get install -y --no-install-recommends zip && rm -rf /var/lib/apt/lists/* + - apt-get update && apt-get install -y --no-install-recommends curl ca-certificates zip && rm -rf /var/lib/apt/lists/* - | TAG="$${CI_COMMIT_TAG:-$${CI_COMMIT_REF##refs/tags/}}" if [ -z "$${TAG}" ]; then TAG="$(bash scripts/release/resolve-tag.sh)"; fi test -n "$${TAG}" || (echo "TAG is required" && exit 1) + TOOL_DIR="$$(pwd)/.release-tools" + bash scripts/release/install-tool.sh syft "$${TOOL_DIR}" + export PATH="$${TOOL_DIR}:$${PATH}" bash scripts/release/build-artifacts.sh "$${TAG}" bash scripts/release/create-checksums.sh "$${TAG}" + bash scripts/release/generate-sboms.sh "$${TAG}" - name: publish-release-and-vps image: alpine:3.20 @@ -24,12 +28,16 @@ steps: from_secret: doppler_token commands: - test -n "$${DOPPLER_TOKEN}" || (echo "DOPPLER_TOKEN is required" && exit 1) - - apk add --no-cache bash curl openssh-client rsync git gnupg github-cli + - apk add --no-cache bash curl openssh-client rsync git github-cli - curl -Ls https://cli.doppler.com/install.sh | sh - | TAG="$${CI_COMMIT_TAG:-$${CI_COMMIT_REF##refs/tags/}}" if [ -z "$${TAG}" ]; then TAG="$(bash scripts/release/resolve-tag.sh)"; fi test -n "$${TAG}" || (echo "TAG is required" && exit 1) + TOOL_DIR="$$(pwd)/.release-tools" + bash scripts/release/install-tool.sh cosign "$${TOOL_DIR}" + export PATH="$${TOOL_DIR}:$${PATH}" + doppler run --project profitctl --config prd_ci_woodpecker -- bash scripts/release/sign-release-assets.sh "$${TAG}" doppler run --project profitctl --config prd_ci_woodpecker -- bash scripts/release/publish-github-release.sh "$${TAG}" doppler run --project profitctl --config prd_ci_woodpecker -- bash deployment/releases/publish-to-vps.sh "$${TAG}" @@ -46,6 +54,9 @@ steps: TAG="$${CI_COMMIT_TAG:-$${CI_COMMIT_REF##refs/tags/}}" if [ -z "$${TAG}" ]; then TAG="$(bash scripts/release/resolve-tag.sh)"; fi test -n "$${TAG}" || (echo "TAG is required" && exit 1) + TOOL_DIR="$$(pwd)/.release-tools" + bash scripts/release/install-tool.sh cosign "$${TOOL_DIR}" + export PATH="$${TOOL_DIR}:$${PATH}" doppler run --project profitctl --config prd_ci_woodpecker -- bash scripts/release/smoke-published-release.sh "$${TAG}" --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b22b04..404b66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - GitHub Actions PR/main verification workflow with stable `verify-go` and `verify-install-smoke` checks. - Homebrew formula and tap-publish script for the public release channel. - Committed benchmark comparison reports for open-core and hybrid pricing scenarios. +- Release SBOM generation, detached Cosign signatures, and published verification key assets. ### Changed - Canonical module/repository identity aligned to `IntelIP/ProfitCtl`. @@ -22,3 +23,4 @@ All notable changes to this project will be documented in this file. - Public installer defaults now use GitHub Releases as the canonical source, with the Hostinger mirror available as an explicit override. - Open-core packaging docs now define who the product is for, what stays free, and what the first paid layer should cover. - Install docs now include Homebrew and the Quick Start includes concrete output snippets. +- Release docs now include explicit archive, SBOM, and checksum verification steps. diff --git a/README.md b/README.md index 6c112e2..06c37da 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ OPENROUTER_API_KEY=... profitctl detect --path . --out detect-report.json Canonical public release artifacts are published on GitHub Releases: - `https://github.com/IntelIP/ProfitCtl/releases` +- each release includes: + - per-platform archives + - per-platform SPDX JSON SBOMs + - detached Cosign signatures + - `SHA256SUMS` plus a detached signature + - `profitctl-release-cosign.pub` for offline verification Operational mirrors may also publish to: - `https://downloads.intelip.co/profitctl/releases//` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1cbb4d1..728b17a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,9 @@ - Woodpecker pipeline file: `.woodpecker.yml` - PR/main verification jobs on `pool=shared-kvm` -- Tag release jobs on `pool=builder` +- Tag release jobs on `pool=shared-kvm` +- GitHub Actions provides required public repo checks: `verify-go` and `verify-install-smoke` - Canonical public release channel: GitHub Releases +- Public releases include detached Cosign signatures, SPDX JSON SBOMs, and the release verification public key - Release artifacts mirrored to Hostinger VPS at `/opt/profitctl/releases` - Optional mirror endpoint: `https://downloads.intelip.co/profitctl` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b37ba90..d96b008 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -70,6 +70,35 @@ profitctl --help profitctl validate -f examples/mix_profit.yml ``` +## Verify Release Integrity + +Public GitHub releases include: + +- detached Cosign signatures for each archive +- SPDX JSON SBOMs for each archive +- a signed `SHA256SUMS` manifest +- `profitctl-release-cosign.pub` + +Example verification flow for `darwin_arm64`: + +```bash +TAG= +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/profitctl_${TAG}_darwin_arm64.tar.gz" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/profitctl_${TAG}_darwin_arm64.tar.gz.sig" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/profitctl_${TAG}_darwin_arm64.spdx.json" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/profitctl_${TAG}_darwin_arm64.spdx.json.sig" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/SHA256SUMS" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/SHA256SUMS.sig" +curl -fsSLO "https://github.com/IntelIP/ProfitCtl/releases/download/${TAG}/profitctl-release-cosign.pub" + +cosign verify-blob --key profitctl-release-cosign.pub --signature "profitctl_${TAG}_darwin_arm64.tar.gz.sig" --insecure-ignore-tlog=true "profitctl_${TAG}_darwin_arm64.tar.gz" +cosign verify-blob --key profitctl-release-cosign.pub --signature "profitctl_${TAG}_darwin_arm64.spdx.json.sig" --insecure-ignore-tlog=true "profitctl_${TAG}_darwin_arm64.spdx.json" +cosign verify-blob --key profitctl-release-cosign.pub --signature SHA256SUMS.sig --insecure-ignore-tlog=true SHA256SUMS +shasum -a 256 -c SHA256SUMS +``` + +Use `brew install cosign` or the upstream Sigstore install path if `cosign` is not already available. + ## Exit Codes - `0`: success diff --git a/docs/deployment/WOODPECKER_HOSTINGER_SETUP.md b/docs/deployment/WOODPECKER_HOSTINGER_SETUP.md index ecc0a0b..b3e6fb7 100644 --- a/docs/deployment/WOODPECKER_HOSTINGER_SETUP.md +++ b/docs/deployment/WOODPECKER_HOSTINGER_SETUP.md @@ -13,6 +13,8 @@ This runbook onboards `IntelIP/ProfitCtl` into your existing Woodpecker infrastr ## Required Secrets (Doppler: `profitctl` / `prd_ci_woodpecker`) - `GITHUB_TOKEN_RELEASE` +- `COSIGN_PRIVATE_KEY` +- `COSIGN_PASSWORD` - `VPS_HOST` - `VPS_USER` - `VPS_SSH_PRIVATE_KEY` @@ -79,10 +81,10 @@ Tag push only (semver): ## GitHub Actions -Actions are disabled to enforce Woodpecker-only CI/CD. +GitHub Actions is enabled for repository-facing verification (`verify-go` and `verify-install-smoke`), while Woodpecker remains the authoritative tag release pipeline. ```bash -printf '{"enabled":false}' | gh api repos/IntelIP/ProfitCtl/actions/permissions -X PUT --input - +gh api repos/IntelIP/ProfitCtl/actions/permissions ``` ## Rollback diff --git a/docs/release/MVP_PRICING_RELEASE.md b/docs/release/MVP_PRICING_RELEASE.md index 9ebe4cb..012d41a 100644 --- a/docs/release/MVP_PRICING_RELEASE.md +++ b/docs/release/MVP_PRICING_RELEASE.md @@ -31,18 +31,34 @@ Use `GOCACHE` and `GOTMPDIR` overrides in restricted environments. 2. Run the smoke tests above. 3. Run the simulation benchmark command so the core economics path has a fresh baseline. 4. Review benchmark scenario outputs in `benchmark_scenarios/README.md`. -5. Tag the release and publish binaries. +5. Tag the release and publish binaries, SBOMs, and detached signatures. 6. Run the published-artifact smoke path: - `bash scripts/release/smoke-published-release.sh ` -7. Point downstream docs or GTM collateral to: +7. Verify the release contains: + - `profitctl___.spdx.json` + - `profitctl___.tar.gz.sig` or `profitctl___.zip.sig` + - `SHA256SUMS.sig` + - `profitctl-release-cosign.pub` +8. Confirm the public verification instructions in `docs/INSTALL.md` still match the published assets. +9. Point downstream docs or GTM collateral to: - `compare` for pricing review - `calibrate` plus `calibration_file` for assumption grounding - `operating_margin` covenants for contract safety checks -8. Verify the exact public install path from the README against the new tag: +10. Verify the exact public install path from the README against the new tag: - `curl -fsSL https://raw.githubusercontent.com/IntelIP/ProfitCtl/main/scripts/install.sh | env -u PROFITCTL_DOWNLOAD_BASE_URL PROFITCTL_VERSION= bash` -9. Update and publish the Homebrew tap if `Formula/profitctl.rb` changed: +11. Update and publish the Homebrew tap if `Formula/profitctl.rb` changed: - `bash scripts/release/publish-homebrew-tap.sh` +## Verification Model + +The current release pipeline uses Cosign key-pair signing because the authoritative release pipeline runs in Woodpecker, not GitHub Actions. That means releases do not currently use GitHub OIDC keyless signing or Rekor-backed transparency bundles. Consumers verify with the committed and published `profitctl-release-cosign.pub` key instead. + +This is a deliberate tradeoff: + +- it fits the current release infrastructure +- it provides deterministic offline verification for archives, SBOMs, and checksum manifests +- it keeps the upgrade path open if release publishing moves to an OIDC-capable environment later + ## Suggested Release Notes `ProfitCtl` now supports open-core pricing comparison and calibration workflows end to end. Teams can model tiered, mix, and hybrid contracts; separate booked from operating economics; ingest normalized calibration exports; and enforce recurring-margin guardrails in covenant checks. diff --git a/keys/profitctl-release-cosign.pub b/keys/profitctl-release-cosign.pub new file mode 100644 index 0000000..e4d1355 --- /dev/null +++ b/keys/profitctl-release-cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqanVKBvzbrySoIvurg2hvQ/BYcVX +oE457ag3IX77JbP3qCXoYD4Ws9NCMwhbIwE/Cu5LcZnpzxn3IyUAW95zUw== +-----END PUBLIC KEY----- diff --git a/scripts/release/generate-sboms.sh b/scripts/release/generate-sboms.sh new file mode 100755 index 0000000..cceae87 --- /dev/null +++ b/scripts/release/generate-sboms.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-${CI_COMMIT_TAG:-}}" +if [[ -z "${TAG}" ]]; then + echo "TAG is required (arg1 or CI_COMMIT_TAG)" >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${ROOT}/dist/${TAG}" + +command -v syft >/dev/null 2>&1 || { + echo "syft is required on PATH" >&2 + exit 1 +} + +mkdir -p "${OUT_DIR}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +export SYFT_CHECK_FOR_APP_UPDATE=false +export XDG_CACHE_HOME="${TMP_DIR}/cache" +mkdir -p "${XDG_CACHE_HOME}" + +artifact_stem() { + local file_name="$1" + file_name="${file_name%.tar.gz}" + file_name="${file_name%.zip}" + printf '%s\n' "${file_name}" +} + +shopt -s nullglob +archives=("${OUT_DIR}"/profitctl_"${TAG}"_*.tar.gz "${OUT_DIR}"/profitctl_"${TAG}"_*.zip) +shopt -u nullglob + +if [[ "${#archives[@]}" -eq 0 ]]; then + echo "no release archives found in ${OUT_DIR}" >&2 + exit 1 +fi + +for archive in "${archives[@]}"; do + stem="$(artifact_stem "$(basename "${archive}")")" + syft scan "file:${archive}" \ + --quiet \ + --source-name "${stem}" \ + --source-version "${TAG}" \ + --output "spdx-json=${OUT_DIR}/${stem}.spdx.json" +done + +syft scan "dir:${ROOT}" \ + --quiet \ + --exclude "./.git" \ + --exclude "./dist" \ + --source-name "profitctl-source" \ + --source-version "${TAG}" \ + --output "spdx-json=${OUT_DIR}/profitctl_${TAG}_source.spdx.json" + +echo "SBOMs written in ${OUT_DIR}" diff --git a/scripts/release/install-tool.sh b/scripts/release/install-tool.sh new file mode 100755 index 0000000..6436499 --- /dev/null +++ b/scripts/release/install-tool.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +TOOL="${1:-}" +INSTALL_DIR="${2:-$(pwd)/.release-tools}" + +if [[ -z "${TOOL}" ]]; then + echo "usage: $0 [install-dir]" >&2 + exit 1 +fi + +OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "${OS_NAME}" in + linux|darwin) ;; + *) + echo "unsupported operating system for ${TOOL}: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ARCH_NAME="amd64" ;; + arm64|aarch64) ARCH_NAME="arm64" ;; + *) + echo "unsupported architecture for ${TOOL}: $(uname -m)" >&2 + exit 1 + ;; +esac + +mkdir -p "${INSTALL_DIR}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +download() { + local url="$1" + local output="$2" + curl -fsSL --retry 5 --retry-all-errors "${url}" -o "${output}" +} + +case "${TOOL}" in + syft) + VERSION="${SYFT_VERSION:-v1.42.4}" + VERSION_NO_V="${VERSION#v}" + ARCHIVE="syft_${VERSION_NO_V}_${OS_NAME}_${ARCH_NAME}.tar.gz" + URL="https://github.com/anchore/syft/releases/download/${VERSION}/${ARCHIVE}" + download "${URL}" "${TMP_DIR}/${ARCHIVE}" + tar -xzf "${TMP_DIR}/${ARCHIVE}" -C "${INSTALL_DIR}" syft + chmod +x "${INSTALL_DIR}/syft" + ;; + cosign) + VERSION="${COSIGN_VERSION:-v3.0.6}" + URL="https://github.com/sigstore/cosign/releases/download/${VERSION}/cosign-${OS_NAME}-${ARCH_NAME}" + download "${URL}" "${INSTALL_DIR}/cosign" + chmod +x "${INSTALL_DIR}/cosign" + ;; + *) + echo "unsupported tool: ${TOOL}" >&2 + exit 1 + ;; +esac + +echo "${INSTALL_DIR}/${TOOL}" diff --git a/scripts/release/publish-github-release.sh b/scripts/release/publish-github-release.sh index 84fc8ad..9cccf52 100755 --- a/scripts/release/publish-github-release.sh +++ b/scripts/release/publish-github-release.sh @@ -16,10 +16,16 @@ export GH_TOKEN="${GITHUB_TOKEN_RELEASE}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" OUT_DIR="${ROOT}/dist/${TAG}" +PUBLIC_KEY="${ROOT}/keys/profitctl-release-cosign.pub" + +[[ -f "${PUBLIC_KEY}" ]] || { + echo "missing release public key: ${PUBLIC_KEY}" >&2 + exit 1 +} if ! gh release view "${TAG}" --repo IntelIP/ProfitCtl >/dev/null 2>&1; then gh release create "${TAG}" --repo IntelIP/ProfitCtl --title "${TAG}" --notes "Automated Woodpecker release for ${TAG}." fi -gh release upload "${TAG}" "${OUT_DIR}"/profitctl_* "${OUT_DIR}"/SHA256SUMS --repo IntelIP/ProfitCtl --clobber +gh release upload "${TAG}" "${OUT_DIR}"/profitctl_* "${OUT_DIR}"/SHA256SUMS "${OUT_DIR}"/SHA256SUMS.sig "${PUBLIC_KEY}" --repo IntelIP/ProfitCtl --clobber echo "Published GitHub release assets for ${TAG}" diff --git a/scripts/release/sign-release-assets.sh b/scripts/release/sign-release-assets.sh new file mode 100755 index 0000000..c9f506e --- /dev/null +++ b/scripts/release/sign-release-assets.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-${CI_COMMIT_TAG:-}}" +if [[ -z "${TAG}" ]]; then + echo "TAG is required (arg1 or CI_COMMIT_TAG)" >&2 + exit 1 +fi + +if [[ -z "${COSIGN_PRIVATE_KEY:-}" ]]; then + echo "COSIGN_PRIVATE_KEY is required" >&2 + exit 1 +fi + +if [[ -z "${COSIGN_PASSWORD:-}" ]]; then + echo "COSIGN_PASSWORD is required" >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${ROOT}/dist/${TAG}" + +command -v cosign >/dev/null 2>&1 || { + echo "cosign is required on PATH" >&2 + exit 1 +} + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT +export HOME="${TMP_DIR}/home" +mkdir -p "${HOME}" + +sign_blob() { + local blob_path="$1" + rm -f "${blob_path}.sig" + cosign sign-blob \ + --key env://COSIGN_PRIVATE_KEY \ + --tlog-upload=false \ + --use-signing-config=false \ + --new-bundle-format=false \ + --output-signature "${blob_path}.sig" \ + --yes \ + "${blob_path}" >/dev/null +} + +targets=() +while IFS= read -r target; do + targets+=("${target}") +done < <( + find "${OUT_DIR}" -maxdepth 1 -type f \ + \( -name "profitctl_${TAG}_*.tar.gz" -o -name "profitctl_${TAG}_*.zip" -o -name "profitctl_${TAG}_*.spdx.json" -o -name "SHA256SUMS" \) \ + | sort +) + +if [[ "${#targets[@]}" -eq 0 ]]; then + echo "no release assets found to sign in ${OUT_DIR}" >&2 + exit 1 +fi + +for target in "${targets[@]}"; do + sign_blob "${target}" +done + +echo "Signatures written in ${OUT_DIR}" diff --git a/scripts/release/smoke-published-release.sh b/scripts/release/smoke-published-release.sh index cf2b06e..487f52e 100755 --- a/scripts/release/smoke-published-release.sh +++ b/scripts/release/smoke-published-release.sh @@ -19,35 +19,6 @@ need_cmd() { } } -verify_checksum() { - local checksum_file="$1" - local archive_file="$2" - local archive_name - archive_name="$(basename "$archive_file")" - local expected_hash - expected_hash="$(awk -v name="${archive_name}" '$2 == name || $2 == "*"name { print $1; exit }' "${checksum_file}")" - [[ -n "${expected_hash}" ]] || { - echo "checksum entry missing for ${archive_name}" >&2 - exit 1 - } - - local actual_hash - - if command -v shasum >/dev/null 2>&1; then - actual_hash="$(shasum -a 256 "${archive_file}" | awk '{ print $1 }')" - elif command -v sha256sum >/dev/null 2>&1; then - actual_hash="$(sha256sum "${archive_file}" | awk '{ print $1 }')" - else - echo "missing required checksum verifier: shasum or sha256sum" >&2 - exit 1 - fi - - [[ "${actual_hash}" == "${expected_hash}" ]] || { - echo "checksum verification failed" >&2 - exit 1 - } -} - case "$(uname -s | tr '[:upper:]' '[:lower:]')" in linux) OS_NAME="linux" ;; darwin) OS_NAME="darwin" ;; @@ -64,19 +35,25 @@ ARCHIVE_EXT="tar.gz" need_cmd gh need_cmd tar +need_cmd cosign ARCHIVE_NAME="profitctl_${TAG}_${OS_NAME}_${ARCH_NAME}.${ARCHIVE_EXT}" +SBOM_NAME="profitctl_${TAG}_${OS_NAME}_${ARCH_NAME}.spdx.json" gh release download "${TAG}" \ --repo "${REPO}" \ --pattern "${ARCHIVE_NAME}" \ + --pattern "${ARCHIVE_NAME}.sig" \ + --pattern "${SBOM_NAME}" \ + --pattern "${SBOM_NAME}.sig" \ --pattern "SHA256SUMS" \ + --pattern "SHA256SUMS.sig" \ + --pattern "profitctl-release-cosign.pub" \ --dir "${TMP_DIR}" ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" CHECKSUM_PATH="${TMP_DIR}/SHA256SUMS" - -verify_checksum "${CHECKSUM_PATH}" "${ARCHIVE_PATH}" +bash "${ROOT}/scripts/release/verify-release-assets.sh" "${TAG}" "${TMP_DIR}" EXTRACT_DIR="${TMP_DIR}/extract" mkdir -p "${EXTRACT_DIR}" diff --git a/scripts/release/verify-release-assets.sh b/scripts/release/verify-release-assets.sh new file mode 100755 index 0000000..44e4f39 --- /dev/null +++ b/scripts/release/verify-release-assets.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +TAG="${1:-}" +ASSET_DIR="${2:-}" +PUBLIC_KEY="${3:-}" + +if [[ -z "${TAG}" || -z "${ASSET_DIR}" ]]; then + echo "usage: $0 [public-key-path]" >&2 + exit 1 +fi + +if [[ -z "${PUBLIC_KEY}" ]]; then + PUBLIC_KEY="${ASSET_DIR}/profitctl-release-cosign.pub" +fi + +command -v cosign >/dev/null 2>&1 || { + echo "cosign is required on PATH" >&2 + exit 1 +} + +verify_checksum() { + local checksum_file="$1" + local archive_file="$2" + local archive_name + archive_name="$(basename "${archive_file}")" + local expected_hash + expected_hash="$(awk -v name="${archive_name}" '$2 == name || $2 == "*"name { print $1; exit }' "${checksum_file}")" + [[ -n "${expected_hash}" ]] || { + echo "checksum entry missing for ${archive_name}" >&2 + exit 1 + } + + local actual_hash + if command -v shasum >/dev/null 2>&1; then + actual_hash="$(shasum -a 256 "${archive_file}" | awk '{ print $1 }')" + elif command -v sha256sum >/dev/null 2>&1; then + actual_hash="$(sha256sum "${archive_file}" | awk '{ print $1 }')" + else + echo "missing required checksum verifier: shasum or sha256sum" >&2 + exit 1 + fi + + [[ "${actual_hash}" == "${expected_hash}" ]] || { + echo "checksum verification failed for ${archive_name}" >&2 + exit 1 + } +} + +case "$(uname -s | tr '[:upper:]' '[:lower:]')" in + linux) OS_NAME="linux" ;; + darwin) OS_NAME="darwin" ;; + *) echo "unsupported operating system: $(uname -s)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ARCH_NAME="amd64" ;; + arm64|aarch64) ARCH_NAME="arm64" ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +ARCHIVE_NAME="profitctl_${TAG}_${OS_NAME}_${ARCH_NAME}.tar.gz" +SBOM_NAME="profitctl_${TAG}_${OS_NAME}_${ARCH_NAME}.spdx.json" + +ARCHIVE_PATH="${ASSET_DIR}/${ARCHIVE_NAME}" +SBOM_PATH="${ASSET_DIR}/${SBOM_NAME}" +CHECKSUM_PATH="${ASSET_DIR}/SHA256SUMS" + +[[ -f "${PUBLIC_KEY}" ]] || { echo "missing public key: ${PUBLIC_KEY}" >&2; exit 1; } +[[ -f "${CHECKSUM_PATH}" ]] || { echo "missing checksum file: ${CHECKSUM_PATH}" >&2; exit 1; } +[[ -f "${CHECKSUM_PATH}.sig" ]] || { echo "missing checksum signature: ${CHECKSUM_PATH}.sig" >&2; exit 1; } +[[ -f "${ARCHIVE_PATH}" ]] || { echo "missing archive: ${ARCHIVE_PATH}" >&2; exit 1; } +[[ -f "${ARCHIVE_PATH}.sig" ]] || { echo "missing archive signature: ${ARCHIVE_PATH}.sig" >&2; exit 1; } +[[ -f "${SBOM_PATH}" ]] || { echo "missing sbom: ${SBOM_PATH}" >&2; exit 1; } +[[ -f "${SBOM_PATH}.sig" ]] || { echo "missing sbom signature: ${SBOM_PATH}.sig" >&2; exit 1; } + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT +export HOME="${TMP_DIR}/home" +mkdir -p "${HOME}" + +cosign verify-blob --key "${PUBLIC_KEY}" --signature "${CHECKSUM_PATH}.sig" --insecure-ignore-tlog=true "${CHECKSUM_PATH}" >/dev/null +cosign verify-blob --key "${PUBLIC_KEY}" --signature "${ARCHIVE_PATH}.sig" --insecure-ignore-tlog=true "${ARCHIVE_PATH}" >/dev/null +cosign verify-blob --key "${PUBLIC_KEY}" --signature "${SBOM_PATH}.sig" --insecure-ignore-tlog=true "${SBOM_PATH}" >/dev/null + +verify_checksum "${CHECKSUM_PATH}" "${ARCHIVE_PATH}" + +echo "Release signatures and checksums verified for ${TAG}"