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
17 changes: 11 additions & 6 deletions .github/scripts/parse_release_tag.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
"""Parse a release tag and emit setup outputs for release.yml.

Writes 12 keys to $GITHUB_OUTPUT:
Writes 13 keys to $GITHUB_OUTPUT:
tag, worker, version, deploy, language, bin, manifest,
registry_tag, is_prerelease, dry_run, targets, experimental
registry_tag, is_prerelease, dry_run, targets, experimental, tag_sha
"""
from __future__ import annotations

import argparse
import os
import pathlib
import re
import subprocess
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
Expand All @@ -19,7 +20,7 @@

TAG_RE = re.compile(r"^([a-z0-9][a-z0-9_-]*)/v(.+)$")
DRY_RUN_RE = re.compile(r"-dry-run\.\d+$")
PRERELEASE_RE = re.compile(r"-[a-z]+\.\d+$")
STABLE_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")


def main(argv: list[str] | None = None) -> int:
Expand All @@ -36,10 +37,10 @@ def main(argv: list[str] | None = None) -> int:

if DRY_RUN_RE.search(version):
dry_run, is_pre = "true", "true"
elif PRERELEASE_RE.search(version):
dry_run, is_pre = "false", "true"
else:
elif STABLE_VERSION_RE.fullmatch(version):
dry_run, is_pre = "false", "false"
else:
dry_run, is_pre = "false", "true"

worker_dir = pathlib.Path(worker)
try:
Expand All @@ -57,6 +58,9 @@ def main(argv: list[str] | None = None) -> int:

annotation = _lib.read_tag_annotation(raw)
registry_tag = annotation.get("registry-tag", "latest") or "latest"
tag_sha = subprocess.check_output(
["git", "rev-list", "-n", "1", raw], text=True
).strip()

# Anything but a literal `true` is false: a lightweight tag, a missing
# line, or a typo publishes as stable. Marking a worker experimental is
Expand Down Expand Up @@ -87,6 +91,7 @@ def main(argv: list[str] | None = None) -> int:
("dry_run", dry_run),
("targets", targets),
("experimental", experimental),
("tag_sha", tag_sha),
]

