sdk-release-kit 0.2.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.
Files changed (39) hide show
  1. sdk_release_kit/__init__.py +3 -0
  2. sdk_release_kit/_data/examples/custom.example.yaml +18 -0
  3. sdk_release_kit/_data/examples/keys.example.yaml +18 -0
  4. sdk_release_kit/_data/examples/outputs.example.yaml +19 -0
  5. sdk_release_kit/_data/examples/repos.example.yaml +33 -0
  6. sdk_release_kit/_data/recipes/sdks.yaml +222 -0
  7. sdk_release_kit/_data/web/app.js +305 -0
  8. sdk_release_kit/_data/web/index.html +142 -0
  9. sdk_release_kit/_data/web/styles.css +93 -0
  10. sdk_release_kit/builders/__init__.py +24 -0
  11. sdk_release_kit/builders/android.py +86 -0
  12. sdk_release_kit/builders/base.py +217 -0
  13. sdk_release_kit/builders/flutter.py +16 -0
  14. sdk_release_kit/builders/generic.py +56 -0
  15. sdk_release_kit/builders/ios.py +204 -0
  16. sdk_release_kit/builders/reactnative.py +32 -0
  17. sdk_release_kit/cli.py +198 -0
  18. sdk_release_kit/mcp_server.py +240 -0
  19. sdk_release_kit/models.py +306 -0
  20. sdk_release_kit/paths.py +85 -0
  21. sdk_release_kit/pipeline.py +446 -0
  22. sdk_release_kit/recipes.py +110 -0
  23. sdk_release_kit/runlog.py +62 -0
  24. sdk_release_kit/server.py +331 -0
  25. sdk_release_kit/store/__init__.py +29 -0
  26. sdk_release_kit/store/_yaml.py +46 -0
  27. sdk_release_kit/store/custom.py +34 -0
  28. sdk_release_kit/store/keys.py +111 -0
  29. sdk_release_kit/store/outputs.py +15 -0
  30. sdk_release_kit/store/repos.py +15 -0
  31. sdk_release_kit/uploaders/__init__.py +20 -0
  32. sdk_release_kit/uploaders/onedrive.py +129 -0
  33. sdk_release_kit/uploaders/play.py +108 -0
  34. sdk_release_kit/uploaders/testflight.py +105 -0
  35. sdk_release_kit-0.2.0.dist-info/METADATA +307 -0
  36. sdk_release_kit-0.2.0.dist-info/RECORD +39 -0
  37. sdk_release_kit-0.2.0.dist-info/WHEEL +4 -0
  38. sdk_release_kit-0.2.0.dist-info/entry_points.txt +2 -0
  39. sdk_release_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,142 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="srk-token" content="{{SRK_TOKEN}}">
