diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml deleted file mode 100644 index 57c7965c5..000000000 --- a/.github/workflows/chart.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Helm Chart Publisher - -on: - push: - # Pre-release tags (e.g. v0.4.0-rc.1) build images via release.yml but - # must not land in the public Helm index. The negative pattern below - # filters them out; workflow_dispatch can still publish a specific - # tag manually if ever needed. - tags: - - "v*.*.*" - - "!v*-rc.*" - workflow_dispatch: - inputs: - tag: - description: "Release tag (e.g., v1.0.0)" - required: true - type: string -permissions: - contents: write - packages: write - -env: - REGISTRY: ghcr.io - -jobs: - export-registry: - uses: ./.github/workflows/setup-release.yml - with: - tag: ${{ inputs.tag || github.ref_name }} - - publish-github-pages: - needs: export-registry - runs-on: ubuntu-latest - # Only the gh-pages publish needs serialization: helm-gh-pages always - # rewrites the gh-pages branch, so concurrent runs for different tags - # would race. The OCI publish below pushes immutable per-tag blobs and - # is safe to run in parallel across tags, so it stays unguarded. - concurrency: - group: helm-chart-publish-gh-pages - cancel-in-progress: false - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - submodules: true - fetch-depth: 0 - - name: Publish Helm chart to GitHub Pages - uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - charts_dir: charts - target_dir: charts - linting: on - - publish-oci: - needs: export-registry - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Package and push Helm charts to GHCR via Makefile - run: | - set -euo pipefail - - RELEASE_TAG="${{ needs.export-registry.outputs.tag }}" - CHART_VERSION="${{ needs.export-registry.outputs.version }}" - OCI_REGISTRY="${{ needs.export-registry.outputs.registry }}/charts" - - make helm-push REGISTRY="${OCI_REGISTRY}" TAG="${RELEASE_TAG}" CHART_VERSION="${CHART_VERSION}" - - - name: Verify chart appVersion matches release tag - run: | - set -euo pipefail - - RELEASE_TAG="${{ needs.export-registry.outputs.tag }}" - CHART_VERSION="${{ needs.export-registry.outputs.version }}" - EXPECTED_APP_VERSION="${RELEASE_TAG}" - - rm -rf .helm-verify - mkdir -p .helm-verify - - for chart in hub-agent member-agent; do - helm pull "oci://${{ needs.export-registry.outputs.registry }}/charts/${chart}" --version "${CHART_VERSION}" --destination .helm-verify >/dev/null - packaged=".helm-verify/${chart}-${CHART_VERSION}.tgz" - actual_app_version=$(tar -xOf "${packaged}" "${chart}/Chart.yaml" | awk -F': ' '/^appVersion:/ {gsub(/"/, "", $2); print $2}') - if [[ "${actual_app_version}" != "${EXPECTED_APP_VERSION}" ]]; then - echo "ERROR: ${chart} appVersion (${actual_app_version}) does not match release tag (${EXPECTED_APP_VERSION})" - exit 1 - fi - echo "✅ ${chart} appVersion=${actual_app_version} matches release tag=${EXPECTED_APP_VERSION}" - done - - rm -rf .helm-verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c5c1f5f8..d7f0e9d31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,14 @@ -name: Release Images +name: Release + +# One workflow owns the whole release. Every artifact a tag produces - images, +# the CRD bundle, the Helm charts - is built by a job in this graph, and the +# GitHub Release stays a draft until all of them have succeeded. +# +# This replaces the previous split between release.yml and chart.yml, which had +# no ordering between them: a chart publish could fail (or simply never run) +# while the release was already public, leaving a release that advertised charts +# nobody could pull, and images the charts pointed at could be published after +# the charts that referenced them. on: push: @@ -11,50 +21,73 @@ on: required: true type: string -permissions: - contents: read - packages: write - -# Serialize releases per ref so concurrent tag pushes can't race on image -# pushes to the same ${REGISTRY}/${IMAGE}:${TAG}. Different tags can still -# run in parallel. We never want cancel-in-progress here: aborting a -# half-pushed image is worse than letting it finish. +# Serialize per release tag: a re-run must not race the original run on the same +# registry paths or the same draft release. Distinct tags still run in parallel. +# Never cancel-in-progress - aborting a half-pushed image or a half-uploaded +# release asset leaves more mess than letting the run finish. concurrency: - group: release-images-${{ github.ref }} + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false +# Least privilege by default; each job widens only what it needs. +permissions: + contents: read + env: - REGISTRY: ghcr.io HUB_AGENT_IMAGE_NAME: hub-agent MEMBER_AGENT_IMAGE_NAME: member-agent REFRESH_TOKEN_IMAGE_NAME: refresh-token - GO_VERSION: "1.25.12" jobs: - export-registry: + # Validates the tag shape and derives every value the rest of the graph keys + # off (registry path, tag, version, prerelease). A malformed tag fails here, + # before anything is published. + setup: uses: ./.github/workflows/setup-release.yml with: tag: ${{ inputs.tag || github.ref_name }} - build-and-publish: - needs: export-registry - env: - REGISTRY: ${{ needs.export-registry.outputs.registry }} - TAG: ${{ needs.export-registry.outputs.tag }} + # Create the release up front, as a draft, so the producer jobs have somewhere + # to upload while the release stays invisible to consumers. publish-release + # flips it at the end; until then a failed run leaves only a draft. + create-draft-release: + needs: setup runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ needs.setup.outputs.tag }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} steps: - - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: ${{ env.GO_VERSION }} + ref: ${{ needs.setup.outputs.tag }} + - name: Create or reuse the draft release + run: ./hack/release/create-draft-release.sh + + publish-images: + needs: [setup, create-draft-release] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + REGISTRY: ${{ needs.setup.outputs.registry }} + TAG: ${{ needs.setup.outputs.tag }} + VERSION: ${{ needs.setup.outputs.version }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} + steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ needs.export-registry.outputs.tag }} + ref: ${{ needs.setup.outputs.tag }} - name: Login to ghcr.io - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -66,12 +99,10 @@ jobs: # published under the long form ("v0.4.0-rc.1") for testers only: the # short-tag namespace is deliberately reserved for stable releases that # consumers can safely pin to, so RC tags get no short alias. - - name: Build and push images with tag ${{ env.TAG }} - env: - VERSION: ${{ needs.export-registry.outputs.version }} + - name: Build and push images with tag ${{ needs.setup.outputs.tag }} run: | set -euo pipefail - if [[ "${TAG}" == *-rc.* ]]; then + if [ "${PRERELEASE}" = "true" ]; then make push else make push IMAGE_EXTRA_TAG="${VERSION}" @@ -82,18 +113,16 @@ jobs: # architecture would otherwise go unnoticed until a consumer on the other # architecture failed to pull. Stable releases also carry the short alias. - name: Verify images are multi-arch - env: - VERSION: ${{ needs.export-registry.outputs.version }} run: | set -euo pipefail tags="${TAG}" - if [[ "${TAG}" != *-rc.* ]]; then + if [ "${PRERELEASE}" != "true" ]; then tags="${tags} ${VERSION}" fi echo "✅ Verifying published images:" - for IMAGE in ${{ env.HUB_AGENT_IMAGE_NAME }} ${{ env.MEMBER_AGENT_IMAGE_NAME }} ${{ env.REFRESH_TOKEN_IMAGE_NAME }}; do + for IMAGE in "${HUB_AGENT_IMAGE_NAME}" "${MEMBER_AGENT_IMAGE_NAME}" "${REFRESH_TOKEN_IMAGE_NAME}"; do for tag in ${tags}; do - ref="${{ env.REGISTRY }}/${IMAGE}:${tag}" + ref="${REGISTRY}/${IMAGE}:${tag}" echo " - ${ref}" manifest="$(docker buildx imagetools inspect "${ref}")" for platform in linux/amd64 linux/arm64; do @@ -106,45 +135,216 @@ jobs: # Publish the raw CRDs as a standalone release asset so consumers can install # them without pulling a Helm chart. The bundle carries the unmodified CRDs the # charts install (no downstream-specific labels), split into crds/hub and - # crds/member so each set can be applied to the right cluster. Runs after the - # images are published so a release is only created once the build succeeds. + # crds/member so each set can be applied to the right cluster. publish-crds: - needs: [export-registry, build-and-publish] + needs: [setup, create-draft-release] runs-on: ubuntu-latest permissions: contents: write env: - TAG: ${{ needs.export-registry.outputs.tag }} + TAG: ${{ needs.setup.outputs.tag }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ needs.export-registry.outputs.tag }} + ref: ${{ needs.setup.outputs.tag }} - name: Package CRDs run: make crd-package TAG="${TAG}" - - name: Create or update the release and upload the CRD bundle + # --clobber makes the upload idempotent so a re-run replaces the asset + # rather than failing on a name collision. The token is scoped to this + # step so it is not in the environment of the packaging step above. + - name: Upload the CRD bundle to the draft release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - created_draft="" - if ! gh release view "${TAG}" >/dev/null 2>&1; then - # Any semver pre-release suffix (-rc.N, -alpha, -beta, ...) is a prerelease. - prerelease="" - case "${TAG}" in *-*) prerelease="--prerelease" ;; esac - # Create as a draft first so a partially uploaded release is never public. - gh release create "${TAG}" --title "${TAG}" --generate-notes --draft ${prerelease} - created_draft="true" - fi gh release upload "${TAG}" \ "_crd-package/kubefleet-crds-${TAG}.tgz" \ "_crd-package/kubefleet-crds-${TAG}.tgz.sha256" \ --clobber - # Only publish releases this job created; never flip a maintainer's existing release. - if [ "${created_draft}" = "true" ]; then - gh release edit "${TAG}" --draft=false - elif [ "$(gh release view "${TAG}" --json isDraft --jq .isDraft)" = "true" ]; then - echo "::warning::Release ${TAG} already existed as a draft; the CRD bundle was uploaded but the release was left unpublished. Publish it manually." - fi + + # Charts are published only for stable releases: an RC must be installable by + # testers from its images, but must never land in the public chart index that + # `helm repo update` resolves. Charts wait on publish-images because a chart + # whose appVersion points at images that do not exist yet is broken on arrival. + publish-charts-oci: + needs: [setup, publish-images] + if: ${{ needs.setup.outputs.prerelease == 'false' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + REGISTRY: ${{ needs.setup.outputs.registry }} + TAG: ${{ needs.setup.outputs.tag }} + CHART_VERSION: ${{ needs.setup.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.setup.outputs.tag }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Pin Helm rather than inheriting whatever the runner image ships, so the + # version that packages a release is the same one code-lint.yml lints the + # charts with. + - name: Set up Helm + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 + with: + version: v3.17.0 + + - name: Package and push Helm charts to GHCR + run: | + set -euo pipefail + make helm-push REGISTRY="${REGISTRY}/charts" TAG="${TAG}" CHART_VERSION="${CHART_VERSION}" + + - name: Verify chart appVersion matches release tag + run: | + set -euo pipefail + rm -rf .helm-verify + mkdir -p .helm-verify + + for chart in hub-agent member-agent; do + helm pull "oci://${REGISTRY}/charts/${chart}" --version "${CHART_VERSION}" --destination .helm-verify >/dev/null + packaged=".helm-verify/${chart}-${CHART_VERSION}.tgz" + actual_app_version="$(tar -xOf "${packaged}" "${chart}/Chart.yaml" | awk -F': ' '/^appVersion:/ {gsub(/"/, "", $2); print $2}')" + if [ "${actual_app_version}" != "${TAG}" ]; then + echo "::error::${chart} appVersion (${actual_app_version}) does not match release tag (${TAG})" + exit 1 + fi + echo "✅ ${chart} appVersion=${actual_app_version} matches release tag=${TAG}" + done + + rm -rf .helm-verify + + publish-charts-pages: + needs: [setup, publish-images] + if: ${{ needs.setup.outputs.prerelease == 'false' }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ needs.setup.outputs.tag }} + CHART_VERSION: ${{ needs.setup.outputs.version }} + # helm-gh-pages rewrites the whole gh-pages branch, so this job serializes + # across every release rather than per tag like the rest of the workflow. + # + # Only one run may be *pending* on a group by default, so a third overlapping + # release cancels the one already waiting and strands it as a draft until + # someone re-runs the job. `queue: max` is the real fix, but actionlint (this + # repo's lint gate, pinned at 1.7.12) rejects the key as unknown - support is + # merged upstream but unreleased. Until it ships, RELEASING.md documents the + # symptom and its one-click recovery. + concurrency: + group: helm-chart-publish-gh-pages + cancel-in-progress: false + steps: + # This job hands a contents:write token to a third-party Docker action + # whose base image is a floating tag (see the note in RELEASING.md and the + # follow-up issue). Auditing egress at least records what it reaches out + # to until that action is replaced. + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.setup.outputs.tag }} + fetch-depth: 0 + + # chart_version/app_version are what make the index carry the release + # being cut. Without them the action packages charts/*/Chart.yaml + # verbatim, and those are pinned at 0.1.0/v0.1.0 in-tree - so every + # release republished "hub-agent 0.1.0" pointing at image tag v0.1.0, + # overwriting the previous entry. The OCI path already overrides both + # (see make helm-push); this brings the index in line with it. + - name: Publish Helm chart to GitHub Pages + uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + charts_dir: charts + target_dir: charts + chart_version: ${{ needs.setup.outputs.version }} + app_version: ${{ needs.setup.outputs.tag }} + linting: on + + # Unknown action inputs are a warning, not an error, so a rename or typo + # in the two above would silently reinstate the 0.1.0 bug with a green + # build. Check the branch the action just wrote rather than trusting that + # it accepted them; the published index is not queryable until Pages + # redeploys, but the commit is there immediately. + - name: Verify the published index carries this release + run: | + set -euo pipefail + git fetch --depth=1 origin gh-pages + for chart in hub-agent member-agent; do + packaged="charts/${chart}-${CHART_VERSION}.tgz" + if ! git cat-file -e "FETCH_HEAD:${packaged}" 2>/dev/null; then + echo "::error::gh-pages has no ${packaged}; the chart index was not updated for this release." + exit 1 + fi + app_version="$(git cat-file blob "FETCH_HEAD:${packaged}" \ + | tar -xzO "${chart}/Chart.yaml" \ + | awk -F': ' '/^appVersion:/ {gsub(/"/, "", $2); print $2}')" + if [ "${app_version}" != "${TAG}" ]; then + echo "::error::gh-pages ${chart} appVersion (${app_version}) does not match release tag (${TAG})" + exit 1 + fi + echo "✅ gh-pages carries ${packaged} with appVersion=${app_version}" + done + + # The atomic commit point: the release becomes visible only after every + # producer that was supposed to run has succeeded. + # + # The condition has to override the implicit `success()` on `needs`, because + # the chart jobs are legitimately skipped for release candidates and a skipped + # dependency would otherwise skip this job too. It uses `!cancelled()` rather + # than `always()`: `always()` runs even when the workflow is cancelled, so a + # maintainer hitting Cancel after the producers had finished would still get a + # published release. + # + # Every producer is then required explicitly. `skipped` is only acceptable for + # the chart jobs, and only on a pre-release - otherwise anything that made + # their `if:` evaluate false would silently publish a stable release with no + # charts, which is the exact failure this workflow exists to prevent. + publish-release: + needs: + - setup + - create-draft-release + - publish-images + - publish-crds + - publish-charts-oci + - publish-charts-pages + if: >- + ${{ !cancelled() + && needs.publish-images.result == 'success' + && needs.publish-crds.result == 'success' + && (needs.setup.outputs.prerelease == 'true' + || (needs.publish-charts-oci.result == 'success' + && needs.publish-charts-pages.result == 'success')) }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ needs.setup.outputs.tag }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.setup.outputs.tag }} + + - name: Verify the release assets, then publish + run: ./hack/release/publish-release.sh diff --git a/.github/workflows/setup-release.yml b/.github/workflows/setup-release.yml index ab7aadcaf..eef3ae797 100644 --- a/.github/workflows/setup-release.yml +++ b/.github/workflows/setup-release.yml @@ -17,6 +17,9 @@ on: version: description: "Release version without v prefix (e.g., 1.0.0)" value: ${{ jobs.export.outputs.version }} + prerelease: + description: "\"true\" when the tag is a pre-release (e.g., v1.0.0-rc.1), \"false\" otherwise" + value: ${{ jobs.export.outputs.prerelease }} env: REGISTRY: ghcr.io @@ -28,19 +31,36 @@ jobs: registry: ${{ steps.setup.outputs.registry }} tag: ${{ steps.setup.outputs.tag }} version: ${{ steps.setup.outputs.version }} + prerelease: ${{ steps.setup.outputs.prerelease }} steps: - id: setup + # The tag arrives as an environment variable rather than being + # interpolated into the script: `workflow_dispatch` lets a caller + # supply arbitrary text, and `TAG="${{ inputs.tag }}"` would + # expand it into the shell before the validation below ever sees + # it, so a crafted tag could run commands and forge the outputs + # every downstream job trusts. + env: + INPUT_TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" + TAG="${INPUT_TAG}" if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then echo "Error: Invalid release tag '${TAG}'. Expected format: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N" exit 1 fi + # The regex above admits exactly one pre-release form, so the + # suffix test is a complete classification. Callers gate on + # this single output instead of re-deriving "is this an RC?" + # in every consuming job. + PRERELEASE=false + case "${TAG}" in *-rc.*) PRERELEASE=true ;; esac + # registry must be in lowercase { echo "registry=$(echo "${{ env.REGISTRY }}/${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" echo "tag=${TAG}" echo "version=${TAG#v}" + echo "prerelease=${PRERELEASE}" } >> "$GITHUB_OUTPUT" - echo "Release tag: ${TAG}, version: ${TAG#v}" + echo "Release tag: ${TAG}, version: ${TAG#v}, prerelease: ${PRERELEASE}" diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 793bbcdcd..1d0b133f1 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -6,11 +6,13 @@ on: paths: - ".github/workflows/**" - ".github/release.yml" + - "hack/release/**" pull_request: branches: [main, "release-*"] paths: - ".github/workflows/**" - ".github/release.yml" + - "hack/release/**" permissions: contents: read @@ -43,3 +45,11 @@ jobs: - name: Run actionlint run: actionlint -color + + # The release scripts live outside the workflow files, so actionlint's + # embedded-shell checking does not reach them. + - name: Shellcheck the release scripts + run: shellcheck hack/release/*.sh hack/release/testdata/gh + + - name: Test the release scripts + run: ./hack/release/test-release-scripts.sh diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..5bab9bb4f --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,204 @@ +# Releasing + +This is the operational runbook for cutting a KubeFleet release and for +recovering when a release run fails partway through. It covers *how* a release +is produced; what the version numbers mean and how long each release is +supported are covered in [VERSIONING.md](VERSIONING.md) and +[SECURITY.md](SECURITY.md). + +## What a release publishes + +| Artifact | Location | Stable (`v0.4.0`) | Release candidate (`v0.4.0-rc.1`) | +| --- | --- | --- | --- | +| Agent images (`hub-agent`, `member-agent`, `refresh-token`) | `ghcr.io/kubefleet-dev/kubefleet/` | `:v0.4.0` and `:0.4.0` | `:v0.4.0-rc.1` only | +| CRD bundle (`kubefleet-crds-.tgz` + `.sha256`) | GitHub Release asset | Yes | Yes | +| Helm charts (OCI) | `oci://ghcr.io/kubefleet-dev/kubefleet/charts/` | Yes | No | +| Helm charts (index) | `https://kubefleet-dev.github.io/kubefleet/charts` | Yes | No | +| GitHub Release | Releases page | Published | Published, flagged pre-release | + +Release candidates deliberately get no short image alias and never enter the +public chart index: the short-tag namespace and the `helm repo` index are +reserved for releases users can safely pin to. Testers install an RC from its +full tag. + +## Cutting a release + +All images and charts are built from the tag itself, so everything that ships +must be merged before the tag is pushed. + +1. Confirm `main` (or the `release-0.Y` branch) is green and carries every + change intended for the release, including backports — see + [CONTRIBUTING.md](CONTRIBUTING.md#backporting-to-release-branches). +2. Tag and push. The tag must match `vMAJOR.MINOR.PATCH` or + `vMAJOR.MINOR.PATCH-rc.N`; any other shape is rejected before anything is + published. + + ```bash + git tag -a v0.4.0-rc.1 -m "v0.4.0-rc.1" + git push upstream v0.4.0-rc.1 + ``` + +3. Watch the `Release` workflow. It publishes the GitHub Release only after + every artifact has been produced. +4. For a stable release, repeat with the final tag (for example `v0.4.0`) once + the RC has soaked. + +A release can also be started from the Actions tab via **Run workflow** on the +`Release` workflow, passing the tag as an input. Two preconditions apply: + +- The tag must already exist. The workflow passes `--verify-tag`, so a dispatch + naming a tag that was never pushed fails instead of inventing one at the head + of the default branch. +- The tag must be one cut after this workflow landed. A dispatch runs the + workflow definition from the selected branch but checks out the *tag*, and the + jobs call scripts under `hack/release/`; against an older tag that predates + them, the first job fails immediately with "No such file or directory". + Nothing is published when it does. Tags on a `release-0.Y` branch that predates + this workflow are unaffected — they carry their own contemporary workflow. + +## The release pipeline + +`.github/workflows/release.yml` is the single owner of a release. Its job graph: + +```text +setup validate the tag; derive registry, version, prerelease + └── create-draft-release create (or reuse) the GitHub Release as a draft + ├── publish-images multi-arch buildx push, then verify both platforms + │ ├── publish-charts-oci stable only; helm push + appVersion check + │ └── publish-charts-pages stable only; rewrite the gh-pages index + └── publish-crds package the CRDs, upload the bundle to the draft + +publish-release needs ALL of the jobs above; verifies the release's + assets, then flips the draft to published +``` + +Two properties matter when something goes wrong: + +- **The GitHub Release is a draft until the very end.** No release page, release + notes, or release asset is visible until every producer has succeeded. Note + the scope: this covers the *release*, not the registry. Images and charts are + publicly pullable the moment their own job succeeds, so a run that fails after + `publish-images` has left `ghcr.io/.../hub-agent:v0.4.0` reachable even though + no release mentions it. +- **Charts wait for images.** A chart whose `appVersion` points at images that + do not exist yet is broken on arrival, so the chart jobs run only after the + images are pushed and verified. + +The two jobs with real branching logic — `create-draft-release` and +`publish-release` — live in [`hack/release/`](hack/release) rather than inline +in the workflow, and are covered by `hack/release/test-release-scripts.sh`, +which CI runs on every change to either. + +## Recovering from a failed run + +The normal recovery is **Re-run failed jobs** on the workflow run. Jobs that +already succeeded are not re-run, and every job is safe to repeat: the CRD +upload uses `--clobber`, `create-draft-release` reuses the draft it created the +first time, and image pushes rewrite the same tags. + +Re-running `publish-images` rebuilds from source rather than reproducing the +earlier build byte-for-byte, so the tag ends up pointing at a *new* digest. That +is harmless while the release is still a draft — nothing has been announced yet +— but it is why a re-run is not an option once a release has been published. + +| Where it failed | What is already public | What to do | +| --- | --- | --- | +| `setup` | Nothing | The tag is malformed. Delete it, fix, re-tag. | +| `create-draft-release` | Nothing | See [Re-releasing an existing tag](#re-releasing-an-existing-tag) if it refused because the release is already published. | +| `publish-images` | Any images pushed before the failure (`make push` builds hub-agent, member-agent, then refresh-token in order) | Fix, then re-run failed jobs. | +| `publish-crds` | Possibly the images — it runs in parallel with `publish-images`, not after it | Fix, then re-run failed jobs. | +| `publish-charts-oci` / `publish-charts-pages` | Images; CRD bundle is attached to the still-hidden draft | Fix, then re-run failed jobs. The release stays a draft until the charts land. | +| `publish-release` | Images, charts | The asset check found the draft incomplete or its bundle failed its own checksum. Inspect `gh release view `, re-upload, re-run failed jobs. | + +`publish-charts-pages` serializes across *all* releases, because the action it +uses rewrites the whole `gh-pages` branch. GitHub keeps at most one pending +entry per concurrency group, so if three stable releases overlap, the middle +one's pages job is **cancelled** rather than queued. That leaves its release as +a draft with everything else done; **Re-run failed jobs** finishes it. Cutting +stable releases one at a time avoids the situation entirely. + +If the fix requires a code change, the tag must move or be replaced — see +below. Do **not** rebuild a different commit under a tag that already pushed +images. + +### Re-releasing an existing tag + +`create-draft-release` refuses to run against a release that is already +published. This is deliberate: consumers may already have pinned the images and +charts that release advertises, and a second run would replace them in place +with a different build. + +- **If the release should not have gone out** (wrong commit, broken build): + delete the GitHub Release and the tag, then cut the *next* tag rather than + reusing the old one — `-rc.N+1` for a release candidate, or the next patch + version for a stable release. Container tags that have been pulled are not + safely reusable, and the CRD bundle checksum users recorded would change under + them. Because the bad images stay pullable under their original tag (see + [Abandoning a release](#abandoning-a-release)), also delete those package + versions if the build was actually broken rather than merely superseded. +- **If only one artifact is missing** (for example a chart publish that was + fixed after the release went public): publish that artifact manually rather + than re-running the whole workflow. Note that the OCI registry and the Pages + index are published by two different jobs and need two different fixes: + + ```bash + # OCI charts + make helm-push REGISTRY=ghcr.io/kubefleet-dev/kubefleet/charts \ + TAG=v0.4.0 CHART_VERSION=0.4.0 + + # Pages index: re-run the publish-charts-pages job from the workflow run, + # which is the only thing that rewrites the gh-pages branch. + ``` + +### Abandoning a release + +If a release is called off, delete the draft and the tag so the next attempt +starts clean: + +```bash +gh release delete v0.4.0-rc.1 --yes +git push upstream :refs/tags/v0.4.0-rc.1 +git tag -d v0.4.0-rc.1 +``` + +That removes the release and the tag, but **not** the artifacts the producer +jobs already published. Those outlive the release and have to be cleaned up +deliberately: + +- **Images.** `ghcr.io/kubefleet-dev/kubefleet/:v0.4.0` — and the short + alias `:0.4.0` for a stable tag — stay publicly pullable. The next tag is a + different version, so it never supersedes them. Delete the package versions + (`gh api --method DELETE /orgs/kubefleet-dev/packages/container//versions/`) + if the build was broken rather than merely renumbered. +- **Charts.** If `publish-charts-oci` ran, the chart is in the OCI registry and + needs the same treatment. If `publish-charts-pages` ran, the `gh-pages` index + already advertises the abandoned version, and the next stable release will not + remove it — the entry has to be dropped from `charts/index.yaml` on the + `gh-pages` branch by hand. + +Abandoning a *stable* release after the chart jobs have run is therefore not +cleanly reversible. Soak on release candidates, which publish neither chart. + +## After a release + +- At the first RC of a new minor, cut the matching `release-0.Y` branch. From + then on, fixes land on `main` and are backported with the `cherry-pick/0.Y` + labels described in + [CONTRIBUTING.md](CONTRIBUTING.md#backporting-to-release-branches). +- Verify the published release page lists the CRD bundle and its checksum, and + that the generated notes look right — they come from the `release-note/*` + labels on the PRs in the release. +- **One-time, at the first stable release cut by this workflow:** the `gh-pages` + chart index carries stale `hub-agent 0.1.0` and `member-agent 0.1.0` entries + from before the index was given the real release version. The publish step + merges into the existing index rather than replacing it, so those entries + survive and `helm search repo kubefleet --versions` keeps offering `0.1.0`. + Delete them from `charts/index.yaml` on the `gh-pages` branch (and the + matching `charts/*-0.1.0.tgz`) once a correctly-versioned entry exists. + +## See also + +- [VERSIONING.md](VERSIONING.md) — versioning scheme, agent skew, upgrade order. +- [SECURITY.md](SECURITY.md) — supported versions and security-patch policy. +- [CONTRIBUTING.md](CONTRIBUTING.md) — PR conventions, release-note labels, and + backport policy. diff --git a/VERSIONING.md b/VERSIONING.md index c6e6d3dfa..16a4d253f 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -146,6 +146,8 @@ installs need no separate step: KubeFleet ships its CRDs under ## See also +- [RELEASING.md](RELEASING.md) — how a release is cut and how to recover a + failed release run. - [SECURITY.md](SECURITY.md) — supported versions and security-patch policy. - [CONTRIBUTING.md](CONTRIBUTING.md) — PR conventions and release-note labels. - [Kubernetes version skew policy](https://kubernetes.io/releases/version-skew-policy/) diff --git a/charts/README.md b/charts/README.md index da0068a4b..a20ae2cf9 100644 --- a/charts/README.md +++ b/charts/README.md @@ -124,15 +124,21 @@ helm upgrade member-agent kubefleet/member-agent --namespace fleet-system ## Chart Publishing -Charts are automatically published to both locations when: -- Changes are pushed to the `main` branch affecting chart files -- A version tag (e.g., `v1.0.0`) is created +Charts are published to both locations when a stable version tag (e.g. +`v1.0.0`) is pushed, carrying that release's version and appVersion. +Release-candidate tags (e.g. `v1.0.0-rc.1`) build and publish images but +deliberately do not publish charts, so no pre-release version reaches the chart +index. **Published Locations:** - **OCI Registry**: `oci://ghcr.io/kubefleet-dev/kubefleet/charts/{chart-name}` - **GitHub Pages**: `https://kubefleet-dev.github.io/kubefleet/charts` -The publishing workflow is defined in `.github/workflows/chart.yml`. +Chart publishing is part of the release workflow in +`.github/workflows/release.yml`, which publishes the GitHub Release only after +the charts and every other release artifact have been published. See +[RELEASING.md](../RELEASING.md) for the full pipeline and its recovery +procedure. ## Development diff --git a/hack/release/create-draft-release.sh b/hack/release/create-draft-release.sh new file mode 100755 index 000000000..b7617b7bd --- /dev/null +++ b/hack/release/create-draft-release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Create the GitHub Release for a tag as a draft, or reuse the draft already +# there. +# +# The release is created before any artifact exists so the producer jobs have +# somewhere to upload while the release stays invisible to consumers. +# publish-release.sh makes it public once every producer has succeeded. +# +# Environment: +# TAG release tag, e.g. v0.4.0 (required) +# PRERELEASE "true" when TAG is a release candidate (required) +# GH_TOKEN token with contents: write +# GH_REPO owner/repo + +set -euo pipefail + +: "${TAG:?TAG must be set}" +: "${PRERELEASE:?PRERELEASE must be set}" + +stderr="$(mktemp)" +trap 'rm -f "${stderr}"' EXIT + +# Distinguish "no such release" from a transient API failure. Treating an +# outage as "the release does not exist" would take the create path and bypass +# the published-release guard below. +if is_draft="$(gh release view "${TAG}" --json isDraft --jq .isDraft 2>"${stderr}")"; then + if [ "${is_draft}" != "true" ]; then + echo "::error::Release ${TAG} is already published. Re-running the full workflow for a completed release is refused; see RELEASING.md for the recovery procedure." + exit 1 + fi + # A draft may be a re-run of this workflow, or notes a maintainer pre-staged. + # Either way it is reused as-is; publish-release.sh reconciles the + # pre-release flag at publish time, so a hand-created draft cannot go out + # mislabelled. + echo "Reusing existing draft release ${TAG}." + exit 0 +fi + +if ! grep -qiE "not found|404" "${stderr}"; then + echo "::error::Could not determine the state of release ${TAG}: $(tr '\n' ' ' <"${stderr}")" + exit 1 +fi + +# --verify-tag: without it, `gh release create` happily invents the tag at the +# default branch's HEAD, which would cut a full release from whatever is on +# main under a version nobody intended. +create_args=(--title "${TAG}" --generate-notes --draft --verify-tag) +if [ "${PRERELEASE}" = "true" ]; then + create_args+=(--prerelease) +fi + +gh release create "${TAG}" "${create_args[@]}" +echo "Created draft release ${TAG}." diff --git a/hack/release/publish-release.sh b/hack/release/publish-release.sh new file mode 100755 index 000000000..ed7d91d03 --- /dev/null +++ b/hack/release/publish-release.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Publish the draft release for a tag, after checking it actually carries the +# assets it is supposed to. +# +# This is the atomic commit point of a release: everything else in the release +# workflow produces artifacts, and this is the only step that makes the release +# visible. +# +# Environment: +# TAG release tag, e.g. v0.4.0 (required) +# PRERELEASE "true" when TAG is a release candidate (required) +# GH_TOKEN token with contents: write +# GH_REPO owner/repo + +set -euo pipefail + +: "${TAG:?TAG must be set}" +: "${PRERELEASE:?PRERELEASE must be set}" + +bundle="kubefleet-crds-${TAG}.tgz" +checksum="${bundle}.sha256" + +# Only assets GitHub finished receiving count. An upload interrupted mid-stream +# leaves an asset row with the right name in a non-"uploaded" state, which a +# name-only check would accept. +assets="$(gh release view "${TAG}" --json assets \ + --jq '.assets[] | select(.state == "uploaded" and .size > 0) | .name')" + +for want in "${bundle}" "${checksum}"; do + if ! grep -qxF -- "${want}" <<<"${assets}"; then + echo "::error::Release ${TAG} is missing fully-uploaded asset ${want}; leaving it as a draft." + exit 1 + fi +done + +# Verify the bytes, not just the names: the bundle ships with a checksum, so +# confirming it here is the difference between "an asset with that name exists" +# and "the artifact users will download is intact". +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT +gh release download "${TAG}" --dir "${workdir}" --pattern "${bundle}" --pattern "${checksum}" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "${workdir}" && sha256sum -c "${checksum}") +else + (cd "${workdir}" && shasum -a 256 -c "${checksum}") +fi + +# Set the pre-release flag here rather than only at creation time: a draft this +# workflow reused may have been created by hand, and GitHub defaults such +# drafts to "not a pre-release". Publishing an RC under that flag would make it +# the repository's "Latest release". +gh release edit "${TAG}" --draft=false --prerelease="${PRERELEASE}" +echo "✅ Published release ${TAG} (prerelease=${PRERELEASE})." diff --git a/hack/release/test-release-scripts.sh b/hack/release/test-release-scripts.sh new file mode 100755 index 000000000..a7eedda69 --- /dev/null +++ b/hack/release/test-release-scripts.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Exercise the release scripts against a stubbed gh CLI. +# +# These scripts decide whether a release goes public, so their failure modes +# matter more than most: the cases below are the ones where getting it wrong +# publishes something wrong rather than just failing the run. +# +# Run directly: ./hack/release/test-release-scripts.sh +# Requires: bash, jq. No test framework. + +set -uo pipefail + +cd "$(dirname "$0")" || exit 1 +here="${PWD}" +export PATH="${here}/testdata:${PATH}" + +create_script="${here}/create-draft-release.sh" +publish_script="${here}/publish-release.sh" + +passed=0 +failed=0 + +# Every case runs one script with a fresh stub log, then asserts on its exit +# code, its output, and the gh commands it issued. +run_case() { + FAKE_GH_LOG="$(mktemp)" + export FAKE_GH_LOG + output="" + rc=0 + output="$(env "$@" 2>&1)" || rc=$? + log="$(cat "${FAKE_GH_LOG}")" + rm -f "${FAKE_GH_LOG}" +} + +ok() { + echo " PASS $1" + passed=$((passed + 1)) +} + +bad() { + echo " FAIL $1" + echo " rc=${rc}" + echo " output: ${output}" + echo " gh calls: $(tr '\n' '|' <<<"${log}")" + failed=$((failed + 1)) +} + +expect_rc() { # expect_rc + if [ "${rc}" = "$1" ]; then ok "$2 (rc=${rc})"; else bad "$2 - want rc=$1"; fi +} + +expect_gh() { # expect_gh + if grep -qF -- "$1" <<<"${log}"; then ok "$2"; else bad "$2 - no gh call matching '$1'"; fi +} + +expect_no_gh() { # expect_no_gh + if grep -qF -- "$1" <<<"${log}"; then bad "$2 - unexpected gh call '$1'"; else ok "$2"; fi +} + +expect_output() { # expect_output + if grep -qF -- "$1" <<<"${output}"; then ok "$2"; else bad "$2 - output lacks '$1'"; fi +} + +echo "== create-draft-release.sh ==" + +run_case FAKE_GH_STATE=absent TAG=v0.4.0 PRERELEASE=false bash "${create_script}" +expect_rc 0 "no existing release, stable: succeeds" +expect_gh "gh release create v0.4.0 --title v0.4.0 --generate-notes --draft --verify-tag" \ + "no existing release, stable: creates a verified draft" +expect_no_gh "--prerelease" "stable release is not flagged as a pre-release" + +run_case FAKE_GH_STATE=absent TAG=v0.4.0-rc.1 PRERELEASE=true bash "${create_script}" +expect_rc 0 "no existing release, RC: succeeds" +expect_gh "--draft --verify-tag --prerelease" "RC is created as a pre-release" + +run_case FAKE_GH_STATE=draft TAG=v0.4.0 PRERELEASE=false bash "${create_script}" +expect_rc 0 "existing draft: succeeds" +expect_no_gh "release create" "existing draft is reused, not recreated" + +run_case FAKE_GH_STATE=published TAG=v0.4.0 PRERELEASE=false bash "${create_script}" +expect_rc 1 "already-published release: refuses" +expect_output "::error::" "already-published release: annotates the failure" +expect_no_gh "release create" "already-published release: creates nothing" + +# A GitHub outage must not be read as "the release does not exist" - that would +# take the create path and step over the published-release guard above. +run_case FAKE_GH_STATE=absent FAKE_GH_ERROR="HTTP 503: Service unavailable" \ + TAG=v0.4.0 PRERELEASE=false bash "${create_script}" +expect_rc 1 "API error that is not a 404: fails closed" +expect_no_gh "release create" "API error: creates nothing" + +echo "== publish-release.sh ==" + +both_uploaded="$(printf 'kubefleet-crds-v0.4.0.tgz;uploaded;4096\nkubefleet-crds-v0.4.0.tgz.sha256;uploaded;98')" + +run_case FAKE_GH_STATE=draft FAKE_GH_ASSETS="${both_uploaded}" TAG=v0.4.0 PRERELEASE=false \ + bash "${publish_script}" +expect_rc 0 "complete draft, stable: publishes" +expect_gh "gh release edit v0.4.0 --draft=false --prerelease=false" \ + "stable release is published without the pre-release flag" + +run_case FAKE_GH_STATE=draft \ + FAKE_GH_ASSETS="$(printf 'kubefleet-crds-v0.4.0-rc.1.tgz;uploaded;4096\nkubefleet-crds-v0.4.0-rc.1.tgz.sha256;uploaded;98')" \ + TAG=v0.4.0-rc.1 PRERELEASE=true bash "${publish_script}" +expect_rc 0 "complete draft, RC: publishes" +# A draft created by hand defaults to prerelease=false, so the flag has to be +# set at publish time or an RC becomes the repository's "Latest release". +expect_gh "--draft=false --prerelease=true" "RC is published flagged as a pre-release" + +run_case FAKE_GH_STATE=draft FAKE_GH_ASSETS="kubefleet-crds-v0.4.0.tgz;uploaded;4096" \ + TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "missing checksum asset: refuses to publish" +expect_no_gh "release edit" "missing checksum asset: release stays a draft" + +run_case FAKE_GH_STATE=draft FAKE_GH_ASSETS="" TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "no assets at all: refuses to publish" + +# GitHub keeps an asset row for an upload that never finished; it is present by +# name but not in the "uploaded" state. +run_case FAKE_GH_STATE=draft \ + FAKE_GH_ASSETS="$(printf 'kubefleet-crds-v0.4.0.tgz;new;0\nkubefleet-crds-v0.4.0.tgz.sha256;uploaded;98')" \ + TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "interrupted upload (state != uploaded): refuses to publish" +expect_no_gh "release edit" "interrupted upload: release stays a draft" + +run_case FAKE_GH_STATE=draft \ + FAKE_GH_ASSETS="$(printf 'kubefleet-crds-v0.4.0.tgz;uploaded;0\nkubefleet-crds-v0.4.0.tgz.sha256;uploaded;98')" \ + TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "zero-byte asset: refuses to publish" + +# Names alone are not proof; the bundle ships a checksum, so it gets checked. +run_case FAKE_GH_STATE=draft FAKE_GH_ASSETS="${both_uploaded}" FAKE_GH_DOWNLOAD=corrupt \ + TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "bundle that fails its own checksum: refuses to publish" +expect_no_gh "release edit" "failed checksum: release stays a draft" + +# An asset whose name only looks right must not satisfy the check. +run_case FAKE_GH_STATE=draft \ + FAKE_GH_ASSETS="$(printf 'kubefleet-crds-v0.4.0.tgz.sha256;uploaded;98\nkubefleet-crds-v0.4.0.tgz.asc;uploaded;800')" \ + TAG=v0.4.0 PRERELEASE=false bash "${publish_script}" +expect_rc 1 "similar-but-wrong asset names: refuses to publish" + +echo +echo "passed=${passed} failed=${failed}" +[ "${failed}" -eq 0 ] diff --git a/hack/release/testdata/gh b/hack/release/testdata/gh new file mode 100755 index 000000000..7a1955fda --- /dev/null +++ b/hack/release/testdata/gh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Stand-in for the gh CLI, used by test-release-scripts.sh. It is put on PATH +# ahead of the real gh so the release scripts can be exercised without touching +# GitHub. +# +# Behaviour is driven by the environment: +# FAKE_GH_STATE absent | draft | published +# FAKE_GH_ERROR stderr text for the "absent" case (default: release not found) +# FAKE_GH_ASSETS asset rows as "name;state;size", one per line +# FAKE_GH_DOWNLOAD good | corrupt - whether the downloaded bundle matches its +# recorded checksum +# FAKE_GH_LOG file every invocation is appended to +# +# The --jq expressions are handed to the real jq so the scripts' own filters are +# what gets tested, not a reimplementation of them. + +set -uo pipefail + +echo "gh $*" >>"${FAKE_GH_LOG}" + +jq_expr="" +dir="" +args=("$@") +for i in "${!args[@]}"; do + case "${args[$i]}" in + --jq) jq_expr="${args[$((i + 1))]}" ;; + --dir) dir="${args[$((i + 1))]}" ;; + esac +done + +case "${1:-} ${2:-}" in + "release view") + if [ "${FAKE_GH_STATE}" = "absent" ]; then + echo "${FAKE_GH_ERROR:-release not found}" >&2 + exit 1 + fi + if [[ "$*" == *isDraft* ]]; then + [ "${FAKE_GH_STATE}" = "draft" ] && echo "true" || echo "false" + exit 0 + fi + if [[ "$*" == *assets* ]]; then + # Rebuild the assets JSON gh would return, then apply the caller's filter. + json="$( + while IFS=';' read -r name state size; do + [ -n "${name}" ] || continue + jq -n --arg n "${name}" --arg s "${state}" --argjson z "${size}" \ + '{name: $n, state: $s, size: $z}' + done <<<"${FAKE_GH_ASSETS:-}" | jq -s '{assets: .}' + )" + jq -r "${jq_expr}" <<<"${json}" + exit 0 + fi + exit 0 + ;; + "release download") + mkdir -p "${dir}" + printf 'pretend-tarball\n' >"${dir}/kubefleet-crds-${TAG}.tgz" + if command -v sha256sum >/dev/null 2>&1; then + sum="$(cd "${dir}" && sha256sum "kubefleet-crds-${TAG}.tgz")" + else + sum="$(cd "${dir}" && shasum -a 256 "kubefleet-crds-${TAG}.tgz")" + fi + if [ "${FAKE_GH_DOWNLOAD:-good}" = "corrupt" ]; then + # Overwrite the content after the checksum was taken, so the recorded + # checksum no longer describes the file - what a truncated or tampered + # upload looks like on download. + printf 'tampered\n' >"${dir}/kubefleet-crds-${TAG}.tgz" + fi + echo "${sum}" >"${dir}/kubefleet-crds-${TAG}.tgz.sha256" + exit 0 + ;; + "release create" | "release edit" | "release upload") + exit 0 + ;; +esac +exit 0