Skip to content

Commit ad16770

Browse files
Qualify every public target branch under GitHub authority (#256)
1 parent ddfc19e commit ad16770

5 files changed

Lines changed: 454 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ name: CI
33
on:
44
push:
55
branches: [main]
6+
pull_request:
7+
branches: [main]
68
workflow_dispatch:
79

810
permissions:
@@ -11,6 +13,7 @@ permissions:
1113
jobs:
1214
lint:
1315
runs-on: ubuntu-latest
16+
timeout-minutes: 10
1417
steps:
1518
- uses: actions/checkout@v6
1619
- uses: actions/setup-python@v6
@@ -22,7 +25,9 @@ jobs:
2225

2326
test:
2427
runs-on: ubuntu-latest
28+
timeout-minutes: 15
2529
strategy:
30+
fail-fast: false
2631
matrix:
2732
python-version: ["3.10", "3.11", "3.12"]
2833
steps:
@@ -35,6 +40,7 @@ jobs:
3540

3641
package:
3742
runs-on: ubuntu-latest
43+
timeout-minutes: 10
3844
steps:
3945
- uses: actions/checkout@v6
4046
- uses: actions/setup-python@v6
@@ -50,48 +56,69 @@ jobs:
5056

5157
cli-parity:
5258
runs-on: ubuntu-latest
59+
timeout-minutes: 5
5360
steps:
5461
- uses: actions/checkout@v6
5562
with:
5663
path: sdk-python
57-
- uses: actions/checkout@v6
58-
with:
59-
repository: durable-workflow/cli
60-
path: cli
6164
- uses: actions/setup-python@v6
6265
with:
6366
python-version: "3.12"
67+
- name: Checkout public CLI integration source
68+
run: python sdk-python/scripts/ci/checkout-public-repository.py cli cli
6469
- name: Compare shared control-plane parity fixtures
6570
working-directory: sdk-python
6671
run: python scripts/check-cli-parity.py --cli ../cli
6772

6873
integration:
6974
runs-on: ubuntu-latest
75+
timeout-minutes: 25
7076
needs: [lint, test]
7177
steps:
7278
- uses: actions/checkout@v6
7379
with:
7480
path: sdk-python
75-
- uses: actions/checkout@v6
76-
with:
77-
repository: durable-workflow/server
78-
path: server
7981
- uses: actions/setup-python@v6
8082
with:
8183
python-version: "3.12"
84+
- name: Checkout public Server integration source
85+
run: python sdk-python/scripts/ci/checkout-public-repository.py server server
8286
- run: pip install -e '.[dev]'
8387
working-directory: sdk-python
8488
- name: Start server stack
8589
working-directory: sdk-python
8690
run: |
87-
docker compose -f docker-compose.test.yml up -d --wait --timeout 120
91+
docker compose -f docker-compose.test.yml up -d --wait --timeout 300
92+
- name: Select and probe integration endpoint
93+
working-directory: sdk-python
94+
run: python scripts/ci/configure-integration-endpoint.py
8895
- name: Run integration tests
8996
working-directory: sdk-python
9097
env:
91-
DURABLE_WORKFLOW_SERVER_URL: http://localhost:8080
9298
DURABLE_WORKFLOW_AUTH_TOKEN: test-token
9399
run: pytest tests/integration/ -v
94100
- name: Teardown
95101
if: always()
96102
working-directory: sdk-python
97103
run: docker compose -f docker-compose.test.yml down -v
104+
105+
target-branch-qualification:
106+
name: Target branch qualification
107+
if: ${{ always() }}
108+
needs: [lint, test, package, cli-parity, integration]
109+
runs-on: ubuntu-latest
110+
timeout-minutes: 2
111+
steps:
112+
- name: Require every supported Python and integration cell
113+
env:
114+
LINT_RESULT: ${{ needs.lint.result }}
115+
TEST_RESULT: ${{ needs.test.result }}
116+
PACKAGE_RESULT: ${{ needs.package.result }}
117+
PARITY_RESULT: ${{ needs.cli-parity.result }}
118+
INTEGRATION_RESULT: ${{ needs.integration.result }}
119+
run: |
120+
test "$LINT_RESULT" = success
121+
test "$TEST_RESULT" = success
122+
test "$PACKAGE_RESULT" = success
123+
test "$PARITY_RESULT" = success
124+
test "$INTEGRATION_RESULT" = success
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env python3
2+
"""Checkout a public integration source from its GitHub authority."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import subprocess
9+
from collections.abc import Sequence
10+
from pathlib import Path
11+
12+
PUBLIC_REPOSITORIES = {
13+
"cli": "https://github.com/durable-workflow/cli.git",
14+
"server": "https://github.com/durable-workflow/server.git",
15+
}
16+
17+
18+
def checkout(repository: str, destination: Path) -> None:
19+
"""Clone a supported public repository without runner-host credentials."""
20+
environment = os.environ.copy()
21+
environment["GIT_TERMINAL_PROMPT"] = "0"
22+
23+
subprocess.run(
24+
[
25+
"git",
26+
"-c",
27+
"credential.helper=",
28+
"clone",
29+
"--depth=1",
30+
"--no-tags",
31+
PUBLIC_REPOSITORIES[repository],
32+
str(destination),
33+
],
34+
check=True,
35+
env=environment,
36+
)
37+
38+
39+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
40+
parser = argparse.ArgumentParser(description=__doc__)
41+
parser.add_argument("repository", choices=sorted(PUBLIC_REPOSITORIES))
42+
parser.add_argument("destination", type=Path)
43+
return parser.parse_args(argv)
44+
45+
46+
def main(argv: Sequence[str] | None = None) -> int:
47+
args = parse_args(argv)
48+
checkout(args.repository, args.destination)
49+
return 0
50+
51+
52+
if __name__ == "__main__":
53+
raise SystemExit(main())
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
"""Select and probe the integration server endpoint for the current CI runner."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import os
8+
import socket
9+
import struct
10+
import time
11+
import urllib.error
12+
import urllib.request
13+
from collections.abc import Mapping, Sequence
14+
from pathlib import Path
15+
from urllib.parse import urlsplit
16+
17+
HEALTH_PATH = "/api/health"
18+
PUBLIC_GITHUB_HOST = "github.com"
19+
20+
21+
def _docker_host_name(value: str) -> str | None:
22+
if not value or value.startswith("unix:"):
23+
return None
24+
25+
parsed = urlsplit(value if "://" in value else f"//{value}")
26+
return parsed.hostname
27+
28+
29+
def _default_route_gateway(route_file: Path = Path("/proc/net/route")) -> str | None:
30+
try:
31+
routes = route_file.read_text(encoding="ascii").splitlines()[1:]
32+
except OSError:
33+
return None
34+
35+
for route in routes:
36+
fields = route.split()
37+
if len(fields) < 4 or fields[1] != "00000000":
38+
continue
39+
40+
try:
41+
flags = int(fields[3], 16)
42+
gateway = socket.inet_ntoa(struct.pack("<L", int(fields[2], 16)))
43+
except (OSError, ValueError, struct.error):
44+
continue
45+
46+
if flags & 0x2:
47+
return gateway
48+
49+
return None
50+
51+
52+
def _host_docker_internal() -> str | None:
53+
try:
54+
socket.getaddrinfo("host.docker.internal", None, type=socket.SOCK_STREAM)
55+
except socket.gaierror:
56+
return None
57+
return "host.docker.internal"
58+
59+
60+
def _endpoint(host: str, port: int) -> str:
61+
formatted_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
62+
return f"http://{formatted_host}:{port}"
63+
64+
65+
def endpoint_candidates(environment: Mapping[str, str]) -> list[str]:
66+
"""Return runner-appropriate endpoints in reachability preference order."""
67+
runner_host = urlsplit(environment.get("GITHUB_SERVER_URL", "https://github.com")).hostname
68+
port = int(environment.get("SERVER_PORT", "8080"))
69+
if not 1 <= port <= 65535:
70+
raise ValueError("SERVER_PORT must be between 1 and 65535")
71+
72+
if runner_host == PUBLIC_GITHUB_HOST:
73+
hosts = ["localhost"]
74+
else:
75+
hosts = [
76+
_docker_host_name(environment.get("DURABLE_WORKFLOW_DOCKER_HOST", "")),
77+
_docker_host_name(environment.get("DOCKER_HOST", "")),
78+
_host_docker_internal(),
79+
_default_route_gateway(),
80+
"localhost",
81+
]
82+
83+
unique_hosts = list(dict.fromkeys(host for host in hosts if host))
84+
return [_endpoint(host, port) for host in unique_hosts]
85+
86+
87+
def probe_endpoint(
88+
candidates: Sequence[str],
89+
*,
90+
attempts: int,
91+
retry_delay: float,
92+
timeout: float,
93+
) -> str:
94+
"""Return the first endpoint with a healthy HTTP response."""
95+
if attempts < 1:
96+
raise ValueError("attempts must be at least 1")
97+
98+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
99+
failures: dict[str, str] = {}
100+
for attempt in range(1, attempts + 1):
101+
for endpoint in candidates:
102+
health_url = f"{endpoint}{HEALTH_PATH}"
103+
try:
104+
request = urllib.request.Request(health_url, method="GET")
105+
with opener.open(request, timeout=timeout) as response:
106+
if 200 <= response.status < 400:
107+
return endpoint
108+
failures[endpoint] = f"HTTP {response.status}"
109+
except (OSError, urllib.error.URLError) as error:
110+
failures[endpoint] = str(error)
111+
112+
if attempt < attempts:
113+
time.sleep(retry_delay)
114+
115+
details = "; ".join(f"{endpoint}: {failures.get(endpoint, 'unreachable')}" for endpoint in candidates)
116+
raise RuntimeError(f"integration server health probe failed after {attempts} attempts ({details})")
117+
118+
119+
def export_endpoint(endpoint: str, github_environment: Path) -> None:
120+
with github_environment.open("a", encoding="utf-8") as environment_file:
121+
environment_file.write(f"DURABLE_WORKFLOW_SERVER_URL={endpoint}\n")
122+
123+
124+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
125+
parser = argparse.ArgumentParser(description=__doc__)
126+
parser.add_argument("--attempts", type=int, default=30)
127+
parser.add_argument("--retry-delay", type=float, default=2.0)
128+
parser.add_argument("--timeout", type=float, default=2.0)
129+
return parser.parse_args(argv)
130+
131+
132+
def main(argv: Sequence[str] | None = None) -> int:
133+
args = parse_args(argv)
134+
github_environment = os.environ.get("GITHUB_ENV")
135+
if not github_environment:
136+
raise RuntimeError("GITHUB_ENV must name the CI environment export file")
137+
138+
candidates = endpoint_candidates(os.environ)
139+
endpoint = probe_endpoint(
140+
candidates,
141+
attempts=args.attempts,
142+
retry_delay=args.retry_delay,
143+
timeout=args.timeout,
144+
)
145+
export_endpoint(endpoint, Path(github_environment))
146+
print(f"Integration server is reachable at {endpoint}")
147+
return 0
148+
149+
150+
if __name__ == "__main__":
151+
raise SystemExit(main())

tests/test_ci_checkout.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
CHECKOUT_SCRIPT = ROOT / "scripts" / "ci" / "checkout-public-repository.py"
12+
CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml"
13+
14+
15+
@pytest.mark.parametrize(
16+
"runner_server_url",
17+
["https://github.com", "https://forgejo.example.test"],
18+
ids=["github", "forgejo"],
19+
)
20+
@pytest.mark.parametrize(
21+
("repository", "public_url"),
22+
[
23+
("cli", "https://github.com/durable-workflow/cli.git"),
24+
("server", "https://github.com/durable-workflow/server.git"),
25+
],
26+
)
27+
def test_public_checkout_uses_github_authority_on_every_runner(
28+
tmp_path: Path,
29+
runner_server_url: str,
30+
repository: str,
31+
public_url: str,
32+
) -> None:
33+
bin_dir = tmp_path / "bin"
34+
bin_dir.mkdir()
35+
git_capture = tmp_path / "git-arguments"
36+
fake_git = bin_dir / "git"
37+
fake_git.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$GIT_CAPTURE"\n')
38+
fake_git.chmod(0o755)
39+
40+
environment = os.environ.copy()
41+
environment.update(
42+
{
43+
"GITHUB_SERVER_URL": runner_server_url,
44+
"GIT_CAPTURE": str(git_capture),
45+
"PATH": f"{bin_dir}{os.pathsep}{environment['PATH']}",
46+
}
47+
)
48+
destination = tmp_path / repository
49+
50+
subprocess.run(
51+
[sys.executable, str(CHECKOUT_SCRIPT), repository, str(destination)],
52+
check=True,
53+
env=environment,
54+
)
55+
56+
assert git_capture.read_text().splitlines() == [
57+
"-c",
58+
"credential.helper=",
59+
"clone",
60+
"--depth=1",
61+
"--no-tags",
62+
public_url,
63+
str(destination),
64+
]
65+
66+
67+
def test_ci_workflow_uses_portable_public_checkouts() -> None:
68+
workflow = CI_WORKFLOW.read_text()
69+
70+
assert "checkout-public-repository.py cli cli" in workflow
71+
assert "checkout-public-repository.py server server" in workflow
72+
assert "repository: durable-workflow/" not in workflow

0 commit comments

Comments
 (0)