Skip to content

Commit 3affa4d

Browse files
committed
(ELI-466) pulling out shared code
1 parent 171c932 commit 3affa4d

File tree

3 files changed

+146
-130
lines changed

3 files changed

+146
-130
lines changed

scripts/workflow/ci_utils.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
CI shared utilities:
3+
- light wrappers around subprocess and GitHub CLI
4+
- git/gh helpers used by both resolver scripts
5+
- consistent failure handling and environment access
6+
"""
7+
8+
from __future__ import annotations
9+
import json
10+
import os
11+
import subprocess
12+
import sys
13+
from typing import List, Optional, NoReturn
14+
15+
BRANCH = os.getenv("BRANCH", "main")
16+
17+
def fail(msg: str) -> NoReturn:
18+
print(f"::error::{msg}", file=sys.stderr)
19+
sys.exit(1)
20+
21+
def run(cmd: List[str], *, check: bool = True) -> subprocess.CompletedProcess:
22+
# cp = completed process (will use this to refer)
23+
return subprocess.run(cmd, check=check, capture_output=True, text=True)
24+
25+
def ensure_token() -> None:
26+
if not (os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")):
27+
fail("GH_TOKEN/GITHUB_TOKEN is required")
28+
29+
def fetch_latest_from_remote() -> None:
30+
run(["git", "fetch", "origin", BRANCH, "--quiet"])
31+
run(["git", "fetch", "--tags", "--force", "--quiet"])
32+
33+
def git_ok(args: List[str]) -> bool:
34+
return subprocess.run(["git", *args]).returncode == 0
35+
36+
def is_ancestor(older: str, newer: str) -> bool:
37+
return subprocess.run(["git", "merge-base", "--is-ancestor", older, newer]).returncode == 0
38+
39+
def gh_json(args: List[str]) -> list:
40+
cp = run(["gh", *args])
41+
raw = cp.stdout.strip()
42+
return json.loads(raw) if raw else []
43+
44+
def gh_api(path: str, jq: Optional[str] = None) -> List[str]:
45+
"""
46+
A simple python wrapper around the GitHub API
47+
to make it a callable function.
48+
"""
49+
args = ["gh", "api", path]
50+
if jq:
51+
args += ["--jq", jq]
52+
cp = run(args, check=True)
53+
return [x for x in cp.stdout.splitlines() if x]
54+
55+
def dev_tag_for_sha(sha: str) -> Optional[str]:
56+
cp = run(["git", "tag", "--points-at", sha], check=False)
57+
for t in cp.stdout.splitlines():
58+
if t.startswith("dev-"):
59+
return t
60+
return None
61+
62+
def sha_for_tag(tag: str) -> Optional[str]:
63+
cp = run(["git", "rev-list", "-n1", tag], check=False)
64+
return cp.stdout.strip() or None
65+
66+
def latest_final_tag() -> Optional[str]:
67+
cp = run(["git", "tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"], check=True)
68+
tags = cp.stdout.splitlines()
69+
return tags[0] if tags else None
70+
71+
def first_commit() -> str:
72+
"""
73+
Returns the first commit of the current branch.
74+
75+
We will never use this for our project since we
76+
already have a release but can be used as a
77+
fallback for new projects.
78+
"""
79+
return run(["git", "rev-list", "--max-parents=0", "HEAD"], check=True).stdout.strip()
80+
81+
def list_merged_pr_commits(base: str, head: str) -> List[str]:
82+
rng = f"{base}..{head}"
83+
cp = run(["git", "rev-list", "--merges", "--first-parent", rng], check=False)
84+
return [x for x in cp.stdout.splitlines() if x]
85+
86+
def prs_for_commit(sha: str) -> List[int]:
87+
nums = gh_api(
88+
f"/repos/{os.getenv('GITHUB_REPOSITORY')}/commits/{sha}/pulls",
89+
jq=".[].number",
90+
)
91+
return [int(n) for n in nums]
92+
93+
def labels_for_pr(pr: int) -> List[str]:
94+
return gh_api(
95+
f"/repos/{os.getenv('GITHUB_REPOSITORY')}/issues/{pr}/labels",
96+
jq=".[].name",
97+
)

scripts/workflow/pre-release_resolver.py

Lines changed: 20 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,74 +11,37 @@
1111
"""
1212

