diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4bf9d6d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,292 @@ +name: Release + +on: + push: + branches: [main] + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: saola-cli-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: true + - name: Test release metadata contract + run: bash hack/release-metadata_test.sh + - name: Test Go packages + run: make test + + metadata: + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.metadata.outputs.channel }} + version: ${{ steps.metadata.outputs.version }} + commit: ${{ steps.metadata.outputs.commit }} + build_date: ${{ steps.metadata.outputs.build_date }} + source_date_epoch: ${{ steps.metadata.outputs.source_date_epoch }} + event_type: ${{ steps.metadata.outputs.event_type }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - name: Derive immutable metadata + id: metadata + env: + EXPECTED_COMMIT: ${{ github.sha }} + GIT_REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${EXPECTED_COMMIT}" + source_date_epoch="$(git show -s --format=%ct "${EXPECTED_COMMIT}")" + metadata="$(SOURCE_DATE_EPOCH="${source_date_epoch}" ./hack/release-metadata.sh "${GIT_REF}" "${EXPECTED_COMMIT}" "${REPOSITORY}")" + printf '%s\n' "${metadata}" >> "${GITHUB_OUTPUT}" + printf 'source_date_epoch=%s\n' "${source_date_epoch}" >> "${GITHUB_OUTPUT}" + channel="$(printf '%s\n' "${metadata}" | sed -n 's/^channel=//p')" + case "${channel}" in + dev) event_type=saola-cli-dev ;; + stable) event_type=saola-cli-stable ;; + *) echo "unsupported release channel: ${channel}" >&2; exit 1 ;; + esac + printf 'event_type=%s\n' "${event_type}" >> "${GITHUB_OUTPUT}" + + build: + needs: [test, metadata] + runs-on: ubuntu-latest + permissions: + actions: read + attestations: write + contents: write + id-token: write + env: + VERSION: ${{ needs.metadata.outputs.version }} + GIT_COMMIT: ${{ needs.metadata.outputs.commit }} + BUILD_DATE: ${{ needs.metadata.outputs.build_date }} + SOURCE_DATE_EPOCH: ${{ needs.metadata.outputs.source_date_epoch }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - name: Validate dispatch credential + env: + OPENSAOLA_DISPATCH_TOKEN: ${{ secrets.OPENSAOLA_DISPATCH_TOKEN }} + run: | + set -euo pipefail + test -n "${OPENSAOLA_DISPATCH_TOKEN}" || { + echo "OPENSAOLA_DISPATCH_TOKEN is required" >&2 + exit 1 + } + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache: true + - name: Build official architectures + run: make release-build + - name: Inspect release binaries + run: | + set -euo pipefail + file dist/saola-linux-amd64 dist/saola-linux-arm64 + file dist/saola-linux-amd64 | grep -q 'x86-64' + file dist/saola-linux-arm64 | grep -Eq 'ARM aarch64|aarch64' + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + with: + platforms: arm64 + - name: Smoke-test both architectures + run: | + set -euo pipefail + amd64_output="$(dist/saola-linux-amd64 version)" + arm64_output="$(dist/saola-linux-arm64 version)" + printf '%s\n' "${amd64_output}" + printf '%s\n' "${arm64_output}" + grep -Fq "Version: ${VERSION}" <<<"${amd64_output}" + grep -Fq "Git Commit: ${GIT_COMMIT}" <<<"${amd64_output}" + grep -Fq "Version: ${VERSION}" <<<"${arm64_output}" + grep -Fq "Git Commit: ${GIT_COMMIT}" <<<"${arm64_output}" + - name: Create checksums + run: make release-checksums + - name: Generate amd64 SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + file: dist/saola-linux-amd64 + format: spdx-json + output-file: dist/saola-linux-amd64.spdx.json + artifact-name: saola-linux-amd64.spdx.json + upload-artifact: false + upload-release-assets: false + - name: Generate arm64 SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + file: dist/saola-linux-arm64 + format: spdx-json + output-file: dist/saola-linux-arm64.spdx.json + artifact-name: saola-linux-arm64.spdx.json + upload-artifact: false + upload-release-assets: false + - name: Attest release artifacts + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-path: | + dist/saola-linux-amd64 + dist/saola-linux-arm64 + dist/SHA256SUMS + dist/saola-linux-amd64.spdx.json + dist/saola-linux-arm64.spdx.json + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Keyless-sign release artifacts + run: | + set -euo pipefail + for artifact in \ + dist/saola-linux-amd64 \ + dist/saola-linux-arm64 \ + dist/SHA256SUMS \ + dist/saola-linux-amd64.spdx.json \ + dist/saola-linux-arm64.spdx.json; do + cosign sign-blob --yes --bundle "${artifact}.sigstore.json" "${artifact}" + done + - name: Upload immutable workflow artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: saola-cli-${{ needs.metadata.outputs.version }} + path: dist/ + if-no-files-found: error + retention-days: 30 + - name: Publish stable tag assets + if: needs.metadata.outputs.channel == 'stable' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.metadata.outputs.version }} + run: | + set -euo pipefail + release_created=false + if ! release_json="$(gh release view "${RELEASE_TAG}" --json isDraft,assets 2>/dev/null)"; then + gh release create "${RELEASE_TAG}" --verify-tag --draft --generate-notes --title "Saola CLI ${RELEASE_TAG}" + release_created=true + release_json="$(gh release view "${RELEASE_TAG}" --json isDraft,assets)" + fi + + is_draft="$(jq -r '.isDraft' <<<"${release_json}")" + [[ "${is_draft}" == "true" || "${is_draft}" == "false" ]] || { + echo "unable to determine release draft state" >&2 + exit 1 + } + mapfile -t remote_assets < <(jq -r '.assets[].name' <<<"${release_json}") + + for remote_asset in "${remote_assets[@]}"; do + [[ "${remote_asset}" == "$(basename "${remote_asset}")" && -f "dist/${remote_asset}" ]] || { + echo "release contains unexpected asset: ${remote_asset}" >&2 + exit 1 + } + done + + verify_dir="$(mktemp -d)" + trap 'rm -rf "${verify_dir}"' EXIT + for artifact in dist/*; do + asset_name="$(basename "${artifact}")" + asset_exists=false + for remote_asset in "${remote_assets[@]}"; do + if [[ "${remote_asset}" == "${asset_name}" ]]; then + asset_exists=true + break + fi + done + + if [[ "${asset_exists}" == "false" ]]; then + if [[ "${is_draft}" != "true" ]]; then + echo "published release is missing immutable asset: ${asset_name}" >&2 + exit 1 + fi + upload_artifact="${artifact}" + if [[ "${asset_name}" == *.spdx.json.sigstore.json ]]; then + sbom_name="${asset_name%.sigstore.json}" + canonical_sbom="${verify_dir}/${sbom_name}/${sbom_name}" + if [[ -f "${canonical_sbom}" ]]; then + mkdir -p "${verify_dir}/canonical-bundles" + upload_artifact="${verify_dir}/canonical-bundles/${asset_name}" + cosign sign-blob --yes --bundle "${upload_artifact}" "${canonical_sbom}" + fi + fi + gh release upload "${RELEASE_TAG}" "${upload_artifact}" + fi + + asset_verify_dir="${verify_dir}/${asset_name}" + mkdir -p "${asset_verify_dir}" + gh release download "${RELEASE_TAG}" --pattern "${asset_name}" --dir "${asset_verify_dir}" + if [[ "${asset_name}" == *.sigstore.json ]]; then + signed_artifact="${artifact%.sigstore.json}" + signed_name="$(basename "${signed_artifact}")" + canonical_signed_artifact="${verify_dir}/${signed_name}/${signed_name}" + if [[ -f "${canonical_signed_artifact}" ]]; then + signed_artifact="${canonical_signed_artifact}" + fi + cosign verify-blob \ + --bundle "${asset_verify_dir}/${asset_name}" \ + --certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/${RELEASE_TAG}" \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + "${signed_artifact}" + elif [[ "${asset_name}" == *.spdx.json ]]; then + canonical_sbom="${asset_verify_dir}/${asset_name}" + jq -e ' + (.spdxVersion | type == "string" and startswith("SPDX-")) and + (.SPDXID == "SPDXRef-DOCUMENT") and + (.packages | type == "array" and length > 0) + ' "${canonical_sbom}" >/dev/null || { + echo "release SBOM is not a valid SPDX document: ${asset_name}" >&2 + exit 1 + } + else + cmp -s "${artifact}" "${asset_verify_dir}/${asset_name}" || { + echo "release asset differs from local immutable artifact: ${asset_name}" >&2 + exit 1 + } + fi + done + + if [[ "${is_draft}" == "true" ]]; then + gh release edit "${RELEASE_TAG}" --draft=false + elif [[ "${release_created}" == "true" ]]; then + echo "new release unexpectedly lost draft state" >&2 + exit 1 + fi + - name: Dispatch immutable OpenSaola update + env: + OPENSAOLA_DISPATCH_TOKEN: ${{ secrets.OPENSAOLA_DISPATCH_TOKEN }} + EVENT_TYPE: ${{ needs.metadata.outputs.event_type }} + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} + RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} + RELEASE_EPOCH: ${{ needs.metadata.outputs.source_date_epoch }} + run: | + set -euo pipefail + amd64_sha="$(awk '$2 == "saola-linux-amd64" { print $1 }' dist/SHA256SUMS)" + arm64_sha="$(awk '$2 == "saola-linux-arm64" { print $1 }' dist/SHA256SUMS)" + test "${#amd64_sha}" -eq 64 + test "${#arm64_sha}" -eq 64 + payload="$(jq -n \ + --arg event_type "${EVENT_TYPE}" \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg channel "${RELEASE_CHANNEL}" \ + --arg version "${RELEASE_VERSION}" \ + --arg commit "${RELEASE_COMMIT}" \ + --arg source_date_epoch "${RELEASE_EPOCH}" \ + --arg amd64_sha256 "${amd64_sha}" \ + --arg arm64_sha256 "${arm64_sha}" \ + '{event_type: $event_type, client_payload: {repository: $repository, channel: $channel, version: $version, commit: $commit, source_date_epoch: $source_date_epoch, checksums: {"linux/amd64": $amd64_sha256, "linux/arm64": $arm64_sha256}}}')" + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${OPENSAOLA_DISPATCH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/harmonycloud/opensaola/dispatches \ + --data "${payload}" diff --git a/Makefile b/Makefile index 1ee61ee..8b8a7c8 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,42 @@ -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown) +SOURCE_DATE_EPOCH ?= $(shell git show -s --format=%ct HEAD 2>/dev/null || echo 0) +BUILD_DATE ?= $(shell date -u -d "@$(SOURCE_DATE_EPOCH)" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$(SOURCE_DATE_EPOCH)" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown) +DIST_DIR ?= dist MODULE := github.com/harmonycloud/saola-cli LDFLAGS := -s -w \ -X $(MODULE)/internal/version.Version=$(VERSION) \ -X $(MODULE)/internal/version.GitCommit=$(GIT_COMMIT) \ -X $(MODULE)/internal/version.BuildDate=$(BUILD_DATE) -.PHONY: build clean tidy lint test test-e2e fmt help +.PHONY: build release-build release-checksums clean tidy lint test test-e2e fmt help ## build: compile saola binary into bin/ build: go build -ldflags "$(LDFLAGS)" -o bin/saola ./cmd/saola/ +## release-build: reproducibly build static Linux binaries for amd64 and arm64 into dist/ +release-build: + @test "$(GIT_COMMIT)" != "unknown" || { echo "GIT_COMMIT must be a full commit SHA" >&2; exit 1; } + @printf '%s\n' "$(GIT_COMMIT)" | grep -Eq '^[0-9a-f]{40}$$' || { echo "GIT_COMMIT must be a full lowercase 40-character SHA" >&2; exit 1; } + @test "$(BUILD_DATE)" != "unknown" || { echo "BUILD_DATE could not be derived from SOURCE_DATE_EPOCH" >&2; exit 1; } + rm -rf "$(DIST_DIR)" + mkdir -p "$(DIST_DIR)" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -ldflags "$(LDFLAGS)" -o "$(DIST_DIR)/saola-linux-amd64" ./cmd/saola/ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -buildvcs=false -ldflags "$(LDFLAGS)" -o "$(DIST_DIR)/saola-linux-arm64" ./cmd/saola/ + +## release-checksums: write sorted SHA-256 checksums for both release binaries +release-checksums: + @test -f "$(DIST_DIR)/saola-linux-amd64" -a -f "$(DIST_DIR)/saola-linux-arm64" || { echo "run make release-build first" >&2; exit 1; } + @cd "$(DIST_DIR)" && if command -v sha256sum >/dev/null 2>&1; then \ + sha256sum saola-linux-amd64 saola-linux-arm64 > SHA256SUMS; \ + else \ + shasum -a 256 saola-linux-amd64 saola-linux-arm64 > SHA256SUMS; \ + fi + ## clean: remove build artifacts clean: - rm -rf bin/ + rm -rf bin/ "$(DIST_DIR)/" ## tidy: tidy go modules tidy: diff --git a/README.md b/README.md index 954ee92..b891b68 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,22 @@ saola version # Build Date: 2026-03-31T07:46:11Z ``` +### Automated releases + +The release workflow has two channels: + +- A push to `main` publishes an immutable workflow artifact with version `dev-<12-character-commit>` and dispatches the `saola-cli-dev` event. It is a snapshot for the OpenSaola `dev` channel and is not a GitHub Release. +- A `vMAJOR.MINOR.PATCH` tag (optionally with a SemVer prerelease suffix) publishes the same immutable artifacts as GitHub Release assets and dispatches the `saola-cli-stable` event. Only this stable event is eligible for OpenSaola `master` promotion. + +Each release build contains static `linux/amd64` and `linux/arm64` binaries, `SHA256SUMS`, SPDX JSON SBOMs, and keyless Sigstore bundles. GitHub provenance attestations are associated with these files through GitHub's artifact attestation service; they are not files in `dist/` or GitHub Release assets. Stable Release assets are immutable: an existing asset must match byte-for-byte, a published release cannot be repaired in place, and a draft is published only after every uploaded asset is downloaded and verified. The version, full commit SHA, and UTC build date are derived from the triggering commit. Local release artifacts can be reproduced with: + +```bash +make release-build +make release-checksums +``` + +Repository administrators must configure the Actions secret `OPENSAOLA_DISPATCH_TOKEN` with permission to send `repository_dispatch` events to `harmonycloud/opensaola`. The workflow fails closed before dispatch when this credential is absent; configuring the secret does not replace required branch protection or promotion policy in the target repository. + ## Managed CRD Types Saola manages the following Kubernetes custom resources via the [OpenSaola](https://github.com/harmonycloud/opensaola) operator: diff --git a/README_zh.md b/README_zh.md index c5a676c..3186651 100644 --- a/README_zh.md +++ b/README_zh.md @@ -218,6 +218,22 @@ saola version # Build Date: 2026-03-31T07:46:11Z ``` +### 自动发布 + +发布工作流分为两个通道: + +- 推送到 `main` 时,以 `dev-<12 位 commit>` 版本生成不可变的工作流产物,并发送 `saola-cli-dev` 事件。该快照只供 OpenSaola `dev` 通道使用,不创建 GitHub Release。 +- 推送 `vMAJOR.MINOR.PATCH` tag(可带 SemVer 预发布后缀)时,将同一组不可变产物发布为 GitHub Release assets,并发送 `saola-cli-stable` 事件。只有该稳定事件可进入 OpenSaola `master` 晋级流程。 + +每次发布包含静态的 `linux/amd64`、`linux/arm64` 二进制、`SHA256SUMS`、SPDX JSON SBOM 和无密钥 Sigstore bundle。GitHub provenance attestation 通过 GitHub artifact attestation 服务与这些文件关联,不是 `dist/` 中的文件,也不是 GitHub Release asset。stable Release asset 不可变:已有资产必须逐字节一致,已发布 Release 不允许原地修补,draft 只有在每项上传资产均被重新下载并验证后才会发布。版本、完整 commit SHA 与 UTC 构建时间均从触发提交确定。可在本地复现发布产物: + +```bash +make release-build +make release-checksums +``` + +仓库管理员必须配置 Actions secret `OPENSAOLA_DISPATCH_TOKEN`,并授予它向 `harmonycloud/opensaola` 发送 `repository_dispatch` 事件的权限。凭据缺失时工作流会在 dispatch 前 fail closed;配置该 secret 不能替代目标仓库所需的分支保护和晋级策略。 + ## 管理的 CRD 类型 Saola 通过 [OpenSaola](https://github.com/harmonycloud/opensaola) Operator 管理以下 Kubernetes 自定义资源: diff --git a/hack/release-metadata.sh b/hack/release-metadata.sh new file mode 100755 index 0000000..aeb9f0e --- /dev/null +++ b/hack/release-metadata.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + printf 'usage: %s \n' "${0##*/}" >&2 + exit 2 +} + +[[ "$#" -eq 3 ]] || usage + +git_ref="$1" +commit="$2" +repository="$3" + +[[ "${commit}" =~ ^[0-9a-f]{40}$ ]] || { + printf 'invalid commit: expected a full lowercase 40-character SHA\n' >&2 + exit 1 +} +[[ "${repository}" == "harmonycloud/saola-cli" ]] || { + printf 'invalid repository: expected harmonycloud/saola-cli\n' >&2 + exit 1 +} + +case "${git_ref}" in + refs/heads/main) + channel="dev" + version="dev-${commit:0:12}" + ;; + refs/tags/v*) + version="${git_ref#refs/tags/}" + semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$' + [[ "${version}" =~ ${semver} ]] || { + printf 'invalid release tag: expected vMAJOR.MINOR.PATCH with optional SemVer prerelease\n' >&2 + exit 1 + } + channel="stable" + ;; + *) + printf 'invalid git ref: only refs/heads/main and SemVer tags are releasable\n' >&2 + exit 1 + ;; +esac + +source_date_epoch="${SOURCE_DATE_EPOCH:-}" +if [[ -z "${source_date_epoch}" ]]; then + source_date_epoch="$(git show -s --format=%ct "${commit}" 2>/dev/null)" || { + printf 'SOURCE_DATE_EPOCH is unset and commit timestamp cannot be resolved\n' >&2 + exit 1 + } +fi +[[ "${source_date_epoch}" =~ ^(0|[1-9][0-9]*)$ ]] || { + printf 'invalid SOURCE_DATE_EPOCH: expected a non-negative integer\n' >&2 + exit 1 +} + +if build_date="$(date -u -d "@${source_date_epoch}" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; then + : +elif build_date="$(date -u -r "${source_date_epoch}" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"; then + : +else + printf 'unable to format SOURCE_DATE_EPOCH\n' >&2 + exit 1 +fi + +printf 'channel=%s\n' "${channel}" +printf 'version=%s\n' "${version}" +printf 'commit=%s\n' "${commit}" +printf 'build_date=%s\n' "${build_date}" diff --git a/hack/release-metadata_test.sh b/hack/release-metadata_test.sh new file mode 100755 index 0000000..6e13521 --- /dev/null +++ b/hack/release-metadata_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +helper="${script_dir}/release-metadata.sh" +workflow="${script_dir}/../.github/workflows/release.yml" +commit="0123456789abcdef0123456789abcdef01234567" +epoch="1782812176" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + local output="$1" + local expected="$2" + [[ "${output}" == *"${expected}"* ]] || fail "expected output to contain ${expected}, got: ${output}" +} + +assert_rejected() { + if "${helper}" "$@" >/dev/null 2>&1; then + fail "expected rejection for arguments: $*" + fi +} + +main_output="$(SOURCE_DATE_EPOCH="${epoch}" "${helper}" refs/heads/main "${commit}" harmonycloud/saola-cli)" +assert_contains "${main_output}" "channel=dev" +assert_contains "${main_output}" "version=dev-0123456789ab" +assert_contains "${main_output}" "commit=${commit}" +assert_contains "${main_output}" "build_date=2026-06-30T09:36:16Z" + +tag_output="$(SOURCE_DATE_EPOCH="${epoch}" "${helper}" refs/tags/v1.2.3-rc.1 "${commit}" harmonycloud/saola-cli)" +assert_contains "${tag_output}" "channel=stable" +assert_contains "${tag_output}" "version=v1.2.3-rc.1" + +repeat_output="$(SOURCE_DATE_EPOCH="${epoch}" "${helper}" refs/heads/main "${commit}" harmonycloud/saola-cli)" +[[ "${main_output}" == "${repeat_output}" ]] || fail "metadata output is not deterministic" + +assert_rejected refs/heads/dev "${commit}" harmonycloud/saola-cli +assert_rejected refs/tags/latest "${commit}" harmonycloud/saola-cli +assert_rejected refs/tags/v1.2 "${commit}" harmonycloud/saola-cli +assert_rejected refs/heads/main 0123456789abcdef harmonycloud/saola-cli +assert_rejected refs/heads/main "${commit}" 'harmonycloud/saola-cli;echo-owned' +assert_rejected refs/heads/main "${commit}" other/saola-cli +if SOURCE_DATE_EPOCH=not-an-epoch "${helper}" refs/heads/main "${commit}" harmonycloud/saola-cli >/dev/null 2>&1; then + fail "expected rejection for an invalid SOURCE_DATE_EPOCH" +fi + +grep -Fq -- '--draft' "${workflow}" || fail "stable releases must be created as drafts" +grep -Fq 'gh release download' "${workflow}" || fail "existing assets must be downloaded before comparison" +grep -Fq 'cmp -s' "${workflow}" || fail "existing assets must be compared byte-for-byte" +grep -Fq 'cosign verify-blob' "${workflow}" || fail "existing signature bundles must be cryptographically verified" +grep -Fq -- '--certificate-identity' "${workflow}" || fail "bundle verification must bind the workflow identity" +grep -Fq 'sigstore.json' "${workflow}" || fail "bundle replay handling is missing" +grep -Fq 'canonical_sbom' "${workflow}" || fail "existing SBOM is not treated as the canonical release asset" +grep -Fq 'spdxVersion' "${workflow}" || fail "canonical SBOM structure is not validated" +grep -Fq 'sign-blob --yes --bundle' "${workflow}" || fail "missing canonical SBOM bundle cannot be regenerated" +grep -Fq -- '--draft=false' "${workflow}" || fail "draft releases must be published only after verification" +if grep -Fq -- '--clobber' "${workflow}"; then + fail "stable release assets must never be overwritten with --clobber" +fi + +uses_count="$(grep -Ec '^[[:space:]]+(- )?uses:' "${workflow}")" +pinned_uses_count="$(grep -Ec '^[[:space:]]+(- )?uses: [^@[:space:]]+@[0-9a-f]{40}[[:space:]]+# v[^[:space:]]+$' "${workflow}")" +[[ "${uses_count}" -gt 0 && "${uses_count}" -eq "${pinned_uses_count}" ]] || { + fail "all workflow actions must be pinned to a full 40-character commit SHA with a version comment" +} + +printf 'PASS: release metadata contract\n'