Skip to content

Commit ce5acf4

Browse files
authored
fix(ci): bootstrap Sprig rolling release (#29)
Signed-off-by: Brian Charbonneau <github@briancharbonneau.com>
1 parent 4ff4064 commit ce5acf4

5 files changed

Lines changed: 252 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ jobs:
7777
run: scripts/test-release-ref-contract.sh
7878
- name: Private CA release workflow contract
7979
run: scripts/test-private-ca-release-contract.sh
80+
- name: Sprig rolling release contract
81+
run: scripts/test-publish-sprig-rolling-release.sh
8082
- name: Desktop release candidate contract
8183
run: scripts/test-desktop-release-candidate.sh
8284
- name: Mobile release contract

.github/workflows/sprig.yml

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,19 +131,7 @@ jobs:
131131
- name: Update rolling release
132132
env:
133133
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
134-
REPO: ${{ github.repository }}
135-
SHA: ${{ github.sha }}
136-
run: |
137-
set -euo pipefail
138-
TAG="sprig-latest"
139-
TITLE="Sprig (rolling)"
140-
NOTES="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${SHA}\`)."
141-
142-
gh release edit "$TAG" \
143-
--prerelease \
144-
--title "$TITLE" \
145-
--notes "$NOTES"
146-
gh release upload "$TAG" dist/* --clobber
134+
run: scripts/publish-sprig-rolling-release.sh
147135

148136
publish-tag:
149137
name: Publish tagged release

docs/ci-sprig-rolling-release.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Sprig rolling-release bootstrap remediation
2+
3+
## Status
4+
5+
Implemented with TDD on `fix/sprig-rolling-release-bootstrap` from merged
6+
`origin/main` commit `4deea1a0d7fc`. Beads item `ios-buzz-59e.9` tracks
7+
delivery. The pull request and merged commit are recorded at promotion time.
8+
9+
## Root cause
10+
11+
The `Sprig` workflow successfully built both static Linux artifacts on a push
12+
to `main`, then unconditionally ran `gh release edit sprig-latest`. This fork
13+
had no `sprig-latest` release, so its first rolling publication failed with
14+
`release not found` after all build work had completed.
15+
16+
Creating the release manually would leave the workflow unable to bootstrap a
17+
new fork or recover after deliberate release removal. The defect therefore
18+
required a source-controlled fix rather than a one-time GitHub mutation.
19+
20+
## Remediation
21+
22+
The workflow now delegates rolling publication to
23+
`scripts/publish-sprig-rolling-release.sh`. The helper:
24+
25+
- validates the repository, triggering SHA, GitHub CLI, artifact directory,
26+
and non-empty artifact set before mutation;
27+
- queries the exact `sprig-latest` release through the GitHub API;
28+
- creates the prerelease with all assets when the API returns `404`;
29+
- updates metadata and replaces assets when the release already exists;
30+
- treats every non-404 query failure and every release command failure as
31+
fatal; and
32+
- explicitly scopes all release operations to the triggering repository.
33+
34+
No release, tag, or asset is created manually. The first successful
35+
post-merge workflow run is the acceptance path that bootstraps
36+
`sprig-latest`.
37+
38+
## TDD evidence
39+
40+
The new contract first failed because the checked-in publisher did not exist.
41+
After implementation it proves:
42+
43+
- a missing release performs one create operation and no edit/upload path;
44+
- an existing release performs edit plus clobber upload and no create path;
45+
- both paths publish metadata for the triggering SHA;
46+
- a non-404 API failure stops before any release mutation;
47+
- a release command failure remains fatal; and
48+
- both the Sprig workflow and the Ubuntu CI detector execute the checked-in
49+
helper and contract respectively.
50+
51+
## Rollback
52+
53+
Revert the delivery pull request. If the first successful promotion created
54+
`sprig-latest`, retain it unless release removal is separately authorized;
55+
deleting a published release or its tag is not part of this rollback.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
repo=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}
6+
sha=${GITHUB_SHA:?GITHUB_SHA is required}
7+
dist_dir=${SPRIG_DIST_DIR:-dist}
8+
tag='sprig-latest'
9+
title='Sprig (rolling)'
10+
notes="Rolling Linux build of Sprig (all-in-one buzz-acp + buzz-agent + buzz-dev-mcp), tracking \`main\` (\`${sha}\`)."
11+
12+
if ! command -v gh >/dev/null 2>&1; then
13+
echo "gh is required to publish the Sprig rolling release" >&2
14+
exit 1
15+
fi
16+
17+
if [[ ! "${repo}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
18+
echo "GITHUB_REPOSITORY must use the owner/repository form" >&2
19+
exit 1
20+
fi
21+
22+
if [[ ! -d "${dist_dir}" ]]; then
23+
echo "Sprig artifact directory does not exist: ${dist_dir}" >&2
24+
exit 1
25+
fi
26+
27+
shopt -s nullglob
28+
assets=("${dist_dir}"/*)
29+
shopt -u nullglob
30+
if ((${#assets[@]} == 0)); then
31+
echo "Sprig artifact directory is empty: ${dist_dir}" >&2
32+
exit 1
33+
fi
34+
35+
release_response=''
36+
if release_response=$(gh api --include "repos/${repo}/releases/tags/${tag}" 2>&1); then
37+
release_exists=true
38+
elif grep -E -q '^HTTP/[0-9.]+ 404([[:space:]]|$)' <<<"${release_response}"; then
39+
release_exists=false
40+
else
41+
printf '%s\n' "${release_response}" >&2
42+
exit 1
43+
fi
44+
45+
if [[ "${release_exists}" == true ]]; then
46+
gh release edit "${tag}" \
47+
--prerelease \
48+
--target "${sha}" \
49+
--title "${title}" \
50+
--notes "${notes}" \
51+
--repo "${repo}"
52+
gh release upload "${tag}" "${assets[@]}" \
53+
--clobber \
54+
--repo "${repo}"
55+
else
56+
gh release create "${tag}" "${assets[@]}" \
57+
--prerelease \
58+
--target "${sha}" \
59+
--title "${title}" \
60+
--notes "${notes}" \
61+
--repo "${repo}"
62+
fi
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
6+
publisher="${repo_root}/scripts/publish-sprig-rolling-release.sh"
7+
sprig_workflow="${repo_root}/.github/workflows/sprig.yml"
8+
ci_workflow="${repo_root}/.github/workflows/ci.yml"
9+
10+
fail() {
11+
echo "sprig rolling release contract failed: $*" >&2
12+
exit 1
13+
}
14+
15+
[[ -x "${publisher}" ]] || fail "missing executable ${publisher}"
16+
grep -F -q 'scripts/publish-sprig-rolling-release.sh' "${sprig_workflow}" \
17+
|| fail "Sprig workflow must invoke the checked-in publisher"
18+
grep -F -q 'scripts/test-publish-sprig-rolling-release.sh' "${ci_workflow}" \
19+
|| fail "CI must execute the Sprig rolling release contract"
20+
21+
tmp=$(mktemp -d)
22+
trap 'rm -rf "${tmp}"' EXIT
23+
mkdir -p "${tmp}/bin" "${tmp}/dist"
24+
touch \
25+
"${tmp}/dist/sprig-aarch64-unknown-linux-musl.tar.gz" \
26+
"${tmp}/dist/sprig-aarch64-unknown-linux-musl.tar.gz.sha256" \
27+
"${tmp}/dist/sprig-x86_64-unknown-linux-musl.tar.gz" \
28+
"${tmp}/dist/sprig-x86_64-unknown-linux-musl.tar.gz.sha256"
29+
30+
cat >"${tmp}/bin/gh" <<'EOF'
31+
#!/usr/bin/env bash
32+
33+
set -euo pipefail
34+
35+
command_name=${1-}
36+
shift || true
37+
{
38+
printf '%s' "${command_name}"
39+
for argument in "$@"; do
40+
printf '\t%s' "${argument}"
41+
done
42+
printf '\n'
43+
} >>"${GH_LOG:?}"
44+
45+
case "${command_name}" in
46+
api)
47+
case "${GH_API_MODE:?}" in
48+
existing)
49+
printf 'HTTP/2.0 200 OK\n\n{"tag_name":"sprig-latest"}\n'
50+
;;
51+
missing)
52+
printf 'HTTP/2.0 404 Not Found\n\n{"message":"Not Found"}\n'
53+
exit 1
54+
;;
55+
error)
56+
printf 'HTTP/2.0 500 Internal Server Error\n\n{"message":"failure"}\n'
57+
exit 1
58+
;;
59+
*)
60+
echo "unexpected GH_API_MODE=${GH_API_MODE}" >&2
61+
exit 2
62+
;;
63+
esac
64+
;;
65+
release)
66+
exit "${GH_RELEASE_STATUS:-0}"
67+
;;
68+
*)
69+
echo "unexpected gh command: ${command_name}" >&2
70+
exit 2
71+
;;
72+
esac
73+
EOF
74+
chmod +x "${tmp}/bin/gh"
75+
76+
run_publisher() {
77+
local mode=$1
78+
local log=$2
79+
GH_API_MODE="${mode}" \
80+
GH_LOG="${log}" \
81+
GITHUB_REPOSITORY='BrianInAz/buzz' \
82+
GITHUB_SHA='0123456789abcdef' \
83+
PATH="${tmp}/bin:${PATH}" \
84+
SPRIG_DIST_DIR="${tmp}/dist" \
85+
"${publisher}"
86+
}
87+
88+
missing_log="${tmp}/missing.log"
89+
run_publisher missing "${missing_log}"
90+
grep -F -q $'api\t--include\trepos/BrianInAz/buzz/releases/tags/sprig-latest' "${missing_log}" \
91+
|| fail "missing-release path must query the exact tag"
92+
grep -F -q $'release\tcreate\tsprig-latest' "${missing_log}" \
93+
|| fail "missing-release path must create sprig-latest"
94+
grep -F -q $'\t--prerelease\t--target\t0123456789abcdef' "${missing_log}" \
95+
|| fail "create must publish a prerelease at the triggering SHA"
96+
grep -F -q $'\t--repo\tBrianInAz/buzz' "${missing_log}" \
97+
|| fail "create must target the triggering repository explicitly"
98+
if grep -F -q $'release\tedit\t' "${missing_log}" \
99+
|| grep -F -q $'release\tupload\t' "${missing_log}"; then
100+
fail "missing-release path must not edit or separately upload"
101+
fi
102+
103+
existing_log="${tmp}/existing.log"
104+
run_publisher existing "${existing_log}"
105+
grep -F -q $'release\tedit\tsprig-latest' "${existing_log}" \
106+
|| fail "existing-release path must edit sprig-latest"
107+
grep -F -q $'\t--prerelease\t--target\t0123456789abcdef' "${existing_log}" \
108+
|| fail "edit must retarget the prerelease to the triggering SHA"
109+
grep -F -q $'release\tupload\tsprig-latest' "${existing_log}" \
110+
|| fail "existing-release path must replace rolling assets"
111+
grep -F -q $'\t--clobber\t--repo\tBrianInAz/buzz' "${existing_log}" \
112+
|| fail "asset replacement must be explicit and repository-scoped"
113+
if grep -F -q $'release\tcreate\t' "${existing_log}"; then
114+
fail "existing-release path must not create a duplicate release"
115+
fi
116+
117+
error_log="${tmp}/error.log"
118+
if run_publisher error "${error_log}" >"${tmp}/error.out" 2>&1; then
119+
fail "non-404 API failure must stop publication"
120+
fi
121+
grep -F -q '500 Internal Server Error' "${tmp}/error.out" \
122+
|| fail "non-404 API failure must remain visible"
123+
if grep -F -q $'release\t' "${error_log}"; then
124+
fail "non-404 API failure must not attempt release mutation"
125+
fi
126+
127+
release_error_log="${tmp}/release-error.log"
128+
if GH_RELEASE_STATUS=23 run_publisher missing "${release_error_log}" >/dev/null 2>&1; then
129+
fail "release command failure must remain fatal"
130+
fi
131+
132+
echo "sprig rolling release contract passed"

0 commit comments

Comments
 (0)