1313
from __future__ import annotations
14-
import json
1514
import os
1615
import re
17-
import subprocess
1816
import sys
1917
from dataclasses import dataclass
2018
from typing import List, Optional, NoReturn
2119

22-
WORKFLOW_NAME = os.getenv("WORKFLOW_NAME", "3. CD | Deploy to Test")
23-
BRANCH = os.getenv("BRANCH", "main")
24-
LIMIT = int(os.getenv("LIMIT", "30")) #how many previous successful test deployed commits to consider
20+
from ci_utils import (
21+
BRANCH,
22+
fail,
23+
ensure_token,
24+
fetch_latest_from_remote,
25+
gh_json,
26+
dev_tag_for_sha,
27+
sha_for_tag,
28+
is_ancestor,
29+
git_ok,
30+
)
2531

32+
WORKFLOW_NAME = os.getenv("WORKFLOW_NAME", "3. CD | Deploy to Test")
33+
LIMIT = int(os.getenv("LIMIT", "30"))
2634
EVENT_NAME = os.getenv("EVENT_NAME", "")
2735
HEAD_SHA_AUTO = os.getenv("WORKFLOW_RUN_HEAD_SHA", "")
2836
MANUAL_REF = os.getenv("MANUAL_REF", "")
2937
ALLOW_OLDER = os.getenv("ALLOW_OLDER", "true").lower()
3038

31-
32-
def fail(msg: str) -> "NoReturn":
33-
print(f"::error::{msg}", file=sys.stderr)
34-
sys.exit(1)
35-
36-
def run(cmd: List[str], *, check: bool = True) -> subprocess.CompletedProcess:
37-
# cp = completed process (will use this to refer)
38-
return subprocess.run(cmd, check=check, capture_output=True, text=True)
39-
40-
def git_ok(args: List[str]) -> bool:
41-
return subprocess.run(["git", *args]).returncode == 0
42-
43-
def is_ancestor(older: str, newer: str) -> bool:
44-
return subprocess.run(["git", "merge-base", "--is-ancestor", older, newer]).returncode == 0
45-
46-
def fetch_latest_from_remote() -> None:
47-
run(["git", "fetch", "origin", BRANCH, "--quiet"])
48-
run(["git", "fetch", "--tags", "--force", "--quiet"])
49-
50-
def gh_json(args: List[str]) -> list:
51-
cp = run(["gh", *args])
52-
raw = cp.stdout.strip()
53-
return json.loads(raw) if raw else []
54-
55-
def dev_tag_for_sha(sha: str) -> Optional[str]:
56-
cp = run(["git", "tag", "--points-at", sha], check=False)
57-
for tag in cp.stdout.splitlines():
58-
if tag.startswith("dev-"):
59-
return tag
60-
return None
61-
62-
def sha_for_tag(tag: str) -> Optional[str]:
63-
cp = run(["git", "rev-list", "-n1", tag], check=False)
64-
return cp.stdout.strip() or None
65-
66-
6739
@dataclass(frozen=True)
6840
class RefInfo:
6941
sha: str
7042
ref: str
7143