7
+ <title>SDK Release Kit</title>
8
+ <link rel="stylesheet" href="/static/styles.css">
9
+ </head>
10
+ <body>
11
+ <header class="top">
12
+ <div class="brand">SDK Release Kit <span id="version" class="muted"></span></div>
13
+ <nav id="tabs">
14
+ <button data-tab="repos" class="active">Repositories</button>
15
+ <button data-tab="outputs">Outputs</button>
16
+ <button data-tab="keys">Keys</button>
17
+ <button data-tab="custom">Custom data</button>
18
+ <button data-tab="run">Run</button>
19
+ </nav>
20
+ </header>
21
+
22
+ <main>
23
+ <div id="toast" class="toast" hidden></div>
24
+
25
+ <section id="tab-repos" class="tab">
26
+ <h1>Repositories</h1>
27
+ <p class="muted">Point each recipe at its checkout on this machine. Paths are stored in
28
+ <code id="repos-file"></code>, never in git.</p>
29
+ <table class="grid" id="repos-table">
30
+ <thead><tr><th>On</th><th>Recipe</th><th>Checkout path</th><th>Check</th></tr></thead>
31
+ <tbody></tbody>
32
+ </table>
33
+ <h2>Tooling</h2>
34
+ <div class="form" id="tooling-form">
35
+ <label>JAVA_HOME <input name="java_home" placeholder="/Applications/Android Studio.app/Contents/jbr/Contents/Home"></label>
36
+ <label>Android SDK <input name="android_sdk" placeholder="~/Library/Android/sdk"></label>
37
+ <label>Flutter bin <input name="flutter_bin" placeholder="~/path/to/flutter/bin"></label>
38
+ <label>Xcode DEVELOPER_DIR <input name="developer_dir" placeholder="/Applications/Xcode.app/Contents/Developer"></label>
39
+ <label>Extra PATH entries <input name="extra_path" placeholder="comma-separated"></label>
40
+ </div>
41
+ <div class="actions"><button class="primary" id="save-repos">Save</button></div>
42
+ </section>
43
+
44
+ <section id="tab-outputs" class="tab" hidden>
45
+ <h1>Outputs</h1>
46
+ <div class="form">
47
+ <label>Staging directory <input data-k="staging" placeholder="~/sdk-release-kit/staging"></label>
48
+ </div>
49
+ <h2><label class="inline"><input type="checkbox" data-k="onedrive.enabled"> Shared folder (OneDrive or any synced folder)</label></h2>
50
+ <div class="form">
51
+ <label>Root <input data-k="onedrive.root" placeholder="~/Library/CloudStorage/OneDrive-Org/SDK Builds"></label>
52
+ <label>Android demos subfolder <input data-k="onedrive.demos_android"></label>
53
+ <label>iOS demos subfolder <input data-k="onedrive.demos_ios"></label>
54
+ <label>Packages subfolder <input data-k="onedrive.packages"></label>
55
+ <label class="inline"><input type="checkbox" data-k="onedrive.copy_untargeted"> Also copy demos without a target id</label>
56
+ </div>
57
+ <h2><label class="inline"><input type="checkbox" data-k="play.enabled"> Google Play</label></h2>
58
+ <div class="form">
59
+ <label>Track <input data-k="play.track"></label>
60
+ <label>Release status
61
+ <select data-k="play.release_status"><option>completed</option><option>draft</option></select></label>
62
+ <label>Method
63
+ <select data-k="play.method"><option>auto</option><option>api</option><option>fastlane</option></select></label>
64
+ <p class="muted">The service account JSON is set on the Keys page.</p>
65
+ </div>
66
+ <h2><label class="inline"><input type="checkbox" data-k="testflight.enabled"> TestFlight</label></h2>
67
+ <div class="form">
68
+ <label>Method
69
+ <select data-k="testflight.method"><option>altool</option><option>fastlane</option></select></label>
70
+ <p class="muted">The App Store Connect API key is set on the Keys page.</p>
71
+ </div>
72
+ <div class="actions"><button class="primary" id="save-outputs">Save</button></div>
73
+ </section>
74
+
75
+ <section id="tab-keys" class="tab" hidden>
76
+ <h1>Keys</h1>
77
+ <p class="muted">Key files are referenced by path or uploaded into the private settings
78
+ directory. Passwords go to the OS keychain or an environment variable; their values are
79
+ never shown here.</p>
80
+ <div id="keys-status" class="chips"></div>
81
+
82
+ <h2>Android signing</h2>
83
+ <div class="form">
84
+ <label>Keystore path <span class="row"><input data-key="android.keystore_path">
85
+ <input type="file" data-upload="android.keystore_path"></span></label>
86
+ <label>Key alias <input data-key="android.key_alias"></label>
87
+ <div class="secret" data-secret="android.store_password"></div>
88
+ <div class="secret" data-secret="android.key_password"></div>
89
+ <label class="inline"><input type="checkbox" data-key="android.inject_signing"> Inject signing into Gradle builds</label>
90
+ </div>
91
+
92
+ <h2>Google Play</h2>
93
+ <div class="form">
94
+ <label>Service account JSON <span class="row"><input data-key="play.json_key_path">
95
+ <input type="file" data-upload="play.json_key_path" accept=".json"></span></label>
96
+ </div>
97
+
98
+ <h2>App Store Connect (TestFlight)</h2>
99
+ <div class="form">
100
+ <label>.p8 key file <span class="row"><input data-key="testflight.key_path">
101
+ <input type="file" data-upload="testflight.key_path" accept=".p8"></span></label>
102
+ <div class="secret" data-secret="testflight.key_id"></div>
103
+ <div class="secret" data-secret="testflight.issuer_id"></div>
104
+ </div>
105
+ <div class="actions"><button class="primary" id="save-keys">Save</button></div>
106
+ </section>
107
+
108
+ <section id="tab-custom" class="tab" hidden>
109
+ <h1>Custom data</h1>
110
+ <p class="muted">Free-form YAML. Recipes read values as <code>${custom.some.key}</code>.
111
+ Comments and unknown keys are kept exactly as written.</p>
112
+ <textarea id="custom-text" spellcheck="false" rows="22"></textarea>
113
+ <div class="actions"><button class="primary" id="save-custom">Save</button></div>
114
+ </section>
115
+
116
+ <section id="tab-run" class="tab" hidden>
117
+ <h1>Run</h1>
118
+ <div class="run-grid">
119
+ <fieldset><legend>SDKs</legend><div id="run-sdks"></div></fieldset>
120
+ <fieldset><legend>Artifacts</legend>
121
+ <label class="inline"><input type="radio" name="artifacts" value="both" checked> Both</label>
122
+ <label class="inline"><input type="radio" name="artifacts" value="demos"> Demos</label>
123
+ <label class="inline"><input type="radio" name="artifacts" value="packages"> Packages</label>
124
+ </fieldset>
125
+ <fieldset><legend>Destinations</legend>
126
+ <label class="inline"><input type="checkbox" name="dest" value="onedrive"> Shared folder</label>
127
+ <label class="inline"><input type="checkbox" name="dest" value="play"> Google Play</label>
128
+ <label class="inline"><input type="checkbox" name="dest" value="testflight"> TestFlight</label>
129
+ <label class="inline"><input type="checkbox" id="skip-upload"> Build only (skip upload)</label>
130
+ </fieldset>
131
+ </div>
132
+ <div class="actions">
133
+ <button id="dry-run">Dry run</button>
134
+ <button class="primary" id="start-run">Start</button>
135
+ <span id="run-state" class="muted"></span>
136
+ </div>
137
+ <pre id="run-log" class="log"></pre>
138
+ </section>
139
+ </main>
140
+ <script src="/static/app.js"></script>
141
+ </body>
142
+ </html>
@@ -0,0 +1,93 @@
1
+ :root {
2
+ --bg: #f7f8fa;
3
+ --surface: #ffffff;
4
+ --text: #1b1f24;
5
+ --muted: #5f6b7a;
6
+ --border: #d9dee5;
7
+ --accent: #2563eb;
8
+ --accent-text: #ffffff;
9
+ --ok: #157f3b;
10
+ --ok-bg: #e5f5ea;
11
+ --bad: #b42318;
12
+ --bad-bg: #fdecea;
13
+ --log-bg: #0f141a;
14
+ --log-text: #d6dde6;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #0f141a;
19
+ --surface: #161c24;
20
+ --text: #e6ebf1;
21
+ --muted: #93a0b0;
22
+ --border: #2a3340;
23
+ --accent: #4c8dff;
24
+ --ok: #5fd08a;
25
+ --ok-bg: #12301f;
26
+ --bad: #ff8a80;
27
+ --bad-bg: #3a1a18;
28
+ --log-bg: #0a0e13;
29
+ }
30
+ }
31
+ * { box-sizing: border-box; }
32
+ body {
33
+ margin: 0; background: var(--bg); color: var(--text);
34
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
35
+ }
36
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
37
+ .top {
38
+ position: sticky; top: 0; z-index: 2; background: var(--surface);
39
+ border-bottom: 1px solid var(--border); padding: 10px 16px;
40
+ display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: center;
41
+ }
42
+ .brand { font-weight: 650; }
43
+ nav { display: flex; flex-wrap: wrap; gap: 4px; }
44
+ nav button { background: none; border: 0; padding: 6px 10px; border-radius: 6px; color: var(--muted); cursor: pointer; font: inherit; }
45
+ nav button.active { background: var(--bg); color: var(--text); font-weight: 600; }
46
+ main { max-width: 980px; margin: 0 auto; padding: 16px; }
47
+ h1 { font-size: 1.35rem; margin: 8px 0 4px; }
48
+ h2 { font-size: 1.05rem; margin: 24px 0 8px; }
49
+ .muted { color: var(--muted); }
50
+ .form { display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
51
+ .form label { display: grid; gap: 4px; font-size: 0.9rem; color: var(--muted); }
52
+ label.inline { display: inline-flex; gap: 6px; align-items: center; color: var(--text); margin-right: 14px; }
53
+ input:not([type=checkbox]):not([type=radio]):not([type=file]), select, textarea {
54
+ width: 100%; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px;
55
+ background: var(--bg); color: var(--text); font: inherit;
56
+ }
57
+ textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
58
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
59
+ .row input:first-child { flex: 1 1 260px; }
60
+ .grid { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
61
+ .grid th, .grid td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
62
+ .grid th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
63
+ .grid td.check { font-size: 0.85rem; white-space: nowrap; }
64
+ .actions { margin: 16px 0; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
65
+ button { font: inherit; padding: 7px 14px; border-radius: 7px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; }
66
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
67
+ button:disabled { opacity: .5; cursor: default; }
68
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
69
+ .chip { font-size: 0.8rem; padding: 3px 9px; border-radius: 999px; }
70
+ .chip.ok, .ok { color: var(--ok); }
71
+ .chip.ok { background: var(--ok-bg); }
72
+ .chip.bad, .bad { color: var(--bad); }
73
+ .chip.bad { background: var(--bad-bg); }
74
+ .secret { display: grid; gap: 6px; border-top: 1px dashed var(--border); padding-top: 10px; }
75
+ .secret .row select { width: auto; flex: 0 0 auto; }
76
+ .run-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
77
+ fieldset { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; }
78
+ fieldset label.inline { display: flex; margin: 4px 0; }
79
+ legend { font-weight: 600; padding: 0 4px; }
80
+ .log { background: var(--log-bg); color: var(--log-text); padding: 12px; border-radius: 10px; min-height: 160px; max-height: 60vh; overflow: auto; font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
81
+ .toast { position: fixed; right: 16px; bottom: 16px; max-width: min(90vw, 420px); padding: 10px 14px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 6px 20px rgba(0,0,0,.15); z-index: 5; white-space: pre-wrap; }
82
+ .toast.err { border-color: var(--bad); color: var(--bad); }
83
+ main, section, .form, fieldset { min-width: 0; }
84
+ p code { overflow-wrap: anywhere; }
85
+ @media (max-width: 640px) {
86
+ nav { width: 100%; flex-wrap: nowrap; overflow-x: auto; }
87
+ nav button { flex: 0 0 auto; }
88
+ .grid, .grid tbody { display: block; width: 100%; }
89
+ .grid thead { display: none; }
90
+ .grid tr { display: grid; grid-template-columns: auto 1fr; gap: 4px 8px; padding: 8px 0; border-bottom: 1px solid var(--border); }
91
+ .grid td { border: 0; padding: 2px 10px; }
92
+ .grid td.path, .grid td.check { grid-column: 1 / -1; }
93
+ }
@@ -0,0 +1,24 @@
1
+ """Builders. Importing this package registers every step handler."""
2
+
3
+ from . import android, flutter, generic, ios, reactnative # noqa: F401 (registration)
4
+ from .base import (
5
+ HANDLERS,
6
+ Artifact,
7
+ BuildContext,
8
+ StepError,
9
+ describe_step,
10
+ execute_step,
11
+ skip_reason,
12
+ step_label,
13
+ )
14
+
15
+ __all__ = [
16
+ "HANDLERS",
17
+ "Artifact",
18
+ "BuildContext",
19
+ "StepError",
20
+ "describe_step",
21
+ "execute_step",
22
+ "skip_reason",
23
+ "step_label",
24
+ ]
@@ -0,0 +1,86 @@
1
+ """Android: Gradle steps, JAVA_HOME / Android SDK environment, and release signing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from ..models import GradleStep
9
+ from ..paths import expand
10
+ from ..store.keys import resolve_secret
11
+ from .base import BuildContext, Command, StepPlan, handler
12
+
13
+ _INJECT = "ORG_GRADLE_PROJECT_android.injected.signing."
14
+
15
+
16
+ def signing_env(ctx: BuildContext) -> dict[str, str]:
17
+ """Signing properties for the Android Gradle plugin, passed through the environment.
18
+
19
+ Using ``ORG_GRADLE_PROJECT_*`` keeps passwords off the command line (and out of ``ps``).
20
+ Returns an empty dict when signing is not configured, so Gradle uses the project's own
21
+ signing setup.
22
+ """
23
+ k = ctx.settings.keys.android
24
+ if not k.inject_signing:
25
+ return {}
26
+ keystore = expand(k.keystore_path)
27
+ if keystore is None or not k.key_alias:
28
+ return {}
29
+ if not keystore.is_file():
30
+ ctx.log.info("WARNING: keystore path is set but the file is missing; not injecting signing")
31
+ return {}
32
+ store_pw = resolve_secret(k.store_password)
33
+ key_pw = resolve_secret(k.key_password) or store_pw
34
+ if not store_pw:
35
+ ctx.log.info("WARNING: keystore password not available; not injecting signing")
36
+ return {}
37
+ ctx.log.add_secret(store_pw)
38
+ ctx.log.add_secret(key_pw)
39
+ return {
40
+ _INJECT + "store.file": str(keystore),
41
+ _INJECT + "store.password": store_pw,
42
+ _INJECT + "key.alias": k.key_alias,
43
+ _INJECT + "key.password": key_pw or store_pw,
44
+ }
45
+
46
+
47
+ def tooling_env(ctx_env: dict[str, str], tooling) -> dict[str, str]:
48
+ """Environment adjustments from Repositories > Tooling. Applied to every step."""
49
+ env = dict(ctx_env)
50
+ path_parts: list[str] = []
51
+ java_home = expand(tooling.java_home)
52
+ if java_home:
53
+ env["JAVA_HOME"] = str(java_home)
54
+ path_parts.append(str(java_home / "bin"))
55
+ sdk = expand(tooling.android_sdk)
56
+ if sdk:
57
+ env["ANDROID_HOME"] = str(sdk)
58
+ env["ANDROID_SDK_ROOT"] = str(sdk)
59
+ path_parts.append(str(sdk / "platform-tools"))
60
+ flutter = expand(tooling.flutter_bin)
61
+ if flutter:
62
+ # Accept the executable, its bin/ folder, or the SDK root.
63
+ if flutter.is_file():
64
+ flutter = flutter.parent
65
+ elif (flutter / "bin" / "flutter").exists():
66
+ flutter = flutter / "bin"
67
+ path_parts.append(str(flutter))
68
+ dev_dir = expand(tooling.developer_dir)
69
+ if dev_dir:
70
+ env["DEVELOPER_DIR"] = str(dev_dir)
71
+ for extra in tooling.extra_path:
72
+ p = expand(extra)
73
+ if p:
74
+ path_parts.append(str(p))
75
+ if path_parts:
76
+ env["PATH"] = os.pathsep.join([*path_parts, env.get("PATH", "")])
77
+ return env
78
+
79
+
80
+ @handler("gradle")
81
+ def gradle_step(ctx: BuildContext, step: GradleStep, cwd: Path) -> StepPlan:
82
+ wrapper = "gradlew.bat" if os.name == "nt" else "gradlew"
83
+ exe = f".{os.sep}{wrapper}" if (cwd / wrapper).exists() else "gradle"
84
+ env = signing_env(ctx) if step.sign else {}
85
+ notes = ["(release signing injected from Keys)"] if env else []
86
+ return StepPlan(commands=[Command([exe, *step.tasks, *step.args], cwd, env)], notes=notes)
@@ -0,0 +1,217 @@
1
+ """Step execution framework shared by every platform builder.
2
+
3
+ A recipe stage (``demos`` or ``packages``) is a list of steps. Each step type has a handler
4
+ that turns the step into a ``StepPlan``: commands to run plus how to find what they produced.
5
+ Dry runs print the plan; real runs execute it and copy collected artifacts into staging.
6
+
7
+ To add a step type: write a handler in a builder module, decorate it with ``@handler("name")``,
8
+ add the matching model to ``models.Step``, and import the module in ``builders/__init__.py``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from ..models import Recipe
24
+ from ..paths import safe_join
25
+ from ..runlog import RunLog
26
+ from ..store import Settings
27
+
28
+
29
+ class StepError(RuntimeError):
30
+ pass
31
+
32
+
33
+ @dataclass
34
+ class Artifact:
35
+ path: Path
36
+ kind: str
37
+ recipe: str
38
+ stage: str # "demos" or "packages"
39
+ version: str
40
+ target: str | None = None
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "path": str(self.path),
45
+ "kind": self.kind,
46
+ "recipe": self.recipe,
47
+ "stage": self.stage,
48
+ "version": self.version,
49
+ "target": self.target,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class Command:
55
+ argv: list[str]
56
+ cwd: Path
57
+ env: dict[str, str] = field(default_factory=dict)
58
+
59
+ def display(self) -> str:
60
+ return f"(cd {shlex.quote(str(self.cwd))} && {shlex.join(self.argv)})"
61
+
62
+
63
+ @dataclass
64
+ class Produced:
65
+ path: Path
66
+ kind: str
67
+ target: str | None = None
68
+
69
+
70
+ @dataclass
71
+ class StepPlan:
72
+ commands: list[Command] = field(default_factory=list)
73
+ roots: dict[str, Path] = field(default_factory=dict)
74
+ prepare: Callable[[], None] | None = None
75
+ finish: Callable[[], list[Produced]] | None = None
76
+ notes: list[str] = field(default_factory=list)
77
+
78
+
79
+ @dataclass
80
+ class BuildContext:
81
+ recipe_id: str
82
+ recipe: Recipe
83
+ repo: Path
84
+ out_dir: Path # staging/runs/<ts>/<recipe-id>
85
+ work_dir: Path # staging/work/<recipe-id>, reused across runs (derived data, archives)
86
+ version: str
87
+ env: dict[str, str]
88
+ settings: Settings
89
+ log: RunLog
90
+ active_destinations: set[str]
91
+
92
+
93
+ Handler = Callable[[BuildContext, Any, Path], StepPlan]
94
+ HANDLERS: dict[str, Handler] = {}
95
+
96
+
97
+ def handler(step_type: str) -> Callable[[Handler], Handler]:
98
+ def register(fn: Handler) -> Handler:
99
+ HANDLERS[step_type] = fn
100
+ return fn
101
+
102
+ return register
103
+
104
+
105
+ def step_label(step: Any) -> str:
106
+ return step.name or f"{step.type} in {step.workdir}"
107
+
108
+
109
+ def skip_reason(ctx: BuildContext, step: Any) -> str | None:
110
+ if step.when and not (set(step.when) & ctx.active_destinations):
111
+ return f"only runs for {', '.join(step.when)}"
112
+ if step.host == "macos" and sys.platform != "darwin":
113
+ return "needs macOS"
114
+ return None
115
+
116
+
117
+ def plan_step(ctx: BuildContext, step: Any) -> tuple[Path, StepPlan]:
118
+ try:
119
+ cwd = safe_join(ctx.repo, step.workdir)
120
+ except ValueError as exc:
121
+ raise StepError(str(exc)) from exc
122
+ fn = HANDLERS.get(step.type)
123
+ if fn is None:
124
+ raise StepError(f"no handler for step type '{step.type}'")
125
+ plan = fn(ctx, step, cwd)
126
+ for cmd in plan.commands:
127
+ cmd.env = {**step.env, **cmd.env}
128
+ return cwd, plan
129
+
130
+
131
+ def run_command(ctx: BuildContext, cmd: Command) -> None:
132
+ env = {**ctx.env, **cmd.env}
133
+ argv0 = cmd.argv[0]
134
+ if os.sep in argv0:
135
+ exe = str((cmd.cwd / argv0).resolve()) if not os.path.isabs(argv0) else argv0
136
+ else:
137
+ exe = shutil.which(argv0, path=env.get("PATH"))
138
+ if exe is None:
139
+ raise StepError(f"'{argv0}' not found on PATH (set it in Repositories > Tooling)")
140
+ ctx.log.info(f"$ {cmd.display()}")
141
+ proc = subprocess.Popen(
142
+ [exe, *cmd.argv[1:]],
143
+ cwd=cmd.cwd,
144
+ env=env,
145
+ stdout=subprocess.PIPE,
146
+ stderr=subprocess.STDOUT,
147
+ text=True,
148
+ bufsize=1,
149
+ errors="replace",
150
+ )
151
+ assert proc.stdout is not None
152
+ for out_line in proc.stdout:
153
+ ctx.log.line(f" {out_line.rstrip()}")
154
+ code = proc.wait()
155
+ if code != 0:
156
+ raise StepError(f"command failed with exit code {code}: {shlex.join(cmd.argv)}")
157
+
158
+
159
+ def _copy_into(src: Path, dest_dir: Path) -> Path:
160
+ dest_dir.mkdir(parents=True, exist_ok=True)
161
+ dest = dest_dir / src.name
162
+ if dest.exists():
163
+ shutil.rmtree(dest) if dest.is_dir() and not dest.is_symlink() else dest.unlink()
164
+ if src.is_dir():
165
+ shutil.copytree(src, dest, symlinks=True)
166
+ else:
167
+ shutil.copy2(src, dest)
168
+ return dest
169
+
170
+
171
+ def execute_step(ctx: BuildContext, step: Any, stage: str) -> list[Artifact]:
172
+ cwd, plan = plan_step(ctx, step)
173
+ if not cwd.is_dir():
174
+ raise StepError(f"workdir '{step.workdir}' does not exist in {ctx.repo}")
175
+ if plan.prepare:
176
+ plan.prepare()
177
+ for cmd in plan.commands:
178
+ run_command(ctx, cmd)
179
+ produced: list[Produced] = list(plan.finish() if plan.finish else [])
180
+
181
+ roots = {"workdir": cwd, "repo": ctx.repo, **plan.roots}
182
+ for spec in step.collect:
183
+ root = roots.get(spec.root)
184
+ if root is None:
185
+ raise StepError(f"collect root '{spec.root}' is not available for {step.type} steps")
186
+ matches = sorted(p for p in root.glob(spec.glob) if p.exists())
187
+ if not matches and spec.required:
188
+ raise StepError(f"no files matched '{spec.glob}' under {root}")
189
+ produced += [Produced(m, spec.kind, spec.target) for m in matches]
190
+
191
+ artifacts = []
192
+ stage_dir = ctx.out_dir / stage
193
+ for item in produced:
194
+ dest = _copy_into(item.path, stage_dir)
195
+ ctx.log.info(
196
+ f"collected {item.kind}: {dest.name}" + (f" -> {item.target}" if item.target else "")
197
+ )
198
+ artifacts.append(Artifact(dest, item.kind, ctx.recipe_id, stage, ctx.version, item.target))
199
+ return artifacts
200
+
201
+
202
+ def describe_step(ctx: BuildContext, step: Any) -> list[str]:
203
+ """Lines for a dry run: the commands that would run and what would be collected."""
204
+ reason = skip_reason(ctx, step)
205
+ if reason:
206
+ return [f"skip {step_label(step)} ({reason})"]
207
+ cwd, plan = plan_step(ctx, step)
208
+ lines = [cmd.display() for cmd in plan.commands]
209
+ lines += plan.notes
210
+ for spec in step.collect:
211
+ lines.append(
212
+ f"collect {spec.kind} '{spec.glob}' from {spec.root}"
213
+ + (f" -> {spec.target}" if spec.target else "")
214
+ )
215
+ if not cwd.is_dir():
216
+ lines.append(f"WARNING: workdir '{step.workdir}' does not exist yet")
217
+ return lines
@@ -0,0 +1,16 @@
1
+ """Flutter: ``flutter <args>`` from the recipe, with Android signing injected for Gradle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..models import FlutterStep
8
+ from .android import signing_env
9
+ from .base import BuildContext, Command, StepPlan, handler
10
+
11
+
12
+ @handler("flutter")
13
+ def flutter_step(ctx: BuildContext, step: FlutterStep, cwd: Path) -> StepPlan:
14
+ builds_android = any(a in ("apk", "appbundle") for a in step.args)
15
+ env = signing_env(ctx) if step.sign and builds_android else {}
16
+ return StepPlan(commands=[Command(["flutter", *step.args], cwd, env)])
@@ -0,0 +1,56 @@
1
+ """Platform-neutral steps: ``run`` (any command) and ``source-zip``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import shlex
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from ..models import RunStep, SourceZipStep
11
+ from .base import BuildContext, Command, Produced, StepPlan, handler
12
+
13
+
14
+ @handler("run")
15
+ def run_step(ctx: BuildContext, step: RunStep, cwd: Path) -> StepPlan:
16
+ argv = shlex.split(step.cmd) if isinstance(step.cmd, str) else list(step.cmd)
17
+ return StepPlan(commands=[Command(argv, cwd)])
18
+
19
+
20
+ def _excluded(rel: Path, patterns: list[str]) -> bool:
21
+ return any(fnmatch.fnmatch(part, pat) for part in rel.parts for pat in patterns)
22
+
23
+
24
+ @handler("source-zip")
25
+ def source_zip(ctx: BuildContext, step: SourceZipStep, cwd: Path) -> StepPlan:
26
+ out = ctx.work_dir / "source-zip" / f"{ctx.recipe_id}-{ctx.version}.zip"
27
+ prefix = f"{ctx.recipe_id}-{ctx.version}/"
28
+ use_git = step.method == "git-archive" and (ctx.repo / ".git").exists()
29
+
30
+ def prepare() -> None:
31
+ out.parent.mkdir(parents=True, exist_ok=True)
32
+ out.unlink(missing_ok=True)
33
+
34
+ if use_git:
35
+ cmd = Command(
36
+ ["git", "archive", "--format=zip", f"--prefix={prefix}", "-o", str(out), "HEAD", "."],
37
+ cwd,
38
+ )
39
+ return StepPlan(
40
+ commands=[cmd],
41
+ prepare=prepare,
42
+ finish=lambda: [Produced(out, "zip")],
43
+ notes=["(committed files only; uncommitted changes are not included)"],
44
+ )
45
+
46
+ def build_zip() -> list[Produced]:
47
+ prepare()
48
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
49
+ for path in sorted(cwd.rglob("*")):
50
+ rel = path.relative_to(cwd)
51
+ if path.is_file() and not _excluded(rel, step.exclude):
52
+ zf.write(path, prefix + rel.as_posix())
53
+ ctx.log.info(f"zipped sources into {out.name}")
54
+ return [Produced(out, "zip")]
55
+
56
+ return StepPlan(finish=build_zip, notes=[f"zip files under {cwd} excluding {step.exclude}"])