Skip to content

Commit e3b4a89

Browse files
Nelson-PROIAclaude
andcommitted
fix: harden CI publish guard and improve prepare_readme script
- Fix CI workflow logic bug where workflow_dispatch from v1 branch bypassed the publish confirmation gate - Broaden regex to exclude all URI schemes (mailto, ftp, etc.) - Normalize relative paths by stripping leading ./ and / to avoid double-slash and dot-slash artifacts in rewritten URLs - Move rewritten file write inside try/finally for safer restoration - Return clean error message instead of raw traceback for missing README - Add full type annotations and expand test coverage from 3 to 16 tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4b550b commit e3b4a89

3 files changed

Lines changed: 130 additions & 13 deletions

File tree

.github/workflows/sdk_publish_mistralai_sdk.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ permissions:
1919
- "*/RELEASES.md"
2020
jobs:
2121
publish:
22-
# Auto-publish from v1 branch; require manual confirmation from main
22+
# Auto-publish on push to v1 branch; require manual confirmation for workflow_dispatch
2323
if: |
24-
github.ref == 'refs/heads/v1' ||
24+
(github.event_name == 'push' && github.ref == 'refs/heads/v1') ||
2525
(github.event_name == 'workflow_dispatch' && github.event.inputs.confirm_publish == 'publish')
2626
uses: speakeasy-api/sdk-generation-action/.github/workflows/sdk-publish.yaml@7951d9dce457425b900b2dd317253499d98c2587 # v15
2727
secrets:

scripts/prepare_readme.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
DEFAULT_REPO_URL = "https://github.com/mistralai/client-python.git"
88
DEFAULT_BRANCH = "main"
9-
LINK_PATTERN = re.compile(r"(\[[^\]]+\]\()((?!https?:|#)[^\)]+)(\))")
9+
LINK_PATTERN = re.compile(r"(\[[^\]]+\]\()((?![a-zA-Z][a-zA-Z0-9+.-]*:|#)[^\)]+)(\))")
1010

1111

1212
def build_base_url(repo_url: str, branch: str, repo_subdir: str) -> str:
@@ -18,10 +18,23 @@ def build_base_url(repo_url: str, branch: str, repo_subdir: str) -> str:
1818
return f"{normalized_repo_url}/blob/{branch}/{normalized_subdir}"
1919

2020

21+
def _normalize_relative_path(path: str) -> str:
22+
"""Strip leading './' and '/' from a relative path."""
23+
if path.startswith("./"):
24+
path = path[2:]
25+
elif path.startswith("/"):
26+
path = path[1:]
27+
return path
28+
29+
2130
def rewrite_relative_links(contents: str, base_url: str) -> str:
2231
"""Rewrite Markdown relative links to absolute GitHub URLs."""
2332
return LINK_PATTERN.sub(
24-
lambda match: f"{match.group(1)}{base_url}{match.group(2)}{match.group(3)}",
33+
lambda match: (
34+
f"{match.group(1)}"
35+
f"{base_url}{_normalize_relative_path(match.group(2))}"
36+
f"{match.group(3)}"
37+
),
2538
contents,
2639
)
2740