gh_out = os.environ.get("GITHUB_OUTPUT")
Expand Down
127 changes: 127 additions & 0 deletions .github/scripts/registry_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Promote a staged worker release through the Registry HTTP API."""

from __future__ import annotations

import argparse
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any


class RegistryError(RuntimeError):
pass


def request_json(
method: str,
url: str,
payload: dict[str, Any],
*,
api_key: str | None = None,
) -> tuple[int, dict[str, Any]]:
body = json.dumps(payload).encode()
headers = {"Content-Type": "application/json"}
if api_key:
headers["X-API-Key"] = api_key
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return response.status, json.loads(response.read().decode())
except urllib.error.HTTPError as error:
try:
response = json.loads(error.read().decode())
except (json.JSONDecodeError, UnicodeDecodeError):
response = {"error": f"HTTP {error.code}"}
return error.code, response


def resolved_root_version(response: dict[str, Any]) -> str:
root = response.get("root")
version = root.get("version") if isinstance(root, dict) else None
if not isinstance(version, str) or not version:
raise RegistryError("Registry resolve response has no root.version")
return version


def resolve_version(api_url: str, worker: str, selector: str, *, allow_missing: bool = False) -> str | None:
status, response = request_json(
"POST",
f"{api_url.rstrip('/')}/resolve",
{"worker": worker, "version": selector},
)
if status == 200:
return resolved_root_version(response)
error = response.get("error")
code = error.get("code") if isinstance(error, dict) else None
if allow_missing and code in {"version_not_found", "worker_not_found"}:
return None
raise RegistryError(f"resolve {worker}@{selector} failed with HTTP {status}: {json.dumps(response)}")


def promotion_payload(version: str, current_latest: str | None) -> dict[str, str]:
payload = {"version": version, "expected_tag": "next"}
if current_latest is not None:
payload["expected_current_version"] = current_latest
return payload


def promote(api_url: str, api_key: str, worker: str, version: str) -> dict[str, Any]:
current_latest = resolve_version(api_url, worker, "latest", allow_missing=True)
current_next = resolve_version(api_url, worker, "next", allow_missing=True)
# A first promotion must still own `next`. Once Registry latest already
# points at the requested immutable version, allow an idempotent retry to
# repair GitHub/GHCR/Slack even if a newer candidate has moved `next`.
if current_latest != version and current_next != version:
raise RegistryError(f"next points to {current_next}, expected {version}")
encoded_worker = urllib.parse.quote(worker, safe="")
status, response = request_json(
"PUT",
f"{api_url.rstrip('/')}/w/{encoded_worker}/tags/latest",
promotion_payload(version, current_latest),
Comment on lines +76 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  'expected_tag|expected_current_version|tags/latest|promotion_payload' \
  .

Repository: iii-hq/workers

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching registry_release.py:"
fd -a 'registry_release\.py$' . || true

echo
echo "Changed files/stat:"
git diff --stat || true

echo
echo "Search target filename:"
if [ -f .github/scripts/registry_release.py ]; then
  nl -ba .github/scripts/registry_release.py | sed -n '1,180p'
else
  echo ".github/scripts/registry_release.py not found"
fi

echo
echo "Broader search for target terms (case-insensitive):"
rg -n -i -C 6 \
  'expected_tag|expected_current_version|tags/latest|promotion_payload|current_next|current_latest' .github src . 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "pwd:"
pwd
echo

echo "Files matching registry_release.py:"
find . -type f -name 'registry_release.py' 2>/dev/null || true
echo

echo "Changed files/stat:"
git diff --stat 2>/dev/null || true
echo

echo "File contents:"
if [ -f .github/scripts/registry_release.py ]; then
  awk '{ printf "%6d\t%s\n", NR, $0 }' .github/scripts/registry_release.py | sed -n '1,180p'
else
  echo ".github/scripts/registry_release.py not found"
fi
echo

echo "Broader search for target terms:"
grep -RIn -C 6 -E 'expected_tag|expected_current_version|tags/latest|promotion_payload|current_next|current_latest' .github src . 2>/dev/null || true
echo

echo "Git status short:"
git status --short 2>/dev/null || true

Repository: iii-hq/workers

Length of output: 17601


Remove the next precondition from the idempotent retry path.

When latest already equals version, latest has moved, and next is version, the check allows the retry, but promotion_payload() still sends expected_tag: next. A Registry that requires ownership of the source tag can reject this retry. Return a verified no-op when current_latest == version, or use a promotion API that does not require ownership of next. Update the tests to stop asserting that idempotent reties include expected_tag: next.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/registry_release.py around lines 76 - 85, The promotion flow
around promotion_payload() must not require ownership of next when
current_latest already equals version. Return a verified no-op for this
idempotent retry path, or use a promotion API that omits expected_tag: next,
while preserving the existing next precondition for first promotions. Update the
related tests to stop expecting expected_tag: next on idempotent retries.

api_key=api_key,
)
if status != 200:
raise RegistryError(f"promotion failed with HTTP {status}: {json.dumps(response)}")

promoted = resolve_version(api_url, worker, "latest")
if promoted != version:
raise RegistryError(f"promotion verification resolved {promoted}, expected {version}")

return {
"worker": worker,
"version": version,
"previous_latest": current_latest,
"next": current_next,
"latest": promoted,
"changed": bool(response.get("changed")),
"registry_response": response,
}


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--api-url", default="https://api.workers.iii.dev")
parser.add_argument("--worker", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()

api_key = os.environ.get("WORKERS_REGISTRY_API_KEY", "")
if not api_key:
raise SystemExit("WORKERS_REGISTRY_API_KEY is required")
try:
result = promote(args.api_url, api_key, args.worker, args.version)
except RegistryError as error:
raise SystemExit(str(error)) from error
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
print(json.dumps(result, sort_keys=True))


if __name__ == "__main__":
main()
151 changes: 151 additions & 0 deletions .github/scripts/release_candidate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Build and validate evidence for a staged worker release."""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path


SHA_RE = re.compile(r"^[0-9a-f]{40}$")
VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")


def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized == "true":
return True
if normalized == "false":
return False
raise argparse.ArgumentTypeError("expected true or false")


def build_evidence(args: argparse.Namespace) -> dict:
results = {
"publish": args.publish_result,
"candidate_smoke": args.candidate_smoke_result,
"harness_quickstart": args.harness_quickstart_result,
"harness_e2e": args.harness_e2e_result,
}
candidate_ready = results["publish"] == "success" and results["candidate_smoke"] == "success"
if args.harness_gate_required:
candidate_ready = (
candidate_ready
and results["harness_quickstart"] == "success"
and results["harness_e2e"] == "success"
)

return {
"schema_version": 1,
"repository": args.repository,
"release_run_id": args.release_run_id,
"run_attempt": args.run_attempt,
"tag_sha": args.tag_sha,
"release_tag": args.release_tag,
"worker": args.worker,
"version": args.version,
"deploy": args.deploy,
"registry_tag": args.registry_tag,
"harness_gate_required": args.harness_gate_required,
"promotable": args.promotable,
"candidate_ready": candidate_ready,
"results": results,
}


def validate_evidence(args: argparse.Namespace) -> dict:
evidence = json.loads(args.evidence.read_text())
failures: list[str] = []

expected = {
"schema_version": 1,
"repository": args.repository,
"release_run_id": args.release_run_id,
"release_tag": f"{args.worker}/v{args.version}",
"worker": args.worker,
"version": args.version,
"registry_tag": "next",
"candidate_ready": True,
"promotable": True,
}
for key, value in expected.items():
if evidence.get(key) != value:
failures.append(f"{key}: expected {value!r}, got {evidence.get(key)!r}")

