Skip to content

Commit 2330f85

Browse files
committed
ci: add commit message linting to ci workflow
1 parent df108f6 commit 2330f85

17 files changed

Lines changed: 680 additions & 130 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ on:
1212
branches: [ main ]
1313

1414
jobs:
15+
lint-commits:
16+
if: github.event_name == 'pull_request'
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20+
with:
21+
fetch-depth: 0
22+
- name: Set up Python
23+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
24+
with:
25+
python-version: "3.12"
26+
- name: Lint commit messages
27+
run: python ops/lintcommit.py --range "origin/${{ github.event.pull_request.base.ref }}..${{ github.event.pull_request.head.sha }}"
28+
1529
build:
1630
runs-on: ubuntu-latest
1731
strategy:

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
run: |
5151
echo "Running Testing SDK tests against Language SDK PR changes..."
5252
echo "Using Language SDK from: $AWS_DURABLE_SDK_URL"
53-
python -m pip install -e .
53+
hatch run -- test:pip install -e ../language-sdk
5454
hatch fmt --check
5555
hatch run types:check
5656
hatch run test:cov

.github/workflows/scorecard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,6 @@ jobs:
7575
# Upload the results to GitHub's code scanning dashboard (optional).
7676
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
7777
- name: "Upload to code-scanning"
78-
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
78+
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
7979
with:
8080
sarif_file: results.sarif

ops/lintcommit.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,29 +124,33 @@ def validate_message(message: str) -> tuple[str | None, list[str]]:
124124
return (error, warnings)
125125

126126