72-
73-
def require_token() -> None:
74-
if not (os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")):
75-
fail("GH_TOKEN/GITHUB_TOKEN is required")
76-
7744
def list_successful_test_shas() -> List[str]:
78-
"""
79-
These are commits that have been deployed into
80-
the test env successfully
81-
"""
8245
data = gh_json([
8346
"run", "list",
8447
"--workflow", WORKFLOW_NAME,
@@ -87,7 +50,7 @@ def list_successful_test_shas() -> List[str]:
8750
"--json", "headSha",
8851
"--limit", str(LIMIT),
8952
])
90-
return [commit["headSha"] for commit in data if commit.get("headSha")]
53+
return [c["headSha"] for c in data if c.get("headSha")]
9154

9255
def pick_furthest_ahead(shas: List[str]) -> str:
9356
latest: Optional[str] = None
@@ -111,7 +74,7 @@ def resolve_latest_test() -> RefInfo:
11174
fail(f"No dev-* tag found on latest TEST SHA ({latest_sha})")
11275
return RefInfo(sha=latest_sha, ref=latest_ref)
11376

114-
def resolve_this_run() -> RefInfo | None:
77+
def resolve_this_run() -> RefInfo:
11578
if EVENT_NAME == "workflow_run":
11679
if not HEAD_SHA_AUTO:
11780
fail("WORKFLOW_RUN_HEAD_SHA missing for workflow_run event")
@@ -123,6 +86,8 @@ def resolve_this_run() -> RefInfo | None:
12386
if EVENT_NAME == "workflow_dispatch":
12487
if not MANUAL_REF:
12588
fail("MANUAL_REF (inputs.ref) is required for manual dispatch")
89+
if not re.match(r"^dev-\d{14}$", MANUAL_REF):
90+
fail(f"Invalid dev-* tag format: {MANUAL_REF}")
12691
if not git_ok(["rev-parse", "-q", "--verify", f"refs/tags/{MANUAL_REF}"]):
12792
fail(f"Tag not found: {MANUAL_REF}")
12893
sha = sha_for_tag(MANUAL_REF)
@@ -132,9 +97,10 @@ def resolve_this_run() -> RefInfo | None:
13297
fail(f"Chosen tag {MANUAL_REF} is not on origin/{BRANCH} history")
13398
return RefInfo(sha=sha, ref=MANUAL_REF)
13499

100+
fail(f"Unsupported EVENT_NAME: {EVENT_NAME}")
101+
135102
def enforce_guard(current: RefInfo, latest: RefInfo) -> None:
136103
older_than_latest = (current.sha != latest.sha) and is_ancestor(current.sha, latest.sha)
137-
138104
if EVENT_NAME == "workflow_run":
139105
if older_than_latest:
140106
fail(
@@ -155,9 +121,8 @@ def write_outputs(current: RefInfo, latest: RefInfo) -> None:
155121
f.write(f"latest_test_sha={latest.sha}\n")
156122
f.write(f"latest_test_ref={latest.ref}\n")
157123

158-
159124
def main() -> int:
160-
require_token()
125+
ensure_token()
161126
fetch_latest_from_remote()
162127
latest = resolve_latest_test()
163128
current = resolve_this_run()
@@ -167,6 +132,5 @@ def main() -> int:
167132
print(f"LATEST TEST: {latest.ref} ({latest.sha})")
168133
return 0
169134

170-
171135
if __name__ == "__main__":
172136
sys.exit(main())

scripts/workflow/release_type_resolver.py

Lines changed: 29 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,42 @@
1+
#!/usr/bin/env python3
12
"""
23
Resolve release_type from PR labels with safe defaults.
34
45
Modes
5-
66
- Manual override (MANUAL_RELEASE_TYPE): emit that and exit.
77
- Single-PR mode (default): inspect PR labels for THIS_SHA; default "rc".
8-
- Aggregate mode (AGGREGATE=true): consider TEST deployed PRs merged since latest final tag*
9-
up to LATEST_TEST_SHA (BOUNDARY), and pick highest of major > minor > patch > rc
8+
- Aggregate mode (AGGREGATE=true): consider TEST-deployed PRs merged since latest final tag
9+
up to LATEST_TEST_SHA (BOUNDARY), and pick highest of major > minor > patch > rc.
1010
1111
Env inputs
12-
13-
GH_TOKEN / GITHUB_TOKEN: required
14-
THIS_SHA: SHA being promoted (required unless manual override)
15-
LATEST_TEST_SHA: required when AGGREGATE=true
16-
MANUAL_RELEASE_TYPE: (rc|patch|minor|major)
17-
AGGREGATE: "true"|"false" (default "false")
18-
BRANCH: branch (default "main")
12+
- GH_TOKEN / GITHUB_TOKEN: required
13+
- THIS_SHA: SHA being promoted (required unless manual override)
14+
- LATEST_TEST_SHA: required when AGGREGATE=true
15+
- MANUAL_RELEASE_TYPE: (rc|patch|minor|major)
16+
- AGGREGATE: "true"|"false" (default "false")
17+
- BRANCH: branch (default "main")
1918
2019
Outputs
21-
22-
release_type: rc|patch|minor|major
23-
basis: manual|single-pr|aggregate
24-
pr_numbers: comma-separated PR numbers considered
20+
- release_type: rc|patch|minor|major
21+
- basis: manual|single-pr|aggregate
22+
- pr_numbers: comma-separated PR numbers considered
2523
"""
2624

27-
import os, subprocess, sys
25+
from __future__ import annotations
26+
import os
27+
import sys
2828
from typing import List, Set
2929

30-
BRANCH = os.getenv("BRANCH", "main")
31-
32-
def run(cmd: List[str], check=True, capture=True) -> subprocess.CompletedProcess:
33-
# cp = completed process (will use this to refer)
34-
return subprocess.run(cmd, check=check, capture_output=capture, text=True)
35-
36-
def fail(msg: str) -> int:
37-
print(f"::error::{msg}", file=sys.stderr)
38-
return 1
39-
40-
def fetch_latest_from_remote():
41-
run(["git","fetch","origin", BRANCH, "--quiet"], check=True)
42-
run(["git","fetch","--tags","--force","--quiet"], check=True)
43-
44-
def gh_api(path: str, jq: str | None = None) -> List[str]:
45-
"""
46-
A simple python wrapper around the GitHub API
47-
to make it a callable function.
48-
"""
49-
args = ["gh","api", path]
50-
if jq:
51-
args += ["--jq", jq]
52-
cp = run(args, check=True)
53-
return [x for x in cp.stdout.splitlines() if x]
54-
55-
def latest_final_tag() -> str | None:
56-
"""
57-
Grabs the version tags and sorts semantically desc
58-
"""
59-
cp = run(["git","tag","--list","v[0-9]*.[0-9]*.[0-9]*","--sort=-v:refname"], check=True)
60-
tags = cp.stdout.splitlines()
61-
return tags[0] if tags else None
62-
63-
def first_commit() -> str:
64-
"""
65-
Returns the first commit of the current branch.
66-
67-
We will never use this for our project since we
68-
already have a release but can be used as a
69-
fallback for new projects.
70-
"""
71-
return run(["git","rev-list","--max-parents=0","HEAD"], check=True).stdout.strip()
72-
73-
def list_merged_pr_commits(base: str, head: str) -> List[str]:
74-
rng = f"{base}..{head}"
75-
cp = run(["git","rev-list","--merges","--first-parent", rng], check=False)
76-
return [x for x in cp.stdout.splitlines() if x]
77-
78-
def prs_for_commit(sha: str) -> List[int]:
79-
nums = gh_api(f"/repos/{os.getenv('GITHUB_REPOSITORY')}/commits/{sha}/pulls", jq=".[].number")
80-
return [int(n) for n in nums]
81-
82-
def labels_for_pr(pr: int) -> List[str]:
83-
return gh_api(f"/repos/{os.getenv('GITHUB_REPOSITORY')}/issues/{pr}/labels", jq=".[].name")
30+
from ci_utils import (
31+
ensure_token,
32+
fetch_latest_from_remote,
33+
latest_final_tag,
34+
first_commit,
35+
list_merged_pr_commits,
36+
prs_for_commit,
37+
labels_for_pr,
38+
fail,
39+
)
8440

8541
def pick_highest(labels: List[str]) -> str | None:
8642
has_major = any(l == "release:major" for l in labels)
@@ -94,13 +50,12 @@ def pick_highest(labels: List[str]) -> str | None:
9450
return None
9551

9652
def main() -> int:
97-
if not (os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN")):
98-
return fail("GH_TOKEN/GITHUB_TOKEN is required")
53+
ensure_token()
9954

10055
manual = (os.getenv("MANUAL_RELEASE_TYPE") or "").strip()
10156
if manual:
10257
if manual not in {"rc","patch","minor","major"}:
103-
return fail(f"Invalid MANUAL_RELEASE_TYPE: {manual}")
58+
fail(f"Invalid MANUAL_RELEASE_TYPE: {manual}")
10459
out = os.getenv("GITHUB_OUTPUT")
10560
if out:
10661
with open(out, "a") as f:
@@ -112,7 +67,7 @@ def main() -> int:
11267

11368
this_sha = (os.getenv("THIS_SHA") or "").strip()
11469
if not this_sha:
115-
return fail("Cannot determine sha")
70+
fail("Cannot determine sha")
11671

11772
fetch_latest_from_remote()
11873

@@ -124,7 +79,7 @@ def main() -> int:
12479
if aggregate:
12580
latest_test_sha = (os.getenv("LATEST_TEST_SHA") or "").strip()
12681
if not latest_test_sha:
127-
return fail("LATEST_TEST_SHA is required when AGGREGATE=true")
82+
fail("LATEST_TEST_SHA is required when AGGREGATE=true")
12883
base = latest_final_tag() or first_commit()
12984
merges = list_merged_pr_commits(base, latest_test_sha)
13085
for m in merges:

0 commit comments

Comments
 (0)