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.
- sdk_release_kit/__init__.py +3 -0
- sdk_release_kit/_data/examples/custom.example.yaml +18 -0
- sdk_release_kit/_data/examples/keys.example.yaml +18 -0
- sdk_release_kit/_data/examples/outputs.example.yaml +19 -0
- sdk_release_kit/_data/examples/repos.example.yaml +33 -0
- sdk_release_kit/_data/recipes/sdks.yaml +222 -0
- sdk_release_kit/_data/web/app.js +305 -0
- sdk_release_kit/_data/web/index.html +142 -0
- sdk_release_kit/_data/web/styles.css +93 -0
- sdk_release_kit/builders/__init__.py +24 -0
- sdk_release_kit/builders/android.py +86 -0
- sdk_release_kit/builders/base.py +217 -0
- sdk_release_kit/builders/flutter.py +16 -0
- sdk_release_kit/builders/generic.py +56 -0
- sdk_release_kit/builders/ios.py +204 -0
- sdk_release_kit/builders/reactnative.py +32 -0
- sdk_release_kit/cli.py +198 -0
- sdk_release_kit/mcp_server.py +240 -0
- sdk_release_kit/models.py +306 -0
- sdk_release_kit/paths.py +85 -0
- sdk_release_kit/pipeline.py +446 -0
- sdk_release_kit/recipes.py +110 -0
- sdk_release_kit/runlog.py +62 -0
- sdk_release_kit/server.py +331 -0
- sdk_release_kit/store/__init__.py +29 -0
- sdk_release_kit/store/_yaml.py +46 -0
- sdk_release_kit/store/custom.py +34 -0
- sdk_release_kit/store/keys.py +111 -0
- sdk_release_kit/store/outputs.py +15 -0
- sdk_release_kit/store/repos.py +15 -0
- sdk_release_kit/uploaders/__init__.py +20 -0
- sdk_release_kit/uploaders/onedrive.py +129 -0
- sdk_release_kit/uploaders/play.py +108 -0
- sdk_release_kit/uploaders/testflight.py +105 -0
- sdk_release_kit-0.2.0.dist-info/METADATA +307 -0
- sdk_release_kit-0.2.0.dist-info/RECORD +39 -0
- sdk_release_kit-0.2.0.dist-info/WHEEL +4 -0
- sdk_release_kit-0.2.0.dist-info/entry_points.txt +2 -0
- 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}"])
|