Skip to content

Commit c036db9

Browse files
ci(release): gate module tags behind manual approval (#4)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 02996c9 commit c036db9

4 files changed

Lines changed: 391 additions & 9 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
name: release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: Canonical Go module version without the v prefix
8+
required: true
9+
type: string
10+
commit_sha:
11+
description: Full 40-character lowercase SHA of the current main commit
12+
required: true
13+
type: string
14+
batch_id:
15+
description: '1-64 characters; start with a letter or digit; use only letters, digits, ., _, or -'
16+
required: true
17+
type: string
18+
19+
concurrency:
20+
group: release
21+
cancel-in-progress: false
22+
23+
permissions:
24+
contents: read
25+
26+
env:
27+
MODULE_PATH: github.com/QoderAI/qoder-cloud-agents-sdk-go
28+
PROXY_MODULE_PATH: github.com/!qoder!a!i/qoder-cloud-agents-sdk-go
29+
30+
jobs:
31+
preflight:
32+
name: preflight
33+
runs-on: ubuntu-latest
34+
permissions:
35+
contents: read
36+
steps:
37+
- name: Validate dispatch inputs
38+
shell: bash
39+
env:
40+
RELEASE_VERSION: ${{ inputs.version }}
41+
COMMIT_SHA: ${{ inputs.commit_sha }}
42+
BATCH_ID: ${{ inputs.batch_id }}
43+
run: |
44+
set -euo pipefail
45+
46+
if [[ "$GITHUB_REF" != refs/heads/main || "$GITHUB_WORKFLOW_REF" != *@refs/heads/main ]]; then
47+
echo "Release workflow must be dispatched from the main workflow ref." >&2
48+
exit 1
49+
fi
50+
if [[ ! "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]; then
51+
echo "commit_sha must be a full 40-character lowercase commit SHA" >&2
52+
exit 1
53+
fi
54+
if [[ ! "$BATCH_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]]; then
55+
echo "batch_id must be 1-64 safe audit-token characters" >&2
56+
exit 1
57+
fi
58+
59+
semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
60+
if [[ ! "$RELEASE_VERSION" =~ $semver_re ]]; then
61+
echo "version must be canonical Go semver without a v prefix or build metadata" >&2
62+
exit 1
63+
fi
64+
if [[ "$RELEASE_VERSION" == *-* ]]; then
65+
prerelease="${RELEASE_VERSION#*-}"
66+
IFS=. read -ra identifiers <<< "$prerelease"
67+
for identifier in "${identifiers[@]}"; do
68+
if [[ "$identifier" =~ ^[0-9]+$ && "$identifier" != "0" && "$identifier" == 0* ]]; then
69+
echo "version has a numeric prerelease identifier with a leading zero: $identifier" >&2
70+
exit 1
71+
fi
72+
done
73+
fi
74+
75+
- name: Check out the approved commit
76+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
77+
with:
78+
ref: ${{ inputs.commit_sha }}
79+
fetch-depth: 0
80+
81+
- name: Set up Go
82+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
83+
with:
84+
go-version: "1.26.x"
85+
cache: true
86+
87+
- name: Validate main, module version, tag, and proxy state
88+
shell: bash
89+
env:
90+
RELEASE_VERSION: ${{ inputs.version }}
91+
COMMIT_SHA: ${{ inputs.commit_sha }}
92+
BATCH_ID: ${{ inputs.batch_id }}
93+
run: |
94+
set -euo pipefail
95+
96+
git fetch --force --prune origin \
97+
'+refs/heads/main:refs/remotes/origin/main' \
98+
'+refs/tags/*:refs/tags/*'
99+
100+
checked_out_sha=$(git rev-parse HEAD)
101+
main_sha=$(git rev-parse refs/remotes/origin/main)
102+
tag="v${RELEASE_VERSION}"
103+
tag_sha=$(git rev-parse -q --verify "refs/tags/$tag^{commit}" || true)
104+
if [[ "$checked_out_sha" != "$COMMIT_SHA" ]]; then
105+
echo "Checked out $checked_out_sha instead of requested $COMMIT_SHA" >&2
106+
exit 1
107+
fi
108+
if [[ "$main_sha" != "$COMMIT_SHA" && "$tag_sha" != "$COMMIT_SHA" ]]; then
109+
echo "commit_sha $COMMIT_SHA is not current origin/main $main_sha and has no matching release tag" >&2
110+
exit 1
111+
fi
112+
113+
VERSION="$RELEASE_VERSION" make check-version
114+
115+
module=$(go list -m -f '{{.Path}}')
116+
if [[ "$module" != "$MODULE_PATH" ]]; then
117+
echo "Workflow module $MODULE_PATH does not match go.mod module $module" >&2
118+
exit 1
119+
fi
120+
121+
tag="v${RELEASE_VERSION}"
122+
tag_exists=false
123+
if git show-ref --verify --quiet "refs/tags/$tag"; then
124+
tag_exists=true
125+
if [[ "$(git cat-file -t "refs/tags/$tag")" != tag ]]; then
126+
echo "Remote tag $tag must be annotated." >&2
127+
exit 1
128+
fi
129+
tag_sha=$(git rev-list -n 1 "refs/tags/$tag")
130+
if [[ "$tag_sha" != "$COMMIT_SHA" ]]; then
131+
echo "Remote tag $tag already points to $tag_sha" >&2
132+
exit 1
133+
fi
134+
tag_message=$(git for-each-ref --format='%(contents)' "refs/tags/$tag")
135+
if [[ "$tag_message" != "Release $tag (batch_id: $BATCH_ID)" ]]; then
136+
echo "Remote tag $tag does not have the expected release annotation." >&2
137+
exit 1
138+
fi
139+
fi
140+
141+
proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
142+
proxy_response="$RUNNER_TEMP/go-proxy-version"
143+
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
144+
if ! proxy_status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output "$proxy_response" --write-out '%{http_code}' "$proxy_url"); then
145+
echo "Failed to query the public Go proxy" >&2
146+
exit 1
147+
fi
148+
case "$proxy_status" in
149+
200)
150+
if [[ "$tag_exists" != true ]]; then
151+
echo "Public Go proxy already has $tag but the remote tag is absent" >&2
152+
exit 1
153+
fi
154+
;;
155+
404|410)
156+
;;
157+
*)
158+
echo "Public Go proxy returned HTTP $proxy_status" >&2
159+
exit 1
160+
;;
161+
esac
162+
163+
- name: Lint
164+
run: make lint
165+
- name: Build
166+
run: make build
167+
- name: Test
168+
run: make test
169+
- name: Check generated documentation
170+
run: make docs-check
171+
- name: Build examples
172+
run: go build ./examples/...
173+
174+
publish:
175+
name: publish tag
176+
needs: preflight
177+
runs-on: ubuntu-latest
178+
environment: release
179+
permissions:
180+
contents: write
181+
steps:
182+
- name: Check out the approved commit
183+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
184+
with:
185+
ref: ${{ inputs.commit_sha }}
186+
fetch-depth: 0
187+
188+
- name: Revalidate main and publish tag
189+
shell: bash
190+
env:
191+
RELEASE_VERSION: ${{ inputs.version }}
192+
COMMIT_SHA: ${{ inputs.commit_sha }}
193+
BATCH_ID: ${{ inputs.batch_id }}
194+
run: |
195+
set -euo pipefail
196+
197+
git fetch --force --prune origin \
198+
'+refs/heads/main:refs/remotes/origin/main' \
199+
'+refs/tags/*:refs/tags/*'
200+
201+
checked_out_sha=$(git rev-parse HEAD)
202+
main_sha=$(git rev-parse refs/remotes/origin/main)
203+
tag="v${RELEASE_VERSION}"
204+
tag_sha=$(git rev-parse -q --verify "refs/tags/$tag^{commit}" || true)
205+
if [[ "$checked_out_sha" != "$COMMIT_SHA" ]]; then
206+
echo "Checked out $checked_out_sha instead of requested $COMMIT_SHA" >&2
207+
exit 1
208+
fi
209+
if [[ "$main_sha" != "$COMMIT_SHA" && "$tag_sha" != "$COMMIT_SHA" ]]; then
210+
echo "origin/main moved to $main_sha before the release tag was created; dispatch a new release" >&2
211+
exit 1
212+
fi
213+
214+
tag_exists=false
215+
if git show-ref --verify --quiet "refs/tags/$tag"; then
216+
tag_exists=true
217+
if [[ "$(git cat-file -t "refs/tags/$tag")" != tag ]]; then
218+
echo "Remote tag $tag must be annotated." >&2
219+
exit 1
220+
fi
221+
tag_sha=$(git rev-list -n 1 "refs/tags/$tag")
222+
if [[ "$tag_sha" != "$COMMIT_SHA" ]]; then
223+
echo "Remote tag $tag already points to $tag_sha" >&2
224+
exit 1
225+
fi
226+
tag_message=$(git for-each-ref --format='%(contents)' "refs/tags/$tag")
227+
if [[ "$tag_message" != "Release $tag (batch_id: $BATCH_ID)" ]]; then
228+
echo "Remote tag $tag does not have the expected release annotation." >&2
229+
exit 1
230+
fi
231+
fi
232+
233+
proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
234+
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
235+
proxy_status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output /dev/null --write-out '%{http_code}' "$proxy_url")
236+
if [[ "$proxy_status" == 200 && "$tag_exists" != true ]]; then
237+
echo "Public Go proxy already has $tag but the remote tag is absent." >&2
238+
exit 1
239+
fi
240+
if [[ "$proxy_status" != 200 && "$proxy_status" != 404 && "$proxy_status" != 410 ]]; then
241+
echo "Public Go proxy returned HTTP $proxy_status." >&2
242+
exit 1
243+
fi
244+
245+
if [[ "$tag_exists" == true ]]; then
246+
echo "Remote tag $tag already points to $COMMIT_SHA; reusing it"
247+
exit 0
248+
fi
249+
250+
git config user.name "github-actions[bot]"
251+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
252+
git tag --annotate "$tag" "$COMMIT_SHA" \
253+
--message "Release $tag (batch_id: $BATCH_ID)"
254+
git push --atomic --force-with-lease="refs/heads/main:$COMMIT_SHA" origin \
255+
"${COMMIT_SHA}:refs/heads/main" "refs/tags/$tag"
256+
257+
verify:
258+
name: verify public module
259+
needs: publish
260+
runs-on: ubuntu-latest
261+
permissions:
262+
contents: read
263+
steps:
264+
- name: Set up Go
265+
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
266+
with:
267+
go-version: "1.26.x"
268+
cache: false
269+
270+
- name: Wait for the public Go proxy
271+
shell: bash
272+
env:
273+
RELEASE_VERSION: ${{ inputs.version }}
274+
run: |
275+
set -euo pipefail
276+
277+
tag="v${RELEASE_VERSION}"
278+
proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
279+
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
280+
proxy_response="$RUNNER_TEMP/go-proxy-version-info"
281+
for ((attempt = 1; attempt <= 30; attempt++)); do
282+
proxy_status=000
283+
if status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output "$proxy_response" --write-out '%{http_code}' "$proxy_url"); then
284+
proxy_status=$status
285+
fi
286+
if [[ "$proxy_status" == 200 ]]; then
287+
echo "Public Go proxy serves $tag"
288+
break
289+
fi
290+
if [[ "$attempt" == 30 ]]; then
291+
echo "Public Go proxy did not serve $tag after 30 attempts (last HTTP status: $proxy_status)" >&2
292+
exit 1
293+
fi
294+
echo "Waiting for $tag on the public Go proxy (attempt $attempt/30, HTTP $proxy_status)"
295+
sleep 10
296+
done
297+
298+
- name: Verify clean module consumption
299+
shell: bash
300+
env:
301+
RELEASE_VERSION: ${{ inputs.version }}
302+
COMMIT_SHA: ${{ inputs.commit_sha }}
303+
run: |
304+
set -euo pipefail
305+
306+
tag="v${RELEASE_VERSION}"
307+
temp_root=$(mktemp -d)
308+
cleanup() {
309+
chmod -R u+w "$temp_root" 2>/dev/null || true
310+
rm -rf "$temp_root"
311+
}
312+
trap cleanup EXIT
313+
export GOMODCACHE="$temp_root/gomodcache"
314+
export GOCACHE="$temp_root/gocache"
315+
export GOPROXY=https://proxy.golang.org
316+
module_dir="$temp_root/module"
317+
mkdir -p "$GOMODCACHE" "$GOCACHE" "$module_dir"
318+
cd "$module_dir"
319+
320+
go mod init example.com/qoder-release-verification
321+
download_json=$(go mod download -json "${MODULE_PATH}@${tag}")
322+
origin_hash=$(python3 -c 'import json, sys; print(json.load(sys.stdin).get("Origin", {}).get("Hash", ""))' <<< "$download_json")
323+
if [[ "$origin_hash" != "$COMMIT_SHA" ]]; then
324+
echo "Public Go proxy origin is $origin_hash, not approved commit $COMMIT_SHA." >&2
325+
exit 1
326+
fi
327+
go get "${MODULE_PATH}@${tag}"
328+
selected_version=$(go list -m -f '{{.Version}}' "$MODULE_PATH")
329+
if [[ "$selected_version" != "$tag" ]]; then
330+
echo "Resolved $MODULE_PATH@$selected_version instead of $tag" >&2
331+
exit 1
332+
fi
333+
334+
cat > main.go <<EOF
335+
package main
336+
337+
import (
338+
_ "${MODULE_PATH}/convention"
339+
_ "${MODULE_PATH}/forward"
340+
_ "${MODULE_PATH}/managed"
341+
)
342+
343+
func main() {}
344+
EOF
345+
gofmt -w main.go
346+
go build .

