Skip to content

Commit be8617d

Browse files
authored
Merge pull request #62 from legendu-net/push-yovnpzrwmkvt
Merge push-yovnpzrwmkvt Into main
2 parents 6799c7d + acf1b84 commit be8617d

27 files changed

Lines changed: 1331 additions & 74 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Create PR To main
2+
on:
3+
push:
4+
branches-ignore:
5+
- main
6+
- _**
7+
jobs:
8+
create_pr_main:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Install uv
12+
run: |
13+
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
14+
- name: Create PR To main
15+
run: |
16+
curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/create_pull_request.py \
17+
| uv run --script - \
18+
--head-branch ${{ github.ref_name }} \
19+
--base-branch main \
20+
--token ${{ secrets.GITHUBACTIONS }}
21+

.github/workflows/lint.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
name: Lint Code
22

33
on:
4-
push:
5-
branches: [ dev, main ]
64
pull_request:
7-
branches: [ dev ]
5+
branches:
6+
- main
87

98
jobs:
109
lint_code:
@@ -22,4 +21,4 @@ jobs:
2221
- name: Lint with Ty
2322
run: uv run ty check
2423
- name: Analyze Dependencies
25-
run: uv run deptry .
24+
run: uv run deptry .
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Remove Branches
2+
on:
3+
schedule:
4+
- cron: '0 3 * * *'
5+
timezone: 'America/Los_Angeles'
6+
jobs:
7+
remove_branch:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- name: Install uv
11+
run: |
12+
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
13+
- name: Remove Branches
14+
run: |
15+
curl -sSL https://raw.githubusercontent.com/legendu-net/github_actions_scripts/main/remove_branch.py \
16+
| uv run --script - \
17+
--repo ${{ github.repository }} \
18+
--pattern '^(?!main$)' \
19+
--token ${{ secrets.GITHUBACTIONS }}

.github/workflows/test.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Run Tests
2+
on:
3+
pull_request:
4+
branches:
5+
- main
6+
jobs:
7+
test_code:
8+
runs-on: ${{matrix.os}}
9+
strategy:
10+
matrix:
11+
os:
12+
- macOS-latest
13+
- ubuntu-latest
14+
python-version:
15+
- "3.12"
16+
- "3.13"
17+
- "3.14"
18+
steps:
19+
- uses: actions/checkout@v6
20+
- name: Install dependencies
21+
run: |
22+
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
23+
uv sync --all-extras
24+
- name: Test with pytest
25+
run: uv run --python ${{ matrix.python-version }} pytest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ dist/
1919
doc*/_build/
2020
*.prof
2121
core
22+
.claude/settings.local.json

GEMINI.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ A simple Python wrapper for GitHub REST APIs, optimized for use in GitHub Action
77
- **Purpose:** Provide a streamlined interface for interacting with GitHub's REST API
88
and performing Git operations within automation scripts.
99
- **Main Technologies:**
10-
- **Python 3.11+**: Core language.
10+
- **Python 3.12+**: Core language.
1111
- **requests**: For HTTP interactions with the GitHub API.
1212
- **dulwich**: A pure-Python implementation of Git for repository operations.
13+
- **tenacity**: For retry logic on API requests.
14+
- **tomli-w**: For writing TOML files.
1315
- **psutil**: For system and process utilities.
1416
- **Architecture:**
1517
- `github_rest_api/github.py`: Contains the `GitHub` class for handling API requests (GET, POST, DELETE, PUT, PATCH).
16-
- `github_rest_api/actions/`: Focused utilities for GitHub Actions, including branch management and pushing changes.
18+
- `github_rest_api/actions/github/`: Utilities for GitHub actions like creating pull requests, managing releases, and adding repositories.
19+
- `github_rest_api/actions/container/`: Utilities for building and configuring container images.
1720
- `github_rest_api/actions/cargo/`: Specific support for Rust projects (benchmarking and profiling).
1821
- `github_rest_api/utils.py`: General-purpose utilities (versioning, partitioning).
1922

@@ -28,6 +31,7 @@ This project uses `uv` for dependency and environment management.
2831
- **Code Formatting:**
2932
```bash
3033
uv run ruff format ./
34+
uv run pyproject-fmt pyproject.toml
3135
```
3236
- **Linting:**
3337
```bash
@@ -36,6 +40,7 @@ This project uses `uv` for dependency and environment management.
3640
- **Type Checking:**
3741
```bash
3842
uv run ty check
43+
uv run pyright
3944
```
4045
- **Dependency Analysis:**
4146
```bash
@@ -48,8 +53,8 @@ This project uses `uv` for dependency and environment management.
4853