@@ -32,8 +45,8 @@ def run_with_rewritten_readme(
3245
"""Rewrite README links, run a command, and restore the original README."""
3346
original_contents = readme_path.read_text(encoding="utf-8")
3447
rewritten_contents = rewrite_relative_links(original_contents, base_url)
35-
readme_path.write_text(rewritten_contents, encoding="utf-8")
3648
try:
49+
readme_path.write_text(rewritten_contents, encoding="utf-8")
3750
if not command:
3851
return 0
3952
result = subprocess.run(command, check=False)
@@ -86,7 +99,8 @@ def main(argv: list[str]) -> int:
8699
args = parse_args(argv)
87100
readme_path = args.readme
88101
if not readme_path.is_file():
89-
raise FileNotFoundError(f"README file not found: {readme_path}")
102+
print(f"Error: README file not found: {readme_path}", file=sys.stderr)
103+
return 1
90104
base_url = build_base_url(args.repo_url, args.branch, args.repo_subdir)
91105
command = (
92106
args.command[1:]

tests/test_prepare_readme.py

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,126 @@
11
import importlib.util
22
from pathlib import Path
33

4+
import pytest
5+
46
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "prepare_readme.py"
57
SPEC = importlib.util.spec_from_file_location("prepare_readme", SCRIPT_PATH)
68
if SPEC is None or SPEC.loader is None:
79
raise ImportError(f"Unable to load prepare_readme from {SCRIPT_PATH}")
810
prepare_readme = importlib.util.module_from_spec(SPEC)
911
SPEC.loader.exec_module(prepare_readme)
1012

13+
BASE_URL = "https://example.com/blob/main/"
14+
1115

1216
def test_rewrite_relative_links_keeps_absolute() -> None:
13-
base_url = "https://example.com/blob/main/"
1417
contents = "[Migration](MIGRATION.md)\n[Docs](https://docs.mistral.ai)"
1518
expected = (
16-
"[Migration](https://example.com/blob/main/MIGRATION.md)\n"
19+
f"[Migration]({BASE_URL}MIGRATION.md)\n"
1720
"[Docs](https://docs.mistral.ai)"
1821
)
19-
assert prepare_readme.rewrite_relative_links(contents, base_url) == expected
22+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == expected
23+
24+
25+
def test_rewrite_relative_links_keeps_http() -> None:
26+
contents = "[Site](http://example.com)"
27+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == contents
2028

2129

2230
def test_rewrite_relative_links_keeps_anchors() -> None:
23-
base_url = "https://example.com/blob/main/"
2431
contents = "[Retries](#retries)\n[File](docs/README.md#upload)"
2532
expected = (
2633
"[Retries](#retries)\n"
27-
"[File](https://example.com/blob/main/docs/README.md#upload)"
34+
f"[File]({BASE_URL}docs/README.md#upload)"
35+
)
36+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == expected
37+
38+
39+
def test_rewrite_relative_links_keeps_mailto() -> None:
40+
contents = "[Email](mailto:user@example.com)"
41+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == contents
42+
43+
44+
def test_rewrite_relative_links_keeps_ftp() -> None:
45+
contents = "[FTP](ftp://files.example.com/data)"
46+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == contents
47+
48+
49+
def test_rewrite_strips_leading_dot_slash() -> None:
50+
contents = "[Errors](./src/errors.py)"
51+
expected = f"[Errors]({BASE_URL}src/errors.py)"
52+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == expected
53+
54+
55+
def test_rewrite_strips_leading_slash() -> None:
56+
contents = "[Examples](/examples/azure)"
57+
expected = f"[Examples]({BASE_URL}examples/azure)"
58+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == expected
59+
60+
61+
def test_rewrite_multiple_links_same_line() -> None:
62+
contents = "[A](a.md) and [B](b.md)"
63+
expected = f"[A]({BASE_URL}a.md) and [B]({BASE_URL}b.md)"
64+
assert prepare_readme.rewrite_relative_links(contents, BASE_URL) == expected
65+
66+
67+
def test_build_base_url_strips_git_suffix() -> None:
68+
url = prepare_readme.build_base_url(
69+
"https://github.com/org/repo.git", "main", ""
2870
)
29-
assert prepare_readme.rewrite_relative_links(contents, base_url) == expected
71+
assert url == "https://github.com/org/repo/blob/main/"
3072

3173

32-
def test_main_prints_rewritten_readme_with_defaults(tmp_path, capsys) -> None:
74+
def test_build_base_url_no_git_suffix() -> None:
75+
url = prepare_readme.build_base_url(
76+
"https://github.com/org/repo", "main", ""
77+
)
78+
assert url == "https://github.com/org/repo/blob/main/"
79+
80+
81+
def test_build_base_url_with_subdir() -> None:
82+
url = prepare_readme.build_base_url(
83+
"https://github.com/org/repo.git", "main", "packages/azure"
84+
)
85+
assert url == "https://github.com/org/repo/blob/main/packages/azure/"
86+
87+
88+
def test_build_base_url_strips_subdir_slashes() -> None:
89+
url = prepare_readme.build_base_url(
90+
"https://github.com/org/repo.git", "main", "/packages/azure/"
91+
)
92+
assert url == "https://github.com/org/repo/blob/main/packages/azure/"
93+
94+
95+
def test_run_with_rewritten_readme_restores_on_success(tmp_path: Path) -> None:
96+
readme = tmp_path / "README.md"
97+
original = "[Link](file.md)\n"
98+
readme.write_text(original, encoding="utf-8")
99+
100+
exit_code = prepare_readme.run_with_rewritten_readme(
101+
readme, BASE_URL, ["echo", "hello"]
102+
)
103+
104+
assert exit_code == 0
105+
assert readme.read_text(encoding="utf-8") == original
106+
107+
108+
def test_run_with_rewritten_readme_restores_on_failure(tmp_path: Path) -> None:
109+
readme = tmp_path / "README.md"
110+
original = "[Link](file.md)\n"
111+
readme.write_text(original, encoding="utf-8")
112+
113+
exit_code = prepare_readme.run_with_rewritten_readme(
114+
readme, BASE_URL, ["false"]
115+
)
116+
117+
assert exit_code != 0
118+
assert readme.read_text(encoding="utf-8") == original
119+
120+
121+
def test_main_prints_rewritten_readme_with_defaults(
122+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
123+
) -> None:
33124
original = "[Migration](MIGRATION.md)\n"
34125
base_url = prepare_readme.build_base_url(
35126
prepare_readme.DEFAULT_REPO_URL,
@@ -45,3 +136,15 @@ def test_main_prints_rewritten_readme_with_defaults(tmp_path, capsys) -> None:
45136
captured = capsys.readouterr()
46137
assert exit_code == 0
47138
assert captured.out == expected
139+
140+
141+
def test_main_missing_readme_returns_error(
142+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
143+
) -> None:
144+
readme_path = tmp_path / "MISSING.md"
145+
146+
exit_code = prepare_readme.main(["--readme", str(readme_path)])
147+
148+
assert exit_code == 1
149+
captured = capsys.readouterr()
150+
assert "Error" in captured.err

0 commit comments

Comments
 (0)