From dee13876ec98f0a08f4dd13e178e1754cb681dee Mon Sep 17 00:00:00 2001 From: CandidXD Date: Mon, 13 Jul 2026 15:50:44 +0800 Subject: [PATCH 1/2] bundle saola-cli with automated updates --- .dockerignore | 2 + .github/workflows/docker.yml | 41 ++- .github/workflows/saola-cli-promote.yml | 167 +++++++++ .github/workflows/saola-cli-update.yml | 281 ++++++++++++++ Dockerfile | 28 ++ Makefile | 47 ++- build/saola-cli.lock | 5 + docs/release-process.md | 29 ++ docs/release-process_zh.md | 29 ++ ...2026-07-13-bundled-saola-cli-automation.md | 103 ++++++ hack/saola-cli-lock.sh | 136 +++++++ hack/saola-cli-lock_test.sh | 342 ++++++++++++++++++ 12 files changed, 1193 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/saola-cli-promote.yml create mode 100644 .github/workflows/saola-cli-update.yml create mode 100644 build/saola-cli.lock create mode 100644 docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md create mode 100755 hack/saola-cli-lock.sh create mode 100755 hack/saola-cli-lock_test.sh diff --git a/.dockerignore b/.dockerignore index 7da01b5..cbf4d53 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ .git/ +.saola-cli-source/ bin/ +build/ vendor/ .idea/ .vscode/ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f60ea4a..d62d659 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,12 +32,28 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Export locked Saola CLI metadata + id: cli + run: | + set -euo pipefail + hack/saola-cli-lock.sh validate build/saola-cli.lock + for key in repository version commit channel source_date_epoch; do + value="$(hack/saola-cli-lock.sh get build/saola-cli.lock "${key}")" + echo "${key}=${value}" >>"${GITHUB_OUTPUT}" + done + - name: Check out locked Saola CLI source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: harmonycloud/saola-cli + ref: ${{ steps.cli.outputs.commit }} + path: .saola-cli-source + persist-credentials: false + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -54,7 +70,7 @@ jobs: } - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ steps.image.outputs.name }} flavor: | @@ -67,13 +83,26 @@ jobs: type=ref,event=pr type=sha,prefix=sha- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') && github.ref_type == 'tag' }} + labels: | + org.opensaola.saola-cli.repository=${{ steps.cli.outputs.repository }} + org.opensaola.saola-cli.version=${{ steps.cli.outputs.version }} + org.opensaola.saola-cli.revision=${{ steps.cli.outputs.commit }} + org.opensaola.saola-cli.channel=${{ steps.cli.outputs.channel }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . + build-contexts: | + saola-cli=./.saola-cli-source + build-args: | + SAOLA_CLI_VERSION=${{ steps.cli.outputs.version }} + SAOLA_CLI_COMMIT=${{ steps.cli.outputs.commit }} + SAOLA_CLI_SOURCE_DATE_EPOCH=${{ steps.cli.outputs.source_date_epoch }} platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + provenance: mode=max + sbom: true diff --git a/.github/workflows/saola-cli-promote.yml b/.github/workflows/saola-cli-promote.yml new file mode 100644 index 0000000..cc90b9f --- /dev/null +++ b/.github/workflows/saola-cli-promote.yml @@ -0,0 +1,167 @@ +name: Saola CLI Promote + +on: + schedule: + - cron: '17 * * * *' + workflow_dispatch: + inputs: + soak_hours: + description: Minimum hours after the stable update merged into dev + required: true + default: '24' + +permissions: + contents: read + actions: read + pull-requests: read + +concurrency: + group: saola-cli-stable-promotion + cancel-in-progress: false + +jobs: + promote: + name: Promote soaked stable lock to master + runs-on: ubuntu-latest + env: + AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + SOAK_HOURS: ${{ inputs.soak_hours || '24' }} + EXPECTED_AUTOMATION_LOGIN: ${{ vars.OPENSAOLA_AUTOMATION_LOGIN }} + steps: + - name: Require dedicated automation credential and valid soak + run: | + set -euo pipefail + test -n "${AUTOMATION_TOKEN}" || { + echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' + exit 1 + } + [[ "${SOAK_HOURS}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ -n "${EXPECTED_AUTOMATION_LOGIN}" ]] || { + echo 'repository variable OPENSAOLA_AUTOMATION_LOGIN is required' >&2 + exit 1 + } + - name: Select the newest merged stable update + id: candidate + run: | + set -euo pipefail + candidate="$(gh pr list --state merged --base dev \ + --label 'automation:saola-cli-stable' --limit 100 \ + --json number,mergedAt,mergeCommit \ + --jq 'sort_by(.mergedAt) | last // empty')" + [[ -n "${candidate}" ]] || { + echo 'No merged stable Saola CLI update PR is available.' + echo 'found=false' >>"${GITHUB_OUTPUT}" + exit 0 + } + number="$(jq -r '.number' <<<"${candidate}")" + merged_at="$(jq -r '.mergedAt' <<<"${candidate}")" + merge_sha="$(jq -r '.mergeCommit.oid' <<<"${candidate}")" + details="$(gh pr view "${number}" --json author,headRefName,files)" + [[ "$(jq -r '.author.login' <<<"${details}")" = "${EXPECTED_AUTOMATION_LOGIN}" ]] + version_from_branch="$(jq -r '.headRefName' <<<"${details}")" + [[ "${version_from_branch}" =~ ^automation/saola-cli-stable-v[0-9A-Za-z.-]+$ ]] + [[ "$(jq '.files | length' <<<"${details}")" -eq 1 ]] + [[ "$(jq -r '.files[0].path' <<<"${details}")" = build/saola-cli.lock ]] + [[ "${merge_sha}" =~ ^[0-9a-f]{40}$ ]] + merged_epoch="$(date -u -d "${merged_at}" +%s)" + ready_epoch="$((merged_epoch + SOAK_HOURS * 3600))" + if (( $(date -u +%s) < ready_epoch )); then + echo "Stable PR #${number} has not completed the ${SOAK_HOURS}h soak." + echo 'found=false' >>"${GITHUB_OUTPUT}" + exit 0 + fi + echo "found=true" >>"${GITHUB_OUTPUT}" + echo "number=${number}" >>"${GITHUB_OUTPUT}" + echo "merge_sha=${merge_sha}" >>"${GITHUB_OUTPUT}" + echo "head_ref=${version_from_branch}" >>"${GITHUB_OUTPUT}" + - name: Read the exact merge commit lock and verify its workflows + if: steps.candidate.outputs.found == 'true' + env: + MERGE_SHA: ${{ steps.candidate.outputs.merge_sha }} + HEAD_REF: ${{ steps.candidate.outputs.head_ref }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/contents/build/saola-cli.lock?ref=${MERGE_SHA}" \ + --jq '.content' | base64 --decode >"${RUNNER_TEMP}/saola-cli.lock" + channel="$(awk -F= '$1 == "channel" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" + version="$(awk -F= '$1 == "version" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" + repository="$(awk -F= '$1 == "repository" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" + commit="$(awk -F= '$1 == "commit" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" + source_epoch="$(awk -F= '$1 == "source_date_epoch" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" + [[ "${channel}" = stable ]] + [[ "${repository}" = harmonycloud/saola-cli ]] + [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] + [[ "${HEAD_REF}" = "automation/saola-cli-stable-${version}" ]] + tag_refs="$(git ls-remote "https://github.com/${repository}.git" \ + "refs/tags/${version}" "refs/tags/${version}^{}")" + tag_commit="$(awk '$2 ~ /\^\{\}$/ { print $1; found=1 } END { if (!found) exit 1 }' <<<"${tag_refs}" || true)" + [[ -n "${tag_commit}" ]] || tag_commit="$(awk -v ref="refs/tags/${version}" '$2 == ref { print $1 }' <<<"${tag_refs}")" + [[ "${tag_commit}" = "${commit}" ]] + commit_date="$(gh api "repos/${repository}/commits/${commit}" --jq '.commit.committer.date')" + [[ "$(date -u -d "${commit_date}" +%s)" = "${source_epoch}" ]] + releases="$(gh api --paginate --slurp "repos/${repository}/releases?per_page=100")" + latest_release="$(jq -c 'flatten | [.[] | select(.draft == false)] | max_by(.published_at)' <<<"${releases}")" + [[ "$(jq -r '.tag_name' <<<"${latest_release}")" = "${version}" ]] + release_dir="$(mktemp -d)" + gh release download "${version}" --repo "${repository}" \ + --pattern SHA256SUMS --pattern saola-linux-amd64 --pattern saola-linux-arm64 \ + --dir "${release_dir}" + [[ "$(awk '$2 == "saola-linux-amd64" && $1 ~ /^[0-9a-f]{64}$/ { n++ } $2 == "saola-linux-arm64" && $1 ~ /^[0-9a-f]{64}$/ { n++ } END { print n+0 }' "${release_dir}/SHA256SUMS")" -eq 2 ]] + (cd "${release_dir}" && sha256sum --check SHA256SUMS) + ci_result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${MERGE_SHA}&branch=dev&event=push&per_page=1" \ + --jq '.workflow_runs[0] | [.status, .conclusion] | @tsv')" + docker_result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/docker.yml/runs?head_sha=${MERGE_SHA}&branch=dev&event=push&per_page=1" \ + --jq '.workflow_runs[0] | [.status, .conclusion] | @tsv')" + check_workflow() { + local workflow="$1" + local result="$2" + [[ "${result}" = $'completed\tsuccess' ]] || { + echo "${workflow} is not successful for exact merge ${MERGE_SHA}: ${result:-missing}" >&2 + exit 1 + } + } + check_workflow ci.yml "${ci_result}" + check_workflow docker.yml "${docker_result}" + - name: Check out master with the dedicated token + if: steps.candidate.outputs.found == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + - name: Validate and promote only the candidate lock + if: steps.candidate.outputs.found == 'true' + env: + MERGE_SHA: ${{ steps.candidate.outputs.merge_sha }} + SOURCE_PR: ${{ steps.candidate.outputs.number }} + run: | + set -euo pipefail + candidate_lock="${RUNNER_TEMP}/saola-cli.lock" + hack/saola-cli-lock.sh validate "${candidate_lock}" + [[ "$(hack/saola-cli-lock.sh get "${candidate_lock}" channel)" = stable ]] + version="$(hack/saola-cli-lock.sh get "${candidate_lock}" version)" + if hack/saola-cli-lock.sh validate build/saola-cli.lock && + cmp -s "${candidate_lock}" build/saola-cli.lock; then + echo "master already contains the complete ${version} lock; nothing to promote." + exit 0 + fi + cp "${candidate_lock}" build/saola-cli.lock + hack/saola-cli-lock.sh validate build/saola-cli.lock + safe_version="${version//[^0-9A-Za-z._-]/-}" + branch="automation/promote-saola-cli-${safe_version}" + git config user.name 'opensaola-automation' + git config user.email 'opensaola-automation@users.noreply.github.com' + git checkout -B "${branch}" + git add build/saola-cli.lock + git commit -m "build: promote bundled saola CLI ${version}" + git push --force-with-lease origin "HEAD:refs/heads/${branch}" + pr="$(gh pr list --state open --base master --head "${branch}" --json number --jq '.[0].number // empty')" + body="Promotes the exact stable lock merged by dev PR #${SOURCE_PR} at \`${MERGE_SHA}\` after the configured ${SOAK_HOURS}h soak." + if [[ -z "${pr}" ]]; then + pr="$(gh pr create --base master --head "${branch}" \ + --title "build: promote bundled saola CLI ${version}" --body "${body}")" + else + gh pr edit "${pr}" --body "${body}" + fi + gh pr merge "${pr}" --auto --squash diff --git a/.github/workflows/saola-cli-update.yml b/.github/workflows/saola-cli-update.yml new file mode 100644 index 0000000..ccb99a7 --- /dev/null +++ b/.github/workflows/saola-cli-update.yml @@ -0,0 +1,281 @@ +name: Saola CLI Update + +on: + repository_dispatch: + types: [saola-cli-dev, saola-cli-stable] + workflow_dispatch: + inputs: + event_type: + description: Immutable release event type + required: true + type: choice + options: [saola-cli-dev, saola-cli-stable] + repository: + description: Source repository + required: true + default: harmonycloud/saola-cli + channel: + description: Release channel + required: true + type: choice + options: [dev, stable] + version: + description: dev-SHORTSHA or stable SemVer tag + required: true + commit: + description: Full source commit SHA + required: true + source_date_epoch: + description: Reproducible source timestamp + required: true + amd64_sha256: + description: linux/amd64 artifact SHA-256 + required: true + arm64_sha256: + description: linux/arm64 artifact SHA-256 + required: true + +permissions: + contents: read + pull-requests: read + +concurrency: + group: saola-cli-update-dev + cancel-in-progress: false + +jobs: + validate-source: + name: Validate untrusted source and artifacts + runs-on: ubuntu-latest + outputs: + repository: ${{ steps.identity.outputs.repository }} + channel: ${{ steps.identity.outputs.channel }} + version: ${{ steps.identity.outputs.version }} + commit: ${{ steps.identity.outputs.commit }} + source_date_epoch: ${{ steps.identity.outputs.source_date_epoch }} + amd64_sha256: ${{ steps.identity.outputs.amd64_sha256 }} + arm64_sha256: ${{ steps.identity.outputs.arm64_sha256 }} + release_id: ${{ steps.identity.outputs.release_id }} + env: + EVENT_TYPE: ${{ github.event.action || inputs.event_type }} + CLI_REPOSITORY: ${{ github.event.client_payload.repository || inputs.repository }} + CLI_CHANNEL: ${{ github.event.client_payload.channel || inputs.channel }} + CLI_VERSION: ${{ github.event.client_payload.version || inputs.version }} + CLI_COMMIT: ${{ github.event.client_payload.commit || inputs.commit }} + CLI_SOURCE_DATE_EPOCH: ${{ github.event.client_payload.source_date_epoch || inputs.source_date_epoch }} + CLI_AMD64_SHA256: ${{ github.event.client_payload.checksums['linux/amd64'] || inputs.amd64_sha256 }} + CLI_ARM64_SHA256: ${{ github.event.client_payload.checksums['linux/arm64'] || inputs.arm64_sha256 }} + steps: + - name: Validate immutable event payload + id: identity + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + release_id='' + test "${CLI_REPOSITORY}" = 'harmonycloud/saola-cli' + [[ "${CLI_COMMIT}" =~ ^[0-9a-f]{40}$ ]] + [[ "${CLI_SOURCE_DATE_EPOCH}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "${CLI_AMD64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + [[ "${CLI_ARM64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + commit_date="$(gh api "repos/${CLI_REPOSITORY}/commits/${CLI_COMMIT}" --jq '.commit.committer.date')" + commit_epoch="$(date -u -d "${commit_date}" +%s)" + [[ "${commit_epoch}" = "${CLI_SOURCE_DATE_EPOCH}" ]] || { + echo 'source_date_epoch does not match the commit committer timestamp' >&2 + exit 1 + } + case "${EVENT_TYPE}:${CLI_CHANNEL}" in + saola-cli-dev:dev) + [[ "${CLI_VERSION}" =~ ^dev-[0-9a-f]{12}$ ]] + [[ "${CLI_VERSION#dev-}" = "${CLI_COMMIT:0:12}" ]] + remote_commit="$(git ls-remote --refs "https://github.com/${CLI_REPOSITORY}.git" refs/heads/main | awk 'NR == 1 { print $1 }')" + [[ "${remote_commit}" = "${CLI_COMMIT}" ]] || { + echo 'dev event commit is not the current refs/heads/main commit' >&2 + exit 1 + } + ;; + saola-cli-stable:stable) + 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-]*))*))?$' + [[ "${CLI_VERSION}" =~ ${semver} ]] + tag_refs="$(git ls-remote "https://github.com/${CLI_REPOSITORY}.git" \ + "refs/tags/${CLI_VERSION}" "refs/tags/${CLI_VERSION}^{}")" + tag_commit="$(awk '$2 ~ /\^\{\}$/ { print $1; found=1 } END { if (!found) exit 1 }' <<<"${tag_refs}" || true)" + if [[ -z "${tag_commit}" ]]; then + tag_commit="$(awk -v ref="refs/tags/${CLI_VERSION}" '$2 == ref { print $1 }' <<<"${tag_refs}")" + fi + [[ -n "${tag_commit}" && "${tag_commit}" = "${CLI_COMMIT}" ]] || { + echo 'stable event commit does not match the peeled same-name tag' >&2 + exit 1 + } + releases="$(gh api --paginate --slurp \ + "repos/${CLI_REPOSITORY}/releases?per_page=100")" + latest_release="$(jq -c 'flatten | [.[] | select(.draft == false)] | max_by(.published_at)' <<<"${releases}")" + [[ "$(jq -r '.tag_name' <<<"${latest_release}")" = "${CLI_VERSION}" ]] || { + echo 'stable event is not the latest published non-draft release' >&2 + exit 1 + } + release_id="$(jq -r '.id' <<<"${latest_release}")" + [[ "${release_id}" =~ ^[1-9][0-9]*$ ]] + release_dir="$(mktemp -d)" + gh release download "${CLI_VERSION}" --repo "${CLI_REPOSITORY}" \ + --pattern SHA256SUMS --dir "${release_dir}" + release_amd64="$(awk '$2 == "saola-linux-amd64" { print $1 }' "${release_dir}/SHA256SUMS")" + release_arm64="$(awk '$2 == "saola-linux-arm64" { print $1 }' "${release_dir}/SHA256SUMS")" + [[ "${release_amd64}" = "${CLI_AMD64_SHA256}" ]] + [[ "${release_arm64}" = "${CLI_ARM64_SHA256}" ]] + ;; + *) + echo 'event type and channel do not match an allowed route' >&2 + exit 1 + ;; + esac + [[ "${CLI_COMMIT}" =~ ^[0-9a-f]{40}$ ]] + [[ "${CLI_SOURCE_DATE_EPOCH}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "${CLI_AMD64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + [[ "${CLI_ARM64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + echo "repository=${CLI_REPOSITORY}" >>"${GITHUB_OUTPUT}" + echo "channel=${CLI_CHANNEL}" >>"${GITHUB_OUTPUT}" + echo "version=${CLI_VERSION}" >>"${GITHUB_OUTPUT}" + echo "commit=${CLI_COMMIT}" >>"${GITHUB_OUTPUT}" + echo "source_date_epoch=${CLI_SOURCE_DATE_EPOCH}" >>"${GITHUB_OUTPUT}" + echo "amd64_sha256=${CLI_AMD64_SHA256}" >>"${GITHUB_OUTPUT}" + echo "arm64_sha256=${CLI_ARM64_SHA256}" >>"${GITHUB_OUTPUT}" + echo "release_id=${release_id}" >>"${GITHUB_OUTPUT}" + - name: Check out the exact Saola CLI source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: harmonycloud/saola-cli + ref: ${{ env.CLI_COMMIT }} + path: saola-cli-source + persist-credentials: false + - name: Set up Go for checksum reconstruction + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: saola-cli-source/go.mod + cache-dependency-path: saola-cli-source/go.sum + - name: Rebuild and verify both published checksums + id: verified + run: | + set -euo pipefail + env -u GITHUB_OUTPUT -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \ + make -C saola-cli-source \ + VERSION="${CLI_VERSION}" \ + GIT_COMMIT="${CLI_COMMIT}" \ + SOURCE_DATE_EPOCH="${CLI_SOURCE_DATE_EPOCH}" \ + release-build release-checksums + actual_amd64="$(awk '$2 == "saola-linux-amd64" { print $1 }' saola-cli-source/dist/SHA256SUMS)" + actual_arm64="$(awk '$2 == "saola-linux-arm64" { print $1 }' saola-cli-source/dist/SHA256SUMS)" + [[ "${actual_amd64}" = "${CLI_AMD64_SHA256}" ]] || { + echo 'rebuilt linux/amd64 checksum does not match the event' >&2 + exit 1 + } + [[ "${actual_arm64}" = "${CLI_ARM64_SHA256}" ]] || { + echo 'rebuilt linux/arm64 checksum does not match the event' >&2 + exit 1 + } + + update-dev: + name: Update dev lock after source verification + needs: validate-source + runs-on: ubuntu-latest + env: + AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + CLI_REPOSITORY: ${{ needs.validate-source.outputs.repository }} + CLI_CHANNEL: ${{ needs.validate-source.outputs.channel }} + CLI_VERSION: ${{ needs.validate-source.outputs.version }} + CLI_COMMIT: ${{ needs.validate-source.outputs.commit }} + CLI_SOURCE_DATE_EPOCH: ${{ needs.validate-source.outputs.source_date_epoch }} + CLI_AMD64_SHA256: ${{ needs.validate-source.outputs.amd64_sha256 }} + CLI_ARM64_SHA256: ${{ needs.validate-source.outputs.arm64_sha256 }} + CLI_RELEASE_ID: ${{ needs.validate-source.outputs.release_id }} + steps: + - name: Require dedicated automation credential + run: | + set -euo pipefail + test -n "${AUTOMATION_TOKEN}" || { + echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' + exit 1 + } + [[ "${CLI_REPOSITORY}" = harmonycloud/saola-cli ]] + [[ "${CLI_COMMIT}" =~ ^[0-9a-f]{40}$ ]] + [[ "${CLI_SOURCE_DATE_EPOCH}" =~ ^(0|[1-9][0-9]*)$ ]] + [[ "${CLI_AMD64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + [[ "${CLI_ARM64_SHA256}" =~ ^[0-9a-f]{64}$ ]] + case "${CLI_CHANNEL}" in + dev) + [[ -z "${CLI_RELEASE_ID}" ]] + [[ "${CLI_VERSION}" =~ ^dev-[0-9a-f]{12}$ ]] + [[ "${CLI_VERSION#dev-}" = "${CLI_COMMIT:0:12}" ]] + ;; + stable) + [[ "${CLI_RELEASE_ID}" =~ ^[1-9][0-9]*$ ]] + [[ "${CLI_VERSION}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] + ;; + *) exit 1 ;; + esac + - name: Check out dev with the dedicated token + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: dev + fetch-depth: 0 + token: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + - name: Update the validated lock + id: lock + run: | + set -euo pipefail + git fetch origin dev + git reset --hard origin/dev + candidate_lock="${RUNNER_TEMP}/saola-cli.lock" + printf '%s\n' \ + "repository=${CLI_REPOSITORY}" \ + "version=${CLI_VERSION}" \ + "commit=${CLI_COMMIT}" \ + "channel=${CLI_CHANNEL}" \ + "source_date_epoch=${CLI_SOURCE_DATE_EPOCH}" \ + >"${candidate_lock}" + hack/saola-cli-lock.sh validate "${candidate_lock}" + if hack/saola-cli-lock.sh validate build/saola-cli.lock && + cmp -s "${candidate_lock}" build/saola-cli.lock; then + echo 'changed=false' >>"${GITHUB_OUTPUT}" + exit 0 + fi + cp "${candidate_lock}" build/saola-cli.lock + hack/saola-cli-lock.sh validate build/saola-cli.lock + echo 'changed=true' >>"${GITHUB_OUTPUT}" + - name: Create auditable dev pull request and enable auto-merge + if: steps.lock.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + run: | + set -euo pipefail + safe_version="${CLI_VERSION//[^0-9A-Za-z._-]/-}" + branch="automation/saola-cli-${CLI_CHANNEL}-${safe_version}" + git config user.name 'opensaola-automation' + git config user.email 'opensaola-automation@users.noreply.github.com' + git checkout -B "${branch}" + git add build/saola-cli.lock + git commit -m "build: update bundled saola CLI to ${CLI_VERSION}" + git push --force-with-lease origin "HEAD:refs/heads/${branch}" + pr="$(gh pr list --state open --base dev --head "${branch}" --json number --jq '.[0].number // empty')" + body="$(printf '%s\n' \ + 'Automated immutable Saola CLI lock update.' \ + '' \ + "- Repository: \`${CLI_REPOSITORY}\`" \ + "- Channel: \`${CLI_CHANNEL}\`" \ + "- Version: \`${CLI_VERSION}\`" \ + "- Commit: \`${CLI_COMMIT}\`" \ + "- SOURCE_DATE_EPOCH: \`${CLI_SOURCE_DATE_EPOCH}\`" \ + "- GitHub Release ID: \`${CLI_RELEASE_ID:-not-applicable}\`" \ + "- linux/amd64 SHA-256: \`${CLI_AMD64_SHA256}\`" \ + "- linux/arm64 SHA-256: \`${CLI_ARM64_SHA256}\`")" + if [[ -z "${pr}" ]]; then + pr="$(gh pr create --base dev --head "${branch}" \ + --title "build: update bundled saola CLI to ${CLI_VERSION}" \ + --body "${body}")" + else + gh pr edit "${pr}" --body "${body}" + fi + if [[ "${CLI_CHANNEL}" = stable ]]; then + gh pr edit "${pr}" --add-label 'automation:saola-cli-stable' + fi + gh pr merge "${pr}" --auto --squash diff --git a/Dockerfile b/Dockerfile index 9a30f30..9a6b9a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,33 @@ RUN curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/${TARGETOS}/${TAR && chmod 0555 kubectl \ && rm kubectl.sha256 +# Build the architecture-matched saola CLI from the immutable named context. +FROM --platform=$BUILDPLATFORM golang:1.26.4 AS saola-cli-builder + +ARG TARGETOS=linux +ARG TARGETARCH=amd64 +ARG SAOLA_CLI_VERSION +ARG SAOLA_CLI_COMMIT +ARG SAOLA_CLI_SOURCE_DATE_EPOCH + +WORKDIR /workspace/saola-cli + +COPY --from=saola-cli go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY --from=saola-cli . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + build_date="$(date -u -d "@${SAOLA_CLI_SOURCE_DATE_EPOCH}" +%Y-%m-%dT%H:%M:%SZ)" \ + && CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -trimpath \ + -ldflags="-s -w \ + -X github.com/harmonycloud/saola-cli/internal/version.Version=${SAOLA_CLI_VERSION} \ + -X github.com/harmonycloud/saola-cli/internal/version.GitCommit=${SAOLA_CLI_COMMIT} \ + -X github.com/harmonycloud/saola-cli/internal/version.BuildDate=${build_date}" \ + -o /workspace/saola ./cmd/saola/ + # Runtime image - minimal Alpine with necessary tools (kubectl, curl, jq, sh) FROM alpine:3.20 @@ -69,6 +96,7 @@ RUN mkdir -p /app/logs && chown 65532:65532 /app/logs # Copy binaries from builder COPY --from=builder --chown=65532:65532 --chmod=0555 /workspace/manager . COPY --from=builder --chown=65532:65532 --chmod=0555 /workspace/kubectl /usr/bin/kubectl +COPY --from=saola-cli-builder --chown=65532:65532 --chmod=0555 /workspace/saola /usr/local/bin/saola # Copy only the runtime config file (not dev config) COPY --chown=65532:65532 pkg/config/config.yaml /app/pkg/config/config.yaml diff --git a/Makefile b/Makefile index ae76827..1f5230e 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,29 @@ endif # scaffolded by default. However, you might want to replace it to use other # tools. (i.e. podman) CONTAINER_TOOL ?= docker +SAOLA_CLI_LOCK ?= build/saola-cli.lock +SAOLA_CLI_LOCK_HELPER ?= hack/saola-cli-lock.sh +override SAOLA_CLI_REPOSITORY = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) repository 2>/dev/null) +override SAOLA_CLI_VERSION = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) version 2>/dev/null) +override SAOLA_CLI_COMMIT = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) commit 2>/dev/null) +override SAOLA_CLI_SOURCE_DATE_EPOCH = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) source_date_epoch 2>/dev/null) +SAOLA_CLI_CONTEXT ?= +export SAOLA_CLI_CONTEXT +DOCKER_PLATFORM ?= linux/amd64 +override SAOLA_CLI_BUILD_ARGS = \ + --build-arg SAOLA_CLI_VERSION=$(SAOLA_CLI_VERSION) \ + --build-arg SAOLA_CLI_COMMIT=$(SAOLA_CLI_COMMIT) \ + --build-arg SAOLA_CLI_SOURCE_DATE_EPOCH=$(SAOLA_CLI_SOURCE_DATE_EPOCH) + +define with-saola-cli-context + @source_repo="$${SAOLA_CLI_CONTEXT:-../saola-cli}"; \ + tmp_context="$$(mktemp -d "$${TMPDIR:-/tmp}/opensaola-saola-cli.XXXXXX")"; \ + trap 'rm -rf "$$tmp_context"' EXIT; \ + git -C "$$source_repo" cat-file -e '$(SAOLA_CLI_COMMIT)^{commit}' || { echo 'locked saola-cli commit is unavailable in source repo' >&2; exit 1; }; \ + git -C "$$source_repo" archive '$(SAOLA_CLI_COMMIT)' | tar -x -C "$$tmp_context" || { echo 'failed to export locked saola-cli commit' >&2; exit 1; }; \ + context="$$tmp_context"; \ + $(1) +endef # Setting SHELL to bash allows bash commands to be executed by recipes. # Options are set to exit when a recipe line exits non-zero or a piped command fails. @@ -228,8 +251,16 @@ run: manifests generate fmt vet ## Run a controller from your host. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build -docker-build: ## Build docker image with the manager. - $(CONTAINER_TOOL) build -t ${IMG} . +.PHONY: saola-cli-lock-validate +saola-cli-lock-validate: + @$(SAOLA_CLI_LOCK_HELPER) validate $(SAOLA_CLI_LOCK) + +.PHONY: docker-platform-validate +docker-platform-validate: + @case '$(DOCKER_PLATFORM)' in linux/amd64|linux/arm64) ;; *) echo 'DOCKER_PLATFORM must be linux/amd64 or linux/arm64' >&2; exit 1 ;; esac + +docker-build: saola-cli-lock-validate docker-platform-validate ## Build docker image with the manager. + $(call with-saola-cli-context,$(CONTAINER_TOOL) build --build-context "saola-cli=$$context" $(SAOLA_CLI_BUILD_ARGS) --platform=$(DOCKER_PLATFORM) -t ${IMG} .) .PHONY: docker-push docker-push: ## Push docker image with the manager. @@ -241,16 +272,10 @@ docker-push: ## Push docker image with the manager. # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=> then the export will fail) # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. -PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +override PLATFORMS = linux/amd64,linux/arm64 .PHONY: docker-buildx -docker-buildx: ## Build and push docker image for the manager for cross-platform support - # copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile - sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - - $(CONTAINER_TOOL) buildx create --name opensaola-builder - $(CONTAINER_TOOL) buildx use opensaola-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - - $(CONTAINER_TOOL) buildx rm opensaola-builder - rm Dockerfile.cross +docker-buildx: saola-cli-lock-validate ## Build and push docker image for the manager for cross-platform support + $(call with-saola-cli-context,$(CONTAINER_TOOL) buildx build --build-context "saola-cli=$$context" $(SAOLA_CLI_BUILD_ARGS) --push --platform=$(PLATFORMS) --tag ${IMG} .) .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. diff --git a/build/saola-cli.lock b/build/saola-cli.lock new file mode 100644 index 0000000..92140fe --- /dev/null +++ b/build/saola-cli.lock @@ -0,0 +1,5 @@ +repository=harmonycloud/saola-cli +version=dev-dfe685bacbeb +commit=dfe685bacbeb036a06316561a1e982813763e2e4 +channel=dev +source_date_epoch=1782812176 diff --git a/docs/release-process.md b/docs/release-process.md index e4c651c..26910f3 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -53,6 +53,35 @@ If a chart-only fix is needed, bump only the chart version. The application imag The Helm chart default `image.pullPolicy` is `IfNotPresent` for reproducible release installs. The Makefile also uses `IfNotPresent` for immutable `sha-*` and `v*` tags, and switches to `Always` only when a floating tag such as `dev`, `master`, `main`, or `latest` is explicitly selected. +## Bundled Saola CLI Channels + +`build/saola-cli.lock` is the sole source of the bundled CLI repository, version, full commit, channel, and reproducible source timestamp. The Docker workflow validates that lock, builds from a BuildKit named context pinned to the full commit, and records the CLI version and revision in OCI labels. Official images remain exactly `linux/amd64` and `linux/arm64` and include BuildKit provenance and SBOM attestations. + +The `saola-cli` repository may dispatch either of these immutable events: + +| Event | Accepted lock | Destination | +|-------|---------------|-------------| +| `saola-cli-dev` | `channel=dev`, `version=dev-<12-character commit prefix>` | Auto-merge PR into `dev` | +| `saola-cli-stable` | `channel=stable`, strict SemVer tag (prereleases allowed) | Auto-merge PR into `dev`, labelled `automation:saola-cli-stable` | + +Both automatic and manual dispatches must supply `repository`, `channel`, `version`, the full 40-character `commit`, `source_date_epoch`, and lowercase 64-character SHA-256 checksums for both Linux artifacts. The update workflow binds the epoch to the commit timestamp, rebuilds both artifacts, and for stable releases also compares the payload with the published `SHA256SUMS` asset before writing the strict five-field lock. It never pushes directly to `dev` or `master`. + +Stable automation accepts only a same-name, non-draft GitHub Release that is currently the latest published non-draft release by `published_at` (prereleases included); replaying an older valid tag fails closed. The hourly promotion workflow selects the newest merged stable-labelled update PR, reads `build/saola-cli.lock` from that PR's exact merge commit (not the later `dev` head), and waits 24 hours by default from `mergedAt`. It requires successful `CI` and `Docker` push workflow runs for that same merge SHA. It exits without a PR only when `master` already has the identical complete five-field lock; otherwise it opens a lock-only auto-merge PR into `master`. Promotion never resolves `latest`, a snapshot, or another floating CLI revision. + +### Required GitHub configuration + +These files do not activate the automation by themselves. Repository administrators must configure all of the following externally: + +- Bootstrap both `saola-cli-update.yml` and `saola-cli-promote.yml` on the repository's default branch, `master`, before sending a `repository_dispatch` or expecting a scheduled run. GitHub only routes these event types to workflows present on the default branch. +- Create `OPENSAOLA_AUTOMATION_TOKEN` as an Actions secret backed by a dedicated fine-grained bot PAT. Its repository permissions must include **Contents: read and write**, **Pull requests: read and write**, and **Actions: read**. It must also have repository **Metadata: read** and permission to apply the pre-created stable label (for fine-grained PATs, label operations are covered by Pull requests write plus Metadata read). The workflows fail closed when it is absent and deliberately do not substitute `GITHUB_TOKEN`, because bot PRs must trigger normal downstream CI. +- Enable repository auto-merge and protect both `dev` and `master` as PR-only branches. Require the relevant CI and Docker checks and forbid direct pushes, including from the automation bot. +- Pre-create the exact label `automation:saola-cli-stable` and restrict who may apply it. Promotion treats that label as the stable candidate boundary. +- Set repository variable `OPENSAOLA_AUTOMATION_LOGIN` to the dedicated bot login. Promotion fails closed unless the stable PR author matches it, the head branch is deterministic, and the PR changes only `build/saola-cli.lock`. +- Keep the default soak at 24 hours for scheduled runs. A manual run may explicitly choose another non-negative soak value for an authorized incident response; that exception should be auditable. +- Configure an operational stable-release denylist or rollback decision outside these workflows before activation. To stop a candidate, disable auto-merge/remove the stable label or close its promotion PR. Automated stable events accept only the latest published release; rollback must therefore use a normal reviewed lock-only PR pointing to a previously verified stable version. Do not edit the lock to a floating ref. + +No local validation can prove that secrets, token scopes, label policy, branch protection, auto-merge, or hosted-runner behavior are configured correctly; verify them in GitHub before relying on this path. + ## Local Helm Deploy Use the Makefile wrapper from a source checkout: diff --git a/docs/release-process_zh.md b/docs/release-process_zh.md index aa18a60..7b3eaae 100644 --- a/docs/release-process_zh.md +++ b/docs/release-process_zh.md @@ -53,6 +53,35 @@ helm package chart/opensaola \ Helm Chart 默认 `image.pullPolicy` 为 `IfNotPresent`,保证正式版本部署可复现。Makefile 对不可变的 `sha-*` 和 `v*` tag 也使用 `IfNotPresent`;只有显式选择 `dev`、`master`、`main`、`latest` 等浮动 tag 时才切换为 `Always`。 +## 内置 Saola CLI 渠道 + +`build/saola-cli.lock` 是内置 CLI 仓库、版本、完整 commit、渠道和可复现时间戳的唯一来源。Docker workflow 会先严格校验该锁文件,再用固定到完整 commit 的 BuildKit named context 构建,并把 CLI 版本和 revision 写入 OCI labels。官方镜像平台仍严格限定为 `linux/amd64` 和 `linux/arm64`,同时生成 BuildKit provenance 与 SBOM attestations。 + +`saola-cli` 仓库可以发送以下两种不可变事件: + +| 事件 | 可接受的锁 | 目标 | +|------|------------|------| +| `saola-cli-dev` | `channel=dev`、`version=dev-` | 自动合并 PR 到 `dev` | +| `saola-cli-stable` | `channel=stable`、严格 SemVer tag(允许 prerelease) | 自动合并 PR 到 `dev`,并添加 `automation:saola-cli-stable` label | + +自动 dispatch 和手动 dispatch 都必须提供 `repository`、`channel`、`version`、完整 40 位 `commit`、`source_date_epoch`,以及两个 Linux 产物各自的小写 64 位 SHA-256。更新 workflow 会把 epoch 绑定到 commit 时间戳、重建两个产物;stable 还会把 payload 与 Release 的 `SHA256SUMS` 资产逐项比较,然后才写入严格五字段 lock。该 workflow 永不直接 push `dev` 或 `master`。 + +stable 自动化只接受同名、非 draft,且按 `published_at` 当前最新的非 draft GitHub Release(包含 prerelease);重放旧的合法 tag 也会 fail closed。每小时运行的晋级 workflow 会选择最新一个已合并、带稳定 label 的更新 PR,从该 PR 的精确 merge commit 中读取 `build/saola-cli.lock`(不会读取之后继续前进的 `dev` HEAD),并从 `mergedAt` 起默认等待 24 小时。只有同一 merge SHA 上的 `CI` 和 `Docker` push workflow 都成功后才允许晋级。仅当 `master` 已有逐字节一致的完整五字段 lock 时才 no-op;否则只创建修改 lock 的 `master` auto-merge PR。晋级过程绝不解析 `latest`、snapshot 或其他浮动 CLI revision。 + +### 必需的 GitHub 外部配置 + +仅合入这些文件不会自动启用整条链路。仓库管理员还必须在 GitHub 外部完成以下配置: + +- 发送 `repository_dispatch` 或等待 schedule 之前,必须先把 `saola-cli-update.yml` 和 `saola-cli-promote.yml` 部署到仓库默认分支 `master`;GitHub 只会把这两类事件路由给默认分支上已经存在的 workflow。 +- 创建 Actions secret `OPENSAOLA_AUTOMATION_TOKEN`,其值必须是专用机器人 fine-grained PAT。仓库权限至少包括 **Contents: read and write**、**Pull requests: read and write**、**Actions: read**,还需要 **Metadata: read** 以及施加预创建 stable label 的权限(fine-grained PAT 的 label 操作由 Pull requests write 与 Metadata read 覆盖)。缺少该 secret 时 workflow 会 fail closed,并且不会回退到 `GITHUB_TOKEN`,这样机器人 PR 才能正常触发后续 CI。 +- 开启 repository auto-merge,并把 `dev`、`master` 都设置成只允许 PR 的保护分支;要求相关 CI 与 Docker checks,且包括自动化机器人在内都禁止直接 push。 +- 预先创建固定 label `automation:saola-cli-stable`,并限制其施加权限;晋级 workflow 把它作为 stable 候选边界。 +- 设置仓库变量 `OPENSAOLA_AUTOMATION_LOGIN` 为专用机器人 login。stable PR 作者、确定性 head branch 或“只改 `build/saola-cli.lock`”任一不匹配时,晋级都会 fail closed。 +- 定时任务保持默认 24 小时 soak。手动运行可为经授权的事故处置显式指定其他非负时长,但应保留可审计记录。 +- 启用前在 workflow 外建立 stable release denylist 或回滚决策流程。需要阻止候选时,应关闭 auto-merge、移除 stable label 或关闭 promotion PR。自动 stable 事件只接受最新 published release;回滚必须通过普通受审 lock-only PR 指向此前已验证的稳定版本,禁止把 lock 改成浮动 ref。 + +本地验证无法证明 secret、token scope、label 策略、branch protection、auto-merge 或 hosted runner 行为已正确配置;正式依赖此链路前必须在 GitHub 上逐项核验。 + ## 本地 Helm 部署 从源码目录部署时使用 Makefile 包装命令: diff --git a/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md b/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md new file mode 100644 index 0000000..130230e --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md @@ -0,0 +1,103 @@ +# Bundled Saola CLI Automation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically build the architecture-matched `saola` binary into OpenSaola images, update the dev channel from CLI snapshots, and promote only stable CLI releases to master after fail-closed checks. + +**Architecture:** `saola-cli` publishes immutable metadata and multi-architecture artifacts, then dispatches an authenticated update event. `opensaola` stores the exact CLI source revision in a validated lock file, builds it through a BuildKit named context with `TARGETOS/TARGETARCH`, and uses auditable auto-merge PRs for dev and master channels. Master never resolves `latest`; it consumes only a stable event carrying an immutable CLI commit. + +**Tech Stack:** Go 1.26.4, GNU Make, Docker BuildKit/buildx, GitHub Actions, Bash, GitHub CLI, Cosign keyless signing, GitHub artifact attestations. + +## Global Constraints + +- Preserve all pre-existing uncommitted changes in both repositories. +- Do not push, tag, publish, configure secrets, or change external repositories during local implementation. +- Official image platforms are exactly `linux/amd64` and `linux/arm64`. +- CLI builds use `CGO_ENABLED=0`, `-trimpath`, and the CLI module's existing version ldflags. +- `dev` may consume CLI snapshots; `master` may consume only stable `vMAJOR.MINOR.PATCH` or prerelease tags. +- Every OpenSaola image records the exact CLI version and full 40-character commit. +- Automation fails closed on malformed versions, revisions, repositories, channels, missing checksums, failed tests, or absent credentials. +- Runtime remains UID/GID 65532 with `/app/manager` as the unchanged entrypoint and `/usr/local/bin/saola` mode `0555`. + +--- + +### Task 1: Saola CLI release metadata and multi-architecture build + +**Files:** +- Create: `saola-cli/hack/release-metadata.sh` +- Create: `saola-cli/hack/release-metadata_test.sh` +- Modify: `saola-cli/Makefile` +- Create: `saola-cli/.github/workflows/release.yml` +- Modify: `saola-cli/README.md` +- Modify: `saola-cli/README_zh.md` + +**Interfaces:** +- Consumes: Git ref, full commit SHA, repository name, and optional `SOURCE_DATE_EPOCH`. +- Produces: `channel`, `version`, `commit`, `build_date`, two Linux binaries, `SHA256SUMS`, SBOM/provenance/signature artifacts, and `saola-cli-dev` or `saola-cli-stable` dispatch payloads. + +- [ ] Write shell tests asserting `refs/heads/main` maps to `dev-`/`dev`, a SemVer tag maps to the tag/stable channel, malformed refs and non-40-character SHAs fail, and metadata is deterministic. +- [ ] Run `bash hack/release-metadata_test.sh` and confirm it fails because the metadata helper does not exist. +- [ ] Implement the smallest strict metadata helper that makes the tests pass without evaluating input as shell code. +- [ ] Add `release-build` and `release-checksums` Make targets that build only `linux/amd64` and `linux/arm64`, use the existing ldflags, and write deterministic files under `dist/`. +- [ ] Add a GitHub workflow that tests, builds, smoke-tests both architectures, creates checksums/SBOM/attestations/signatures, publishes tag assets, and dispatches immutable metadata to `harmonycloud/opensaola` using `OPENSAOLA_DISPATCH_TOKEN`. +- [ ] Document snapshot versus stable behavior and required repository secret. +- [ ] Run the helper tests, `make test`, `make release-build`, inspect both files with `file`, and run `saola version` for the native binary. + +### Task 2: OpenSaola CLI lock contract and image integration + +**Files:** +- Create: `opensaola/build/saola-cli.lock` +- Create: `opensaola/hack/saola-cli-lock.sh` +- Create: `opensaola/hack/saola-cli-lock_test.sh` +- Modify: `opensaola/Dockerfile` +- Modify: `opensaola/Makefile` +- Modify: `opensaola/.dockerignore` + +**Interfaces:** +- Consumes: strict key/value lock fields `repository`, `version`, `commit`, `channel`, and `source_date_epoch`; BuildKit context named `saola-cli`. +- Produces: validated build arguments and `/usr/local/bin/saola` matching the final image platform. + +- [ ] Write failing shell tests for a valid lock, duplicate/missing/unknown keys, invalid repository/channel/version/commit/epoch, and safe value extraction. +- [ ] Run `bash hack/saola-cli-lock_test.sh` and confirm it fails because the validator does not exist. +- [ ] Implement strict `validate` and `get` commands without sourcing the lock file. +- [ ] Add the initial lock using the clean committed `saola-cli` main revision, not the dirty working tree contents. +- [ ] Add a cached CLI builder stage using the named context and copy the static binary to `/usr/local/bin/saola` with mode `0555`. +- [ ] Update local `docker-build` and `docker-buildx` to validate/read the lock, pass the named context and build args, use exactly amd64/arm64, and remove obsolete `Dockerfile.cross` generation. +- [ ] Run lock tests, Dockerfile/Make dry-run checks, both CLI cross-builds, and a local single-platform image smoke if the container runtime is available. + +### Task 3: Automated dev update and stable master promotion + +**Files:** +- Create: `opensaola/.github/workflows/saola-cli-update.yml` +- Create: `opensaola/.github/workflows/saola-cli-promote.yml` +- Modify: `opensaola/.github/workflows/docker.yml` +- Modify: `opensaola/docs/release-process.md` +- Modify: `opensaola/docs/release-process_zh.md` + +**Interfaces:** +- Consumes: authenticated `repository_dispatch` payload produced by Task 1 and the validated lock contract from Task 2. +- Produces: auto-merge update PRs targeting `dev` for snapshots/stable releases and targeting `master` only for stable releases after required checks and configured soak policy. + +- [ ] Add static workflow contract tests to the lock test script for event names, permissions, exact branch/channel routing, auto-merge, concurrency, and fail-closed secret checks; run them and observe failure before creating workflows. +- [ ] Implement update workflow payload validation, source revision verification, lock update, auditable PR creation, and auto-merge. +- [ ] Implement stable promotion workflow that rejects snapshots, verifies the stable lock and successful checks for the exact candidate, enforces the configured soak interval, updates master through an auto-merge PR, and never directly pushes master. +- [ ] Pass lock-derived CLI context and version args into the existing multi-architecture Docker workflow and add OCI labels/attestations. +- [ ] Document dev/master/tag behavior, required branch protection, secrets, environments, rollback/denylist expectations, and the fact that external setup remains necessary before workflows can operate. +- [ ] Run workflow contract tests and available workflow syntax validation. + +### Task 4: Cross-repository verification and review + +**Files:** +- Review all files changed by Tasks 1-3. + +**Interfaces:** +- Consumes: both repository diffs and all test reports. +- Produces: verified implementation status, explicit external prerequisites, and no unverified completion claims. + +- [ ] Confirm unrelated pre-existing `saola-cli` modifications remain intact and outside this feature diff. +- [ ] Run `git diff --check` in both repositories. +- [ ] Run all new shell contract tests and both Go unit suites. +- [ ] Cross-compile CLI for both official architectures and inspect static ELF architecture. +- [ ] Validate Docker/Make/workflow wiring with available local tools; record any check that cannot run locally rather than claiming it passed. +- [ ] Conduct task-scoped and whole-change reviews, fix all Critical/Important findings, and rerun covering tests. +- [ ] Report changed files, verified evidence, unconfigured GitHub secrets/branch rules/environments, and the exact next external activation steps. diff --git a/hack/saola-cli-lock.sh b/hack/saola-cli-lock.sh new file mode 100755 index 0000000..4fab9ac --- /dev/null +++ b/hack/saola-cli-lock.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + printf 'usage: %s validate \n' "$0" >&2 + printf ' %s get \n' "$0" >&2 + exit 2 +} + +die() { + printf 'saola-cli lock: %s\n' "$*" >&2 + exit 1 +} + +validate_stable_version() { + local version="$1" + local prerelease identifier + + [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || return 1 + [[ "${version}" == *-* ]] || return 0 + + prerelease="${version#*-}" + while [[ -n "${prerelease}" ]]; do + identifier="${prerelease%%.*}" + [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || return 1 + if [[ "${prerelease}" == *.* ]]; then + prerelease="${prerelease#*.}" + else + prerelease='' + fi + done +} + +parse_lock() { + local lock_file="$1" + local line key value + local repository='' version='' commit='' channel='' source_date_epoch='' + local seen_repository=0 seen_version=0 seen_commit=0 seen_channel=0 seen_source_date_epoch=0 + + [[ -f "${lock_file}" && -r "${lock_file}" ]] || die "cannot read ${lock_file}" + + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ -n "${line}" && "${line}" == *=* ]] || die 'each line must be a non-empty key=value pair' + key="${line%%=*}" + value="${line#*=}" + [[ -n "${value}" && "${value}" != *=* ]] || die "invalid value for ${key}" + + case "${key}" in + repository) + ((seen_repository == 0)) || die 'duplicate key: repository' + repository="${value}" + seen_repository=1 + ;; + version) + ((seen_version == 0)) || die 'duplicate key: version' + version="${value}" + seen_version=1 + ;; + commit) + ((seen_commit == 0)) || die 'duplicate key: commit' + commit="${value}" + seen_commit=1 + ;; + channel) + ((seen_channel == 0)) || die 'duplicate key: channel' + channel="${value}" + seen_channel=1 + ;; + source_date_epoch) + ((seen_source_date_epoch == 0)) || die 'duplicate key: source_date_epoch' + source_date_epoch="${value}" + seen_source_date_epoch=1 + ;; + *) + die "unknown key: ${key}" + ;; + esac + done <"${lock_file}" + + ((seen_repository == 1)) || die 'missing key: repository' + ((seen_version == 1)) || die 'missing key: version' + ((seen_commit == 1)) || die 'missing key: commit' + ((seen_channel == 1)) || die 'missing key: channel' + ((seen_source_date_epoch == 1)) || die 'missing key: source_date_epoch' + + [[ "${repository}" == 'harmonycloud/saola-cli' ]] || die 'repository must be harmonycloud/saola-cli' + [[ "${commit}" =~ ^[0-9a-f]{40}$ ]] || die 'commit must be a full lowercase 40-character SHA' + [[ "${source_date_epoch}" =~ ^(0|[1-9][0-9]*)$ ]] || die 'source_date_epoch must be a non-negative integer' + + case "${channel}" in + dev) + [[ "${version}" =~ ^dev-[0-9a-f]{12}$ ]] || die 'dev version must be dev- followed by a 12-character SHA' + [[ "${version#dev-}" == "${commit:0:12}" ]] || die 'dev version must match the commit prefix' + ;; + stable) + validate_stable_version "${version}" || die 'stable version must be a SemVer release or prerelease tag without build metadata' + ;; + *) + die 'channel must be dev or stable' + ;; + esac + + LOCK_REPOSITORY="${repository}" + LOCK_VERSION="${version}" + LOCK_COMMIT="${commit}" + LOCK_CHANNEL="${channel}" + LOCK_SOURCE_DATE_EPOCH="${source_date_epoch}" +} + +(( $# >= 2 )) || usage +command="$1" +lock_file="$2" + +case "${command}" in + validate) + (( $# == 2 )) || usage + parse_lock "${lock_file}" + ;; + get) + (( $# == 3 )) || usage + key="$3" + parse_lock "${lock_file}" + case "${key}" in + repository) printf '%s\n' "${LOCK_REPOSITORY}" ;; + version) printf '%s\n' "${LOCK_VERSION}" ;; + commit) printf '%s\n' "${LOCK_COMMIT}" ;; + channel) printf '%s\n' "${LOCK_CHANNEL}" ;; + source_date_epoch) printf '%s\n' "${LOCK_SOURCE_DATE_EPOCH}" ;; + *) die "unknown key: ${key}" ;; + esac + ;; + *) + usage + ;; +esac diff --git a/hack/saola-cli-lock_test.sh b/hack/saola-cli-lock_test.sh new file mode 100755 index 0000000..c29727a --- /dev/null +++ b/hack/saola-cli-lock_test.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +helper="${script_dir}/saola-cli-lock.sh" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +commit="dfe685bacbeb036a06316561a1e982813763e2e4" + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +write_lock() { + local path="$1" + shift + printf '%s\n' "$@" >"${path}" +} + +assert_rejected() { + local path="$1" + if "${helper}" validate "${path}" >/dev/null 2>&1; then + fail "expected invalid lock to be rejected: ${path}" + fi +} + +valid_lock="${tmp_dir}/valid.lock" +write_lock "${valid_lock}" \ + 'repository=harmonycloud/saola-cli' \ + 'version=dev-dfe685bacbeb' \ + "commit=${commit}" \ + 'channel=dev' \ + 'source_date_epoch=1782812176' + +"${helper}" validate "${valid_lock}" +[[ "$("${helper}" get "${valid_lock}" repository)" == 'harmonycloud/saola-cli' ]] || fail 'repository extraction failed' +[[ "$("${helper}" get "${valid_lock}" version)" == 'dev-dfe685bacbeb' ]] || fail 'version extraction failed' +[[ "$("${helper}" get "${valid_lock}" commit)" == "${commit}" ]] || fail 'commit extraction failed' +[[ "$("${helper}" get "${valid_lock}" channel)" == 'dev' ]] || fail 'channel extraction failed' +[[ "$("${helper}" get "${valid_lock}" source_date_epoch)" == '1782812176' ]] || fail 'epoch extraction failed' + +duplicate="${tmp_dir}/duplicate.lock" +cp "${valid_lock}" "${duplicate}" +printf 'channel=dev\n' >>"${duplicate}" +assert_rejected "${duplicate}" + +missing="${tmp_dir}/missing.lock" +sed '/^version=/d' "${valid_lock}" >"${missing}" +assert_rejected "${missing}" + +unknown="${tmp_dir}/unknown.lock" +cp "${valid_lock}" "${unknown}" +printf 'unexpected=value\n' >>"${unknown}" +assert_rejected "${unknown}" + +bad_repository="${tmp_dir}/bad-repository.lock" +sed 's#^repository=.*#repository=other/saola-cli#' "${valid_lock}" >"${bad_repository}" +assert_rejected "${bad_repository}" + +bad_channel="${tmp_dir}/bad-channel.lock" +sed 's/^channel=.*/channel=preview/' "${valid_lock}" >"${bad_channel}" +assert_rejected "${bad_channel}" + +bad_dev_version="${tmp_dir}/bad-dev-version.lock" +sed 's/^version=.*/version=v1.2.3/' "${valid_lock}" >"${bad_dev_version}" +assert_rejected "${bad_dev_version}" + +stable_lock="${tmp_dir}/stable.lock" +write_lock "${stable_lock}" \ + 'repository=harmonycloud/saola-cli' \ + 'version=v1.2.3-rc.1' \ + "commit=${commit}" \ + 'channel=stable' \ + 'source_date_epoch=1782812176' +"${helper}" validate "${stable_lock}" + +bad_stable_version="${tmp_dir}/bad-stable-version.lock" +sed 's/^version=.*/version=dev-dfe685bacbeb/' "${stable_lock}" >"${bad_stable_version}" +assert_rejected "${bad_stable_version}" + +bad_stable_prerelease="${tmp_dir}/bad-stable-prerelease.lock" +sed 's/^version=.*/version=v1.2.3-01/' "${stable_lock}" >"${bad_stable_prerelease}" +assert_rejected "${bad_stable_prerelease}" + +bad_stable_build_metadata="${tmp_dir}/bad-stable-build-metadata.lock" +sed 's/^version=.*/version=v1.2.3+build.1/' "${stable_lock}" >"${bad_stable_build_metadata}" +assert_rejected "${bad_stable_build_metadata}" + +bad_commit="${tmp_dir}/bad-commit.lock" +sed 's/^commit=.*/commit=dfe685b/' "${valid_lock}" >"${bad_commit}" +assert_rejected "${bad_commit}" + +bad_epoch="${tmp_dir}/bad-epoch.lock" +sed 's/^source_date_epoch=.*/source_date_epoch=-1/' "${valid_lock}" >"${bad_epoch}" +assert_rejected "${bad_epoch}" + +marker="${tmp_dir}/sourced" +unsafe_value="${tmp_dir}/unsafe-value.lock" +sed "s#^repository=.*#repository=\$(touch ${marker})#" "${valid_lock}" >"${unsafe_value}" +assert_rejected "${unsafe_value}" +[[ ! -e "${marker}" ]] || fail 'lock content was executed' + +if "${helper}" get "${valid_lock}" 'repository;touch-pwned' >/dev/null 2>&1; then + fail 'unsafe key was accepted' +fi +[[ ! -e "${tmp_dir}/touch-pwned" ]] || fail 'get key was executed' + +repo_root="$(cd "${script_dir}/.." && pwd)" +docker_build_output="$(TMPDIR="${tmp_dir}" make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test)" +[[ "${docker_build_output}" != *'git#'* ]] || fail 'docker-build still uses an unusable remote git#SHA context' +docker_build_context="$(sed -n 's/.*--build-context saola-cli=\([^ ]*\).*/\1/p' <<<"${docker_build_output}")" +[[ "${docker_build_context}" == /* ]] || fail 'docker-build default context is not an absolute temporary local directory' +[[ ! -e "${docker_build_context}" ]] || fail 'docker-build temporary context was not cleaned after success' +[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_VERSION=dev-dfe685bacbeb"* ]] || fail 'docker-build is missing the locked CLI version' +[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'docker-build is missing the locked CLI commit' +[[ "${docker_build_output}" == *'--platform=linux/amd64'* ]] || fail 'docker-build default platform is not linux/amd64' +if make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test DOCKER_PLATFORM=linux/s390x >/dev/null 2>&1; then + fail 'docker-build accepted an unsupported platform' +fi + +docker_buildx_output="$(TMPDIR="${tmp_dir}" make -s -C "${repo_root}" docker-buildx CONTAINER_TOOL=echo IMG=controller:test)" +[[ "${docker_buildx_output}" == *'--platform=linux/amd64,linux/arm64'* ]] || fail 'docker-buildx platforms are not exactly amd64 and arm64' +[[ "${docker_buildx_output}" != *'Dockerfile.cross'* ]] || fail 'docker-buildx still generates Dockerfile.cross' +[[ "${docker_buildx_output}" != *'git#'* ]] || fail 'docker-buildx still uses an unusable remote git#SHA context' +docker_buildx_context="$(sed -n 's/.*--build-context saola-cli=\([^ ]*\).*/\1/p' <<<"${docker_buildx_output}")" +[[ "${docker_buildx_context}" == /* ]] || fail 'docker-buildx default context is not an absolute temporary local directory' +[[ ! -e "${docker_buildx_context}" ]] || fail 'docker-buildx temporary context was not cleaned after success' + +override_output="$(make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test \ + SAOLA_CLI_REPOSITORY=attacker/saola-cli \ + SAOLA_CLI_VERSION=v9.9.9 \ + SAOLA_CLI_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + SAOLA_CLI_SOURCE_DATE_EPOCH=1 \ + 'SAOLA_CLI_BUILD_ARGS=--build-arg PWNED=true')" +[[ "${override_output}" != *'attacker/saola-cli'* && "${override_output}" != *'git#'* ]] || fail 'command line overrode the lock-derived repository or selected a remote context' +[[ "${override_output}" == *'--build-arg SAOLA_CLI_VERSION=dev-dfe685bacbeb'* ]] || fail 'command line overrode the lock-derived version' +[[ "${override_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'command line overrode the lock-derived commit build arg' +[[ "${override_output}" == *'--build-arg SAOLA_CLI_SOURCE_DATE_EPOCH=1782812176'* ]] || fail 'command line overrode the lock-derived source epoch' +[[ "${override_output}" != *'PWNED=true'* ]] || fail 'command line overrode SAOLA_CLI_BUILD_ARGS' + +local_context_output="$(make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test SAOLA_CLI_CONTEXT=../saola-cli)" +[[ "${local_context_output}" != *'--build-context saola-cli=../saola-cli'* ]] || fail 'explicit SAOLA_CLI_CONTEXT was passed through without a clean archive' +local_build_context="$(sed -n 's/.*--build-context saola-cli=\([^ ]*\).*/\1/p' <<<"${local_context_output}")" +[[ "${local_build_context}" == /* && ! -e "${local_build_context}" ]] || fail 'explicit source repo did not use a cleaned temporary archive' +[[ "${local_context_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'local context override changed the locked commit metadata' + +platform_override_output="$(make -s -C "${repo_root}" docker-buildx CONTAINER_TOOL=echo IMG=controller:test PLATFORMS=linux/s390x)" +[[ "${platform_override_output}" == *'--platform=linux/amd64,linux/arm64'* ]] || fail 'command line overrode the fixed buildx platforms' +[[ "${platform_override_output}" != *'linux/s390x'* ]] || fail 'unsupported buildx platform reached the command' + +inspector="${tmp_dir}/inspect-container-context.sh" +cat >"${inspector}" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +context='' +previous='' +for argument in "$@"; do + if [[ "${previous}" == '--build-context' ]]; then + context="${argument#saola-cli=}" + break + fi + previous="${argument}" +done +[[ -n "${context}" && -d "${context}" ]] +[[ ! -e "${context}/.git" ]] + +expected="$(mktemp -d)" +trap 'rm -rf "${expected}"' EXIT +git -C "${EXPECTED_REPOSITORY}" archive "${EXPECTED_COMMIT}" | tar -x -C "${expected}" +diff -qr "${expected}" "${context}" >/dev/null +printf 'INSPECTED_CONTEXT=%s\n' "${context}" +[[ "${FAIL_AFTER_INSPECT:-false}" != 'true' ]] || exit 42 +EOF +chmod +x "${inspector}" + +inspected_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ + make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test)" +inspected_context="${inspected_output#INSPECTED_CONTEXT=}" +[[ "${inspected_context}" == /* ]] || fail 'inspector did not receive an absolute temporary local context' +[[ ! -e "${inspected_context}" ]] || fail 'inspected docker-build context was not cleaned' + +inspected_buildx_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ + make -s -C "${repo_root}" docker-buildx CONTAINER_TOOL="${inspector}" IMG=controller:test)" +inspected_buildx_context="${inspected_buildx_output#INSPECTED_CONTEXT=}" +[[ ! -e "${inspected_buildx_context}" ]] || fail 'inspected docker-buildx context was not cleaned' + +inspected_explicit_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ + make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test SAOLA_CLI_CONTEXT=../saola-cli)" +inspected_explicit_context="${inspected_explicit_output#INSPECTED_CONTEXT=}" +[[ "${inspected_explicit_context}" == "${tmp_dir}"/opensaola-saola-cli.* ]] || fail 'explicit source repo did not produce a temporary archive context' +[[ ! -e "${inspected_explicit_context}" ]] || fail 'explicit source repo archive was not cleaned' + +if failed_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" FAIL_AFTER_INSPECT=true TMPDIR="${tmp_dir}" \ + make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test 2>&1)"; then + fail 'expected fake container failure' +fi +failed_context="$(sed -n 's/^INSPECTED_CONTEXT=//p' <<<"${failed_output}")" +[[ -n "${failed_context}" && ! -e "${failed_context}" ]] || fail 'docker-build temporary context was not cleaned after failure' + +unrelated_repo="${tmp_dir}/unrelated-repo" +mkdir -p "${unrelated_repo}" +git -C "${unrelated_repo}" init -q +git -C "${unrelated_repo}" config user.name 'Lock Test' +git -C "${unrelated_repo}" config user.email 'lock-test@example.invalid' +printf 'unrelated\n' >"${unrelated_repo}/README" +git -C "${unrelated_repo}" add README +git -C "${unrelated_repo}" commit -q -m unrelated +never_called="${tmp_dir}/container-tool-called" +never_tool="${tmp_dir}/never-container-tool.sh" +cat >"${never_tool}" </dev/null 2>&1; then + fail 'source repo without the locked commit was accepted' +fi +[[ ! -e "${never_called}" ]] || fail 'container tool ran before the locked commit was verified' +if compgen -G "${tmp_dir}/opensaola-saola-cli.*" >/dev/null; then + fail 'temporary archive leaked after locked commit verification failed' +fi + +dockerfile="${repo_root}/Dockerfile" +cli_stage_line="$(grep -n '^FROM .* AS saola-cli-builder$' "${dockerfile}" | cut -d: -f1)" +kubectl_download_line="$(grep -n '^RUN curl -LO ' "${dockerfile}" | cut -d: -f1)" +[[ -n "${cli_stage_line}" && -n "${kubectl_download_line}" && "${cli_stage_line}" -gt "${kubectl_download_line}" ]] || fail 'saola-cli stage interrupts the manager/kubectl builder stage' + +assert_workflow_contains() { + local workflow="$1" + local contract="$2" + local pattern="$3" + grep -Eq -- "${pattern}" "${workflow}" || fail "${contract}: ${workflow}" +} + +assert_workflow_excludes() { + local workflow="$1" + local contract="$2" + local pattern="$3" + if grep -Eq -- "${pattern}" "${workflow}"; then + fail "${contract}: ${workflow}" + fi +} + +update_workflow="${repo_root}/.github/workflows/saola-cli-update.yml" +promote_workflow="${repo_root}/.github/workflows/saola-cli-promote.yml" +docker_workflow="${repo_root}/.github/workflows/docker.yml" + +[[ -f "${update_workflow}" ]] || fail 'missing saola-cli update workflow' +assert_workflow_contains "${update_workflow}" 'update workflow does not accept both dispatch event types' 'types:.*saola-cli-dev.*saola-cli-stable' +assert_workflow_contains "${update_workflow}" 'source validation is not isolated in its own job' '^ validate-source:' +assert_workflow_contains "${update_workflow}" 'mutation job does not depend on source validation' '^ update-dev:|needs: validate-source' +validate_job="$(sed -n '/^ validate-source:/,/^ update-dev:/p' "${update_workflow}")" +[[ "${validate_job}" != *'OPENSAOLA_AUTOMATION_TOKEN'* ]] || fail 'validate-source job can access the OpenSaola automation token' +[[ "${validate_job}" == *'persist-credentials: false'* ]] || fail 'external source checkout persists credentials' +[[ "${validate_job}" == *'outputs:'* ]] || fail 'validate-source job does not expose strict outputs' +assert_workflow_contains "${update_workflow}" 'job outputs are not sourced from trusted identity' 'repository:.*steps\.identity\.outputs\.repository' +verified_step="$(sed -n '/name: Rebuild and verify both published checksums/,/^ update-dev:/p' "${update_workflow}")" +[[ "${verified_step}" != *'>>"${GITHUB_OUTPUT}"'* ]] || fail 'external build step can write trusted identity outputs' +assert_workflow_contains "${update_workflow}" 'external make does not clear workflow command files' 'env -u GITHUB_OUTPUT -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY' +assert_workflow_contains "${update_workflow}" 'release query does not paginate all releases' 'gh api --paginate' +assert_workflow_contains "${update_workflow}" 'source epoch is not bound to commit timestamp' 'committer\.date|commit_epoch' +assert_workflow_contains "${update_workflow}" 'stable payload checksums are not compared with Release SHA256SUMS' 'release_amd64.*CLI_AMD64_SHA256' +identity_step="$(sed -n '/name: Validate immutable event payload/,/name: Check out the exact Saola CLI source/p' "${update_workflow}")" +[[ "${identity_step}" == *'GH_TOKEN: ${{ github.token }}'* ]] || fail 'identity step does not use the read-only GitHub token' +[[ "${validate_job}" != *'token: ${{ github.token }}'* ]] || fail 'GitHub token leaked into checkout configuration' +assert_workflow_contains "${update_workflow}" 'manual update dispatch is missing repository input' 'repository:' +assert_workflow_contains "${update_workflow}" 'manual update dispatch is missing both checksums' 'amd64_sha256:' +assert_workflow_contains "${update_workflow}" 'manual update dispatch is missing both checksums' 'arm64_sha256:' +assert_workflow_contains "${update_workflow}" 'update workflow does not fail closed without the automation token' 'OPENSAOLA_AUTOMATION_TOKEN.*required' +assert_workflow_contains "${update_workflow}" 'update workflow does not verify the immutable source revision' 'git ls-remote' +assert_workflow_contains "${update_workflow}" 'dev event is not bound to the current main ref' 'refs/heads/main' +assert_workflow_contains "${update_workflow}" 'stable event is not bound to its exact tag ref' 'refs/tags/.*CLI_VERSION' +assert_workflow_contains "${update_workflow}" 'stable tag is not peeled before commit comparison' '\^\{\}' +assert_workflow_contains "${update_workflow}" 'stable event does not query GitHub Releases' 'repos/.*/releases\?per_page=' +assert_workflow_contains "${update_workflow}" 'stable event does not reject draft releases' 'draft.*false' +assert_workflow_contains "${update_workflow}" 'stable event is not gated on the latest published release' 'published_at.*max_by|max_by.*published_at' +assert_workflow_contains "${update_workflow}" 'update runs are not globally serialized for dev' 'group: saola-cli-update-dev' +assert_workflow_contains "${update_workflow}" 'update workflow does not refresh the dev baseline before branching' 'fetch origin dev' +assert_workflow_contains "${update_workflow}" 'update workflow does not rebuild the exact CLI source' 'make -C saola-cli-source' +assert_workflow_contains "${update_workflow}" 'update workflow does not generate release checksums' 'release-build release-checksums' +assert_workflow_contains "${update_workflow}" 'update workflow does not compare the rebuilt amd64 checksum' 'actual_amd64.*CLI_AMD64_SHA256' +assert_workflow_contains "${update_workflow}" 'update workflow does not compare the rebuilt arm64 checksum' 'actual_arm64.*CLI_ARM64_SHA256' +assert_workflow_contains "${update_workflow}" 'update workflow does not validate the lock it writes' 'saola-cli-lock\.sh.*validate' +assert_workflow_contains "${update_workflow}" 'stable update PR label contract is missing' 'automation:saola-cli-stable' +assert_workflow_contains "${update_workflow}" 'update workflow does not use concurrency' '^concurrency:' +assert_workflow_contains "${update_workflow}" 'update workflow does not target dev' '(--base|base:) dev' +assert_workflow_contains "${update_workflow}" 'update workflow does not enable auto-merge' 'pr merge.*--auto.*--squash' +assert_workflow_excludes "${update_workflow}" 'update workflow directly pushes a protected branch' 'git push[^#]*(origin )?(dev|master)([[:space:]]|$)' + +[[ -f "${promote_workflow}" ]] || fail 'missing saola-cli promotion workflow' +assert_workflow_contains "${promote_workflow}" 'promotion workflow is not scheduled' '^ schedule:' +assert_workflow_contains "${promote_workflow}" 'promotion workflow is not manually dispatchable' '^ workflow_dispatch:' +assert_workflow_contains "${promote_workflow}" 'promotion does not select the stable automation label' 'automation:saola-cli-stable' +assert_workflow_contains "${promote_workflow}" 'promotion does not verify automation author' 'OPENSAOLA_AUTOMATION_LOGIN|EXPECTED_AUTOMATION_LOGIN' +assert_workflow_contains "${promote_workflow}" 'promotion does not verify deterministic head branch' 'headRefName' +assert_workflow_contains "${promote_workflow}" 'promotion does not enforce lock-only file changes' 'build/saola-cli\.lock' +assert_workflow_contains "${promote_workflow}" 'promotion does not revalidate latest published release' 'published_at.*max_by|max_by.*published_at' +assert_workflow_contains "${promote_workflow}" 'promotion does not revalidate Release checksums' 'SHA256SUMS' +assert_workflow_contains "${promote_workflow}" 'promotion does not use the stable PR merge commit lock' 'mergeCommit\.oid' +assert_workflow_contains "${promote_workflow}" 'promotion does not read the candidate lock at its merge SHA' 'contents/build/saola-cli\.lock\?ref=' +assert_workflow_contains "${promote_workflow}" 'promotion does not enforce the default 24 hour soak' 'SOAK_HOURS:.*24' +assert_workflow_contains "${promote_workflow}" 'promotion does not verify CI on the exact merge SHA' 'workflows/ci\.yml/runs.*head_sha' +assert_workflow_contains "${promote_workflow}" 'promotion does not verify Docker on the exact merge SHA' 'workflows/docker\.yml/runs.*head_sha' +assert_workflow_contains "${promote_workflow}" 'promotion does not target master' '(--base|base:) master' +assert_workflow_contains "${promote_workflow}" 'promotion does not enable auto-merge' 'pr merge.*--auto.*--squash' +assert_workflow_contains "${promote_workflow}" 'promotion no-op does not compare the complete validated lock' 'cmp -s.*candidate_lock.*build/saola-cli\.lock' +assert_workflow_excludes "${promote_workflow}" 'promotion resolves a floating CLI revision' '(version|ref|tag)=(latest|snapshot)' +assert_workflow_excludes "${promote_workflow}" 'promotion directly pushes master' 'git push[^#]*(origin )?master([[:space:]]|$)' + +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not validate the CLI lock' 'saola-cli-lock\.sh.*validate' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not checkout locked CLI source' 'repository: harmonycloud/saola-cli' +assert_workflow_contains "${docker_workflow}" 'Docker workflow checkout does not use locked CLI commit' 'ref:.*steps\.cli\.outputs\.commit' +assert_workflow_contains "${docker_workflow}" 'Docker workflow checkout path is not isolated' 'path: \.saola-cli-source' +assert_workflow_contains "${docker_workflow}" 'Docker workflow external checkout persists credentials' 'persist-credentials: false' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not use local CLI named context' 'saola-cli=\./\.saola-cli-source' +assert_workflow_excludes "${docker_workflow}" 'Docker workflow still uses remote git context' 'git#' +assert_workflow_contains "${docker_workflow}" 'Docker workflow platforms changed' 'platforms: linux/amd64,linux/arm64' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not emit SBOM attestations' 'sbom: true' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not emit provenance attestations' 'provenance:' +assert_workflow_contains "${docker_workflow}" 'Docker workflow is missing CLI version OCI metadata' 'org\.opensaola\.saola-cli\.version' +assert_workflow_contains "${docker_workflow}" 'Docker workflow is missing CLI commit OCI metadata' 'org\.opensaola\.saola-cli\.revision' + +release_doc="${repo_root}/docs/release-process.md" +assert_workflow_contains "${release_doc}" 'release docs omit default-branch workflow bootstrap' 'default branch.*master' +assert_workflow_contains "${release_doc}" 'release docs omit Contents token permissions' 'Contents: read and write' +assert_workflow_contains "${release_doc}" 'release docs omit Pull requests token permissions' 'Pull requests: read and write' +assert_workflow_contains "${release_doc}" 'release docs omit Actions token permissions' 'Actions: read' +assert_workflow_contains "${release_doc}" 'release docs omit label metadata permission requirements' '[Mm]etadata.*label' +assert_workflow_contains "${release_doc}" 'release docs do not describe latest published stable gating' 'latest published.*non-draft release' +assert_workflow_contains "${release_doc}" 'release docs still describe version-only promotion no-op' 'complete five-field lock' + +for workflow in "${update_workflow}" "${promote_workflow}"; do + while IFS= read -r use; do + [[ "${use}" =~ @([0-9a-f]{40})([[:space:]]|$) ]] || fail "workflow action is not pinned to a full commit: ${use}" + done < <(grep -E '^[[:space:]]*-?[[:space:]]*uses:' "${workflow}" || true) +done + +printf 'PASS: saola-cli lock contract\n' From f4ebae05a1b1e54dff39fece6b911bf8aa107469 Mon Sep 17 00:00:00 2001 From: CandidXD Date: Mon, 13 Jul 2026 17:46:36 +0800 Subject: [PATCH 2/2] fix(release): harden bundled saola CLI channels --- .github/workflows/docker.yml | 285 +++++++++++++++++- .github/workflows/helm-chart.yml | 16 + .github/workflows/saola-cli-promote.yml | 53 ++-- .github/workflows/saola-cli-update.yml | 54 ++-- Dockerfile | 8 +- Makefile | 24 +- build/saola-cli-dev.lock | 5 + build/saola-cli.lock | 5 - docs/release-process.md | 12 +- docs/release-process_zh.md | 12 +- ...2026-07-13-bundled-saola-cli-automation.md | 6 +- hack/saola-cli-lock.sh | 17 +- hack/saola-cli-lock_test.sh | 123 ++++++-- 13 files changed, 504 insertions(+), 116 deletions(-) create mode 100644 build/saola-cli-dev.lock delete mode 100644 build/saola-cli.lock diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d62d659..07550e0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,57 +18,312 @@ on: permissions: contents: read - packages: write -# Cancel superseded runs for the same branch/PR, but never cancel a release -# tag build — those must always complete. -# 同一分支 / PR 的老任务被新 push 自动取消,但 tag 触发的 release 构建例外。 +# Cancel only superseded PR runs. Every push gets a SHA-specific group because +# stable promotion requires immutable evidence from the exact dev merge SHA. concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + group: docker-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - docker: + pr-build: + if: github.event_name == 'pull_request' name: Build and Push runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Select the channel-specific Saola CLI lock + id: lock + env: + BASE_REF: ${{ github.base_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.head_ref }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + EXPECTED_AUTOMATION_LOGIN: ${{ vars.OPENSAOLA_AUTOMATION_LOGIN }} + run: | + set -euo pipefail + case "${BASE_REF}" in + dev) + if [[ "${HEAD_REF}" =~ ^automation/saola-cli-stable-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo 'path=build/saola-cli-stable-candidate.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=stable' >>"${GITHUB_OUTPUT}" + else + echo 'path=build/saola-cli-dev.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=dev' >>"${GITHUB_OUTPUT}" + fi + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + ;; + master) + if ! git diff --quiet "${BASE_SHA}...HEAD" -- build/saola-cli-stable.lock; then + changed_files="$(git diff --name-only "${BASE_SHA}...HEAD")" + [[ "${changed_files}" = build/saola-cli-stable.lock ]] || { + echo 'stable promotion PR must change only build/saola-cli-stable.lock' >&2 + exit 1 + } + [[ "${HEAD_REF}" =~ ^automation/promote-saola-cli-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo 'only a deterministic promotion branch may change the stable lock' >&2 + exit 1 + } + [[ -n "${EXPECTED_AUTOMATION_LOGIN}" && "${PR_AUTHOR}" = "${EXPECTED_AUTOMATION_LOGIN}" ]] || { + echo 'stable lock author does not match OPENSAOLA_AUTOMATION_LOGIN' >&2 + exit 1 + } + fi + if [[ -f build/saola-cli-stable.lock ]]; then + echo 'path=build/saola-cli-stable.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=stable' >>"${GITHUB_OUTPUT}" + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + elif git cat-file -e "${BASE_SHA}:build/saola-cli-stable.lock" 2>/dev/null; then + echo 'a master PR removed the promoted stable lock' >&2 + exit 1 + else + echo 'bootstrap=true' >>"${GITHUB_OUTPUT}" + echo 'publish=false' >>"${GITHUB_OUTPUT}" + echo 'enabled=false' >>"${GITHUB_OUTPUT}" + echo 'No final Saola CLI release exists yet; bootstrap PR is intentionally not building an official image.' + fi + ;; + *) + echo "unsupported pull request base: ${BASE_REF}" >&2 + exit 1 + ;; + esac + - name: Export locked Saola CLI metadata + if: steps.lock.outputs.enabled == 'true' + id: cli + env: + LOCK_PATH: ${{ steps.lock.outputs.path }} + EXPECTED_CHANNEL: ${{ steps.lock.outputs.expected_channel }} + run: | + set -euo pipefail + hack/saola-cli-lock.sh validate "${LOCK_PATH}" + actual_channel="$(hack/saola-cli-lock.sh get "${LOCK_PATH}" channel)" + [[ "${actual_channel}" = "${EXPECTED_CHANNEL}" ]] || { + echo "${LOCK_PATH} channel ${actual_channel} does not match expected_channel ${EXPECTED_CHANNEL}" >&2 + exit 1 + } + for key in repository version commit channel source_date_epoch; do + value="$(hack/saola-cli-lock.sh get "${LOCK_PATH}" "${key}")" + echo "${key}=${value}" >>"${GITHUB_OUTPUT}" + done + - name: Check out locked Saola CLI source + if: steps.lock.outputs.enabled == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: harmonycloud/saola-cli + ref: ${{ steps.cli.outputs.commit }} + path: .saola-cli-source + persist-credentials: false + - if: steps.lock.outputs.enabled == 'true' + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - if: steps.lock.outputs.enabled == 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - name: Normalize image name + if: steps.lock.outputs.enabled == 'true' + id: image + run: echo "name=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - name: Docker meta + if: steps.lock.outputs.enabled == 'true' + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 + with: + images: ${{ steps.image.outputs.name }} + flavor: | + latest=false + tags: | + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=ref,event=pr + type=sha,prefix=sha- + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') && github.ref_type == 'tag' }} + labels: | + org.opensaola.saola-cli.repository=${{ steps.cli.outputs.repository }} + org.opensaola.saola-cli.version=${{ steps.cli.outputs.version }} + org.opensaola.saola-cli.revision=${{ steps.cli.outputs.commit }} + org.opensaola.saola-cli.channel=${{ steps.cli.outputs.channel }} + - name: Build without publishing + if: steps.lock.outputs.enabled == 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + build-contexts: | + saola-cli=./.saola-cli-source + build-args: | + SAOLA_CLI_VERSION=${{ steps.cli.outputs.version }} + SAOLA_CLI_COMMIT=${{ steps.cli.outputs.commit }} + SAOLA_CLI_SOURCE_DATE_EPOCH=${{ steps.cli.outputs.source_date_epoch }} + platforms: linux/amd64,linux/arm64 + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + + stable-candidate-build: + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + name: Build stable candidate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Export stable candidate metadata when present + id: cli + run: | + set -euo pipefail + lock_path='build/saola-cli-stable-candidate.lock' + if [[ ! -f "${lock_path}" ]]; then + echo 'enabled=false' >>"${GITHUB_OUTPUT}" + echo 'No stable candidate is present on this dev commit.' + exit 0 + fi + hack/saola-cli-lock.sh validate "${lock_path}" + [[ "$(hack/saola-cli-lock.sh get "${lock_path}" channel)" = stable ]] + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + for key in repository version commit channel source_date_epoch; do + value="$(hack/saola-cli-lock.sh get "${lock_path}" "${key}")" + echo "${key}=${value}" >>"${GITHUB_OUTPUT}" + done + - name: Check out stable candidate source + if: steps.cli.outputs.enabled == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: harmonycloud/saola-cli + ref: ${{ steps.cli.outputs.commit }} + path: .saola-cli-source + persist-credentials: false + - if: steps.cli.outputs.enabled == 'true' + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - if: steps.cli.outputs.enabled == 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - name: Build stable candidate without publishing + if: steps.cli.outputs.enabled == 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + build-contexts: | + saola-cli=./.saola-cli-source + build-args: | + SAOLA_CLI_VERSION=${{ steps.cli.outputs.version }} + SAOLA_CLI_COMMIT=${{ steps.cli.outputs.commit }} + SAOLA_CLI_SOURCE_DATE_EPOCH=${{ steps.cli.outputs.source_date_epoch }} + platforms: linux/amd64,linux/arm64 + push: false + tags: opensaola:stable-candidate-${{ github.sha }} + labels: | + org.opensaola.saola-cli.repository=${{ steps.cli.outputs.repository }} + org.opensaola.saola-cli.version=${{ steps.cli.outputs.version }} + org.opensaola.saola-cli.revision=${{ steps.cli.outputs.commit }} + org.opensaola.saola-cli.channel=stable + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + + publish: + if: github.event_name != 'pull_request' + name: Build and push + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Select the channel-specific Saola CLI lock + id: lock + run: | + set -euo pipefail + case "${GITHUB_REF}" in + refs/heads/dev) + echo 'path=build/saola-cli-dev.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=dev' >>"${GITHUB_OUTPUT}" + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + ;; + refs/heads/master) + if [[ ! -f build/saola-cli-stable.lock ]]; then + echo 'bootstrap=true' >>"${GITHUB_OUTPUT}" + echo 'publish=false' >>"${GITHUB_OUTPUT}" + echo 'enabled=false' >>"${GITHUB_OUTPUT}" + echo 'Master automation is bootstrapped, but no final Saola CLI lock exists; no image will be published.' + exit 0 + fi + echo 'path=build/saola-cli-stable.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=stable' >>"${GITHUB_OUTPUT}" + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + ;; + refs/tags/v*) + echo 'path=build/saola-cli-stable.lock' >>"${GITHUB_OUTPUT}" + echo 'expected_channel=stable' >>"${GITHUB_OUTPUT}" + echo 'enabled=true' >>"${GITHUB_OUTPUT}" + ;; + *) + echo "unsupported publishing ref: ${GITHUB_REF}" >&2 + exit 1 + ;; + esac - name: Export locked Saola CLI metadata + if: steps.lock.outputs.enabled == 'true' id: cli + env: + LOCK_PATH: ${{ steps.lock.outputs.path }} + EXPECTED_CHANNEL: ${{ steps.lock.outputs.expected_channel }} run: | set -euo pipefail - hack/saola-cli-lock.sh validate build/saola-cli.lock + hack/saola-cli-lock.sh validate "${LOCK_PATH}" + actual_channel="$(hack/saola-cli-lock.sh get "${LOCK_PATH}" channel)" + [[ "${actual_channel}" = "${EXPECTED_CHANNEL}" ]] || { + echo "${LOCK_PATH} channel ${actual_channel} does not match expected_channel ${EXPECTED_CHANNEL}" >&2 + exit 1 + } for key in repository version commit channel source_date_epoch; do - value="$(hack/saola-cli-lock.sh get build/saola-cli.lock "${key}")" + value="$(hack/saola-cli-lock.sh get "${LOCK_PATH}" "${key}")" echo "${key}=${value}" >>"${GITHUB_OUTPUT}" done - name: Check out locked Saola CLI source + if: steps.lock.outputs.enabled == 'true' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: harmonycloud/saola-cli ref: ${{ steps.cli.outputs.commit }} path: .saola-cli-source persist-credentials: false - - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - if: steps.lock.outputs.enabled == 'true' + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - if: steps.lock.outputs.enabled == 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR - if: github.event_name != 'pull_request' + if: steps.lock.outputs.enabled == 'true' uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Normalize image name + if: steps.lock.outputs.enabled == 'true' id: image run: echo "name=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - name: Validate release tag - if: startsWith(github.ref, 'refs/tags/v') + if: steps.lock.outputs.enabled == 'true' && startsWith(github.ref, 'refs/tags/v') run: | [[ "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]] || { echo "Release tags must match vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-prerelease" exit 1 } - name: Docker meta + if: steps.lock.outputs.enabled == 'true' id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: @@ -80,7 +335,6 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=ref,event=branch - type=ref,event=pr type=sha,prefix=sha- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') && github.ref_type == 'tag' }} labels: | @@ -89,6 +343,7 @@ jobs: org.opensaola.saola-cli.revision=${{ steps.cli.outputs.commit }} org.opensaola.saola-cli.channel=${{ steps.cli.outputs.channel }} - name: Build and push + if: steps.lock.outputs.enabled == 'true' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . @@ -99,7 +354,7 @@ jobs: SAOLA_CLI_COMMIT=${{ steps.cli.outputs.commit }} SAOLA_CLI_SOURCE_DATE_EPOCH=${{ steps.cli.outputs.source_date_epoch }} platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e918293..9e6d738 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -15,6 +15,22 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Require the promoted stable Saola CLI for release tags + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + lock_path='build/saola-cli-stable.lock' + [[ -f "${lock_path}" ]] || { + echo 'release chart publishing requires a promoted stable Saola CLI lock' >&2 + exit 1 + } + hack/saola-cli-lock.sh validate "${lock_path}" + [[ "$(hack/saola-cli-lock.sh get "${lock_path}" channel)" = stable ]] || { + echo 'release chart publishing requires channel=stable' >&2 + exit 1 + } - uses: azure/setup-helm@v4 - name: Check chart run: make helm-lint helm-template verify-chart-crds diff --git a/.github/workflows/saola-cli-promote.yml b/.github/workflows/saola-cli-promote.yml index cc90b9f..f7acb42 100644 --- a/.github/workflows/saola-cli-promote.yml +++ b/.github/workflows/saola-cli-promote.yml @@ -24,18 +24,12 @@ jobs: name: Promote soaked stable lock to master runs-on: ubuntu-latest env: - AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} - GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} SOAK_HOURS: ${{ inputs.soak_hours || '24' }} EXPECTED_AUTOMATION_LOGIN: ${{ vars.OPENSAOLA_AUTOMATION_LOGIN }} steps: - - name: Require dedicated automation credential and valid soak + - name: Require valid promotion policy run: | set -euo pipefail - test -n "${AUTOMATION_TOKEN}" || { - echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' - exit 1 - } [[ "${SOAK_HOURS}" =~ ^(0|[1-9][0-9]*)$ ]] [[ -n "${EXPECTED_AUTOMATION_LOGIN}" ]] || { echo 'repository variable OPENSAOLA_AUTOMATION_LOGIN is required' >&2 @@ -43,6 +37,8 @@ jobs: } - name: Select the newest merged stable update id: candidate + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail candidate="$(gh pr list --state merged --base dev \ @@ -62,7 +58,7 @@ jobs: version_from_branch="$(jq -r '.headRefName' <<<"${details}")" [[ "${version_from_branch}" =~ ^automation/saola-cli-stable-v[0-9A-Za-z.-]+$ ]] [[ "$(jq '.files | length' <<<"${details}")" -eq 1 ]] - [[ "$(jq -r '.files[0].path' <<<"${details}")" = build/saola-cli.lock ]] + [[ "$(jq -r '.files[0].path' <<<"${details}")" = build/saola-cli-stable-candidate.lock ]] [[ "${merge_sha}" =~ ^[0-9a-f]{40}$ ]] merged_epoch="$(date -u -d "${merged_at}" +%s)" ready_epoch="$((merged_epoch + SOAK_HOURS * 3600))" @@ -78,11 +74,12 @@ jobs: - name: Read the exact merge commit lock and verify its workflows if: steps.candidate.outputs.found == 'true' env: + GH_TOKEN: ${{ github.token }} MERGE_SHA: ${{ steps.candidate.outputs.merge_sha }} HEAD_REF: ${{ steps.candidate.outputs.head_ref }} run: | set -euo pipefail - gh api "repos/${GITHUB_REPOSITORY}/contents/build/saola-cli.lock?ref=${MERGE_SHA}" \ + gh api "repos/${GITHUB_REPOSITORY}/contents/build/saola-cli-stable-candidate.lock?ref=${MERGE_SHA}" \ --jq '.content' | base64 --decode >"${RUNNER_TEMP}/saola-cli.lock" channel="$(awk -F= '$1 == "channel" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" version="$(awk -F= '$1 == "version" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" @@ -91,7 +88,7 @@ jobs: source_epoch="$(awk -F= '$1 == "source_date_epoch" { print $2 }' "${RUNNER_TEMP}/saola-cli.lock")" [[ "${channel}" = stable ]] [[ "${repository}" = harmonycloud/saola-cli ]] - [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] + [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] [[ "${HEAD_REF}" = "automation/saola-cli-stable-${version}" ]] tag_refs="$(git ls-remote "https://github.com/${repository}.git" \ "refs/tags/${version}" "refs/tags/${version}^{}")" @@ -101,7 +98,7 @@ jobs: commit_date="$(gh api "repos/${repository}/commits/${commit}" --jq '.commit.committer.date')" [[ "$(date -u -d "${commit_date}" +%s)" = "${source_epoch}" ]] releases="$(gh api --paginate --slurp "repos/${repository}/releases?per_page=100")" - latest_release="$(jq -c 'flatten | [.[] | select(.draft == false)] | max_by(.published_at)' <<<"${releases}")" + latest_release="$(jq -c 'flatten | [.[] | select(.draft == false and .prerelease == false)] | max_by(.published_at)' <<<"${releases}")" [[ "$(jq -r '.tag_name' <<<"${latest_release}")" = "${version}" ]] release_dir="$(mktemp -d)" gh release download "${version}" --repo "${repository}" \ @@ -123,39 +120,53 @@ jobs: } check_workflow ci.yml "${ci_result}" check_workflow docker.yml "${docker_result}" - - name: Check out master with the dedicated token + candidate_check_count="$(gh api \ + "repos/${GITHUB_REPOSITORY}/commits/${MERGE_SHA}/check-runs?check_name=Build%20stable%20candidate&filter=latest&per_page=100" \ + --jq '[.check_runs[] | select(.name == "Build stable candidate" and .status == "completed" and .conclusion == "success")] | length')" + [[ "${candidate_check_count}" -ge 1 ]] || { + echo "Build stable candidate is not successful for exact merge ${MERGE_SHA}" >&2 + exit 1 + } + - name: Check out master with read-only credentials if: steps.candidate.outputs.found == 'true' uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: master fetch-depth: 0 - token: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + persist-credentials: false - name: Validate and promote only the candidate lock if: steps.candidate.outputs.found == 'true' env: + GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} MERGE_SHA: ${{ steps.candidate.outputs.merge_sha }} SOURCE_PR: ${{ steps.candidate.outputs.number }} run: | set -euo pipefail + test -n "${GH_TOKEN}" || { + echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' >&2 + exit 1 + } + gh auth setup-git candidate_lock="${RUNNER_TEMP}/saola-cli.lock" hack/saola-cli-lock.sh validate "${candidate_lock}" [[ "$(hack/saola-cli-lock.sh get "${candidate_lock}" channel)" = stable ]] version="$(hack/saola-cli-lock.sh get "${candidate_lock}" version)" - if hack/saola-cli-lock.sh validate build/saola-cli.lock && - cmp -s "${candidate_lock}" build/saola-cli.lock; then + if [[ -f build/saola-cli-stable.lock ]] && + hack/saola-cli-lock.sh validate build/saola-cli-stable.lock && + cmp -s "${candidate_lock}" build/saola-cli-stable.lock; then echo "master already contains the complete ${version} lock; nothing to promote." exit 0 fi - cp "${candidate_lock}" build/saola-cli.lock - hack/saola-cli-lock.sh validate build/saola-cli.lock + cp "${candidate_lock}" build/saola-cli-stable.lock + hack/saola-cli-lock.sh validate build/saola-cli-stable.lock safe_version="${version//[^0-9A-Za-z._-]/-}" branch="automation/promote-saola-cli-${safe_version}" git config user.name 'opensaola-automation' git config user.email 'opensaola-automation@users.noreply.github.com' - git checkout -B "${branch}" - git add build/saola-cli.lock - git commit -m "build: promote bundled saola CLI ${version}" - git push --force-with-lease origin "HEAD:refs/heads/${branch}" + git -c core.hooksPath=/dev/null checkout -B "${branch}" + git add build/saola-cli-stable.lock + git -c core.hooksPath=/dev/null commit --no-gpg-sign -m "build: promote bundled saola CLI ${version}" + git -c core.hooksPath=/dev/null push --force-with-lease origin "HEAD:refs/heads/${branch}" pr="$(gh pr list --state open --base master --head "${branch}" --json number --jq '.[0].number // empty')" body="Promotes the exact stable lock merged by dev PR #${SOURCE_PR} at \`${MERGE_SHA}\` after the configured ${SOAK_HOURS}h soak." if [[ -z "${pr}" ]]; then diff --git a/.github/workflows/saola-cli-update.yml b/.github/workflows/saola-cli-update.yml index ccb99a7..8241c72 100644 --- a/.github/workflows/saola-cli-update.yml +++ b/.github/workflows/saola-cli-update.yml @@ -95,7 +95,7 @@ jobs: } ;; saola-cli-stable:stable) - 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-]*))*))?$' + semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' [[ "${CLI_VERSION}" =~ ${semver} ]] tag_refs="$(git ls-remote "https://github.com/${CLI_REPOSITORY}.git" \ "refs/tags/${CLI_VERSION}" "refs/tags/${CLI_VERSION}^{}")" @@ -109,9 +109,9 @@ jobs: } releases="$(gh api --paginate --slurp \ "repos/${CLI_REPOSITORY}/releases?per_page=100")" - latest_release="$(jq -c 'flatten | [.[] | select(.draft == false)] | max_by(.published_at)' <<<"${releases}")" + latest_release="$(jq -c 'flatten | [.[] | select(.draft == false and .prerelease == false)] | max_by(.published_at)' <<<"${releases}")" [[ "$(jq -r '.tag_name' <<<"${latest_release}")" = "${CLI_VERSION}" ]] || { - echo 'stable event is not the latest published non-draft release' >&2 + echo 'stable event is not the latest published final release' >&2 exit 1 } release_id="$(jq -r '.id' <<<"${latest_release}")" @@ -179,7 +179,6 @@ jobs: needs: validate-source runs-on: ubuntu-latest env: - AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} CLI_REPOSITORY: ${{ needs.validate-source.outputs.repository }} CLI_CHANNEL: ${{ needs.validate-source.outputs.channel }} CLI_VERSION: ${{ needs.validate-source.outputs.version }} @@ -189,13 +188,9 @@ jobs: CLI_ARM64_SHA256: ${{ needs.validate-source.outputs.arm64_sha256 }} CLI_RELEASE_ID: ${{ needs.validate-source.outputs.release_id }} steps: - - name: Require dedicated automation credential + - name: Revalidate trusted source outputs run: | set -euo pipefail - test -n "${AUTOMATION_TOKEN}" || { - echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' - exit 1 - } [[ "${CLI_REPOSITORY}" = harmonycloud/saola-cli ]] [[ "${CLI_COMMIT}" =~ ^[0-9a-f]{40}$ ]] [[ "${CLI_SOURCE_DATE_EPOCH}" =~ ^(0|[1-9][0-9]*)$ ]] @@ -209,22 +204,27 @@ jobs: ;; stable) [[ "${CLI_RELEASE_ID}" =~ ^[1-9][0-9]*$ ]] - [[ "${CLI_VERSION}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] + [[ "${CLI_VERSION}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ;; *) exit 1 ;; esac - - name: Check out dev with the dedicated token + - name: Check out dev with read-only credentials uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: dev fetch-depth: 0 - token: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + persist-credentials: false - name: Update the validated lock id: lock run: | set -euo pipefail git fetch origin dev git reset --hard origin/dev + case "${CLI_CHANNEL}" in + dev) lock_path='build/saola-cli-dev.lock' ;; + stable) lock_path='build/saola-cli-stable-candidate.lock' ;; + *) exit 1 ;; + esac candidate_lock="${RUNNER_TEMP}/saola-cli.lock" printf '%s\n' \ "repository=${CLI_REPOSITORY}" \ @@ -233,29 +233,41 @@ jobs: "channel=${CLI_CHANNEL}" \ "source_date_epoch=${CLI_SOURCE_DATE_EPOCH}" \ >"${candidate_lock}" - hack/saola-cli-lock.sh validate "${candidate_lock}" - if hack/saola-cli-lock.sh validate build/saola-cli.lock && - cmp -s "${candidate_lock}" build/saola-cli.lock; then + [[ "$(wc -l <"${candidate_lock}" | tr -d ' ')" -eq 5 ]] + grep -Fxq "repository=${CLI_REPOSITORY}" "${candidate_lock}" + grep -Fxq "version=${CLI_VERSION}" "${candidate_lock}" + grep -Fxq "commit=${CLI_COMMIT}" "${candidate_lock}" + grep -Fxq "channel=${CLI_CHANNEL}" "${candidate_lock}" + grep -Fxq "source_date_epoch=${CLI_SOURCE_DATE_EPOCH}" "${candidate_lock}" + if [[ -f "${lock_path}" ]] && + cmp -s "${candidate_lock}" "${lock_path}"; then echo 'changed=false' >>"${GITHUB_OUTPUT}" exit 0 fi - cp "${candidate_lock}" build/saola-cli.lock - hack/saola-cli-lock.sh validate build/saola-cli.lock + cp "${candidate_lock}" "${lock_path}" + cmp -s "${candidate_lock}" "${lock_path}" echo 'changed=true' >>"${GITHUB_OUTPUT}" + echo "path=${lock_path}" >>"${GITHUB_OUTPUT}" - name: Create auditable dev pull request and enable auto-merge if: steps.lock.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }} + LOCK_PATH: ${{ steps.lock.outputs.path }} run: | set -euo pipefail + test -n "${GH_TOKEN}" || { + echo 'OPENSAOLA_AUTOMATION_TOKEN is required; refusing to use GITHUB_TOKEN' >&2 + exit 1 + } + gh auth setup-git safe_version="${CLI_VERSION//[^0-9A-Za-z._-]/-}" branch="automation/saola-cli-${CLI_CHANNEL}-${safe_version}" git config user.name 'opensaola-automation' git config user.email 'opensaola-automation@users.noreply.github.com' - git checkout -B "${branch}" - git add build/saola-cli.lock - git commit -m "build: update bundled saola CLI to ${CLI_VERSION}" - git push --force-with-lease origin "HEAD:refs/heads/${branch}" + git -c core.hooksPath=/dev/null checkout -B "${branch}" + git add "${LOCK_PATH}" + git -c core.hooksPath=/dev/null commit --no-gpg-sign -m "build: update bundled saola CLI to ${CLI_VERSION}" + git -c core.hooksPath=/dev/null push --force-with-lease origin "HEAD:refs/heads/${branch}" pr="$(gh pr list --state open --base dev --head "${branch}" --json number --jq '.[0].number // empty')" body="$(printf '%s\n' \ 'Automated immutable Saola CLI lock update.' \ diff --git a/Dockerfile b/Dockerfile index 9a6b9a5..f610bf3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # Build the manager binary FROM --platform=$BUILDPLATFORM golang:1.26.4 AS builder -ARG TARGETOS=linux -ARG TARGETARCH=amd64 +ARG TARGETOS +ARG TARGETARCH ARG VERSION=dev ARG GIT_COMMIT=unknown ARG BUILD_DATE=unknown @@ -39,8 +39,8 @@ RUN curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/${TARGETOS}/${TAR # Build the architecture-matched saola CLI from the immutable named context. FROM --platform=$BUILDPLATFORM golang:1.26.4 AS saola-cli-builder -ARG TARGETOS=linux -ARG TARGETARCH=amd64 +ARG TARGETOS +ARG TARGETARCH ARG SAOLA_CLI_VERSION ARG SAOLA_CLI_COMMIT ARG SAOLA_CLI_SOURCE_DATE_EPOCH diff --git a/Makefile b/Makefile index 1f5230e..1920978 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,8 @@ endif # scaffolded by default. However, you might want to replace it to use other # tools. (i.e. podman) CONTAINER_TOOL ?= docker -SAOLA_CLI_LOCK ?= build/saola-cli.lock +SAOLA_CLI_CHANNEL ?= dev +SAOLA_CLI_LOCK ?= build/saola-cli-$(SAOLA_CLI_CHANNEL).lock SAOLA_CLI_LOCK_HELPER ?= hack/saola-cli-lock.sh override SAOLA_CLI_REPOSITORY = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) repository 2>/dev/null) override SAOLA_CLI_VERSION = $(shell $(SAOLA_CLI_LOCK_HELPER) get $(SAOLA_CLI_LOCK) version 2>/dev/null) @@ -28,9 +29,26 @@ override SAOLA_CLI_BUILD_ARGS = \ --build-arg SAOLA_CLI_SOURCE_DATE_EPOCH=$(SAOLA_CLI_SOURCE_DATE_EPOCH) define with-saola-cli-context - @source_repo="$${SAOLA_CLI_CONTEXT:-../saola-cli}"; \ + @source_repo="$${SAOLA_CLI_CONTEXT:-}"; \ + fetched_repo=''; \ + tmp_context=''; \ + cleanup() { \ + [[ -z "$$tmp_context" ]] || rm -rf "$$tmp_context"; \ + [[ -z "$$fetched_repo" ]] || rm -rf "$$fetched_repo"; \ + }; \ + trap cleanup EXIT; \ + if [[ -z "$$source_repo" ]]; then \ + if git -C ../saola-cli cat-file -e '$(SAOLA_CLI_COMMIT)^{commit}' 2>/dev/null; then \ + source_repo=../saola-cli; \ + else \ + fetched_repo="$$(mktemp -d "$${TMPDIR:-/tmp}/opensaola-saola-cli-repo.XXXXXX")"; \ + git -C "$$fetched_repo" init -q; \ + git -C "$$fetched_repo" remote add origin 'https://github.com/$(SAOLA_CLI_REPOSITORY).git'; \ + git -C "$$fetched_repo" fetch -q --depth=1 origin '$(SAOLA_CLI_COMMIT)' || { echo 'failed to fetch locked saola-cli commit' >&2; exit 1; }; \ + source_repo="$$fetched_repo"; \ + fi; \ + fi; \ tmp_context="$$(mktemp -d "$${TMPDIR:-/tmp}/opensaola-saola-cli.XXXXXX")"; \ - trap 'rm -rf "$$tmp_context"' EXIT; \ git -C "$$source_repo" cat-file -e '$(SAOLA_CLI_COMMIT)^{commit}' || { echo 'locked saola-cli commit is unavailable in source repo' >&2; exit 1; }; \ git -C "$$source_repo" archive '$(SAOLA_CLI_COMMIT)' | tar -x -C "$$tmp_context" || { echo 'failed to export locked saola-cli commit' >&2; exit 1; }; \ context="$$tmp_context"; \ diff --git a/build/saola-cli-dev.lock b/build/saola-cli-dev.lock new file mode 100644 index 0000000..fe037ef --- /dev/null +++ b/build/saola-cli-dev.lock @@ -0,0 +1,5 @@ +repository=harmonycloud/saola-cli +version=dev-ca8ab049ce73 +commit=ca8ab049ce73a79b5bd47c29ec91210042689102 +channel=dev +source_date_epoch=1783933287 diff --git a/build/saola-cli.lock b/build/saola-cli.lock deleted file mode 100644 index 92140fe..0000000 --- a/build/saola-cli.lock +++ /dev/null @@ -1,5 +0,0 @@ -repository=harmonycloud/saola-cli -version=dev-dfe685bacbeb -commit=dfe685bacbeb036a06316561a1e982813763e2e4 -channel=dev -source_date_epoch=1782812176 diff --git a/docs/release-process.md b/docs/release-process.md index 26910f3..e9693c6 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -55,28 +55,30 @@ The Helm chart default `image.pullPolicy` is `IfNotPresent` for reproducible rel ## Bundled Saola CLI Channels -`build/saola-cli.lock` is the sole source of the bundled CLI repository, version, full commit, channel, and reproducible source timestamp. The Docker workflow validates that lock, builds from a BuildKit named context pinned to the full commit, and records the CLI version and revision in OCI labels. Official images remain exactly `linux/amd64` and `linux/arm64` and include BuildKit provenance and SBOM attestations. +`build/saola-cli-dev.lock` is the dev image input, `build/saola-cli-stable-candidate.lock` is a soaked candidate that may exist on `dev`, and `build/saola-cli-stable.lock` is the only CLI input accepted by `master` and release tags. A normal `dev -> master` merge may carry the candidate file but cannot change which CLI a master image selects. Only the promotion workflow copies the exact candidate into the promoted stable lock. The Docker workflow builds from a BuildKit named context pinned to the selected full commit and records the CLI version and revision in OCI labels. Official images remain exactly `linux/amd64` and `linux/arm64` and include BuildKit provenance and SBOM attestations. The `saola-cli` repository may dispatch either of these immutable events: | Event | Accepted lock | Destination | |-------|---------------|-------------| | `saola-cli-dev` | `channel=dev`, `version=dev-<12-character commit prefix>` | Auto-merge PR into `dev` | -| `saola-cli-stable` | `channel=stable`, strict SemVer tag (prereleases allowed) | Auto-merge PR into `dev`, labelled `automation:saola-cli-stable` | +| `saola-cli-stable` | `channel=stable`, final `vMAJOR.MINOR.PATCH` tag | Update the candidate lock on `dev`, labelled `automation:saola-cli-stable` | Both automatic and manual dispatches must supply `repository`, `channel`, `version`, the full 40-character `commit`, `source_date_epoch`, and lowercase 64-character SHA-256 checksums for both Linux artifacts. The update workflow binds the epoch to the commit timestamp, rebuilds both artifacts, and for stable releases also compares the payload with the published `SHA256SUMS` asset before writing the strict five-field lock. It never pushes directly to `dev` or `master`. -Stable automation accepts only a same-name, non-draft GitHub Release that is currently the latest published non-draft release by `published_at` (prereleases included); replaying an older valid tag fails closed. The hourly promotion workflow selects the newest merged stable-labelled update PR, reads `build/saola-cli.lock` from that PR's exact merge commit (not the later `dev` head), and waits 24 hours by default from `mergedAt`. It requires successful `CI` and `Docker` push workflow runs for that same merge SHA. It exits without a PR only when `master` already has the identical complete five-field lock; otherwise it opens a lock-only auto-merge PR into `master`. Promotion never resolves `latest`, a snapshot, or another floating CLI revision. +Stable automation accepts only a same-name, non-draft, non-prerelease GitHub Release that is currently the latest published final release by `published_at`; replaying an older valid tag fails closed. The hourly promotion workflow selects the newest merged stable-labelled update PR, reads `build/saola-cli-stable-candidate.lock` from that PR's exact merge commit (not the later `dev` head), and waits 24 hours by default from `mergedAt`. It requires successful `CI`, the Docker workflow, and the concrete `Build stable candidate` check for that exact merge SHA. It exits without a PR only when `master` already has the identical complete stable lock; otherwise it opens a PR that writes only `build/saola-cli-stable.lock`. Master PR checks reject any stable-lock change not authored by the configured automation login from a deterministic promotion branch. Promotion never resolves `latest`, a snapshot, a prerelease, or another floating CLI revision. + +The initial bootstrap is explicit and fail-closed: the workflow files may first enter the default branch while no final Saola CLI release exists, but master pushes publish no image and release tags fail until a promoted stable lock exists. After the first final Saola CLI Release, the normal candidate, exact-build, soak, and promotion path creates that lock without manual file edits. ### Required GitHub configuration These files do not activate the automation by themselves. Repository administrators must configure all of the following externally: -- Bootstrap both `saola-cli-update.yml` and `saola-cli-promote.yml` on the repository's default branch, `master`, before sending a `repository_dispatch` or expecting a scheduled run. GitHub only routes these event types to workflows present on the default branch. +- Bootstrap both `saola-cli-update.yml` and `saola-cli-promote.yml` on the repository's default branch, `master`, before sending a `repository_dispatch` or expecting a scheduled run. GitHub only routes these event types to workflows present on the default branch. During this bootstrap window, missing `build/saola-cli-stable.lock` deliberately suppresses master image publication and blocks tags instead of falling back to dev. - Create `OPENSAOLA_AUTOMATION_TOKEN` as an Actions secret backed by a dedicated fine-grained bot PAT. Its repository permissions must include **Contents: read and write**, **Pull requests: read and write**, and **Actions: read**. It must also have repository **Metadata: read** and permission to apply the pre-created stable label (for fine-grained PATs, label operations are covered by Pull requests write plus Metadata read). The workflows fail closed when it is absent and deliberately do not substitute `GITHUB_TOKEN`, because bot PRs must trigger normal downstream CI. - Enable repository auto-merge and protect both `dev` and `master` as PR-only branches. Require the relevant CI and Docker checks and forbid direct pushes, including from the automation bot. - Pre-create the exact label `automation:saola-cli-stable` and restrict who may apply it. Promotion treats that label as the stable candidate boundary. -- Set repository variable `OPENSAOLA_AUTOMATION_LOGIN` to the dedicated bot login. Promotion fails closed unless the stable PR author matches it, the head branch is deterministic, and the PR changes only `build/saola-cli.lock`. +- Set repository variable `OPENSAOLA_AUTOMATION_LOGIN` to the dedicated bot login. Candidate selection fails closed unless its PR author and candidate-only diff match the contract; master checks likewise allow `build/saola-cli-stable.lock` to change only in the bot's deterministic promotion PR. - Keep the default soak at 24 hours for scheduled runs. A manual run may explicitly choose another non-negative soak value for an authorized incident response; that exception should be auditable. - Configure an operational stable-release denylist or rollback decision outside these workflows before activation. To stop a candidate, disable auto-merge/remove the stable label or close its promotion PR. Automated stable events accept only the latest published release; rollback must therefore use a normal reviewed lock-only PR pointing to a previously verified stable version. Do not edit the lock to a floating ref. diff --git a/docs/release-process_zh.md b/docs/release-process_zh.md index 7b3eaae..f615ace 100644 --- a/docs/release-process_zh.md +++ b/docs/release-process_zh.md @@ -55,28 +55,30 @@ Helm Chart 默认 `image.pullPolicy` 为 `IfNotPresent`,保证正式版本部 ## 内置 Saola CLI 渠道 -`build/saola-cli.lock` 是内置 CLI 仓库、版本、完整 commit、渠道和可复现时间戳的唯一来源。Docker workflow 会先严格校验该锁文件,再用固定到完整 commit 的 BuildKit named context 构建,并把 CLI 版本和 revision 写入 OCI labels。官方镜像平台仍严格限定为 `linux/amd64` 和 `linux/arm64`,同时生成 BuildKit provenance 与 SBOM attestations。 +`build/saola-cli-dev.lock` 是 dev 镜像输入,`build/saola-cli-stable-candidate.lock` 是可以保存在 `dev` 上等待观察的 stable 候选,`build/saola-cli-stable.lock` 才是 `master` 与正式 tag 唯一允许使用的 CLI 输入。普通 `dev -> master` 合并即使带入 candidate 文件,也不能改变 master 镜像选择的 CLI;只有 promotion workflow 会把精确候选复制为正式 stable lock。Docker workflow 使用固定到所选完整 commit 的 BuildKit named context 构建,并把 CLI 版本和 revision 写入 OCI labels。官方镜像平台仍严格限定为 `linux/amd64` 和 `linux/arm64`,同时生成 BuildKit provenance 与 SBOM attestations。 `saola-cli` 仓库可以发送以下两种不可变事件: | 事件 | 可接受的锁 | 目标 | |------|------------|------| | `saola-cli-dev` | `channel=dev`、`version=dev-` | 自动合并 PR 到 `dev` | -| `saola-cli-stable` | `channel=stable`、严格 SemVer tag(允许 prerelease) | 自动合并 PR 到 `dev`,并添加 `automation:saola-cli-stable` label | +| `saola-cli-stable` | `channel=stable`、最终版 `vMAJOR.MINOR.PATCH` tag | 更新 `dev` 上的 candidate lock,并添加 `automation:saola-cli-stable` label | 自动 dispatch 和手动 dispatch 都必须提供 `repository`、`channel`、`version`、完整 40 位 `commit`、`source_date_epoch`,以及两个 Linux 产物各自的小写 64 位 SHA-256。更新 workflow 会把 epoch 绑定到 commit 时间戳、重建两个产物;stable 还会把 payload 与 Release 的 `SHA256SUMS` 资产逐项比较,然后才写入严格五字段 lock。该 workflow 永不直接 push `dev` 或 `master`。 -stable 自动化只接受同名、非 draft,且按 `published_at` 当前最新的非 draft GitHub Release(包含 prerelease);重放旧的合法 tag 也会 fail closed。每小时运行的晋级 workflow 会选择最新一个已合并、带稳定 label 的更新 PR,从该 PR 的精确 merge commit 中读取 `build/saola-cli.lock`(不会读取之后继续前进的 `dev` HEAD),并从 `mergedAt` 起默认等待 24 小时。只有同一 merge SHA 上的 `CI` 和 `Docker` push workflow 都成功后才允许晋级。仅当 `master` 已有逐字节一致的完整五字段 lock 时才 no-op;否则只创建修改 lock 的 `master` auto-merge PR。晋级过程绝不解析 `latest`、snapshot 或其他浮动 CLI revision。 +stable 自动化只接受同名、非 draft、非 prerelease,且按 `published_at` 当前最新的最终版 GitHub Release;重放旧的合法 tag 也会 fail closed。每小时运行的晋级 workflow 会选择最新一个已合并、带稳定 label 的更新 PR,从该 PR 的精确 merge commit 中读取 `build/saola-cli-stable-candidate.lock`(不会读取之后继续前进的 `dev` HEAD),并从 `mergedAt` 起默认等待 24 小时。只有同一 merge SHA 上的 `CI`、Docker workflow 与具体的 `Build stable candidate` check 都成功后才允许晋级。仅当 `master` 已有逐字节一致的完整 stable lock 时才 no-op;否则只创建写入 `build/saola-cli-stable.lock` 的 PR。master PR check 会拒绝非指定机器人、非确定性 promotion 分支的 stable lock 改动。晋级过程绝不解析 `latest`、snapshot、prerelease 或其他浮动 CLI revision。 + +首次启用采用显式 fail-closed bootstrap:可以先把 workflow 文件合入默认分支,而此时如果还没有 Saola CLI 正式 Release,master push 不发布镜像、正式 tag 直接失败,也绝不会回退到 dev lock。首个正式 Release 出现后,candidate、精确构建、观察期和 promotion 流程会自动创建 stable lock,不需要人工编辑文件。 ### 必需的 GitHub 外部配置 仅合入这些文件不会自动启用整条链路。仓库管理员还必须在 GitHub 外部完成以下配置: -- 发送 `repository_dispatch` 或等待 schedule 之前,必须先把 `saola-cli-update.yml` 和 `saola-cli-promote.yml` 部署到仓库默认分支 `master`;GitHub 只会把这两类事件路由给默认分支上已经存在的 workflow。 +- 发送 `repository_dispatch` 或等待 schedule 之前,必须先把 `saola-cli-update.yml` 和 `saola-cli-promote.yml` 部署到仓库默认分支 `master`;GitHub 只会把这两类事件路由给默认分支上已经存在的 workflow。bootstrap 期间缺少 `build/saola-cli-stable.lock` 会主动禁止 master 镜像发布并阻断 tag,绝不回退到 dev。 - 创建 Actions secret `OPENSAOLA_AUTOMATION_TOKEN`,其值必须是专用机器人 fine-grained PAT。仓库权限至少包括 **Contents: read and write**、**Pull requests: read and write**、**Actions: read**,还需要 **Metadata: read** 以及施加预创建 stable label 的权限(fine-grained PAT 的 label 操作由 Pull requests write 与 Metadata read 覆盖)。缺少该 secret 时 workflow 会 fail closed,并且不会回退到 `GITHUB_TOKEN`,这样机器人 PR 才能正常触发后续 CI。 - 开启 repository auto-merge,并把 `dev`、`master` 都设置成只允许 PR 的保护分支;要求相关 CI 与 Docker checks,且包括自动化机器人在内都禁止直接 push。 - 预先创建固定 label `automation:saola-cli-stable`,并限制其施加权限;晋级 workflow 把它作为 stable 候选边界。 -- 设置仓库变量 `OPENSAOLA_AUTOMATION_LOGIN` 为专用机器人 login。stable PR 作者、确定性 head branch 或“只改 `build/saola-cli.lock`”任一不匹配时,晋级都会 fail closed。 +- 设置仓库变量 `OPENSAOLA_AUTOMATION_LOGIN` 为专用机器人 login。candidate PR 的作者与 candidate-only diff 必须匹配;master check 也只允许该机器人从确定性 promotion 分支修改 `build/saola-cli-stable.lock`。 - 定时任务保持默认 24 小时 soak。手动运行可为经授权的事故处置显式指定其他非负时长,但应保留可审计记录。 - 启用前在 workflow 外建立 stable release denylist 或回滚决策流程。需要阻止候选时,应关闭 auto-merge、移除 stable label 或关闭 promotion PR。自动 stable 事件只接受最新 published release;回滚必须通过普通受审 lock-only PR 指向此前已验证的稳定版本,禁止把 lock 改成浮动 ref。 diff --git a/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md b/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md index 130230e..87944b7 100644 --- a/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md +++ b/docs/superpowers/plans/2026-07-13-bundled-saola-cli-automation.md @@ -14,7 +14,7 @@ - Do not push, tag, publish, configure secrets, or change external repositories during local implementation. - Official image platforms are exactly `linux/amd64` and `linux/arm64`. - CLI builds use `CGO_ENABLED=0`, `-trimpath`, and the CLI module's existing version ldflags. -- `dev` may consume CLI snapshots; `master` may consume only stable `vMAJOR.MINOR.PATCH` or prerelease tags. +- `dev` may consume CLI snapshots; `master` may consume only final stable `vMAJOR.MINOR.PATCH` tags. - Every OpenSaola image records the exact CLI version and full 40-character commit. - Automation fails closed on malformed versions, revisions, repositories, channels, missing checksums, failed tests, or absent credentials. - Runtime remains UID/GID 65532 with `/app/manager` as the unchanged entrypoint and `/usr/local/bin/saola` mode `0555`. @@ -46,7 +46,9 @@ ### Task 2: OpenSaola CLI lock contract and image integration **Files:** -- Create: `opensaola/build/saola-cli.lock` +- Create: `opensaola/build/saola-cli-dev.lock` +- Create through stable dispatch on `dev`: `opensaola/build/saola-cli-stable-candidate.lock` +- Create only through promotion on `master`: `opensaola/build/saola-cli-stable.lock` - Create: `opensaola/hack/saola-cli-lock.sh` - Create: `opensaola/hack/saola-cli-lock_test.sh` - Modify: `opensaola/Dockerfile` diff --git a/hack/saola-cli-lock.sh b/hack/saola-cli-lock.sh index 4fab9ac..aad696d 100755 --- a/hack/saola-cli-lock.sh +++ b/hack/saola-cli-lock.sh @@ -15,21 +15,8 @@ die() { validate_stable_version() { local version="$1" - local prerelease identifier - [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] || return 1 - [[ "${version}" == *-* ]] || return 0 - - prerelease="${version#*-}" - while [[ -n "${prerelease}" ]]; do - identifier="${prerelease%%.*}" - [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || return 1 - if [[ "${prerelease}" == *.* ]]; then - prerelease="${prerelease#*.}" - else - prerelease='' - fi - done + [[ "${version}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] } parse_lock() { @@ -94,7 +81,7 @@ parse_lock() { [[ "${version#dev-}" == "${commit:0:12}" ]] || die 'dev version must match the commit prefix' ;; stable) - validate_stable_version "${version}" || die 'stable version must be a SemVer release or prerelease tag without build metadata' + validate_stable_version "${version}" || die 'stable version must be a final vMAJOR.MINOR.PATCH release tag' ;; *) die 'channel must be dev or stable' diff --git a/hack/saola-cli-lock_test.sh b/hack/saola-cli-lock_test.sh index c29727a..308ba9b 100755 --- a/hack/saola-cli-lock_test.sh +++ b/hack/saola-cli-lock_test.sh @@ -71,12 +71,16 @@ assert_rejected "${bad_dev_version}" stable_lock="${tmp_dir}/stable.lock" write_lock "${stable_lock}" \ 'repository=harmonycloud/saola-cli' \ - 'version=v1.2.3-rc.1' \ + 'version=v1.2.3' \ "commit=${commit}" \ 'channel=stable' \ 'source_date_epoch=1782812176' "${helper}" validate "${stable_lock}" +stable_prerelease="${tmp_dir}/stable-prerelease.lock" +sed 's/^version=.*/version=v1.2.3-rc.1/' "${stable_lock}" >"${stable_prerelease}" +assert_rejected "${stable_prerelease}" + bad_stable_version="${tmp_dir}/bad-stable-version.lock" sed 's/^version=.*/version=dev-dfe685bacbeb/' "${stable_lock}" >"${bad_stable_version}" assert_rejected "${bad_stable_version}" @@ -109,13 +113,41 @@ fi [[ ! -e "${tmp_dir}/touch-pwned" ]] || fail 'get key was executed' repo_root="$(cd "${script_dir}/.." && pwd)" +dev_lock_file="${repo_root}/build/saola-cli-dev.lock" +stable_lock_file="${repo_root}/build/saola-cli-stable.lock" +stable_candidate_file="${repo_root}/build/saola-cli-stable-candidate.lock" +[[ -f "${dev_lock_file}" ]] || fail 'missing dedicated dev lock' +[[ ! -e "${repo_root}/build/saola-cli.lock" ]] || fail 'legacy shared lock still exists' +"${helper}" validate "${dev_lock_file}" +[[ "$("${helper}" get "${dev_lock_file}" channel)" = dev ]] || fail 'dev lock channel is not dev' +build_version="$("${helper}" get "${dev_lock_file}" version)" +build_commit="$("${helper}" get "${dev_lock_file}" commit)" +build_epoch="$("${helper}" get "${dev_lock_file}" source_date_epoch)" +grep -Eq '^SAOLA_CLI_LOCK \?= build/saola-cli-\$\(SAOLA_CLI_CHANNEL\)\.lock$' "${repo_root}/Makefile" || fail 'Makefile does not select a channel-specific lock' +[[ ! -e "${stable_lock_file}" ]] || { + "${helper}" validate "${stable_lock_file}" + [[ "$("${helper}" get "${stable_lock_file}" channel)" = stable ]] || fail 'stable lock channel is not stable' +} +[[ ! -e "${stable_candidate_file}" ]] || { + "${helper}" validate "${stable_candidate_file}" + [[ "$("${helper}" get "${stable_candidate_file}" channel)" = stable ]] || fail 'stable candidate lock channel is not stable' +} +common_dir="$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir)" +main_repo_root="$(cd "${common_dir}/.." && pwd)" +test_source_repo="${SAOLA_CLI_TEST_REPOSITORY:-$(dirname "${main_repo_root}")/saola-cli}" +if ! git -C "${test_source_repo}" cat-file -e "${build_commit}^{commit}" 2>/dev/null; then + test_source_repo="${tmp_dir}/saola-cli-source" + git -C "${test_source_repo}" init -q + git -C "${test_source_repo}" remote add origin https://github.com/harmonycloud/saola-cli.git + git -C "${test_source_repo}" fetch -q --depth=1 origin "${build_commit}" +fi docker_build_output="$(TMPDIR="${tmp_dir}" make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test)" [[ "${docker_build_output}" != *'git#'* ]] || fail 'docker-build still uses an unusable remote git#SHA context' docker_build_context="$(sed -n 's/.*--build-context saola-cli=\([^ ]*\).*/\1/p' <<<"${docker_build_output}")" [[ "${docker_build_context}" == /* ]] || fail 'docker-build default context is not an absolute temporary local directory' [[ ! -e "${docker_build_context}" ]] || fail 'docker-build temporary context was not cleaned after success' -[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_VERSION=dev-dfe685bacbeb"* ]] || fail 'docker-build is missing the locked CLI version' -[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'docker-build is missing the locked CLI commit' +[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_VERSION=${build_version}"* ]] || fail 'docker-build is missing the locked CLI version' +[[ "${docker_build_output}" == *"--build-arg SAOLA_CLI_COMMIT=${build_commit}"* ]] || fail 'docker-build is missing the locked CLI commit' [[ "${docker_build_output}" == *'--platform=linux/amd64'* ]] || fail 'docker-build default platform is not linux/amd64' if make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test DOCKER_PLATFORM=linux/s390x >/dev/null 2>&1; then fail 'docker-build accepted an unsupported platform' @@ -136,16 +168,16 @@ override_output="$(make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IM SAOLA_CLI_SOURCE_DATE_EPOCH=1 \ 'SAOLA_CLI_BUILD_ARGS=--build-arg PWNED=true')" [[ "${override_output}" != *'attacker/saola-cli'* && "${override_output}" != *'git#'* ]] || fail 'command line overrode the lock-derived repository or selected a remote context' -[[ "${override_output}" == *'--build-arg SAOLA_CLI_VERSION=dev-dfe685bacbeb'* ]] || fail 'command line overrode the lock-derived version' -[[ "${override_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'command line overrode the lock-derived commit build arg' -[[ "${override_output}" == *'--build-arg SAOLA_CLI_SOURCE_DATE_EPOCH=1782812176'* ]] || fail 'command line overrode the lock-derived source epoch' +[[ "${override_output}" == *"--build-arg SAOLA_CLI_VERSION=${build_version}"* ]] || fail 'command line overrode the lock-derived version' +[[ "${override_output}" == *"--build-arg SAOLA_CLI_COMMIT=${build_commit}"* ]] || fail 'command line overrode the lock-derived commit build arg' +[[ "${override_output}" == *"--build-arg SAOLA_CLI_SOURCE_DATE_EPOCH=${build_epoch}"* ]] || fail 'command line overrode the lock-derived source epoch' [[ "${override_output}" != *'PWNED=true'* ]] || fail 'command line overrode SAOLA_CLI_BUILD_ARGS' -local_context_output="$(make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test SAOLA_CLI_CONTEXT=../saola-cli)" -[[ "${local_context_output}" != *'--build-context saola-cli=../saola-cli'* ]] || fail 'explicit SAOLA_CLI_CONTEXT was passed through without a clean archive' +local_context_output="$(make -s -C "${repo_root}" docker-build CONTAINER_TOOL=echo IMG=controller:test SAOLA_CLI_CONTEXT="${test_source_repo}")" +[[ "${local_context_output}" != *"--build-context saola-cli=${test_source_repo}"* ]] || fail 'explicit SAOLA_CLI_CONTEXT was passed through without a clean archive' local_build_context="$(sed -n 's/.*--build-context saola-cli=\([^ ]*\).*/\1/p' <<<"${local_context_output}")" [[ "${local_build_context}" == /* && ! -e "${local_build_context}" ]] || fail 'explicit source repo did not use a cleaned temporary archive' -[[ "${local_context_output}" == *"--build-arg SAOLA_CLI_COMMIT=${commit}"* ]] || fail 'local context override changed the locked commit metadata' +[[ "${local_context_output}" == *"--build-arg SAOLA_CLI_COMMIT=${build_commit}"* ]] || fail 'local context override changed the locked commit metadata' platform_override_output="$(make -s -C "${repo_root}" docker-buildx CONTAINER_TOOL=echo IMG=controller:test PLATFORMS=linux/s390x)" [[ "${platform_override_output}" == *'--platform=linux/amd64,linux/arm64'* ]] || fail 'command line overrode the fixed buildx platforms' @@ -177,24 +209,24 @@ printf 'INSPECTED_CONTEXT=%s\n' "${context}" EOF chmod +x "${inspector}" -inspected_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ +inspected_output="$(EXPECTED_REPOSITORY="${test_source_repo}" EXPECTED_COMMIT="${build_commit}" TMPDIR="${tmp_dir}" \ make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test)" inspected_context="${inspected_output#INSPECTED_CONTEXT=}" [[ "${inspected_context}" == /* ]] || fail 'inspector did not receive an absolute temporary local context' [[ ! -e "${inspected_context}" ]] || fail 'inspected docker-build context was not cleaned' -inspected_buildx_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ +inspected_buildx_output="$(EXPECTED_REPOSITORY="${test_source_repo}" EXPECTED_COMMIT="${build_commit}" TMPDIR="${tmp_dir}" \ make -s -C "${repo_root}" docker-buildx CONTAINER_TOOL="${inspector}" IMG=controller:test)" inspected_buildx_context="${inspected_buildx_output#INSPECTED_CONTEXT=}" [[ ! -e "${inspected_buildx_context}" ]] || fail 'inspected docker-buildx context was not cleaned' -inspected_explicit_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" TMPDIR="${tmp_dir}" \ - make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test SAOLA_CLI_CONTEXT=../saola-cli)" +inspected_explicit_output="$(EXPECTED_REPOSITORY="${test_source_repo}" EXPECTED_COMMIT="${build_commit}" TMPDIR="${tmp_dir}" \ + make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test SAOLA_CLI_CONTEXT="${test_source_repo}")" inspected_explicit_context="${inspected_explicit_output#INSPECTED_CONTEXT=}" [[ "${inspected_explicit_context}" == "${tmp_dir}"/opensaola-saola-cli.* ]] || fail 'explicit source repo did not produce a temporary archive context' [[ ! -e "${inspected_explicit_context}" ]] || fail 'explicit source repo archive was not cleaned' -if failed_output="$(EXPECTED_REPOSITORY="${repo_root}/../saola-cli" EXPECTED_COMMIT="${commit}" FAIL_AFTER_INSPECT=true TMPDIR="${tmp_dir}" \ +if failed_output="$(EXPECTED_REPOSITORY="${test_source_repo}" EXPECTED_COMMIT="${build_commit}" FAIL_AFTER_INSPECT=true TMPDIR="${tmp_dir}" \ make -s -C "${repo_root}" docker-build CONTAINER_TOOL="${inspector}" IMG=controller:test 2>&1)"; then fail 'expected fake container failure' fi @@ -225,6 +257,11 @@ if compgen -G "${tmp_dir}/opensaola-saola-cli.*" >/dev/null; then fi dockerfile="${repo_root}/Dockerfile" +[[ "$(grep -Ec '^ARG TARGETOS$' "${dockerfile}")" -eq 2 ]] || fail 'Dockerfile does not use BuildKit TARGETOS for both builders' +[[ "$(grep -Ec '^ARG TARGETARCH$' "${dockerfile}")" -eq 2 ]] || fail 'Dockerfile does not use BuildKit TARGETARCH for both builders' +if grep -Eq '^ARG TARGET(OS|ARCH)=' "${dockerfile}"; then + fail 'Dockerfile overrides automatic BuildKit target platform arguments' +fi cli_stage_line="$(grep -n '^FROM .* AS saola-cli-builder$' "${dockerfile}" | cut -d: -f1)" kubectl_download_line="$(grep -n '^RUN curl -LO ' "${dockerfile}" | cut -d: -f1)" [[ -n "${cli_stage_line}" && -n "${kubectl_download_line}" && "${cli_stage_line}" -gt "${kubectl_download_line}" ]] || fail 'saola-cli stage interrupts the manager/kubectl builder stage' @@ -248,6 +285,7 @@ assert_workflow_excludes() { update_workflow="${repo_root}/.github/workflows/saola-cli-update.yml" promote_workflow="${repo_root}/.github/workflows/saola-cli-promote.yml" docker_workflow="${repo_root}/.github/workflows/docker.yml" +helm_workflow="${repo_root}/.github/workflows/helm-chart.yml" [[ -f "${update_workflow}" ]] || fail 'missing saola-cli update workflow' assert_workflow_contains "${update_workflow}" 'update workflow does not accept both dispatch event types' 'types:.*saola-cli-dev.*saola-cli-stable' @@ -277,6 +315,7 @@ assert_workflow_contains "${update_workflow}" 'stable event is not bound to its assert_workflow_contains "${update_workflow}" 'stable tag is not peeled before commit comparison' '\^\{\}' assert_workflow_contains "${update_workflow}" 'stable event does not query GitHub Releases' 'repos/.*/releases\?per_page=' assert_workflow_contains "${update_workflow}" 'stable event does not reject draft releases' 'draft.*false' +assert_workflow_contains "${update_workflow}" 'stable event does not reject prereleases' 'prerelease.*false' assert_workflow_contains "${update_workflow}" 'stable event is not gated on the latest published release' 'published_at.*max_by|max_by.*published_at' assert_workflow_contains "${update_workflow}" 'update runs are not globally serialized for dev' 'group: saola-cli-update-dev' assert_workflow_contains "${update_workflow}" 'update workflow does not refresh the dev baseline before branching' 'fetch origin dev' @@ -284,12 +323,26 @@ assert_workflow_contains "${update_workflow}" 'update workflow does not rebuild assert_workflow_contains "${update_workflow}" 'update workflow does not generate release checksums' 'release-build release-checksums' assert_workflow_contains "${update_workflow}" 'update workflow does not compare the rebuilt amd64 checksum' 'actual_amd64.*CLI_AMD64_SHA256' assert_workflow_contains "${update_workflow}" 'update workflow does not compare the rebuilt arm64 checksum' 'actual_arm64.*CLI_ARM64_SHA256' -assert_workflow_contains "${update_workflow}" 'update workflow does not validate the lock it writes' 'saola-cli-lock\.sh.*validate' +assert_workflow_contains "${update_workflow}" 'update workflow does not validate generated lock fields inline' 'grep -Fxq.*candidate_lock' +assert_workflow_contains "${update_workflow}" 'update workflow does not verify the copied lock byte-for-byte' 'cmp -s.*candidate_lock.*lock_path' +assert_workflow_contains "${update_workflow}" 'dev updates do not target the dedicated dev lock' 'build/saola-cli-dev\.lock' +assert_workflow_contains "${update_workflow}" 'stable updates do not target the dedicated candidate lock' 'build/saola-cli-stable-candidate\.lock' +assert_workflow_excludes "${update_workflow}" 'stable update can write the promoted master lock' "stable\) lock_path='build/saola-cli-stable\.lock'" assert_workflow_contains "${update_workflow}" 'stable update PR label contract is missing' 'automation:saola-cli-stable' assert_workflow_contains "${update_workflow}" 'update workflow does not use concurrency' '^concurrency:' assert_workflow_contains "${update_workflow}" 'update workflow does not target dev' '(--base|base:) dev' assert_workflow_contains "${update_workflow}" 'update workflow does not enable auto-merge' 'pr merge.*--auto.*--squash' assert_workflow_excludes "${update_workflow}" 'update workflow directly pushes a protected branch' 'git push[^#]*(origin )?(dev|master)([[:space:]]|$)' +update_job="$(sed -n '/^ update-dev:/,$p' "${update_workflow}")" +[[ "${update_job}" != *'AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }}'* ]] || fail 'automation token is exposed at update-dev job scope' +update_checkout="$(sed -n '/name: Check out dev/,/name: Update the validated lock/p' "${update_workflow}")" +[[ "${update_checkout}" == *'persist-credentials: false'* ]] || fail 'dev checkout persists credentials' +[[ "${update_checkout}" != *'OPENSAOLA_AUTOMATION_TOKEN'* ]] || fail 'dev checkout receives the write token' +lock_update_step="$(sed -n '/name: Update the validated lock/,/name: Create auditable dev pull request/p' "${update_workflow}")" +[[ "${lock_update_step}" != *'OPENSAOLA_AUTOMATION_TOKEN'* ]] || fail 'unprivileged lock update receives the write token' +[[ "${lock_update_step}" != *'hack/saola-cli-lock.sh'* ]] || fail 'lock update executes dev branch code' +mutation_step="$(sed -n '/name: Create auditable dev pull request/,$p' "${update_workflow}")" +[[ "${mutation_step}" == *'GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }}'* ]] || fail 'mutation step is missing the scoped write token' [[ -f "${promote_workflow}" ]] || fail 'missing saola-cli promotion workflow' assert_workflow_contains "${promote_workflow}" 'promotion workflow is not scheduled' '^ schedule:' @@ -297,21 +350,47 @@ assert_workflow_contains "${promote_workflow}" 'promotion workflow is not manual assert_workflow_contains "${promote_workflow}" 'promotion does not select the stable automation label' 'automation:saola-cli-stable' assert_workflow_contains "${promote_workflow}" 'promotion does not verify automation author' 'OPENSAOLA_AUTOMATION_LOGIN|EXPECTED_AUTOMATION_LOGIN' assert_workflow_contains "${promote_workflow}" 'promotion does not verify deterministic head branch' 'headRefName' -assert_workflow_contains "${promote_workflow}" 'promotion does not enforce lock-only file changes' 'build/saola-cli\.lock' +assert_workflow_contains "${promote_workflow}" 'promotion does not enforce candidate-only file changes' 'build/saola-cli-stable-candidate\.lock' +assert_workflow_contains "${promote_workflow}" 'promotion does not reject prereleases' 'prerelease.*false' assert_workflow_contains "${promote_workflow}" 'promotion does not revalidate latest published release' 'published_at.*max_by|max_by.*published_at' assert_workflow_contains "${promote_workflow}" 'promotion does not revalidate Release checksums' 'SHA256SUMS' assert_workflow_contains "${promote_workflow}" 'promotion does not use the stable PR merge commit lock' 'mergeCommit\.oid' -assert_workflow_contains "${promote_workflow}" 'promotion does not read the candidate lock at its merge SHA' 'contents/build/saola-cli\.lock\?ref=' +assert_workflow_contains "${promote_workflow}" 'promotion does not read the candidate lock at its merge SHA' 'contents/build/saola-cli-stable-candidate\.lock\?ref=' assert_workflow_contains "${promote_workflow}" 'promotion does not enforce the default 24 hour soak' 'SOAK_HOURS:.*24' assert_workflow_contains "${promote_workflow}" 'promotion does not verify CI on the exact merge SHA' 'workflows/ci\.yml/runs.*head_sha' assert_workflow_contains "${promote_workflow}" 'promotion does not verify Docker on the exact merge SHA' 'workflows/docker\.yml/runs.*head_sha' +assert_workflow_contains "${promote_workflow}" 'promotion does not require the exact stable candidate build check' 'Build stable candidate' assert_workflow_contains "${promote_workflow}" 'promotion does not target master' '(--base|base:) master' assert_workflow_contains "${promote_workflow}" 'promotion does not enable auto-merge' 'pr merge.*--auto.*--squash' -assert_workflow_contains "${promote_workflow}" 'promotion no-op does not compare the complete validated lock' 'cmp -s.*candidate_lock.*build/saola-cli\.lock' +assert_workflow_contains "${promote_workflow}" 'promotion no-op does not compare the complete validated lock' 'cmp -s.*candidate_lock.*build/saola-cli-stable\.lock' assert_workflow_excludes "${promote_workflow}" 'promotion resolves a floating CLI revision' '(version|ref|tag)=(latest|snapshot)' assert_workflow_excludes "${promote_workflow}" 'promotion directly pushes master' 'git push[^#]*(origin )?master([[:space:]]|$)' +[[ "$(sed -n '/^ promote:/,$p' "${promote_workflow}")" != *'AUTOMATION_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }}'* ]] || fail 'automation token is exposed at promote job scope' +promote_checkout="$(sed -n '/name: Check out master/,/name: Validate and promote/p' "${promote_workflow}")" +[[ "${promote_checkout}" == *'persist-credentials: false'* ]] || fail 'master checkout persists credentials' +[[ "${promote_checkout}" != *'OPENSAOLA_AUTOMATION_TOKEN'* ]] || fail 'master checkout receives the write token' +promote_mutation="$(sed -n '/name: Validate and promote/,$p' "${promote_workflow}")" +[[ "${promote_mutation}" == *'GH_TOKEN: ${{ secrets.OPENSAOLA_AUTOMATION_TOKEN }}'* ]] || fail 'promotion mutation step is missing the scoped write token' assert_workflow_contains "${docker_workflow}" 'Docker workflow does not validate the CLI lock' 'saola-cli-lock\.sh.*validate' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not isolate pull request builds' '^ pr-build:' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not isolate publishing builds' '^ publish:' +pr_build_job="$(sed -n '/^ pr-build:/,/^ publish:/p' "${docker_workflow}")" +[[ "${pr_build_job}" == *'contents: read'* ]] || fail 'Docker PR job is not contents-read-only' +[[ "${pr_build_job}" != *'packages: write'* ]] || fail 'Docker PR job can write packages' +[[ "${pr_build_job}" == *'persist-credentials: false'* ]] || fail 'Docker PR checkout persists credentials' +publish_job="$(sed -n '/^ publish:/,$p' "${docker_workflow}")" +[[ "${publish_job}" == *'packages: write'* ]] || fail 'Docker publish job cannot write packages' +[[ "${publish_job}" == *'persist-credentials: false'* ]] || fail 'Docker publish checkout persists credentials' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not select the dedicated dev lock' 'build/saola-cli-dev\.lock' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not select the dedicated stable lock' 'build/saola-cli-stable\.lock' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not build the dedicated stable candidate' 'build/saola-cli-stable-candidate\.lock' +assert_workflow_contains "${docker_workflow}" 'Docker workflow is missing the stable candidate check' 'name: Build stable candidate' +assert_workflow_contains "${docker_workflow}" 'Docker push evidence can be superseded before promotion' 'group:.*github\.sha' +assert_workflow_contains "${docker_workflow}" 'master PRs do not gate stable lock mutation to promotion branches' 'automation/promote-saola-cli-' +assert_workflow_contains "${docker_workflow}" 'master bootstrap is not explicit' 'bootstrap=true' +assert_workflow_contains "${docker_workflow}" 'master bootstrap can publish without a stable lock' 'publish=false' +assert_workflow_contains "${docker_workflow}" 'Docker workflow does not fail closed on lock channel mismatch' 'expected_channel' assert_workflow_contains "${docker_workflow}" 'Docker workflow does not checkout locked CLI source' 'repository: harmonycloud/saola-cli' assert_workflow_contains "${docker_workflow}" 'Docker workflow checkout does not use locked CLI commit' 'ref:.*steps\.cli\.outputs\.commit' assert_workflow_contains "${docker_workflow}" 'Docker workflow checkout path is not isolated' 'path: \.saola-cli-source' @@ -324,14 +403,18 @@ assert_workflow_contains "${docker_workflow}" 'Docker workflow does not emit pro assert_workflow_contains "${docker_workflow}" 'Docker workflow is missing CLI version OCI metadata' 'org\.opensaola\.saola-cli\.version' assert_workflow_contains "${docker_workflow}" 'Docker workflow is missing CLI commit OCI metadata' 'org\.opensaola\.saola-cli\.revision' +assert_workflow_contains "${helm_workflow}" 'Helm tag publishing does not require the promoted stable lock' 'build/saola-cli-stable\.lock' +assert_workflow_contains "${helm_workflow}" 'Helm tag publishing does not validate the promoted stable lock' 'saola-cli-lock\.sh.*validate' +assert_workflow_contains "${helm_workflow}" 'Helm tag publishing does not enforce the stable channel' 'saola-cli-lock\.sh.*get.*channel' + release_doc="${repo_root}/docs/release-process.md" assert_workflow_contains "${release_doc}" 'release docs omit default-branch workflow bootstrap' 'default branch.*master' assert_workflow_contains "${release_doc}" 'release docs omit Contents token permissions' 'Contents: read and write' assert_workflow_contains "${release_doc}" 'release docs omit Pull requests token permissions' 'Pull requests: read and write' assert_workflow_contains "${release_doc}" 'release docs omit Actions token permissions' 'Actions: read' assert_workflow_contains "${release_doc}" 'release docs omit label metadata permission requirements' '[Mm]etadata.*label' -assert_workflow_contains "${release_doc}" 'release docs do not describe latest published stable gating' 'latest published.*non-draft release' -assert_workflow_contains "${release_doc}" 'release docs still describe version-only promotion no-op' 'complete five-field lock' +assert_workflow_contains "${release_doc}" 'release docs do not describe latest published stable gating' 'latest published final release' +assert_workflow_contains "${release_doc}" 'release docs still describe version-only promotion no-op' 'complete stable lock' for workflow in "${update_workflow}" "${promote_workflow}"; do while IFS= read -r use; do