if not VERSION_RE.fullmatch(args.version):
failures.append("version must be stable semver MAJOR.MINOR.PATCH")
if not SHA_RE.fullmatch(str(evidence.get("tag_sha", ""))):
failures.append("tag_sha must be a full lowercase commit SHA")
if not isinstance(evidence.get("run_attempt"), int) or evidence["run_attempt"] < 1:
failures.append("run_attempt must be a positive integer")

results = evidence.get("results")
if not isinstance(results, dict):
failures.append("results must be an object")
else:
if results.get("publish") != "success":
failures.append("publish gate did not succeed")
if results.get("candidate_smoke") != "success":
failures.append("candidate smoke gate did not succeed")
if evidence.get("harness_gate_required"):
if results.get("harness_quickstart") != "success":
failures.append("Harness quickstart gate did not succeed")
if results.get("harness_e2e") != "success":
failures.append("Harness E2E gate did not succeed")
Comment on lines +92 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Derive the Harness gate policy independently of the evidence.

The evidence controls harness_gate_required. If a harness candidate records this field as false, validation accepts skipped Harness gates while candidate_ready remains true.

Require the expected gate policy from trusted promotion inputs. Compare that policy with the evidence before checking the results. Add a test for worker="harness" with harness_gate_required=False.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/release_candidate.py around lines 92 - 96, Update the
Harness validation flow around the evidence.get("harness_gate_required") check
to derive the expected gate requirement from trusted promotion inputs rather
than trusting the candidate evidence. Compare the derived policy with the
evidence before validating harness_quickstart and harness_e2e results, and
ensure a harness worker with harness_gate_required=False is rejected when the
trusted policy requires the gates; add coverage for that case.


if failures:
raise SystemExit("invalid release candidate evidence:\n- " + "\n- ".join(failures))
return evidence


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)

build = subparsers.add_parser("build")
build.add_argument("--repository", required=True)
build.add_argument("--release-run-id", required=True)
build.add_argument("--run-attempt", type=int, required=True)
build.add_argument("--tag-sha", required=True)
build.add_argument("--release-tag", required=True)
build.add_argument("--worker", required=True)
build.add_argument("--version", required=True)
build.add_argument("--deploy", choices=("binary", "image", "bundle"), required=True)
build.add_argument("--registry-tag", required=True)
build.add_argument("--harness-gate-required", type=parse_bool, required=True)
build.add_argument("--promotable", type=parse_bool, required=True)
build.add_argument("--publish-result", required=True)
build.add_argument("--candidate-smoke-result", required=True)
build.add_argument("--harness-quickstart-result", required=True)
build.add_argument("--harness-e2e-result", required=True)
build.add_argument("--output", type=Path, required=True)

validate = subparsers.add_parser("validate")
validate.add_argument("--evidence", type=Path, required=True)
validate.add_argument("--repository", required=True)
validate.add_argument("--release-run-id", required=True)
validate.add_argument("--worker", required=True)
validate.add_argument("--version", required=True)
validate.add_argument("--output", type=Path)
return parser


def main() -> None:
args = build_parser().parse_args()
if args.command == "build":
evidence = build_evidence(args)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n")
else:
evidence = validate_evidence(args)
rendered = json.dumps(evidence, sort_keys=True)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n")
print(rendered)


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions .github/scripts/tests/test_parse_release_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_stable_binary_tag(self, tmp_path):
assert out["registry_tag"] == "latest"
assert out["is_prerelease"] == "false"
assert out["dry_run"] == "false"
assert len(out["tag_sha"]) == 40

def test_prerelease_sets_is_prerelease(self, tmp_path):
repo = make_repo_with_tagged_worker(tmp_path, "smoke/v1.2.3-rc.1", "1.2.3-rc.1",
Expand All @@ -82,6 +83,19 @@ def test_prerelease_sets_is_prerelease(self, tmp_path):
assert out["dry_run"] == "false"
assert out["registry_tag"] == "next"

def test_non_numeric_prerelease_suffix_is_not_promotable_stable(self, tmp_path):
repo = make_repo_with_tagged_worker(
tmp_path,
"smoke/v1.2.3-preview",
"1.2.3-preview",
registry_tag_line="registry-tag: next",
)
out_path = tmp_path / "gh_output"
out_path.touch()
r = run_script(repo, "smoke/v1.2.3-preview", out_path)
assert r.returncode == 0
assert parse_outputs(out_path)["is_prerelease"] == "true"

def test_dry_run_tag(self, tmp_path):
repo = make_repo_with_tagged_worker(tmp_path, "smoke/v9.9.9-dry-run.1", "9.9.9-dry-run.1")
out_path = tmp_path / "gh_output"
Expand Down
Loading
Loading