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' diff --git a/internal/cmd/images/cmd.go b/internal/cmd/images/cmd.go index 6ab6fca..d8960b6 100644 --- a/internal/cmd/images/cmd.go +++ b/internal/cmd/images/cmd.go @@ -40,6 +40,7 @@ type ExportOptions struct { Output string LockFile string Repositories []string + Format string Platform string MultiArch bool Insecure bool @@ -74,6 +75,7 @@ func NewCmdImages(cfg *config.Config) *cobra.Command { func NewCmdExport(cfg *config.Config) *cobra.Command { o := &ExportOptions{ Config: cfg, + Format: imageexport.ExportFormatSkopeo, Platform: "all", MultiArch: true, Timeout: imageexport.DefaultProbeTimeout, @@ -90,6 +92,7 @@ func NewCmdExport(cfg *config.Config) *cobra.Command { ), Example: ` saola images export ./redis -r 10.10.101.172:443/middleware -r 10.10.102.124:443/middleware saola images export ./redis -r 10.10.101.172:443/middleware,10.10.102.124:443/middleware -o redis-images.tar + saola images export ./redis -r 10.10.101.172:443/middleware --format docker -o redis-images-docker.tar saola images export ./redis -r 10.10.101.172:443/middleware --dry-run`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -103,7 +106,8 @@ func NewCmdExport(cfg *config.Config) *cobra.Command { cmd.Flags().StringArrayVarP(&o.Repositories, "repository", "r", nil, lang.T("候选镜像仓库,可重复声明或用逗号分隔", "Candidate image repository; repeat or comma-separate values")) cmd.Flags().StringVarP(&o.Output, "output", "o", "", lang.T("输出镜像归档路径(默认:--images.tar)", "Output image archive path (default: --images.tar)")) cmd.Flags().StringVar(&o.LockFile, "lock-file", "", lang.T("输出镜像锁定清单路径(默认:.lock.json)", "Output image lock file path (default: .lock.json)")) - cmd.Flags().StringVar(&o.Platform, "platform", "all", lang.T("导出平台,例如 linux/amd64;all 表示保留多架构", "Export platform, for example linux/amd64; all keeps multi-arch images")) + cmd.Flags().StringVar(&o.Format, "format", imageexport.ExportFormatSkopeo, lang.T("导出格式:skopeo 为 OCI layout,docker 为 docker/nerdctl load 归档", "Export format: skopeo for OCI layout, docker for docker/nerdctl load archive")) + cmd.Flags().StringVar(&o.Platform, "platform", "all", lang.T("导出平台,例如 linux/amd64;skopeo 格式下 all 表示保留多架构", "Export platform, for example linux/amd64; all keeps multi-arch images with skopeo format")) cmd.Flags().BoolVar(&o.MultiArch, "multi-arch", true, lang.T("使用 skopeo 导出时保留多架构清单", "Keep multi-arch manifests when exporting with skopeo")) cmd.Flags().BoolVar(&o.Insecure, "insecure", false, lang.T("跳过镜像仓库 TLS 校验", "Skip registry TLS verification")) cmd.Flags().BoolVar(&o.SkipMissing, "skip-missing", false, lang.T("存在无法解析的镜像时仍导出已命中的镜像", "Export resolved images even when some images are missing")) @@ -130,6 +134,7 @@ func (o *ExportOptions) Run(ctx context.Context) error { Output: o.Output, LockFile: o.LockFile, Repositories: o.Repositories, + Format: o.Format, Platform: o.Platform, MultiArch: o.MultiArch, Insecure: o.Insecure, diff --git a/internal/images/export.go b/internal/images/export.go index 89693a3..8535f5e 100644 --- a/internal/images/export.go +++ b/internal/images/export.go @@ -35,6 +35,13 @@ const ( toolNerdctl = "nerdctl" ) +const ( + // ExportFormatSkopeo exports a skopeo OCI layout archive for registry-to-registry import. + ExportFormatSkopeo = "skopeo" + // ExportFormatDocker exports a Docker archive that can be loaded by docker or nerdctl. + ExportFormatDocker = "docker" +) + // ExportResult contains the resolved images and generated lock file. // // ExportResult 保存解析后的镜像和生成的锁定清单。 @@ -65,6 +72,10 @@ func ExportPackage(ctx context.Context, opts ExportOptions) (*ExportResult, erro if opts.Platform == "" { opts.Platform = "all" } + opts.Format = normalizeExportFormat(opts.Format) + if opts.Format != ExportFormatSkopeo && opts.Format != ExportFormatDocker { + return nil, fmt.Errorf("unsupported image export format %q (supported: %s, %s)", opts.Format, ExportFormatSkopeo, ExportFormatDocker) + } meta, groups, err := DiscoverPackageImages(opts.PkgDir, opts.Repositories) if err != nil { @@ -252,16 +263,31 @@ func inspectImage(ctx context.Context, runner Runner, image string, opts ExportO } func exportImages(ctx context.Context, runner Runner, images []ResolvedImage, output string, opts ExportOptions) error { - if _, err := runner.LookPath(toolSkopeo); err == nil { + switch normalizeExportFormat(opts.Format) { + case ExportFormatSkopeo: + if _, err := runner.LookPath(toolSkopeo); err != nil { + return fmt.Errorf("--format=%s requires skopeo", ExportFormatSkopeo) + } return exportWithSkopeo(ctx, runner, images, output, opts) + case ExportFormatDocker: + if _, err := runner.LookPath(toolDocker); err == nil { + return exportWithDocker(ctx, runner, images, output, opts) + } + if _, err := runner.LookPath(toolNerdctl); err == nil { + return exportWithNerdctl(ctx, runner, images, output, opts) + } + return fmt.Errorf("--format=%s requires docker or nerdctl", ExportFormatDocker) + default: + return fmt.Errorf("unsupported image export format %q (supported: %s, %s)", opts.Format, ExportFormatSkopeo, ExportFormatDocker) } - if _, err := runner.LookPath(toolDocker); err == nil { - return exportWithDocker(ctx, runner, images, output, opts) - } - if _, err := runner.LookPath(toolNerdctl); err == nil { - return exportWithNerdctl(ctx, runner, images, output, opts) +} + +func normalizeExportFormat(format string) string { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + return ExportFormatSkopeo } - return fmt.Errorf("no image export tool found; install skopeo, docker, or nerdctl") + return format } func exportWithSkopeo(ctx context.Context, runner Runner, images []ResolvedImage, output string, opts ExportOptions) error { diff --git a/internal/images/images_test.go b/internal/images/images_test.go index 1dfa507..a6d7602 100644 --- a/internal/images/images_test.go +++ b/internal/images/images_test.go @@ -64,6 +64,10 @@ func TestDiscoverPackageImages_ExpandsMultipleRepositories(t *testing.T) { "repo-a.example:5000/middleware/redis-exporter:v8.2.6", "repo-b.example:5000/middleware/redis-exporter:v8.2.6", }) + assertCandidateImages(t, groups, "redis-audit:v1.0.0", []string{ + "repo-a.example:5000/middleware/redis-audit:v1.0.0", + "repo-b.example:5000/middleware/redis-audit:v1.0.0", + }) } func TestExportPackage_DryRunBuildsLock(t *testing.T) { @@ -264,6 +268,79 @@ func TestExportWithSkopeo_UsesPlatformOverrideParts(t *testing.T) { } } +func TestExportImages_FormatDockerUsesDockerArchive(t *testing.T) { + t.Parallel() + runner := &fakeRunner{tools: map[string]bool{toolSkopeo: true, toolDocker: true}} + + err := exportImages(context.Background(), runner, []ResolvedImage{{ + Name: "redis:v1.0.0", + Image: "repo.example/middleware/redis:v1.0.0", + }}, filepath.Join(t.TempDir(), "images.tar"), ExportOptions{ + Format: ExportFormatDocker, + Platform: "linux/amd64", + }) + if err != nil { + t.Fatalf("exportImages: %v", err) + } + if len(runner.runs) != 2 { + t.Fatalf("expected docker pull and save, got %#v", runner.runs) + } + if runner.runs[0].name != toolDocker || runner.runs[0].args[0] != "pull" { + t.Fatalf("expected docker pull, got %#v", runner.runs[0]) + } + if runner.runs[1].name != toolDocker || runner.runs[1].args[0] != "save" { + t.Fatalf("expected docker save, got %#v", runner.runs[1]) + } +} + +func TestExportImages_FormatDockerFallsBackToNerdctl(t *testing.T) { + t.Parallel() + runner := &fakeRunner{tools: map[string]bool{toolNerdctl: true}} + + err := exportImages(context.Background(), runner, []ResolvedImage{{ + Name: "redis:v1.0.0", + Image: "repo.example/middleware/redis:v1.0.0", + }}, filepath.Join(t.TempDir(), "images.tar"), ExportOptions{Format: ExportFormatDocker}) + if err != nil { + t.Fatalf("exportImages: %v", err) + } + if len(runner.runs) != 2 { + t.Fatalf("expected nerdctl pull and save, got %#v", runner.runs) + } + if runner.runs[0].name != toolNerdctl || runner.runs[0].args[0] != "pull" { + t.Fatalf("expected nerdctl pull, got %#v", runner.runs[0]) + } + if runner.runs[1].name != toolNerdctl || runner.runs[1].args[0] != "save" { + t.Fatalf("expected nerdctl save, got %#v", runner.runs[1]) + } +} + +func TestExportImages_FormatSkopeoRequiresSkopeo(t *testing.T) { + t.Parallel() + runner := &fakeRunner{tools: map[string]bool{toolDocker: true}} + + err := exportImages(context.Background(), runner, []ResolvedImage{{ + Name: "redis:v1.0.0", + Image: "repo.example/middleware/redis:v1.0.0", + }}, filepath.Join(t.TempDir(), "images.tar"), ExportOptions{Format: ExportFormatSkopeo}) + if err == nil || !strings.Contains(err.Error(), "skopeo") { + t.Fatalf("expected skopeo-required error, got %v", err) + } +} + +func TestExportImages_RejectsUnknownFormat(t *testing.T) { + t.Parallel() + runner := &fakeRunner{tools: map[string]bool{toolSkopeo: true}} + + err := exportImages(context.Background(), runner, []ResolvedImage{{ + Name: "redis:v1.0.0", + Image: "repo.example/middleware/redis:v1.0.0", + }}, filepath.Join(t.TempDir(), "images.tar"), ExportOptions{Format: "oci"}) + if err == nil || !strings.Contains(err.Error(), "unsupported image export format") { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + func TestSkopeoPlatformArgsRejectsInvalidPlatform(t *testing.T) { t.Parallel() if _, err := skopeoPlatformArgs("linux"); err == nil { @@ -309,6 +386,12 @@ spec: values: repository: "{{ .Necessary.repository }}" tag: "{{ .Necessary.version }}" + - name: redis-audit + values: + repository: "{{ .Necessary.repository }}" + tag: "v1.0.0" + labels: + app: redis-audit `) writeFile(t, filepath.Join(dir, "configurations", "redis-dashboard.yaml"), `apiVersion: middleware.harmonycloud.cn/v1 kind: MiddlewareConfiguration @@ -339,6 +422,26 @@ spec: containers: - name: exporter image: "{{ $repo }}/redis-exporter:v{{ $tag }}" +`) + writeFile(t, filepath.Join(dir, "configurations", "redis-audit.yaml"), `apiVersion: middleware.harmonycloud.cn/v1 +kind: MiddlewareConfiguration +metadata: + name: redis-audit +spec: + template: |- + {{- $_ := required "repository is required" .Values.repository }} + apiVersion: apps/v1 + kind: Deployment + metadata: + name: redis-audit + labels: + {{- toYaml .Values.labels | nindent 4 }} + spec: + template: + spec: + containers: + - name: audit + image: "{{ .Values.repository }}/redis-audit:{{ .Values.tag }}" `) return dir } diff --git a/internal/images/template.go b/internal/images/template.go index cca7966..f494afe 100644 --- a/internal/images/template.go +++ b/internal/images/template.go @@ -25,6 +25,7 @@ import ( "text/template" "github.com/Masterminds/sprig/v3" + "sigs.k8s.io/yaml" ) type templateValues struct { @@ -36,7 +37,21 @@ type templateValues struct { } func renderImageTemplate(text string, values templateValues) (string, error) { - tpl, err := template.New("image").Funcs(templateFuncs()).Parse(text) + funcs := templateFuncs() + var tpl *template.Template + funcs["include"] = func(name string, data any) (string, error) { + if tpl == nil { + return "", fmt.Errorf("include template %q before parsing completed", name) + } + var included bytes.Buffer + if err := tpl.ExecuteTemplate(&included, name, data); err != nil { + return "", err + } + return included.String(), nil + } + + var err error + tpl, err = template.New("image").Option("missingkey=default").Funcs(funcs).Parse(text) if err != nil { return "", err } @@ -55,7 +70,21 @@ func templateFuncs() template.FuncMap { funcs["contains"] = strings.Contains funcs["replace"] = replaceFunc funcs["hasKey"] = hasKeyFunc + funcs["toYaml"] = toYAMLFunc + funcs["fromYaml"] = fromYAMLFunc + funcs["fromYamlArray"] = fromYAMLArrayFunc funcs["toJson"] = toJSONFunc + funcs["fromJson"] = fromJSONFunc + funcs["fromJsonArray"] = fromJSONArrayFunc + funcs["lookup"] = func(string, string, string, string) map[string]any { + return map[string]any{} + } + funcs["required"] = func(_ string, val any) any { + return val + } + funcs["fail"] = func(string) string { + return "" + } funcs["tpl"] = func(tplText string, vals any) (string, error) { switch typed := vals.(type) { case templateValues: @@ -105,6 +134,30 @@ func replaceFunc(old, new, src string) string { return strings.ReplaceAll(src, old, new) } +func toYAMLFunc(v any) string { + data, err := yaml.Marshal(v) + if err != nil { + return "" + } + return strings.TrimSuffix(string(data), "\n") +} + +func fromYAMLFunc(str string) map[string]any { + out := map[string]any{} + if err := yaml.Unmarshal([]byte(str), &out); err != nil { + out["Error"] = err.Error() + } + return out +} + +func fromYAMLArrayFunc(str string) []any { + var out []any + if err := yaml.Unmarshal([]byte(str), &out); err != nil { + return []any{err.Error()} + } + return out +} + func toJSONFunc(v any) string { data, err := json.Marshal(v) if err != nil { @@ -113,6 +166,22 @@ func toJSONFunc(v any) string { return string(data) } +func fromJSONFunc(str string) map[string]any { + out := map[string]any{} + if err := json.Unmarshal([]byte(str), &out); err != nil { + out["Error"] = err.Error() + } + return out +} + +func fromJSONArrayFunc(str string) []any { + var out []any + if err := json.Unmarshal([]byte(str), &out); err != nil { + return []any{err.Error()} + } + return out +} + func isEmpty(v any) bool { if v == nil { return true diff --git a/internal/images/template_test.go b/internal/images/template_test.go new file mode 100644 index 0000000..037f298 --- /dev/null +++ b/internal/images/template_test.go @@ -0,0 +1,38 @@ +/* +Copyright 2025 The OpenSaola Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package images + +import ( + "strings" + "testing" +) + +func TestRenderImageTemplateExecutesInclude(t *testing.T) { + t.Parallel() + + text := `{{- define "image.repository" -}}{{ .Values.repository }}/redis{{- end -}} +image: "{{ include "image.repository" . }}:v1.0.0"` + got, err := renderImageTemplate(text, templateValues{ + Values: map[string]any{"repository": "repo.example/middleware"}, + }) + if err != nil { + t.Fatalf("renderImageTemplate: %v", err) + } + if want := `image: "repo.example/middleware/redis:v1.0.0"`; !strings.Contains(got, want) { + t.Fatalf("expected rendered template to contain %q, got %q", want, got) + } +} diff --git a/internal/images/types.go b/internal/images/types.go index 83e0014..13e37da 100644 --- a/internal/images/types.go +++ b/internal/images/types.go @@ -114,6 +114,7 @@ type ExportOptions struct { Output string LockFile string Repositories []string + Format string Platform string MultiArch bool Insecure bool