Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 292 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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}"
31 changes: 26 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 自定义资源:
Expand Down
Loading
Loading