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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 1 addition & 13 deletions .github/workflows/sprig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions docs/ci-sprig-rolling-release.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions scripts/publish-sprig-rolling-release.sh
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions scripts/test-publish-sprig-rolling-release.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading