Files
devops-engineer 8d4ef51dee
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
ci / lint (pull_request) Successful in 20s
ci / build (pull_request) Successful in 1m1s
ci / smoke-install (pull_request) Successful in 1m28s
ci / unit-tests (pull_request) Successful in 1m37s
ci / responsiveness-e2e (pull_request) Successful in 2m45s
fix(release): tag-only auto-release (main is PR-only, cannot commit bump)
main is strictly PR-only (branch protection enable_push:false +
required_approvals:2), so the old auto-release bump job that PUT a
pyproject version bump directly onto main via the contents API failed
every run (~17s). Redesign to tag-only:

- auto_release_runtime.py: drop the pyproject-commit-to-main step;
  compute next patch from latest runtime-v* tag and create the tag at
  main HEAD via POST /repos/.../tags (targets the tag ref, not the
  protected branch ref — proven by the manual runtime-v0.3.14 cut, 201).
- auto-release.yml: remove the [skip-bump]/bot-actor loop guard (no
  commit is made anymore; a tag push does not match push:branches:[main]
  so it cannot re-enter this workflow). Rename release job accordingly.
- publish-runtime.yml: when triggered by a runtime-v* tag, STAMP the
  tag version into pyproject.toml at build time (ephemeral, in-CI)
  before python -m build, so the wheel carries the correct version by
  construction. The strict tag==pyproject hard-fail is replaced by a
  post-stamp sanity assertion.

pyproject.toml on main now lags the tag by design (cosmetic; the wheel
version is correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:14:46 +00:00

172 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tag the next runtime release on a green ``main`` (runtime: auto-release).
CTO standing directive (2026-06-10): a green merge to ``main`` must AUTO-release the
runtime and publish to prod — no manual ``runtime-v*`` tag / approval gate. This
script is the release trigger: invoked by ``.gitea/workflows/auto-release.yml`` ONLY
AFTER that workflow has re-run the merge-blocking gates (``unit-tests`` +
``responsiveness-e2e``) inline and they are green (Gitea has no ``workflow_run``
trigger, so the release workflow cannot listen on the ``ci`` workflow's success — it
re-runs the gate itself).
TAG-ONLY (root cause, 2026-06-10): ``main`` is strictly PR-only — branch protection
sets ``enable_push: false`` + ``required_approvals: 2``, so NO identity (not even the
release bot) may push a commit directly to ``main``. The previous design committed a
``pyproject.toml`` version bump to ``main`` via the contents API; that PUT is a direct
push to a protected branch and FAILED every run (~17s). So we DROP the bump-commit
entirely and only CUT THE TAG:
1. Compute the NEXT patch version from the latest ``runtime-v*`` tag
(e.g. 0.3.14 -> 0.3.15).
2. Create the tag ``runtime-v<next>`` pointing at the current ``main`` HEAD via the
Gitea tags API (``POST /repos/{owner}/{repo}/tags``). Tag creation targets the
TAG ref, not the branch ref, so branch *push* protection does not block it
(empirically proven: the manual ``runtime-v0.3.14`` cut returned HTTP 201 with
this same release/devops token).
That tag push trips the EXISTING publish-runtime.yml, which now WRITES the tag's
version into ``pyproject.toml`` at build time (ephemeral, in-CI) before
``python -m build`` — so the built wheel carries the correct version WITHOUT a
pre-committed pyproject match. ``pyproject.toml`` on ``main`` therefore LAGS the tag
by design; this is cosmetic (the wheel version is correct by construction).
All API access is over the Gitea HTTP API (no git clone, so the token never lands in
an on-disk clone URL — mirrors scripts/propagate_runtime_version.py).
Idempotent / safe:
* If the target tag already exists we exit 0 (something already released it).
* No pyproject mutation, so there is no half-bumped state to recover from.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
ORG = "molecule-ai"
REPO = "molecule-ai-workspace-runtime"
TAG_PREFIX = "runtime-v"
def _http(url, *, token, method="GET", payload=None, timeout=30):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"token {token}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read().decode()
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode()
def _api(base):
return f"{base}/api/v1/repos/{ORG}/{REPO}"
def latest_release_tag(base, token):
"""Return the highest runtime-v<semver> tag as a (tag, (maj,min,patch)) pair."""
status, body = _http(f"{_api(base)}/tags?limit=100", token=token)
if status != 200:
raise RuntimeError(f"list tags failed HTTP {status}: {body[:200]}")
best = None
for t in json.loads(body):
name = t.get("name", "")
if not name.startswith(TAG_PREFIX):
continue
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", name[len(TAG_PREFIX):])
if not m:
continue
ver = tuple(int(x) for x in m.groups())
if best is None or ver > best[1]:
best = (name, ver)
if best is None:
raise RuntimeError(f"no {TAG_PREFIX}<semver> tags found")
return best
def next_patch(ver):
maj, minor, patch = ver
return f"{maj}.{minor}.{patch + 1}"
def tag_exists(base, token, tag):
status, _ = _http(f"{_api(base)}/tags/{tag}", token=token)
return status == 200
def branch_head_sha(base, token, branch):
status, body = _http(f"{_api(base)}/branches/{branch}", token=token)
if status != 200:
raise RuntimeError(f"read branch {branch} failed HTTP {status}: {body[:200]}")
return json.loads(body)["commit"]["id"]
def create_tag(base, token, *, tag, commit_sha, target):
"""Cut runtime-v<target> at commit_sha via the tags API.
This targets the TAG ref, NOT the protected branch ref, so branch push
protection (enable_push:false + required_approvals) does not apply. Proven by
the manual runtime-v0.3.14 cut (HTTP 201) with the same release-bot token.
"""
url = f"{_api(base)}/tags"
payload = {"tag_name": tag, "target": commit_sha,
"message": f"runtime release {target} (auto)"}
status, body = _http(url, token=token, method="POST", payload=payload)
if status in (200, 201):
return
raise RuntimeError(f"create tag failed HTTP {status}: {body[:300]}")
def main(argv):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--gitea-url", default=os.environ.get("GITEA_URL", "https://git.moleculesai.app"))
p.add_argument("--token-env", default="RELEASE_BOT_TOKEN",
help="Env var holding the write token (molecule-runtime-release-bot).")
p.add_argument("--branch", default="main")
p.add_argument("--dry-run", action="store_true",
help="Compute + print the plan without mutating anything.")
args = p.parse_args(argv)
base = args.gitea_url.rstrip("/")
token = os.environ.get(args.token_env, "")
if not args.dry_run and not token:
print(f"::error::{args.token_env} is empty — cannot cut a release", file=sys.stderr)
return 1
# For dry-run we still need a token to call the API (private repo); fall back
# to GITEA_TOKEN if the named one is absent so `--dry-run` works in CI logs.
read_token = token or os.environ.get("GITEA_TOKEN", "")
if not read_token:
print("::error::no token available to read tags", file=sys.stderr)
return 1
tag_name, ver = latest_release_tag(base, read_token)
target = next_patch(ver)
next_tag = f"{TAG_PREFIX}{target}"
print(f"latest release tag: {tag_name} -> next: {next_tag}")
if tag_exists(base, read_token, next_tag):
print(f"::notice::{next_tag} already exists — nothing to release")
return 0
head_sha = branch_head_sha(base, read_token, args.branch)
print(f"{args.branch} HEAD: {head_sha[:9]}")
if args.dry_run:
print(f"DRY-RUN: would create tag {next_tag} at {args.branch} HEAD {head_sha[:9]}")
return 0
create_tag(base, token, tag=next_tag, commit_sha=head_sha, target=target)
print(f"::notice::created tag {next_tag} at {head_sha[:9]} — publish-runtime will fire")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))