4954
## Development Conventions
5055

51-
- **Code Style:** Strictly follows `ruff` formatting and linting rules.
52-
- **Type Safety:** Uses `ty` (in addition to standard type hints) to ensure type correctness.
56+
- **Code Style:** Strictly follows `ruff` formatting and linting rules. `pyproject-fmt` is used for TOML formatting.
57+
- **Type Safety:** Uses `ty` and `pyright` to ensure type correctness.
5358
- **CI/CD:** Automated linting and formatting checks are performed
5459
on `push` to `dev`/`main` branches and on `pull_request` to `dev`.
5560
- **Git Operations:** Prefers `dulwich` for programmatic Git interactions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Container-related GitHub Actions scripts."""
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import argparse
2+
import datetime
3+
from pathlib import Path
4+
import subprocess as sp
5+
import sys
6+
from typing import cast
7+
from dulwich.repo import Repo
8+
from dulwich.refs import Ref
9+
from dulwich.objects import Commit
10+
from dulwich.errors import NotGitRepository
11+
from dulwich.diff_tree import tree_changes
12+
from tenacity import retry, stop_after_attempt, wait_exponential
13+
14+
15+
def _get_commit(name: bytes) -> bytes:
16+
"""Resolve a commit SHA or branch name to a commit SHA string."""
17+
repo = Repo(".")
18+
if name in repo:
19+
return name
20+
for prefix in [b"refs/heads/", b"refs/remotes/origin/", b"refs/tags/"]:
21+
ref = cast(Ref, prefix + name)
22+
if ref in repo.refs:
23+
return repo.refs[ref]
24+
raise KeyError(f"Cannot resolve commit or branch: {name}")
25+
26+
27+
def changed_files_between(
28+
commit1: bytes, commit2: bytes, name1: str = "", name2: str = ""
29+
) -> list[Path]:
30+
"""Get a unique list of changed files between 2 commits.
31+
32+
:param commit1: The first commit ID.
33+
:param commit2: The second commit ID.
34+
:param name1: Optional human-readable name for commit1 used in logging (defaults to the commit SHA).
35+
:param name2: Optional human-readable name for commit2 used in logging (defaults to the commit SHA).
36+
:return: A unique list of changed files.
37+
"""
38+
repo = Repo(".")
39+
c1 = cast(Commit, repo[commit1])
40+
c2 = cast(Commit, repo[commit2])
41+
changes = tree_changes(repo.object_store, c1.tree, c2.tree)
42+
files = set()
43+
for change in changes:
44+
if change.old and change.old.path:
45+
files.add(change.old.path.decode())
46+
if change.new and change.new.path:
47+
files.add(change.new.path.decode())
48+
paths = sorted(Path(file) for file in files)
49+
print(
50+
f"Changed files between {name1 or commit1.decode()[:7]} and {
51+
name2 or commit2.decode()[:7]
52+
}:"
53+
)
54+
for p in paths:
55+
print(f" {p}")
56+
return paths
57+
58+
59+
def has_relevant_changes(
60+
commit1: str | bytes,
61+
commit2: str | bytes,
62+
image_dirs: list[str],
63+
name1: str = "",
64+
name2: str = "",
65+
) -> bool:
66+
if not commit1 or not commit2:
67+
return True
68+
if isinstance(commit1, str):
69+
commit1 = commit1.encode()
70+
if isinstance(commit2, str):
71+
commit2 = commit2.encode()
72+
dirs = [Path(d).resolve() for d in image_dirs]
73+
for p in changed_files_between(commit1, commit2, name1=name1, name2=name2):
74+
if any(p.resolve().is_relative_to(d) for d in dirs):
75+
return True
76+
return False
77+
78+
79+
def has_relevant_changes_main_dev(image_dirs: list[str]) -> bool:
80+
try:
81+
c_main = _get_commit(b"main")
82+
c_dev = _get_commit(b"dev")
83+
except (KeyError, NotGitRepository):
84+
return True
85+
return has_relevant_changes(c_main, c_dev, image_dirs, name1="main", name2="dev")
86+
87+
88+
def _tag_date(tag: str) -> str:
89+
"""Suffix a tag with the current date as a 6-digit string.
90+
91+
:param tag: A tag of a Podman image.
92+
:return: A new tag.
93+
"""
94+
return tag + datetime.datetime.now(tz=datetime.timezone.utc).strftime("_%m%d%H")
95+
96+
97+
@retry(
98+
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=60, min=60, max=300)
99+
)
100+
def _push_image(image: str, tool: str = "podman"):
101+
sp.run(
102+
[tool, "push", image],
103+
shell=False,
104+
check=True,
105+
)
106+
107+
108+
def _build_image(
109+
image_dir: str,
110+
tags: str | list[str],
111+
tool: str = "podman",
112+
registry: str = "quay.io/legendu",
113+
):
114+
if isinstance(tags, str):
115+
tags = [tags]
116+
image = f"{registry}/{image_dir}"
117+
print(f"\n\nBuilding the {tool} image {image}...", flush=True)
118+
cmd = [tool, "build", image_dir]
119+
for tag in tags:
120+
cmd.append("-t")
121+
cmd.append(f"{image}:{tag}")
122+
sp.run(cmd, shell=False, check=True)
123+
for tag in tags:
124+
_push_image(f"{image}:{tag}", tool=tool)
125+
126+
127+
def build_images(
128+
commit1: str,
129+
commit2: str,
130+
image_dirs: list[str],
131+
tool: str = "podman",
132+
registry: str = "quay.io/legendu",
133+
):
134+
if not has_relevant_changes(commit1, commit2, image_dirs):
135+
print(
136+
f"Skip building {tool} images as there are no relevant changes between {
137+
commit1
138+
} and {commit2}.\n"
139+
)
140+
return
141+
tags = ["next"]
142+
if not has_relevant_changes_main_dev(image_dirs):
143+
tags.append("latest")
144+
tags.extend([_tag_date(tag) for tag in tags])
145+
print(f"Building {tool} images using tags:", ", ".join(tags), "\n", flush=True)
146+
failures = []
147+
for image_dir in image_dirs:
148+
try:
149+
_build_image(image_dir, tags=tags, tool=tool, registry=registry)
150+
except (sp.CalledProcessError, FileNotFoundError) as e:
151+
print(f"Error building {image_dir}: {e}", flush=True)
152+
failures.append(image_dir)
153+
if failures:
154+
sys.exit(f"\n\nError: failed to build images: {', '.join(failures)}\n")
155+
156+
157+
def parse_args():
158+
"""Parse command-line arguments.
159+
160+
:return: An object containing the parsed arguments.
161+
"""
162+
parser = argparse.ArgumentParser(description="Build container images.")
163+
parser.add_argument(
164+
"-c1",
165+
"--commit1",
166+
dest="commit1",
167+
default="",
168+
help="The first commit ID (empty by default).",
169+
)
170+
parser.add_argument(
171+
"-c2",
172+
"--commit2",
173+
dest="commit2",
174+
default="",
175+
help="The second commit ID (empty by default).",
176+
)
177+
parser.add_argument(
178+
"-r",
179+
"--registry",
180+
dest="registry",
181+
default="quay.io/legendu",
182+
help="Container registry prefix (default: quay.io/legendu).",
183+
)
184+
parser.add_argument(
185+
"-t",
186+
"--tool",
187+
dest="tool",
188+
default="podman",
189+
choices=["podman", "docker"],
190+
help="Container tool to use for building and pushing images (default: podman).",
191+
)
192+
group = parser.add_mutually_exclusive_group(required=True)
193+
group.add_argument(
194+
"-i",
195+
"--image-dirs",
196+
dest="image_dirs",
197+
nargs="+",
198+
default=None,
199+
metavar="IMAGE_DIR",
200+
help="Explicit list of image directories to build.",
201+
)
202+
group.add_argument(
203+
"-f",
204+
"--file-image-dirs",
205+
dest="file_image_dirs",
206+
default=None,
207+
metavar="FILE",
208+
help="Path to a file listing image directories to build, one per line.",
209+
)
210+
return parser.parse_args()
211+
212+
213+
def _resolve_image_dirs(args: argparse.Namespace) -> list[str]:
214+
if args.image_dirs:
215+
return args.image_dirs
216+
lines = Path(args.file_image_dirs).read_text().splitlines()
217+
return [line.strip() for line in lines if line.strip()]
218+
219+
220+
def main():
221+
args = parse_args()
222+
build_images(
223+
args.commit1,
224+
args.commit2,
225+
_resolve_image_dirs(args),
226+
tool=args.tool,
227+
registry=args.registry,
228+
)
229+
230+
231+
if __name__ == "__main__":
232+
main()

0 commit comments

Comments
 (0)