gowalk-cicd 1.0.13 → 1.0.14
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.
- package/README.md +30 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/pbxproj_editor.py +38 -6
- package/action/scripts/test_pbxproj_editor.py +80 -0
- package/android-action/.daemux-version +1 -1
- package/bin/cli.mjs +13 -3
- package/package.json +1 -1
- package/templates/deploy.yml +12 -0
package/README.md
CHANGED
|
@@ -66,6 +66,36 @@ workflow reads these files from the checkout, so they must be available to
|
|
|
66
66
|
GitHub Actions (this package's zero-config convention is to track them only in
|
|
67
67
|
a private repository).
|
|
68
68
|
|
|
69
|
+
### Choosing which stores a repo ships to
|
|
70
|
+
|
|
71
|
+
`deploy.yml` runs iOS and Android in parallel. Set the repository variable
|
|
72
|
+
`DEPLOY_PLATFORMS` when one of them is intentionally out of scope, so an
|
|
73
|
+
unrelated failure on the platform you do not care about cannot block the
|
|
74
|
+
release you do:
|
|
75
|
+
|
|
76
|
+
| `DEPLOY_PLATFORMS` | Result |
|
|
77
|
+
|---|---|
|
|
78
|
+
| unset / `both` | iOS + Android (default) |
|
|
79
|
+
| `android` | Android only — the iOS job is skipped |
|
|
80
|
+
| `ios` | iOS only — the Android job is skipped |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
gh variable set DEPLOY_PLATFORMS --body android
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Track, status and staged rollout
|
|
87
|
+
|
|
88
|
+
| Variable | Default | Purpose |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `GOOGLE_PLAY_TRACK` | `internal` | `internal`, `alpha`, `beta`, `production`, or a custom track name |
|
|
91
|
+
| `GOOGLE_PLAY_STATUS` | `completed` | `completed` (full rollout) or `inProgress` (staged) |
|
|
92
|
+
| `GOOGLE_PLAY_USER_FRACTION` | `0.2` | Audience share, **only** read when the status is `inProgress` |
|
|
93
|
+
|
|
94
|
+
Play rejects an `inProgress` release that does not declare its audience share,
|
|
95
|
+
and rejects a `completed` one that does. The workflow sends `userFraction` only
|
|
96
|
+
in the mode that accepts it, so setting the fraction while leaving the status at
|
|
97
|
+
`completed` is harmless rather than a failed upload.
|
|
98
|
+
|
|
69
99
|
Google Play requires the first AAB to be uploaded through Play Console. The
|
|
70
100
|
first CI run still succeeds and retains the signed AAB as an
|
|
71
101
|
`android-<package>-<versionCode>` workflow artifact. Upload that artifact once
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.14
|
|
@@ -72,20 +72,49 @@ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
|
72
72
|
return ""
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def
|
|
75
|
+
def _expand_entitlements_refs(value: str, target_name: str) -> str:
|
|
76
|
+
"""Expand the build-setting references we can resolve without xcodebuild.
|
|
77
|
+
|
|
78
|
+
``$(TARGET_NAME)`` is the idiom every xcodegen target template uses for a
|
|
79
|
+
per-target entitlements path, and ``$(SRCROOT)``/``$(PROJECT_DIR)`` both
|
|
80
|
+
name the directory the path is already relative to. Those three are
|
|
81
|
+
unambiguous from the pbxproj alone; anything else is left in place so the
|
|
82
|
+
caller can reject the value.
|
|
83
|
+
"""
|
|
84
|
+
for reference in (f"$(TARGET_NAME)", "${TARGET_NAME}"):
|
|
85
|
+
value = value.replace(reference, target_name)
|
|
86
|
+
for name in ("SRCROOT", "PROJECT_DIR"):
|
|
87
|
+
value = value.replace(f"$({name})/", "").replace(f"${{{name}}}/", "")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _entitlements_from_configs(
|
|
92
|
+
objects: dict, config_ids: list[str], target_name: str = "", base: Path | None = None
|
|
93
|
+
) -> str:
|
|
76
94
|
"""First CODE_SIGN_ENTITLEMENTS path across the given configs.
|
|
77
95
|
|
|
78
96
|
Needed to reconcile the App ID's capabilities with what the app declares —
|
|
79
|
-
a profile only carries capabilities enabled on its App ID.
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
a profile only carries capabilities enabled on its App ID.
|
|
98
|
+
|
|
99
|
+
A literal path is returned as-is (the caller warns when it does not exist).
|
|
100
|
+
A path built from build settings is expanded only for the references we can
|
|
101
|
+
resolve exactly, and then only accepted when the resulting file is really
|
|
102
|
+
there — a missed entitlements file costs a clearer error, a wrong one costs
|
|
103
|
+
a wrong App ID edit.
|
|
82
104
|
"""
|
|
83
105
|
for cid in config_ids:
|
|
84
106
|
cfg = objects.get(cid) or {}
|
|
85
107
|
settings = cfg.get("buildSettings") or {}
|
|
86
108
|
value = (settings.get("CODE_SIGN_ENTITLEMENTS") or "").strip().strip('"')
|
|
87
|
-
if
|
|
109
|
+
if not value:
|
|
110
|
+
continue
|
|
111
|
+
if "$(" not in value and "${" not in value:
|
|
88
112
|
return value
|
|
113
|
+
expanded = _expand_entitlements_refs(value, target_name)
|
|
114
|
+
if "$(" in expanded or "${" in expanded:
|
|
115
|
+
continue
|
|
116
|
+
if base is None or (base / expanded).is_file():
|
|
117
|
+
return expanded
|
|
89
118
|
return ""
|
|
90
119
|
|
|
91
120
|
|
|
@@ -102,6 +131,7 @@ def discover_signable_targets(project_path: str) -> list[dict]:
|
|
|
102
131
|
"""
|
|
103
132
|
pbx = _load_pbxproj(project_path)
|
|
104
133
|
objects = pbx["objects"]
|
|
134
|
+
base = Path(project_path).parent
|
|
105
135
|
targets: list[dict] = []
|
|
106
136
|
for obj in objects.values():
|
|
107
137
|
if obj.get("isa") != "PBXNativeTarget":
|
|
@@ -120,7 +150,9 @@ def discover_signable_targets(project_path: str) -> list[dict]:
|
|
|
120
150
|
"name": name,
|
|
121
151
|
"bundle_id": bundle_id,
|
|
122
152
|
"config_ids": config_ids,
|
|
123
|
-
"entitlements": _entitlements_from_configs(
|
|
153
|
+
"entitlements": _entitlements_from_configs(
|
|
154
|
+
objects, config_ids, target_name=name, base=base
|
|
155
|
+
),
|
|
124
156
|
}
|
|
125
157
|
)
|
|
126
158
|
if not targets:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Entitlements-path resolution in pbxproj_editor."""
|
|
3
|
+
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
|
|
8
|
+
from pbxproj_editor import _entitlements_from_configs, _expand_entitlements_refs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _objects(value):
|
|
12
|
+
return {"cfg": {"buildSettings": {"CODE_SIGN_ENTITLEMENTS": value}}}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExpandEntitlementsRefsTest(unittest.TestCase):
|
|
16
|
+
def test_expands_target_name_both_spellings(self):
|
|
17
|
+
self.assertEqual(
|
|
18
|
+
_expand_entitlements_refs("$(TARGET_NAME)/$(TARGET_NAME).entitlements", "Blocker"),
|
|
19
|
+
"Blocker/Blocker.entitlements",
|
|
20
|
+
)
|
|
21
|
+
self.assertEqual(
|
|
22
|
+
_expand_entitlements_refs("${TARGET_NAME}/App.entitlements", "Blocker"),
|
|
23
|
+
"Blocker/App.entitlements",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def test_srcroot_prefix_drops_out(self):
|
|
27
|
+
self.assertEqual(
|
|
28
|
+
_expand_entitlements_refs("$(SRCROOT)/App/App.entitlements", "App"),
|
|
29
|
+
"App/App.entitlements",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def test_unknown_reference_is_left_alone(self):
|
|
33
|
+
self.assertIn("$(CONFIGURATION)", _expand_entitlements_refs(
|
|
34
|
+
"$(CONFIGURATION)/App.entitlements", "App"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EntitlementsFromConfigsTest(unittest.TestCase):
|
|
38
|
+
def test_literal_path_is_returned_without_touching_disk(self):
|
|
39
|
+
self.assertEqual(
|
|
40
|
+
_entitlements_from_configs(_objects("App/App.entitlements"), ["cfg"],
|
|
41
|
+
target_name="App", base=Path("/nonexistent")),
|
|
42
|
+
"App/App.entitlements",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def test_target_name_reference_resolves_when_the_file_exists(self):
|
|
46
|
+
with TemporaryDirectory() as tmp:
|
|
47
|
+
root = Path(tmp)
|
|
48
|
+
(root / "Blocker").mkdir()
|
|
49
|
+
(root / "Blocker" / "Blocker.entitlements").write_text("<plist/>")
|
|
50
|
+
self.assertEqual(
|
|
51
|
+
_entitlements_from_configs(
|
|
52
|
+
_objects("$(TARGET_NAME)/$(TARGET_NAME).entitlements"), ["cfg"],
|
|
53
|
+
target_name="Blocker", base=root),
|
|
54
|
+
"Blocker/Blocker.entitlements",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_expanded_path_that_does_not_exist_is_rejected(self):
|
|
58
|
+
with TemporaryDirectory() as tmp:
|
|
59
|
+
self.assertEqual(
|
|
60
|
+
_entitlements_from_configs(
|
|
61
|
+
_objects("$(TARGET_NAME)/$(TARGET_NAME).entitlements"), ["cfg"],
|
|
62
|
+
target_name="Ghost", base=Path(tmp)),
|
|
63
|
+
"",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def test_unresolvable_reference_is_skipped(self):
|
|
67
|
+
self.assertEqual(
|
|
68
|
+
_entitlements_from_configs(
|
|
69
|
+
_objects("$(CONFIGURATION)/App.entitlements"), ["cfg"],
|
|
70
|
+
target_name="App", base=Path("/tmp")),
|
|
71
|
+
"",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def test_missing_setting_yields_empty(self):
|
|
75
|
+
self.assertEqual(
|
|
76
|
+
_entitlements_from_configs({"cfg": {"buildSettings": {}}}, ["cfg"]), "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.14
|
package/bin/cli.mjs
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
-
import updateNotifier from 'update-notifier';
|
|
7
|
-
|
|
8
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
7
|
const __dirname = dirname(__filename);
|
|
10
8
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
// The update nag is a nicety, not a dependency of installing. Running the CLI
|
|
11
|
+
// straight out of a source checkout (`node ../gowalk-cicd/bin/cli.mjs`, the
|
|
12
|
+
// documented local-development path) has no node_modules, and a hard import
|
|
13
|
+
// would take the whole installer down with it.
|
|
14
|
+
async function loadNotifier() {
|
|
15
|
+
try {
|
|
16
|
+
const { default: updateNotifier } = await import('update-notifier');
|
|
17
|
+
return updateNotifier({ pkg });
|
|
18
|
+
} catch {
|
|
19
|
+
return { notify() {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
13
22
|
|
|
14
23
|
const args = process.argv.slice(2);
|
|
15
24
|
let dryRun = false;
|
|
@@ -60,4 +69,5 @@ After install:
|
|
|
60
69
|
const { runInstall } = await import('../src/install.mjs');
|
|
61
70
|
await runInstall({ dryRun });
|
|
62
71
|
|
|
72
|
+
const notifier = await loadNotifier();
|
|
63
73
|
notifier.notify();
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -19,8 +19,15 @@ concurrency:
|
|
|
19
19
|
cancel-in-progress: true
|
|
20
20
|
|
|
21
21
|
jobs:
|
|
22
|
+
# Repository variable DEPLOY_PLATFORMS selects which stores this repo ships to:
|
|
23
|
+
# unset / 'both' — iOS and Android (the default)
|
|
24
|
+
# 'android' — Android only; skip the iOS job entirely
|
|
25
|
+
# 'ios' — iOS only; skip the Android job entirely
|
|
26
|
+
# Use it when one platform is intentionally out of scope for a repo, so an
|
|
27
|
+
# unrelated failure on the other platform cannot block the release you want.
|
|
22
28
|
ios:
|
|
23
29
|
name: iOS TestFlight
|
|
30
|
+
if: ${{ vars.DEPLOY_PLATFORMS != 'android' }}
|
|
24
31
|
runs-on: macos-15
|
|
25
32
|
timeout-minutes: 60
|
|
26
33
|
steps:
|
|
@@ -78,6 +85,7 @@ jobs:
|
|
|
78
85
|
|
|
79
86
|
android:
|
|
80
87
|
name: Android Google Play
|
|
88
|
+
if: ${{ vars.DEPLOY_PLATFORMS != 'ios' }}
|
|
81
89
|
runs-on: ubuntu-24.04
|
|
82
90
|
timeout-minutes: 45
|
|
83
91
|
steps:
|
|
@@ -187,6 +195,10 @@ jobs:
|
|
|
187
195
|
releaseFiles: ${{ steps.android.outputs.bundle-path }}
|
|
188
196
|
tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
|
|
189
197
|
status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}
|
|
198
|
+
# Play rejects an 'inProgress' release that does not declare how much of
|
|
199
|
+
# the audience it reaches. Only send the fraction in that mode: passing it
|
|
200
|
+
# alongside status 'completed' is an error rather than a no-op.
|
|
201
|
+
userFraction: ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
|
|
190
202
|
# Localized release notes: commit distribution/whatsnew/whatsnew-<bcp47>
|
|
191
203
|
# files (e.g. whatsnew-en-US, whatsnew-de-DE) and they ship with every
|
|
192
204
|
# release. Empty when the directory is absent — the action skips it.
|