diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398e86793d..ffdf17b352 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,8 @@ jobs: run: scripts/test-release-ref-contract.sh - name: Private CA release workflow contract run: scripts/test-private-ca-release-contract.sh + - name: Sprig rolling release contract + run: scripts/test-publish-sprig-rolling-release.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index 5e50808b3b..791383a98f 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -131,19 +131,7 @@ jobs: - name: Update rolling release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - run: | - set -euo pipefail - TAG="sprig-latest" - TITLE="Sprig (rolling)" - NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)." - - gh release edit "$TAG" \ - --prerelease \ - --title "$TITLE" \ - --notes "$NOTES" - gh release upload "$TAG" dist/* --clobber + run: scripts/publish-sprig-rolling-release.sh publish-tag: name: Publish tagged release diff --git a/docs/ci-sprig-rolling-release.md b/docs/ci-sprig-rolling-release.md new file mode 100644 index 0000000000..41ea39bac8 --- /dev/null +++ b/docs/ci-sprig-rolling-release.md @@ -0,0 +1,55 @@ +# Sprig rolling-release bootstrap remediation + +## Status + +Implemented with TDD on `fix/sprig-rolling-release-bootstrap` from merged +`origin/main` commit `4deea1a0d7fc`. Beads item `ios-buzz-59e.9` tracks +delivery. The pull request and merged commit are recorded at promotion time. + +## Root cause + +The `Sprig` workflow successfully built both static Linux artifacts on a push +to `main`, then unconditionally ran `gh release edit sprig-latest`. This fork +had no `sprig-latest` release, so its first rolling publication failed with +`release not found` after all build work had completed. + +Creating the release manually would leave the workflow unable to bootstrap a +new fork or recover after deliberate release removal. The defect therefore +required a source-controlled fix rather than a one-time GitHub mutation. + +## Remediation + +The workflow now delegates rolling publication to +`scripts/publish-sprig-rolling-release.sh`. The helper: + +- validates the repository, triggering SHA, GitHub CLI, artifact directory, + and non-empty artifact set before mutation; +- queries the exact `sprig-latest` release through the GitHub API; +- creates the prerelease with all assets when the API returns `404`; +- updates metadata and replaces assets when the release already exists; +- treats every non-404 query failure and every release command failure as + fatal; and +- explicitly scopes all release operations to the triggering repository. + +No release, tag, or asset is created manually. The first successful +post-merge workflow run is the acceptance path that bootstraps +`sprig-latest`. + +## TDD evidence + +The new contract first failed because the checked-in publisher did not exist. +After implementation it proves: + +- a missing release performs one create operation and no edit/upload path; +- an existing release performs edit plus clobber upload and no create path; +- both paths publish metadata for the triggering SHA; +- a non-404 API failure stops before any release mutation; +- a release command failure remains fatal; and +- both the Sprig workflow and the Ubuntu CI detector execute the checked-in + helper and contract respectively. + +## Rollback + +Revert the delivery pull request. If the first successful promotion created +`sprig-latest`, retain it unless release removal is separately authorized; +deleting a published release or its tag is not part of this rollback. diff --git a/scripts/publish-sprig-rolling-release.sh b/scripts/publish-sprig-rolling-release.sh new file mode 100755 index 0000000000..2d6a2cddde --- /dev/null +++ b/scripts/publish-sprig-rolling-release.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required} +sha=${GITHUB_SHA:?GITHUB_SHA is required} +dist_dir=${SPRIG_DIST_DIR:-dist} +tag='sprig-latest' +title='Sprig (rolling)' +notes="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${sha}\`)." + +if ! command -v gh >/dev/null 2>&1; then + echo "gh is required to publish the Sprig rolling release" >&2 + exit 1 +fi + +if [[ ! "${repo}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "GITHUB_REPOSITORY must use the owner/repository form" >&2 + exit 1 +fi + +if [[ ! -d "${dist_dir}" ]]; then + echo "Sprig artifact directory does not exist: ${dist_dir}" >&2 + exit 1 +fi + +shopt -s nullglob +assets=("${dist_dir}"/*) +shopt -u nullglob +if ((${#assets[@]} == 0)); then + echo "Sprig artifact directory is empty: ${dist_dir}" >&2 + exit 1 +fi + +release_response='' +if release_response=$(gh api --include "repos/${repo}/releases/tags/${tag}" 2>&1); then + release_exists=true +elif grep -E -q '^HTTP/[0-9.]+ 404([[:space:]]|$)' <<<"${release_response}"; then + release_exists=false +else + printf '%s\n' "${release_response}" >&2 + exit 1 +fi + +if [[ "${release_exists}" == true ]]; then + gh release edit "${tag}" \ + --prerelease \ + --target "${sha}" \ + --title "${title}" \ + --notes "${notes}" \ + --repo "${repo}" + gh release upload "${tag}" "${assets[@]}" \ + --clobber \ + --repo "${repo}" +else + gh release create "${tag}" "${assets[@]}" \ + --prerelease \ + --target "${sha}" \ + --title "${title}" \ + --notes "${notes}" \ + --repo "${repo}" +fi diff --git a/scripts/test-publish-sprig-rolling-release.sh b/scripts/test-publish-sprig-rolling-release.sh new file mode 100755 index 0000000000..71284d8f2e --- /dev/null +++ b/scripts/test-publish-sprig-rolling-release.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +publisher="${repo_root}/scripts/publish-sprig-rolling-release.sh" +sprig_workflow="${repo_root}/.github/workflows/sprig.yml" +ci_workflow="${repo_root}/.github/workflows/ci.yml" + +fail() { + echo "sprig rolling release contract failed: $*" >&2 + exit 1 +} + +[[ -x "${publisher}" ]] || fail "missing executable ${publisher}" +grep -F -q 'scripts/publish-sprig-rolling-release.sh' "${sprig_workflow}" \ + || fail "Sprig workflow must invoke the checked-in publisher" +grep -F -q 'scripts/test-publish-sprig-rolling-release.sh' "${ci_workflow}" \ + || fail "CI must execute the Sprig rolling release contract" + +tmp=$(mktemp -d) +trap 'rm -rf "${tmp}"' EXIT +mkdir -p "${tmp}/bin" "${tmp}/dist" +touch \ + "${tmp}/dist/sprig-aarch64-unknown-linux-musl.tar.gz" \ + "${tmp}/dist/sprig-aarch64-unknown-linux-musl.tar.gz.sha256" \ + "${tmp}/dist/sprig-x86_64-unknown-linux-musl.tar.gz" \ + "${tmp}/dist/sprig-x86_64-unknown-linux-musl.tar.gz.sha256" + +cat >"${tmp}/bin/gh" <<'EOF' +#!/usr/bin/env bash + +set -euo pipefail + +command_name=${1-} +shift || true +{ + printf '%s' "${command_name}" + for argument in "$@"; do + printf '\t%s' "${argument}" + done + printf '\n' +} >>"${GH_LOG:?}" + +case "${command_name}" in + api) + case "${GH_API_MODE:?}" in + existing) + printf 'HTTP/2.0 200 OK\n\n{"tag_name":"sprig-latest"}\n' + ;; + missing) + printf 'HTTP/2.0 404 Not Found\n\n{"message":"Not Found"}\n' + exit 1 + ;; + error) + printf 'HTTP/2.0 500 Internal Server Error\n\n{"message":"failure"}\n' + exit 1 + ;; + *) + echo "unexpected GH_API_MODE=${GH_API_MODE}" >&2 + exit 2 + ;; + esac + ;; + release) + exit "${GH_RELEASE_STATUS:-0}" + ;; + *) + echo "unexpected gh command: ${command_name}" >&2 + exit 2 + ;; +esac +EOF +chmod +x "${tmp}/bin/gh" + +run_publisher() { + local mode=$1 + local log=$2 + GH_API_MODE="${mode}" \ + GH_LOG="${log}" \ + GITHUB_REPOSITORY='BrianInAz/buzz' \ + GITHUB_SHA='0123456789abcdef' \ + PATH="${tmp}/bin:${PATH}" \ + SPRIG_DIST_DIR="${tmp}/dist" \ + "${publisher}" +} + +missing_log="${tmp}/missing.log" +run_publisher missing "${missing_log}" +grep -F -q $'api\t--include\trepos/BrianInAz/buzz/releases/tags/sprig-latest' "${missing_log}" \ + || fail "missing-release path must query the exact tag" +grep -F -q $'release\tcreate\tsprig-latest' "${missing_log}" \ + || fail "missing-release path must create sprig-latest" +grep -F -q $'\t--prerelease\t--target\t0123456789abcdef' "${missing_log}" \ + || fail "create must publish a prerelease at the triggering SHA" +grep -F -q $'\t--repo\tBrianInAz/buzz' "${missing_log}" \ + || fail "create must target the triggering repository explicitly" +if grep -F -q $'release\tedit\t' "${missing_log}" \ + || grep -F -q $'release\tupload\t' "${missing_log}"; then + fail "missing-release path must not edit or separately upload" +fi + +existing_log="${tmp}/existing.log" +run_publisher existing "${existing_log}" +grep -F -q $'release\tedit\tsprig-latest' "${existing_log}" \ + || fail "existing-release path must edit sprig-latest" +grep -F -q $'\t--prerelease\t--target\t0123456789abcdef' "${existing_log}" \ + || fail "edit must retarget the prerelease to the triggering SHA" +grep -F -q $'release\tupload\tsprig-latest' "${existing_log}" \ + || fail "existing-release path must replace rolling assets" +grep -F -q $'\t--clobber\t--repo\tBrianInAz/buzz' "${existing_log}" \ + || fail "asset replacement must be explicit and repository-scoped" +if grep -F -q $'release\tcreate\t' "${existing_log}"; then + fail "existing-release path must not create a duplicate release" +fi + +error_log="${tmp}/error.log" +if run_publisher error "${error_log}" >"${tmp}/error.out" 2>&1; then + fail "non-404 API failure must stop publication" +fi +grep -F -q '500 Internal Server Error' "${tmp}/error.out" \ + || fail "non-404 API failure must remain visible" +if grep -F -q $'release\t' "${error_log}"; then + fail "non-404 API failure must not attempt release mutation" +fi + +release_error_log="${tmp}/release-error.log" +if GH_RELEASE_STATUS=23 run_publisher missing "${release_error_log}" >/dev/null 2>&1; then + fail "release command failure must remain fatal" +fi + +echo "sprig rolling release contract passed"