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
160 changes: 160 additions & 0 deletions .github/scripts/test_release_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Exercise release shell steps without network access, Git writes or real delays."""

import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import textwrap
import unittest


WORKFLOW = Path(__file__).resolve().parents[1] / "workflows" / "release.yml"
SHA = "a" * 40
MODULE = "github.com/QoderAI/qoder-cloud-agents-sdk-go"
PROXY_MODULE = "github.com/!qoder!a!i/qoder-cloud-agents-sdk-go"
CHECK_STEPS = (
"Validate main, module version, tag, and proxy state",
"Revalidate main and publish tag",
)


def step_script(name):
lines = WORKFLOW.read_text().splitlines()
start = lines.index(f" - name: {name}")
start = lines.index(" run: |", start) + 1
end = start
while end < len(lines) and (not lines[end].strip() or lines[end].startswith(" ")):
end += 1
return textwrap.dedent("\n".join(lines[start:end]))


FAKE_COMMAND = r"""
import json
import os
from pathlib import Path
import sys

command = Path(sys.argv[0]).name
args = sys.argv[1:]
root = Path(os.environ["RUNNER_TEMP"])
with (root / "commands.jsonl").open("a") as log:
log.write(json.dumps([command, *args]) + "\n")
tag_exists = os.environ["TEST_TAG_EXISTS"] == "true"
if command == "git":
if args[0] == "rev-parse":
if "-q" in args and not tag_exists:
sys.exit(1)
print(os.environ["COMMIT_SHA"])
elif args[0] == "show-ref":
sys.exit(0 if tag_exists else 1)
elif args[0] == "cat-file":
print("tag")
elif args[0] == "rev-list":
print(os.environ["COMMIT_SHA"])
elif args[0] == "for-each-ref":
print(f'Release v{os.environ["RELEASE_VERSION"]} (batch_id: {os.environ["BATCH_ID"]})')
elif command == "go":
print(os.environ["MODULE_PATH"])
elif command == "curl":
count_file = root / "requests"
count = int(count_file.read_text()) + 1 if count_file.exists() else 1
count_file.write_text(str(count))
ready = int(os.environ["TEST_READY_AFTER"])
status = "200" if ready and count > ready else os.environ["TEST_HTTP_STATUS"]
Path(args[args.index("--output") + 1]).write_text(
'{"Version":"v0.7.0"}' if status == "200" else "not found: unknown revision v0.7.0"
)
if "--dump-header" in args:
Path(args[args.index("--dump-header") + 1]).write_text(
f"HTTP/2 {status}\ncache-control: public, max-age=1800\n"
)
print(status, end="")
"""


class ReleaseProxyTests(unittest.TestCase):
def run_step(self, name, *, tag_exists=False, status="404", ready_after=0, version="0.7.0"):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for command in ("git", "go", "make", "curl", "sleep"):
path = root / command
path.write_text(f"#!{sys.executable}\n{FAKE_COMMAND}")
path.chmod(0o755)
env = os.environ | {
"PATH": f"{root}{os.pathsep}{os.environ['PATH']}",
"RUNNER_TEMP": directory,
"COMMIT_SHA": SHA,
"RELEASE_VERSION": version,
"BATCH_ID": "proxy-test",
"MODULE_PATH": MODULE,
"PROXY_MODULE_PATH": PROXY_MODULE,
"TEST_TAG_EXISTS": str(tag_exists).lower(),
"TEST_HTTP_STATUS": status,
"TEST_READY_AFTER": str(ready_after),
}
result = subprocess.run(
["bash", "-c", step_script(name)], env=env, cwd=root,
capture_output=True, text=True, timeout=30,
)
calls = [json.loads(line) for line in (root / "commands.jsonl").read_text().splitlines()]
return result, calls

def test_unpublished_version_uses_cached_only_and_can_create_tag(self):
for name in CHECK_STEPS:
with self.subTest(step=name):
result, calls = self.run_step(name, version="0.8.0-RC.1")
self.assertEqual(result.returncode, 0, result.stderr)
urls = [call[-1] for call in calls if call[0] == "curl"]
self.assertEqual(urls, [
f"https://proxy.golang.org/cached-only/{PROXY_MODULE}/@v/v0.8.0-!r!c.1.info"
])
if name == CHECK_STEPS[1]:
self.assertTrue(any(call[:2] == ["git", "push"] for call in calls))

def test_cached_publication_without_tag_is_rejected(self):
for name in CHECK_STEPS:
with self.subTest(step=name):
result, calls = self.run_step(name, status="200")
self.assertNotEqual(result.returncode, 0)
self.assertIn("remote tag is absent", result.stderr)
self.assertFalse(any(call[:2] == ["git", "push"] for call in calls))

def test_matching_tag_is_reused_without_push(self):
for status in ("200", "404", "410"):
with self.subTest(status=status):
result, calls = self.run_step(CHECK_STEPS[1], tag_exists=True, status=status)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("reusing it", result.stdout)
self.assertFalse(any(call[:2] == ["git", "push"] for call in calls))

def test_unexpected_proxy_error_stops_before_publish(self):
for name in CHECK_STEPS:
with self.subTest(step=name):
result, calls = self.run_step(name, status="503")
self.assertNotEqual(result.returncode, 0)
self.assertIn("HTTP 503", result.stderr)
self.assertFalse(any(call[:2] == ["git", "push"] for call in calls))

def test_verification_survives_thirty_minute_negative_cache(self):
result, calls = self.run_step("Wait for the public Go proxy", ready_after=61)
self.assertEqual(result.returncode, 0, result.stderr)
urls = [call[-1] for call in calls if call[0] == "curl"]
self.assertEqual(len(urls), 62)
self.assertEqual(set(urls), {
f"https://proxy.golang.org/{PROXY_MODULE}/@v/v0.7.0.info"
})
self.assertGreater(sum(int(call[1]) for call in calls if call[0] == "sleep"), 1800)

def test_unavailable_version_fails_with_proxy_diagnostics(self):
result, calls = self.run_step("Wait for the public Go proxy")
self.assertNotEqual(result.returncode, 0)
delays = [int(call[1]) for call in calls if call[0] == "sleep"]
self.assertEqual(sum(delays), 2100)
self.assertIn("cache-control: public, max-age=1800", result.stderr)
self.assertIn("unknown revision v0.7.0", result.stderr)


if __name__ == "__main__":
unittest.main()
23 changes: 15 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ jobs:

proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
proxy_response="$RUNNER_TEMP/go-proxy-version"
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
# Do not fetch a missing tag from origin and seed a 30-minute negative cache.
proxy_url="https://proxy.golang.org/cached-only/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
if ! proxy_status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output "$proxy_response" --write-out '%{http_code}' "$proxy_url"); then
echo "Failed to query the public Go proxy" >&2
exit 1
Expand Down Expand Up @@ -241,7 +242,8 @@ jobs:
fi

proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
# Check for an existing publication without fetching an unpublished tag.
proxy_url="https://proxy.golang.org/cached-only/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
proxy_status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output /dev/null --write-out '%{http_code}' "$proxy_url")
if [[ "$proxy_status" == 200 && "$tag_exists" != true ]]; then
echo "Public Go proxy already has $tag but the remote tag is absent." >&2
Expand All @@ -268,6 +270,7 @@ jobs:
name: verify public module
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
Expand All @@ -288,21 +291,25 @@ jobs:
proxy_tag=$(python3 -c 'import sys; print("".join("!" + c.lower() if c.isupper() else c for c in sys.argv[1]))' "$tag")
proxy_url="https://proxy.golang.org/${PROXY_MODULE_PATH}/@v/${proxy_tag}.info"
proxy_response="$RUNNER_TEMP/go-proxy-version-info"
for ((attempt = 1; attempt <= 30; attempt++)); do
proxy_headers="$RUNNER_TEMP/go-proxy-version-headers"
# An external request before tag creation can still cache a miss for 30 minutes.
max_attempts=71
for ((attempt = 1; attempt <= max_attempts; attempt++)); do
proxy_status=000
if status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --output "$proxy_response" --write-out '%{http_code}' "$proxy_url"); then
if status=$(curl --connect-timeout 10 --max-time 30 --silent --show-error --dump-header "$proxy_headers" --output "$proxy_response" --write-out '%{http_code}' "$proxy_url"); then
proxy_status=$status
fi
if [[ "$proxy_status" == 200 ]]; then
echo "Public Go proxy serves $tag"
break
fi
if [[ "$attempt" == 30 ]]; then
echo "Public Go proxy did not serve $tag after 30 attempts (last HTTP status: $proxy_status)" >&2
if [[ "$attempt" == "$max_attempts" ]]; then
echo "Public Go proxy did not serve $tag after $max_attempts attempts (last HTTP status: $proxy_status)" >&2
cat "$proxy_headers" "$proxy_response" >&2 || true
exit 1
fi
echo "Waiting for $tag on the public Go proxy (attempt $attempt/30, HTTP $proxy_status)"
sleep 10
echo "Waiting for $tag on the public Go proxy (attempt $attempt/$max_attempts, HTTP $proxy_status)"
sleep 30
done

- name: Verify clean module consumption
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ For each release, merge a focused pull request that updates `convention/version.

The workflow revalidates `main`, runs the offline lint, build, test, documentation, and example gates, and creates 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, then publishes the changelog entry as a GitHub Release. 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.

Before creating the tag, proxy checks use the `/cached-only` endpoint so they do
not fetch an unpublished version and cache a miss. After publishing, verification
uses the normal public proxy, with 35 minutes of retry delays and a 45-minute job
timeout. This accommodates the [proxy's 30-minute negative cache](https://proxy.golang.org/)
if another client requested the version before its tag existed. A final failure
prints the response headers and body for diagnosis. Once the proxy serves the
expected version, rerun the failed jobs with the original version, SHA and batch;
do not recreate the tag.

## Pull requests

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.
Expand Down
Loading