127-
def run_local() -> None:
128-
"""Validate local commit messages ahead of origin/main.
127+
def run_range(git_range: str, *, skip_dirty_check: bool = False) -> None:
128+
"""Validate commit messages in a git range (e.g. 'origin/main..HEAD').
129129
130-
If there are uncommitted changes, prints a warning and skips validation.
130+
Args:
131+
git_range: A git revision range like 'origin/main..HEAD'.
132+
skip_dirty_check: When True, skip the uncommitted changes check
133+
(useful in CI where the worktree may be clean by definition).
131134
"""
132135
import subprocess
133136

134-
# Check for uncommitted changes
135-
status: subprocess.CompletedProcess[str] = subprocess.run(
136-
["git", "status", "--porcelain"],
137-
capture_output=True,
138-
text=True,
139-
)
140-
if status.stdout.strip():
141-
print(
142-
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
143-
"Commit your changes and re-run to validate."
137+
if not skip_dirty_check:
138+
# Check for uncommitted changes
139+
status: subprocess.CompletedProcess[str] = subprocess.run(
140+
["git", "status", "--porcelain"],
141+
capture_output=True,
142+
text=True,
144143
)
145-
return
144+
if status.stdout.strip():
145+
print(
146+
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
147+
"Commit your changes and re-run to validate."
148+
)
149+
return
146150

147-
# Get all commit messages ahead of origin/main
151+
# Get all commit messages in the range
148152
result: subprocess.CompletedProcess[str] = subprocess.run(
149-
["git", "log", "origin/main..HEAD", "--format=%H%n%B%n---END---"],
153+
["git", "log", git_range, "--format=%H%n%B%n---END---"],
150154
capture_output=True,
151155
text=True,
152156
)
@@ -156,7 +160,7 @@ def run_local() -> None:
156160

157161
raw: str = result.stdout.strip()
158162
if not raw:
159-
print("No local commits ahead of origin/main")
163+
print(f"No commits in range {git_range}")
160164
return
161165

162166
blocks: list[str] = raw.split("---END---")
@@ -174,15 +178,21 @@ def run_local() -> None:
174178
if not message:
175179
continue
176180

181+
subject_line: str = message.splitlines()[0]
182+
183+
# Skip merge commits (auto-generated by git)
184+
if subject_line.startswith("Merge "):
185+
print(f"SKIP {sha}: {subject_line} (merge commit)")
186+
continue
187+
177188
error, warnings = validate_message(message)
178-
subject: str = message.splitlines()[0]
179189

180190
if error:
181-
print(f"FAIL {sha}: {subject}", file=sys.stderr)
191+
print(f"FAIL {sha}: {subject_line}", file=sys.stderr)
182192
print(f" Error: {error}", file=sys.stderr)
183193
has_errors = True
184194
else:
185-
print(f"PASS {sha}: {subject}")
195+
print(f"PASS {sha}: {subject_line}")
186196

187197
for warning in warnings:
188198
print(f" Warning: {warning}")
@@ -191,8 +201,30 @@ def run_local() -> None:
191201
sys.exit(1)
192202

193203

204+
def run_local() -> None:
205+
"""Validate local commit messages ahead of origin/main."""
206+
run_range("origin/main..HEAD")
207+
208+
194209
def main() -> None:
195-
run_local()
210+
import argparse
211+
212+
parser = argparse.ArgumentParser(
213+
description="Lint commit messages for conventional commits compliance."
214+
)
215+
parser.add_argument(
216+
"--range",
217+
default=None,
218+
dest="git_range",
219+
help="Validate all commits in a git revision range (e.g. 'origin/main..HEAD'). "
220+
"Skips the uncommitted-changes check (useful in CI).",
221+
)
222+
args = parser.parse_args()
223+
224+
if args.git_range is not None:
225+
run_range(args.git_range, skip_dirty_check=True)
226+
else:
227+
run_local()
196228

197229

198230
if __name__ == "__main__":

ops/tests/test_lintcommit.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
#!/usr/bin/env python3
22

3-
from ops.lintcommit import validate_message, validate_subject
3+
from __future__ import annotations
4+
5+
from unittest.mock import patch
6+
7+
import pytest
8+
9+
from ops.lintcommit import run_range, validate_message, validate_subject
410

511

612
# region validate_subject: valid subjects
@@ -151,3 +157,117 @@ def test_empty_message() -> None:
151157
def test_invalid_subject_in_message() -> None:
152158
error, _ = validate_message("invalid title")
153159
assert error == "missing colon (:) char"
160+
161+
162+
# region run_range
163+
164+
165+
def _make_git_log_output(*messages: str) -> str:
166+
"""Build fake ``git log --format=%H%n%B%n---END---`` output."""
167+
blocks: list[str] = []
168+
for i, msg in enumerate(messages):
169+
sha = f"abc{i:04d}" + "0" * 33 # 40-char fake SHA
170+
blocks.append(f"{sha}\n{msg}\n---END---")
171+
return "\n".join(blocks)
172+
173+
174+
def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
175+
"""Shorthand for a ``subprocess.CompletedProcess``."""
176+
from subprocess import CompletedProcess
177+
178+
return CompletedProcess(
179+
args=[], returncode=returncode, stdout=stdout, stderr=stderr
180+
)
181+
182+
183+
@patch("subprocess.run")
184+
def test_run_range_all_valid(mock_run, capsys) -> None:
185+
log_output = _make_git_log_output(
186+
"feat: add new feature",
187+
"fix(sdk): resolve issue",
188+
)
189+
mock_run.return_value = _completed(stdout=log_output)
190+
191+
run_range("origin/main..HEAD", skip_dirty_check=True)
192+
193+
out = capsys.readouterr().out
194+
assert "PASS" in out
195+
assert out.count("PASS") == 2
196+
197+
198+
@patch("subprocess.run")
199+
def test_run_range_with_invalid_commit(mock_run, capsys) -> None:
200+
log_output = _make_git_log_output(
201+
"feat: add new feature",
202+
"bad commit no colon",
203+
)
204+
mock_run.return_value = _completed(stdout=log_output)
205+
206+
with pytest.raises(SystemExit, match="1"):
207+
run_range("origin/main..HEAD", skip_dirty_check=True)
208+
209+
captured = capsys.readouterr()
210+
assert "PASS" in captured.out
211+
assert "FAIL" in captured.err
212+
213+
214+
@patch("subprocess.run")
215+
def test_run_range_empty(mock_run, capsys) -> None:
216+
mock_run.return_value = _completed(stdout="")
217+
218+
run_range("origin/main..HEAD", skip_dirty_check=True)
219+
220+
out = capsys.readouterr().out
221+
assert "No commits in range" in out
222+
223+
224+
@patch("subprocess.run")
225+
def test_run_range_git_failure(mock_run) -> None:
226+
mock_run.return_value = _completed(returncode=1, stderr="fatal: bad range")
227+
228+
with pytest.raises(SystemExit, match="1"):
229+
run_range("bad..range", skip_dirty_check=True)
230+
231+
232+
@patch("subprocess.run")
233+
def test_run_range_dirty_worktree_skips(mock_run, capsys) -> None:
234+
"""When skip_dirty_check=False and worktree is dirty, validation is skipped."""
235+
mock_run.return_value = _completed(stdout=" M ops/lintcommit.py\n")
236+
237+
run_range("origin/main..HEAD", skip_dirty_check=False)
238+
239+
out = capsys.readouterr().out
240+
assert "uncommitted changes" in out
241+
# git log should never have been called (only git status)
242+
mock_run.assert_called_once()
243+
244+
245+
@patch("subprocess.run")
246+
def test_run_range_skips_merge_commits(mock_run, capsys) -> None:
247+
log_output = _make_git_log_output(
248+
"Merge branch 'main' into ci_linter",
249+
"feat: add new feature",
250+
)
251+
mock_run.return_value = _completed(stdout=log_output)
252+
253+
run_range("origin/main..HEAD", skip_dirty_check=True)
254+
255+
out = capsys.readouterr().out
256+
assert "SKIP" in out
257+
assert "merge commit" in out
258+
assert "PASS" in out
259+
assert out.count("PASS") == 1
260+
261+
262+
@patch("subprocess.run")
263+
def test_run_range_warnings_printed(mock_run, capsys) -> None:
264+
log_output = _make_git_log_output(
265+
"feat: add thing\n\n" + "x" * 80,
266+
)
267+
mock_run.return_value = _completed(stdout=log_output)
268+
269+
run_range("origin/main..HEAD", skip_dirty_check=True)
270+
271+
out = capsys.readouterr().out
272+
assert "PASS" in out
273+
assert "exceeds 72 chars" in out

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = "Apache-2.0"
1212
keywords = []
13-
authors = [{ name = "yaythomas", email = "tgaigher@amazon.com" }]
13+
authors = [{ name = "AWS durable-execution-dev", email = "durable-execution-dev@amazon.com" }]
1414
classifiers = [
1515
"Development Status :: 4 - Beta",
1616
"Programming Language :: Python",

src/aws_durable_execution_sdk_python/concurrency/executor.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
ExecutionCounters,
2121
SuspendResult,
2222
)
23-
from aws_durable_execution_sdk_python.config import ChildConfig
23+
from aws_durable_execution_sdk_python.config import (
24+
ChildConfig,
25+
NestingType,
26+
)
2427
from aws_durable_execution_sdk_python.exceptions import (
2528
OrphanedChildException,
2629
SuspendExecution,
@@ -134,6 +137,7 @@ class ConcurrentExecutor(ABC, Generic[CallableType, ResultType]):
134137

135138
def __init__(
136139
self,
140+
operation_identifier: OperationIdentifier,
137141
executables: list[Executable[CallableType]],
138142
max_concurrency: int | None,
139143
completion_config: CompletionConfig,
@@ -143,6 +147,7 @@ def __init__(
143147
serdes: SerDes | None,
144148
item_serdes: SerDes | None = None,
145149
summary_generator: SummaryGenerator | None = None,
150+
nesting_type: NestingType = NestingType.NESTED,
146151
):
147152
"""Initialize ConcurrentExecutor.
148153
@@ -153,13 +158,15 @@ def __init__(
153158
handle large BatchResult payloads efficiently. Matches TypeScript behavior in
154159
run-in-child-context-handler.ts.
155160
"""
161+
self.operation_identifier = operation_identifier
156162
self.executables = executables
157163
self.max_concurrency = max_concurrency
158164
self.completion_config = completion_config
159165
self.sub_type_top = sub_type_top
160166
self.sub_type_iteration = sub_type_iteration
161167
self.name_prefix = name_prefix
162168
self.summary_generator = summary_generator
169+
self.nesting_type = nesting_type
163170

164171
# Event-driven state tracking for when the executor is done
165172
self._completion_event = threading.Event()
@@ -406,7 +413,14 @@ def _execute_item_in_child_context(
406413
executable.index
407414
)
408415
name = f"{self.name_prefix}{executable.index}"
409-
child_context = executor_context.create_child_context(operation_id)
416+
non_virtual_parent_id = (
417+
self.operation_identifier.operation_id
418+
if self.nesting_type is NestingType.FLAT
419+
else None
420+
)
421+
child_context = executor_context.create_child_context(
422+
operation_id, non_virtual_parent_id
423+
)
410424
operation_identifier = OperationIdentifier(
411425
operation_id,
412426
executor_context._parent_id, # noqa: SLF001
@@ -424,6 +438,7 @@ def run_in_child_handler():
424438
serdes=self.item_serdes or self.serdes,
425439
sub_type=self.sub_type_iteration,
426440
summary_generator=self.summary_generator,
441+
is_virtual=self.nesting_type is NestingType.FLAT,
427442
),
428443
)
429444
child_context.state.track_replay(operation_id=operation_id)

0 commit comments

Comments
 (0)