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 +8 -0
- yardmaster/__main__.py +4 -0
- yardmaster/cli.py +74 -0
- yardmaster/commands/__init__.py +0 -0
- yardmaster/commands/epoch.py +28 -0
- yardmaster/commands/jenkins.py +58 -0
- yardmaster/commands/release.py +327 -0
- yardmaster/commands/retag.py +71 -0
- yardmaster/commands/sdk.py +98 -0
- yardmaster/commands/status.py +113 -0
- yardmaster/config.py +127 -0
- yardmaster/core/__init__.py +0 -0
- yardmaster/core/release.py +266 -0
- yardmaster/core/state.py +54 -0
- yardmaster/core/validator.py +34 -0
- yardmaster/core/version.py +54 -0
- yardmaster/services/__init__.py +0 -0
- yardmaster/services/git.py +76 -0
- yardmaster/services/jenkins.py +190 -0
- yardmaster/services/runner.py +66 -0
- yardmaster/services/sdk.py +136 -0
- yardmaster/utils/__init__.py +0 -0
- yardmaster/utils/http.py +32 -0
- yardmaster/utils/logger.py +18 -0
- yardmaster-0.1.0.dist-info/METADATA +185 -0
- yardmaster-0.1.0.dist-info/RECORD +30 -0
- yardmaster-0.1.0.dist-info/WHEEL +5 -0
- yardmaster-0.1.0.dist-info/entry_points.txt +2 -0
- yardmaster-0.1.0.dist-info/licenses/LICENSE +202 -0
- yardmaster-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
+
)
|