‎CONTRIBUTING.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ The fixtures are maintained manually. A passing fixture test proves consistency
5252

5353
The SDK intentionally keeps Qoder-branded `X-Qoder-*` metadata headers and resumable session-event streams. Preserve those extensions unless the change explicitly revises the public contract. Breaking public API changes require a minor-version release while the SDK remains pre-1.0 and must include migration notes.
5454

55+
## Release
56+
57+
Before the first release, create the GitHub `release` Environment with required reviewers and a deployment-branch rule limited to `main`. Add a tag ruleset for `refs/tags/v*` that blocks updates and deletions and allows creation only by the release automation identity used by this workflow. Keep those protections enabled; do not dispatch the workflow until they are configured.
58+
59+
For each release, merge a focused pull request that updates `convention/version.go` and any release notes, then run `make check-version VERSION=<version>` locally. From the workflow page, select the `main` ref and provide the version without `v`, the current full lowercase 40-character `main` commit SHA, and a 1-64 character `batch_id` that starts with a letter or digit and otherwise contains only letters, digits, `.`, `_`, or `-`.
60+
61+
The workflow revalidates `main`, runs the offline lint, build, test, documentation, and example gates, and creates only the annotated module tag after Environment approval. It then waits for the public Go proxy and verifies that the exact tag resolves to the approved commit from empty module and build caches. Release tags are immutable: never move, delete, or overwrite one. Fix a bad release forward with a new version bump and a new workflow run.
62+
5563
## Pull requests
5664

5765
Complete the pull request template, include exact verification commands and results, and identify public API, documentation, integration-test, and cross-SDK effects. Do not combine unrelated refactors with behavior changes.

0 commit comments

Comments
 (0)