mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Merge pull request #138592 from NousResearch/ethie/release-branches
ci(release): admit stable releases from release/* branches, not just main
This commit is contained in:
8 files changed
+90
-28
No files matched your search
@@ -2136,6 +2136,7 @@ jobs:
|
||||
from pathlib import Path
|
||||
from scripts.releases.handoff import fetch
|
||||
from scripts.releases.semver import is_valid_version
|
||||
from scripts.releases.stable import release_line_contains
|
||||
tag = os.environ['PREVIOUS_TAG']
|
||||
if not tag.startswith('v') or not is_valid_version(tag[1:]):
|
||||
raise ValueError('Invalid previous release tag')
|
||||
@@ -2145,7 +2146,8 @@ jobs:
|
||||
if release['tagName'] != tag or release['isDraft']:
|
||||
raise ValueError('Previous package must come from a published release')
|
||||
commit = subprocess.check_output(['git', 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'], text=True).strip()
|
||||
subprocess.run(['git', 'merge-base', '--is-ancestor', commit, 'origin/main'], check=True)
|
||||
if not subprocess.check_output(release_line_contains(commit, 'origin'), text=True).strip():
|
||||
raise ValueError('Previous package must come from main or a release/* branch')
|
||||
fetch(tag, commit, ['termux'], Path('termux-build/previous'), ['deb/*.deb'])
|
||||
PY
|
||||
|
||||
|
||||
+1
-1
@@ -647,7 +647,7 @@ def main():
|
||||
release_cmd = subcommands.add_parser(
|
||||
"release", help="Claim a version, cut a draft, and dispatch the gate")
|
||||
release_cmd.add_argument("--commit", required=True, metavar="SHA",
|
||||
help="The main commit to release")
|
||||
help="The commit to release; it must be on main or a release/* branch")
|
||||
release_cmd.add_argument("--bump", choices=["major", "minor", "patch"], default="patch")
|
||||
release_cmd.add_argument("--autopublish", action="store_true",
|
||||
help="Publish on green instead of leaving a draft")
|
||||
|
||||
@@ -26,6 +26,8 @@ from scripts.releases.versioning import (
|
||||
WORKFLOW = "stable-release.yml"
|
||||
# A fetch refspec may hold one ``*``; the parsers filter what the globs over-match.
|
||||
_ATTEMPT_GLOBS = ("rc.*", "abandoned-rc.*")
|
||||
# Local namespace for the remote's release-line branches, kept apart from the user's own remotes.
|
||||
_RELEASE_LINE_PREFIX = "hermes-release"
|
||||
|
||||
|
||||
class ReleaseRefused(RuntimeError):
|
||||
@@ -60,21 +62,16 @@ def _claim_commit(repo: Path, tag: str) -> str:
|
||||
|
||||
|
||||
def _refresh_claims(repo: Path, remote: str) -> None:
|
||||
_git(
|
||||
repo, "fetch", remote,
|
||||
"+refs/heads/main:refs/remotes/hermes-release/main",
|
||||
*(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS),
|
||||
)
|
||||
# Pruned, so a deleted release/* branch stops admitting commits. Tags are
|
||||
# fetched separately: pruning there would delete local-only claim tags.
|
||||
_git(repo, "fetch", "--prune", remote, *stable.release_line_refspecs(_RELEASE_LINE_PREFIX))
|
||||
_git(repo, "fetch", remote, *(f"+refs/tags/{glob}:refs/tags/{glob}" for glob in _ATTEMPT_GLOBS))
|
||||
|
||||
|
||||
def _require_remote_main(repo: Path, commit: str) -> None:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", commit, "refs/remotes/hermes-release/main"],
|
||||
cwd=repo, capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ReleaseRefused(f"{commit} is not on origin/main")
|
||||
def _require_release_line(repo: Path, commit: str) -> None:
|
||||
"""The commit must be on the remote's ``main`` or a ``release/*`` branch."""
|
||||
if not _git(repo, *stable.release_line_contains(commit, _RELEASE_LINE_PREFIX)[1:]):
|
||||
raise ReleaseRefused(f"{commit} is not on the remote's main or a release/* branch")
|
||||
|
||||
|
||||
def _claim_collision(repo: Path, remote: str, tag: str, error: Exception) -> ReleaseRefused:
|
||||
@@ -243,7 +240,7 @@ def release(commit: str, *, bump: str, repo: Path, remote: str, repository: str,
|
||||
and the cut goes on; no confirmer, or a no, is the refusal.
|
||||
"""
|
||||
_refresh_claims(repo, remote)
|
||||
_require_remote_main(repo, commit)
|
||||
_require_release_line(repo, commit)
|
||||
outstanding = _outstanding_attempt(repo, remote)
|
||||
refusal = None
|
||||
if outstanding is not None:
|
||||
|
||||
+28
-10
@@ -34,12 +34,33 @@ SMOKE_TARGETS = {"smoke-darwin-arm64": "macos/arm64", "smoke-darwin-x64": "macos
|
||||
"smoke-win32-arm64": "windows/arm64", "smoke-win32-x64": "windows/x64"}
|
||||
|
||||
|
||||
# Stable releases are cut from ``main`` or from a ``release/*`` maintenance branch.
|
||||
RELEASE_LINE_BRANCHES = ("main", "release/*")
|
||||
|
||||
|
||||
def release_line_refspecs(remote_prefix: str) -> list[str]:
|
||||
"""Fetch refspecs that mirror every release-line branch under ``refs/remotes/<remote_prefix>/``."""
|
||||
return [f"+refs/heads/{branch}:refs/remotes/{remote_prefix}/{branch}"
|
||||
for branch in RELEASE_LINE_BRANCHES]
|
||||
|
||||
|
||||
def release_line_contains(commit: str, remote_prefix: str) -> list[str]:
|
||||
"""The git command that lists the fetched release-line branches holding ``commit``.
|
||||
|
||||
Empty output means the commit is on no release line. Git matches the
|
||||
``release/*`` pattern across the slash, so ``release/0.22.1`` qualifies.
|
||||
"""
|
||||
return ["git", "for-each-ref", "--contains", commit, "--format=%(refname)",
|
||||
*(f"refs/remotes/{remote_prefix}/{branch}" for branch in RELEASE_LINE_BRANCHES)]
|
||||
|
||||
|
||||
def admit_claim(tag: str, commit: str, *, on_main) -> dict:
|
||||
"""Admit a release from its attempt ref. The checkout version is not read.
|
||||
|
||||
``main`` carries ``0.0.0`` on purpose, so the version and the attempt come
|
||||
from ``rc.<N>-vX.Y.Z`` and the only question about the commit is whether
|
||||
it is on ``main``.
|
||||
it is on a release line: ``main`` or a ``release/*`` branch. ``on_main`` is
|
||||
that predicate; the name predates release branches.
|
||||
"""
|
||||
from scripts.releases.versioning import parse_attempt_ref
|
||||
|
||||
@@ -48,7 +69,7 @@ def admit_claim(tag: str, commit: str, *, on_main) -> dict:
|
||||
raise ValueError(f"{tag} is not a claim tag")
|
||||
version, attempt = parsed
|
||||
if not on_main(commit):
|
||||
raise ValueError(f"{commit} is not on main")
|
||||
raise ValueError(f"{commit} is not on main or a release/* branch")
|
||||
return {"claim_tag": tag, "tag": f"v{version}", "version": version, "attempt": attempt,
|
||||
"commit": commit}
|
||||
|
||||
@@ -542,14 +563,10 @@ def check_claim(env: dict, run=output) -> dict:
|
||||
if (remote_commit != commit or remote_object != local_object
|
||||
or (expected_object and remote_object != expected_object)):
|
||||
raise ValueError("Stable claim tag moved")
|
||||
run(["git", "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main"])
|
||||
run(["git", "fetch", "origin", *release_line_refspecs("origin")])
|
||||
|
||||
def on_main(sha: str) -> bool:
|
||||
try:
|
||||
run(["git", "merge-base", "--is-ancestor", sha, "origin/main"])
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
return True
|
||||
return bool(run(release_line_contains(sha, "origin")))
|
||||
|
||||
admitted = admit_claim(claim_tag, commit, on_main=on_main)
|
||||
raw_metadata = run(["git", "tag", "-l", claim_tag, "--format=%(contents)"])
|
||||
@@ -595,7 +612,7 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
|
||||
or not refs.get(f"refs/tags/{tag}")):
|
||||
raise ValueError("Final release tag custody changed")
|
||||
|
||||
run(["git", "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main",
|
||||
run(["git", "fetch", "origin", *release_line_refspecs("origin"),
|
||||
f"+refs/tags/{claim_tag}:refs/tags/{claim_tag}",
|
||||
f"+refs/tags/{tag}:refs/tags/{tag}"])
|
||||
for receipt, expected_object in ((claim_tag, claim_object),
|
||||
@@ -603,7 +620,8 @@ def final_context(env: dict, run=output) -> tuple[str, str, dict]:
|
||||
local_object = run(["git", "rev-parse", f"refs/tags/{receipt}"])
|
||||
if local_object != expected_object or run(["git", "cat-file", "-t", local_object]) != "tag":
|
||||
raise ValueError("Final release local tag differs from the remote")
|
||||
run(["git", "merge-base", "--is-ancestor", commit, "origin/main"])
|
||||
if not run(release_line_contains(commit, "origin")):
|
||||
raise ValueError(f"{commit} is not on main or a release/* branch")
|
||||
claim = _claim_metadata(
|
||||
run(["git", "tag", "-l", claim_tag, "--format=%(contents)"]),
|
||||
version=admitted["version"], attempt=admitted["attempt"], commit=commit,
|
||||
|
||||
@@ -227,6 +227,25 @@ def test_claim_on_origin_main_is_admitted_and_exports_the_full_sha(
|
||||
assert f"sha={expected}" in gh_output
|
||||
|
||||
|
||||
def test_claim_on_a_release_branch_is_admitted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
origin, clone = _seed_repo(tmp_path)
|
||||
# A hotfix lands on origin's release/0.1.2 only; main never sees it.
|
||||
_git("checkout", "-b", "release/0.1.2", cwd=origin)
|
||||
(origin / "fix.txt").write_text("hotfix\n", encoding="utf-8")
|
||||
_git("add", "-A", cwd=origin)
|
||||
_git("commit", "-m", "hotfix", cwd=origin)
|
||||
_git("checkout", "main", cwd=origin)
|
||||
_git("fetch", "origin", cwd=clone)
|
||||
_git("checkout", "--detach", "origin/release/0.1.2", cwd=clone)
|
||||
monkeypatch.setenv("GIT_COMMITTER_DATE", "@1790000000 +0000")
|
||||
_git("tag", "-a", "rc.1-v0.1.2", "-m", _claim_message(clone), cwd=clone)
|
||||
_git("push", "origin", "refs/tags/rc.1-v0.1.2", cwd=clone)
|
||||
|
||||
proc = _run_admission(clone, "v0.1.2", "rc.1-v0.1.2")
|
||||
assert proc.returncode == 0, proc.stdout + proc.stderr
|
||||
assert f"sha={_git('rev-parse', 'HEAD', cwd=clone)}" in (clone / "github_output.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_claim_not_on_origin_main_is_refused(tmp_path: Path):
|
||||
_origin, clone = _seed_repo(tmp_path)
|
||||
# A commit that exists ONLY in the clone — never pushed, never reviewed.
|
||||
@@ -238,7 +257,7 @@ def test_claim_not_on_origin_main_is_refused(tmp_path: Path):
|
||||
|
||||
proc = _run_admission(clone, "v0.1.2", "rc.1-v0.1.2")
|
||||
assert proc.returncode != 0, "a tag off origin/main must not be admitted"
|
||||
assert "is not on main" in proc.stdout + proc.stderr
|
||||
assert "is not on main or a release/* branch" in proc.stdout + proc.stderr
|
||||
# And nothing was exported for the signing jobs to consume.
|
||||
assert "sha=" not in (clone / "github_output.txt").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -144,6 +144,30 @@ def test_release_claims_the_first_attempt_creates_a_draft_and_dispatches(source)
|
||||
]
|
||||
|
||||
|
||||
def test_a_commit_only_on_a_release_branch_can_be_cut(source):
|
||||
git(source, "checkout", "--quiet", "-b", "release/0.21.5")
|
||||
git(source, "commit", "--allow-empty", "--quiet", "-m", "hotfix")
|
||||
git(source, "push", "--quiet", "origin", "release/0.21.5")
|
||||
commit = git(source, "rev-parse", "HEAD")
|
||||
git(source, "checkout", "--quiet", "main")
|
||||
|
||||
result = _release(source, commit)
|
||||
|
||||
assert result["tag"] == "rc.1-v0.21.5"
|
||||
assert git(source, "rev-parse", "rc.1-v0.21.5^{commit}") == commit
|
||||
|
||||
|
||||
def test_a_commit_on_neither_main_nor_a_release_branch_is_refused(source):
|
||||
from scripts.releases.entrypoint import ReleaseRefused
|
||||
|
||||
git(source, "checkout", "--quiet", "-b", "feature/x")
|
||||
git(source, "commit", "--allow-empty", "--quiet", "-m", "wip")
|
||||
git(source, "push", "--quiet", "origin", "feature/x")
|
||||
|
||||
with pytest.raises(ReleaseRefused, match="not on the remote's main or a release/"):
|
||||
_release(source, git(source, "rev-parse", "HEAD"), execute=_must_not_execute)
|
||||
|
||||
|
||||
def test_release_output_names_the_ci_run_and_the_publish_step():
|
||||
from scripts.releases.entrypoint import next_steps
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def test_admission_reads_the_version_and_attempt_from_the_attempt_ref():
|
||||
def test_admission_refuses_a_claim_for_a_commit_off_main():
|
||||
from scripts.releases.stable import admit_claim
|
||||
|
||||
with pytest.raises(ValueError, match="not on main"):
|
||||
with pytest.raises(ValueError, match="not on main or a release/"):
|
||||
admit_claim("rc.1-v0.21.5", "b" * 40, on_main=lambda _sha: False)
|
||||
|
||||
|
||||
|
||||
@@ -435,6 +435,8 @@ def test_claim_object_movement_and_lightweight_tags_fail_closed(tmp_path, monkey
|
||||
return claim_object if argv[-1] == ref else commit
|
||||
if argv[1] == "tag":
|
||||
return json.dumps(message)
|
||||
if argv[1] == "for-each-ref":
|
||||
return "refs/remotes/origin/release/1.2.3"
|
||||
return ""
|
||||
|
||||
assert check_claim(env, git) == {
|
||||
|
||||
Reference in new issue
Block a user