yardmaster 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yardmaster/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ __version__ = "0.1.0"
2
+ __author__ = "Flatcar Team"
3
+ __license__ = "Apache-2.0"
4
+
5
+ from yardmaster.core.release import ReleaseManager, ReleaseSpec
6
+ from yardmaster.core.version import Channel, Version
7
+
8
+ __all__ = ["ReleaseManager", "ReleaseSpec", "Version", "Channel"]
yardmaster/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from yardmaster.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
yardmaster/cli.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from yardmaster import __version__
10
+ from yardmaster.commands.epoch import epoch_command
11
+ from yardmaster.commands.jenkins import jenkins_command
12
+ from yardmaster.commands.release import release_command
13
+ from yardmaster.commands.retag import retag_command
14
+ from yardmaster.commands.sdk import sdk_command
15
+ from yardmaster.commands.status import status_command
16
+ from yardmaster.config import load_config
17
+ from yardmaster.utils.logger import setup_logger
18
+
19
+ console = Console()
20
+ CONFIG_FILE = ".yardmaster.yaml"
21
+ CONFIG_ENV_VAR = "YARDMASTER_CONFIG"
22
+
23
+
24
+ @click.group()
25
+ @click.version_option(version=__version__, prog_name="yardmaster")
26
+ @click.option(
27
+ "-c",
28
+ "--config",
29
+ type=click.Path(path_type=Path),
30
+ default=None,
31
+ envvar=CONFIG_ENV_VAR,
32
+ help=f"Configuration file path (env: {CONFIG_ENV_VAR})",
33
+ )
34
+ @click.option("-v", "--verbose", is_flag=True, help="Verbose output")
35
+ @click.pass_context
36
+ def cli(ctx: click.Context, config: Path | None, verbose: bool) -> None:
37
+ """🚂 Yardmaster - Flatcar Container Linux Release Management Tool"""
38
+ ctx.ensure_object(dict)
39
+ ctx.obj["verbose"] = verbose
40
+
41
+ # Commands that don't need configuration
42
+ if ctx.invoked_subcommand in ("epoch",):
43
+ return
44
+
45
+ if config is None:
46
+ config = Path(CONFIG_FILE)
47
+
48
+ if not config.exists():
49
+ console.print(f"[red]Error: Config file not found: {config}[/red]")
50
+ console.print(
51
+ "Copy [cyan].yardmaster.yaml.sample[/cyan] to "
52
+ f"[cyan]{CONFIG_FILE}[/cyan] and update the values."
53
+ )
54
+ sys.exit(1)
55
+
56
+ ctx.obj["config"] = load_config(config)
57
+ log_level = "DEBUG" if verbose else ctx.obj["config"].logging.level
58
+ setup_logger(log_level, fmt=ctx.obj["config"].logging.format)
59
+
60
+
61
+ cli.add_command(epoch_command, name="epoch")
62
+ cli.add_command(release_command, name="release")
63
+ cli.add_command(jenkins_command, name="jenkins")
64
+ cli.add_command(retag_command, name="retag")
65
+ cli.add_command(sdk_command, name="sdk")
66
+ cli.add_command(status_command, name="status")
67
+
68
+
69
+ def main() -> None:
70
+ cli(obj={})
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
File without changes
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from yardmaster.core.version import EPOCH_BASE_DATE, days_since_epoch
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command(help="Show the alpha epoch for a given date (default: today).")
14
+ @click.argument("date_str", required=False, default=None)
15
+ def epoch_command(date_str: str | None) -> None:
16
+ if date_str is not None:
17
+ try:
18
+ target = date.fromisoformat(date_str)
19
+ except ValueError:
20
+ console.print(f"[red]Invalid date '{date_str}'. Use YYYY-MM-DD format.[/red]")
21
+ raise SystemExit(1)
22
+ else:
23
+ target = date.today()
24
+
25
+ epoch = days_since_epoch(target)
26
+ console.print(f"Base date: {EPOCH_BASE_DATE}")
27
+ console.print(f"Target: {target}")
28
+ console.print(f"Epoch: [bold green]{epoch}[/bold green]")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from yardmaster.services.jenkins import JenkinsService
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group(help="Jenkins utilities.")
14
+ def jenkins_command() -> None:
15
+ pass
16
+
17
+
18
+ @jenkins_command.command("pr-build", help="Trigger a Jenkins build for a GitHub PR (kernel tests).")
19
+ @click.argument("owner")
20
+ @click.argument("repo")
21
+ @click.argument("pr", type=int)
22
+ @click.option("--job", default="release", help="Jenkins job key or path")
23
+ @click.option("--github-token", default=None, help="GitHub token (or set GITHUB_TOKEN)")
24
+ @click.option("--dry-run", is_flag=True, help="Show what would be sent without triggering")
25
+ @click.pass_context
26
+ def jenkins_pr_build(
27
+ ctx: click.Context,
28
+ owner: str,
29
+ repo: str,
30
+ pr: int,
31
+ job: str,
32
+ github_token: str | None,
33
+ dry_run: bool,
34
+ ) -> None:
35
+ cfg = ctx.obj["config"]
36
+ jenkins = JenkinsService(
37
+ url=cfg.jenkins.url,
38
+ username=cfg.jenkins.username,
39
+ token=cfg.jenkins.token,
40
+ jobs=cfg.jenkins.jobs,
41
+ pipeline_branch=cfg.jenkins.pipeline_branch,
42
+ timeout=cfg.network.timeout,
43
+ retries=cfg.network.retries,
44
+ verify_ssl=cfg.network.verify_ssl,
45
+ )
46
+ try:
47
+ ok = jenkins.trigger_pr_build(
48
+ owner=owner,
49
+ repo=repo,
50
+ pr_number=pr,
51
+ job=job,
52
+ github_token=github_token or os.getenv("GITHUB_TOKEN"),
53
+ dry_run=dry_run,
54
+ )
55
+ except RuntimeError as exc:
56
+ raise click.ClickException(str(exc)) from exc
57
+ if ok:
58
+ console.print("[green]✓[/green] Jenkins build triggered.")
@@ -0,0 +1,327 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date, datetime, timezone
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from yardmaster.config import Config, load_config
12
+ from yardmaster.core.release import ReleaseManager, ReleaseSpec
13
+ from yardmaster.core.state import (
14
+ load_release_state,
15
+ resolve_state_path,
16
+ save_release_state,
17
+ update_release_state,
18
+ )
19
+ from yardmaster.core.version import Version
20
+ from yardmaster.utils.http import HttpClient
21
+
22
+ console = Console()
23
+ ISSUE_TITLE_RE = re.compile(r"\b(alpha|beta|stable|lts)\s+(\d+\.\d+\.\d+)\b", re.IGNORECASE)
24
+ EPOCH_BASE_DATE = date(2013, 7, 1)
25
+
26
+ CHANNEL_ORDER = ["alpha", "beta", "stable", "lts"]
27
+
28
+
29
+ def _parse_issue_url(issue_url: str) -> tuple[str, str, str]:
30
+ parsed = urlparse(issue_url)
31
+ if parsed.netloc not in {"github.com", "www.github.com"}:
32
+ raise click.ClickException("Only github.com issue URLs are supported.")
33
+ parts = [p for p in parsed.path.split("/") if p]
34
+ if len(parts) < 4 or parts[2] != "issues":
35
+ raise click.ClickException(
36
+ "Expected GitHub issue URL like https://github.com/org/repo/issues/1234"
37
+ )
38
+ return parts[0], parts[1], parts[3]
39
+
40
+
41
+ def _extract_versions_from_title(title: str) -> dict[str, str]:
42
+ matches = ISSUE_TITLE_RE.findall(title)
43
+ if not matches:
44
+ raise click.ClickException("Could not find channel versions in issue title.")
45
+ versions: dict[str, str] = {}
46
+ for channel, version in matches:
47
+ key = channel.lower()
48
+ if key in versions and versions[key] != version:
49
+ raise click.ClickException(f"Conflicting versions for {key} in issue title.")
50
+ versions[key] = version
51
+ return versions
52
+
53
+
54
+ def _issue_api_url(issue_url: str) -> tuple[str, str]:
55
+ owner, repo, issue_id = _parse_issue_url(issue_url)
56
+ return issue_id, f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_id}"
57
+
58
+
59
+ def _build_http_client(cfg: Config) -> HttpClient:
60
+ return HttpClient(
61
+ timeout=cfg.network.timeout,
62
+ retries=cfg.network.retries,
63
+ verify_ssl=cfg.network.verify_ssl,
64
+ )
65
+
66
+
67
+ def _fetch_issue_title(http: HttpClient, issue_url: str) -> str:
68
+ issue_id, api_url = _issue_api_url(issue_url)
69
+ title: str | None = http.get_json(api_url).get("title")
70
+ if not title:
71
+ raise click.ClickException(f"Missing issue title for {issue_id}.")
72
+ return title
73
+
74
+
75
+ def _specs_from_state(state: dict[str, object], http: HttpClient, skip_issue_check: bool) -> list[str]:
76
+ versions = state.get("versions")
77
+ if not isinstance(versions, dict) or not versions:
78
+ raise click.ClickException("Release state has no versions.")
79
+
80
+ issue_url = state.get("issue_url")
81
+ if issue_url and not skip_issue_check:
82
+ try:
83
+ title = _fetch_issue_title(http, issue_url)
84
+ except Exception as exc: # noqa: BLE001
85
+ raise click.ClickException(
86
+ "Could not refresh issue title. Use --skip-issue-check or pass specs explicitly."
87
+ ) from exc
88
+ if _extract_versions_from_title(title) != versions:
89
+ raise click.ClickException(
90
+ "Release state is out of date with the issue title. Run `yardmaster release init --force <issue-url>`."
91
+ )
92
+
93
+ return [f"{ch}:{versions[ch]}" for ch in CHANNEL_ORDER if ch in versions]
94
+
95
+
96
+ def _expected_epoch_today() -> int:
97
+ return (date.today() - EPOCH_BASE_DATE).days
98
+
99
+
100
+ def _reconcile_alpha_epoch(specs: list[ReleaseSpec], verbose: bool) -> list[ReleaseSpec]:
101
+ expected_epoch = _expected_epoch_today()
102
+ updated: list[ReleaseSpec] = []
103
+ for spec in specs:
104
+ if spec.channel.value != "alpha" or spec.version.stream != 0 or spec.version.revision != 0:
105
+ if spec.channel.value == "alpha" and spec.version.stream != 0 and verbose:
106
+ console.print("[yellow]Alpha stream is not 0; skipping epoch check.[/yellow]")
107
+ updated.append(spec)
108
+ continue
109
+ if spec.version.epoch == expected_epoch:
110
+ updated.append(spec)
111
+ continue
112
+
113
+ provided_version = f"{spec.version.epoch}.{spec.version.stream}.{spec.version.revision}"
114
+ expected_version = f"{expected_epoch}.{spec.version.stream}.{spec.version.revision}"
115
+ prompt = (
116
+ f"Alpha epoch {spec.version.epoch} does not match expected {expected_epoch} "
117
+ f"(days since {EPOCH_BASE_DATE}).\n"
118
+ f"Provided:\n 1. {provided_version}\n"
119
+ f"Expected:\n 2. {expected_version}\n"
120
+ "Choose version (1 or 2)"
121
+ )
122
+ choice = click.prompt(prompt, type=click.IntRange(1, 2), default=2, prompt_suffix=": ")
123
+ chosen_version = expected_version if choice == 2 else provided_version
124
+ console.print(f"[cyan]Proceeding with alpha version {chosen_version}[/cyan]")
125
+ if choice == 2:
126
+ updated.append(
127
+ ReleaseSpec(
128
+ channel=spec.channel,
129
+ version=Version(expected_epoch, spec.version.stream, spec.version.revision),
130
+ )
131
+ )
132
+ else:
133
+ updated.append(spec)
134
+ return updated
135
+
136
+
137
+ def _run_release(
138
+ specs: list[str],
139
+ *,
140
+ phase: str,
141
+ dry_run: bool,
142
+ force: bool,
143
+ skip_jenkins: bool,
144
+ skip_issue_check: bool,
145
+ config: Path,
146
+ verbose: bool,
147
+ ) -> None:
148
+ cfg = load_config(config)
149
+ state_path = resolve_state_path(cfg)
150
+ if not specs:
151
+ if verbose:
152
+ console.print(f"[cyan]Using stored release state from {state_path}[/cyan]")
153
+ http = _build_http_client(cfg)
154
+ specs = _specs_from_state(load_release_state(state_path), http, skip_issue_check)
155
+
156
+ manager = ReleaseManager(cfg, dry_run=dry_run, verbose=verbose)
157
+ parsed = _reconcile_alpha_epoch(manager.parse_specs(specs), verbose)
158
+ manager.run(parsed, phase=phase, force=force, skip_jenkins=skip_jenkins, state_path=state_path)
159
+
160
+
161
+ @click.group(
162
+ help="Release one or more channels: CHANNEL:VERSION ...",
163
+ invoke_without_command=True,
164
+ context_settings={"allow_extra_args": True},
165
+ )
166
+ @click.option("--dry-run", is_flag=True, help="Show what would be done")
167
+ @click.option("--force", is_flag=True, help="Force tag creation even if exists")
168
+ @click.option("--skip-jenkins", is_flag=True, help="Skip Jenkins build trigger")
169
+ @click.option(
170
+ "--skip-issue-check", is_flag=True, help="Skip verifying issue title when using release state"
171
+ )
172
+ @click.option(
173
+ "-c", "--config", type=click.Path(exists=True, path_type=Path), default=".yardmaster.yaml"
174
+ )
175
+ @click.option("-v", "--verbose", is_flag=True, help="Verbose output")
176
+ @click.pass_context
177
+ def release_command(
178
+ ctx: click.Context,
179
+ dry_run: bool,
180
+ force: bool,
181
+ skip_jenkins: bool,
182
+ skip_issue_check: bool,
183
+ config: Path,
184
+ verbose: bool,
185
+ ) -> None:
186
+ if ctx.invoked_subcommand is not None:
187
+ ctx.obj = {"config": config, "verbose": verbose}
188
+ return
189
+
190
+ _run_release(
191
+ list(ctx.args),
192
+ phase="all",
193
+ dry_run=dry_run,
194
+ force=force,
195
+ skip_jenkins=skip_jenkins,
196
+ skip_issue_check=skip_issue_check,
197
+ config=config,
198
+ verbose=verbose,
199
+ )
200
+
201
+
202
+ @release_command.command("run", help="Run release steps for a phase.")
203
+ @click.argument("specs", nargs=-1, required=False)
204
+ @click.option("--pre", "phase_pre", is_flag=True, help="Run pre-release steps")
205
+ @click.option("--post", "phase_post", is_flag=True, help="Run post-release steps")
206
+ @click.option("--dry-run", is_flag=True, help="Show what would be done")
207
+ @click.option("--force", is_flag=True, help="Force tag creation even if exists")
208
+ @click.option("--skip-jenkins", is_flag=True, help="Skip Jenkins build trigger")
209
+ @click.option(
210
+ "--skip-issue-check", is_flag=True, help="Skip verifying issue title when using release state"
211
+ )
212
+ @click.option(
213
+ "-c", "--config", type=click.Path(exists=True, path_type=Path), default=".yardmaster.yaml"
214
+ )
215
+ @click.option("-v", "--verbose", is_flag=True, help="Verbose output")
216
+ def release_run(
217
+ specs: list[str],
218
+ phase_pre: bool,
219
+ phase_post: bool,
220
+ dry_run: bool,
221
+ force: bool,
222
+ skip_jenkins: bool,
223
+ skip_issue_check: bool,
224
+ config: Path,
225
+ verbose: bool,
226
+ ) -> None:
227
+ if phase_pre == phase_post:
228
+ raise click.ClickException("Choose exactly one of --pre or --post.")
229
+ phase = "pre" if phase_pre else "post"
230
+ cfg = load_config(config)
231
+ state_path = resolve_state_path(cfg)
232
+ if phase == "post":
233
+ state = load_release_state(state_path)
234
+ if state.get("status") != "in_progress":
235
+ raise click.ClickException(
236
+ "Post-release steps require a completed pre-release run. Run `yardmaster release run --pre` first."
237
+ )
238
+ _run_release(
239
+ list(specs),
240
+ phase=phase,
241
+ dry_run=dry_run,
242
+ force=force,
243
+ skip_jenkins=skip_jenkins,
244
+ skip_issue_check=skip_issue_check,
245
+ config=config,
246
+ verbose=verbose,
247
+ )
248
+ if not dry_run:
249
+ if phase == "pre":
250
+ update_release_state("in_progress", state_path)
251
+ elif phase == "post":
252
+ update_release_state("completed", state_path)
253
+
254
+
255
+ @release_command.command("complete", help="Mark the current release as completed.")
256
+ @click.option(
257
+ "-c", "--config", type=click.Path(exists=True, path_type=Path), default=".yardmaster.yaml"
258
+ )
259
+ def release_complete(config: Path) -> None:
260
+ cfg = load_config(config)
261
+ state_path = resolve_state_path(cfg)
262
+ state = load_release_state(state_path)
263
+ status = state.get("status")
264
+ if status == "completed":
265
+ console.print("[yellow]Release is already marked as completed.[/yellow]")
266
+ return
267
+ update_release_state("completed", state_path)
268
+ console.print("[green]✓[/green] Release marked as completed.")
269
+
270
+
271
+ @release_command.command("init", help="Initialize release state from a GitHub issue.")
272
+ @click.argument("issue_url", required=True)
273
+ @click.option(
274
+ "--dry-run", is_flag=True, help="Show what would be done without writing state"
275
+ )
276
+ @click.option("--force", is_flag=True, help="Overwrite existing release state")
277
+ @click.pass_context
278
+ def release_init(
279
+ ctx: click.Context, issue_url: str, dry_run: bool, force: bool
280
+ ) -> None:
281
+ cfg_path = Path(".yardmaster.yaml")
282
+ verbose = False
283
+ if ctx.obj:
284
+ cfg_path = ctx.obj.get("config", cfg_path)
285
+ verbose = ctx.obj.get("verbose", False)
286
+
287
+ cfg = load_config(cfg_path)
288
+ state_path = resolve_state_path(cfg)
289
+ http = _build_http_client(cfg)
290
+ if verbose:
291
+ console.print(f"[cyan]Fetching issue metadata from {issue_url}[/cyan]")
292
+ issue_id, _api_url = _issue_api_url(issue_url)
293
+ title = _fetch_issue_title(http, issue_url)
294
+ versions = _extract_versions_from_title(title)
295
+
296
+ if state_path.exists() and not force:
297
+ existing_state = load_release_state(state_path)
298
+ if (
299
+ existing_state.get("issue_url") != issue_url
300
+ or existing_state.get("versions") != versions
301
+ ):
302
+ raise click.ClickException(
303
+ f"Release state already exists and differs from issue {issue_id}. Use --force to overwrite."
304
+ )
305
+
306
+ now = datetime.now(timezone.utc).isoformat()
307
+ state = {
308
+ "issue_url": issue_url,
309
+ "issue_title": title,
310
+ "issue_id": issue_id,
311
+ "versions": versions,
312
+ "status": "planned",
313
+ "created_at": now,
314
+ "updated_at": now,
315
+ }
316
+ if dry_run:
317
+ console.print(f"[cyan]Dry-run[/cyan]: would write state to {state_path}:")
318
+ for ch in CHANNEL_ORDER:
319
+ if ch in versions:
320
+ console.print(f" {ch}: {versions[ch]}")
321
+ return
322
+
323
+ save_release_state(state, state_path)
324
+ console.print(f"[green]Release state saved to {state_path}[/green]")
325
+ for ch in CHANNEL_ORDER:
326
+ if ch in versions:
327
+ console.print(f" {ch}: {versions[ch]}")
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from yardmaster.config import load_config
9
+ from yardmaster.core.state import load_release_state, resolve_state_path
10
+ from yardmaster.core.version import Channel, Version
11
+ from yardmaster.services.runner import CommandRunner
12
+ from yardmaster.services.sdk import SDKService
13
+ from yardmaster.utils.http import HttpClient
14
+
15
+ console = Console()
16
+
17
+
18
+ @click.command(help="Retag the ongoing release for a channel.")
19
+ @click.argument("channel", nargs=1, required=True)
20
+ @click.option("--dry-run", is_flag=True, help="Show what would be done")
21
+ @click.option("--force", is_flag=True, help="Force retag (delete existing tag)")
22
+ @click.option(
23
+ "-c", "--config", type=click.Path(exists=True, path_type=Path), default=".yardmaster.yaml"
24
+ )
25
+ @click.option("-v", "--verbose", is_flag=True, help="Verbose output")
26
+ def retag_command(
27
+ channel: str,
28
+ dry_run: bool,
29
+ force: bool,
30
+ config: Path,
31
+ verbose: bool,
32
+ ) -> None:
33
+ _ = force
34
+ cfg = load_config(config)
35
+ state_path = resolve_state_path(cfg)
36
+ ch = Channel(channel)
37
+ state = load_release_state(state_path)
38
+ versions = state.get("versions")
39
+ if not isinstance(versions, dict) or ch.value not in versions:
40
+ raise click.ClickException(f"No ongoing release for channel {ch.value}.")
41
+ version = Version.parse(str(versions[ch.value]))
42
+
43
+ if not click.confirm(f"Retag ongoing {ch.value}:{version}?", default=False):
44
+ console.print("[yellow]Cancelled.[/yellow]")
45
+ return
46
+
47
+ http = HttpClient(
48
+ timeout=cfg.network.timeout,
49
+ retries=cfg.network.retries,
50
+ verify_ssl=cfg.network.verify_ssl,
51
+ )
52
+ release_urls = {k: v.release_url for k, v in cfg.channels.items()}
53
+ sdk = SDKService(
54
+ http=http,
55
+ release_urls=release_urls,
56
+ scripts_path=cfg.paths.scripts,
57
+ fallback_strategy=cfg.sdk.fallback_strategy,
58
+ seed_sdk_offset=cfg.sdk.seed_sdk_offset,
59
+ )
60
+ resolution = sdk.resolve_sdk_for_release(ch, version)
61
+ if verbose:
62
+ console.print(f"[cyan]SDK[/cyan]: {ch.value}:{version} -> {resolution.sdk_version}")
63
+
64
+ script_path = Path(cfg.paths.build_scripts) / "tag-release"
65
+ CommandRunner().run_tag_release(
66
+ script_path,
67
+ channel=ch.value,
68
+ version=str(version),
69
+ sdk_version=resolution.sdk_version,
70
+ dry_run=dry_run,
71
+ )
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from tabulate import tabulate
9
+
10
+ from yardmaster.config import load_config
11
+ from yardmaster.core.release import ReleaseManager
12
+ from yardmaster.core.version import STREAM_TO_CHANNEL
13
+ from yardmaster.services.sdk import SDKService
14
+ from yardmaster.utils.http import HttpClient
15
+
16
+ console = Console()
17
+
18
+
19
+ @click.group(help="SDK utilities.")
20
+ def sdk_command() -> None:
21
+ pass
22
+
23
+
24
+ @sdk_command.command("determine", help="Determine SDK versions for upcoming releases.")
25
+ @click.argument("specs", nargs=-1, required=True)
26
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
27
+ @click.option(
28
+ "-c", "--config", type=click.Path(exists=True, path_type=Path), default=".yardmaster.yaml"
29
+ )
30
+ def sdk_determine(specs: list[str], as_json: bool, config: Path) -> None:
31
+ cfg = load_config(config)
32
+ http = HttpClient(
33
+ timeout=cfg.network.timeout,
34
+ retries=cfg.network.retries,
35
+ verify_ssl=cfg.network.verify_ssl,
36
+ )
37
+ release_urls = {k: v.release_url for k, v in cfg.channels.items()}
38
+ sdk = SDKService(
39
+ http=http,
40
+ release_urls=release_urls,
41
+ scripts_path=cfg.paths.scripts,
42
+ fallback_strategy=cfg.sdk.fallback_strategy,
43
+ seed_sdk_offset=cfg.sdk.seed_sdk_offset,
44
+ )
45
+
46
+ parsed = ReleaseManager.parse_specs(list(specs))
47
+ rows = []
48
+ json_payload = []
49
+ for spec in parsed:
50
+ resolution = sdk.resolve_sdk_for_release(spec.channel, spec.version)
51
+ expected = STREAM_TO_CHANNEL.get(spec.version.stream)
52
+ note = resolution.note
53
+ if expected and expected != spec.channel:
54
+ note = f"{note} stream/channel mismatch" if note else "stream/channel mismatch"
55
+ prev_ch = resolution.previous_channel.value if resolution.previous_channel else ""
56
+ prev_ver = str(resolution.previous_version) if resolution.previous_version else ""
57
+ json_payload.append(
58
+ {
59
+ "channel": spec.channel.value,
60
+ "version": str(spec.version),
61
+ "previous_channel": prev_ch,
62
+ "previous_version": prev_ver,
63
+ "sdk_version": resolution.sdk_version,
64
+ "lookup_url": resolution.lookup_url or "",
65
+ "note": note,
66
+ }
67
+ )
68
+ rows.append(
69
+ [
70
+ spec.channel.value,
71
+ str(spec.version),
72
+ prev_ch,
73
+ prev_ver,
74
+ resolution.sdk_version,
75
+ resolution.lookup_url or "",
76
+ note,
77
+ ]
78
+ )
79
+
80
+ if as_json:
81
+ console.print(json.dumps(json_payload, indent=2))
82
+ return
83
+
84
+ console.print(
85
+ tabulate(
86
+ rows,
87
+ headers=[
88
+ "Channel",
89
+ "Version",
90
+ "Prev Channel",
91
+ "Prev Version",
92
+ "SDK",
93
+ "Lookup URL",
94
+ "Note",
95
+ ],
96
+ tablefmt="github",
97
+ )
98
+ )