gowalk-cicd 1.0.50 → 1.0.52
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/CLAUDE.md +2 -1
- package/action/.daemux-version +1 -1
- package/action/scripts/keychain.py +33 -3
- package/action/scripts/test_keychain.py +145 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/select_certbot_account.sh +2 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -110,7 +110,8 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
110
110
|
Default-branch deploys continue through the separate backend workflow.
|
|
111
111
|
- Domain-backed backend deploys fail closed on certificate issuance and the
|
|
112
112
|
public HTTPS health probe. `select_certbot_account.sh` chooses one existing
|
|
113
|
-
ACME account deterministically so multi-account hosts stay
|
|
113
|
+
Let's Encrypt ACME v2 account deterministically so multi-account hosts stay
|
|
114
|
+
non-interactive; legacy v1 account directories are not valid candidates.
|
|
114
115
|
- The Android action serves two build systems. `android_config.project_kind()`
|
|
115
116
|
is the only place that decides which; every downstream step branches on the
|
|
116
117
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.52
|
|
@@ -8,6 +8,16 @@ into it, and prepends it to the user's keychain search list so
|
|
|
8
8
|
keychain is purposely short-lived (default 6h auto-lock) and disposable
|
|
9
9
|
— each CI run re-creates it from scratch.
|
|
10
10
|
|
|
11
|
+
The user's *default* keychain is deliberately never pointed at the
|
|
12
|
+
throwaway keychain: the search list (plus the partition list set at
|
|
13
|
+
import) is what identity resolution actually uses. On a persistent
|
|
14
|
+
runner (a self-hosted Mac), claiming the default meant every credential
|
|
15
|
+
consumer on the host wrote into the throwaway keychain, and once its
|
|
16
|
+
temp dir was reclaimed the dangling default made macOS pop a
|
|
17
|
+
"Keychain ... cannot be found to store" dialog at every credential
|
|
18
|
+
write. Setup instead prunes search-list entries whose backing file is
|
|
19
|
+
gone and repoints a dangling default back at the login keychain.
|
|
20
|
+
|
|
11
21
|
The keychain password (``"ci"``) is intentionally hardcoded: the
|
|
12
22
|
keychain never leaves the runner (it lives in ``$RUNNER_TEMP`` which
|
|
13
23
|
GitHub deletes when the job ends), so encrypting it with a sourced
|
|
@@ -77,15 +87,35 @@ def _prepend_to_user_search_list(keychain_path: str) -> None:
|
|
|
77
87
|
for line in existing.splitlines()
|
|
78
88
|
if line.strip()
|
|
79
89
|
]
|
|
90
|
+
# Prune entries whose file is gone: dangling throwaway keychains from
|
|
91
|
+
# earlier runs on a persistent Mac would otherwise accumulate forever.
|
|
80
92
|
new_list = [keychain_path] + [
|
|
81
|
-
k for k in existing_list
|
|
93
|
+
k for k in existing_list
|
|
94
|
+
if k != keychain_path and os.path.exists(k)
|
|
82
95
|
]
|
|
83
96
|
subprocess.check_call(
|
|
84
97
|
["security", "list-keychains", "-d", "user", "-s", *new_list]
|
|
85
98
|
)
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
_repair_default_keychain()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _repair_default_keychain() -> None:
|
|
103
|
+
"""Repoint an unset or dangling default keychain at the login keychain.
|
|
104
|
+
|
|
105
|
+
Heals hosts damaged by earlier releases (which made the throwaway CI
|
|
106
|
+
keychain the default) without ever claiming the default for this run.
|
|
107
|
+
A healthy default — whatever it points at — is left alone.
|
|
108
|
+
"""
|
|
109
|
+
login = os.path.expanduser("~/Library/Keychains/login.keychain-db")
|
|
110
|
+
if not os.path.exists(login):
|
|
111
|
+
return
|
|
112
|
+
probe = subprocess.run(
|
|
113
|
+
["security", "default-keychain"], capture_output=True, text=True
|
|
88
114
|
)
|
|
115
|
+
current = probe.stdout.strip().strip('"') if probe.returncode == 0 else ""
|
|
116
|
+
if current and os.path.exists(current):
|
|
117
|
+
return
|
|
118
|
+
subprocess.check_call(["security", "default-keychain", "-s", login])
|
|
89
119
|
|
|
90
120
|
|
|
91
121
|
def setup_keychain(p12_path: Path, p12_pass: str, runner_temp: str) -> str:
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Search-list and default-keychain behavior of the throwaway CI keychain.
|
|
2
|
+
|
|
3
|
+
The regression these tests pin down: setup used to run
|
|
4
|
+
``security default-keychain -s <ci keychain>``. On a persistent
|
|
5
|
+
(self-hosted) Mac the throwaway keychain's temp dir is reclaimed after
|
|
6
|
+
the run, the user default dangles, and macOS pops a
|
|
7
|
+
"Keychain ... cannot be found to store" dialog at every credential
|
|
8
|
+
write. Setup must never claim the default and must prune search-list
|
|
9
|
+
entries whose file is gone.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import unittest
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
import keychain
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _SecurityHost:
|
|
19
|
+
"""Record ``security`` invocations against a fake filesystem."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, existing_files, search_list, default=""):
|
|
22
|
+
self.files = set(existing_files)
|
|
23
|
+
self.search_list = list(search_list)
|
|
24
|
+
self.default = default
|
|
25
|
+
self.calls = []
|
|
26
|
+
|
|
27
|
+
def exists(self, path):
|
|
28
|
+
return path in self.files
|
|
29
|
+
|
|
30
|
+
def check_output(self, cmd):
|
|
31
|
+
self.calls.append(cmd)
|
|
32
|
+
assert cmd == ["security", "list-keychains", "-d", "user"]
|
|
33
|
+
return "".join(f' "{p}"\n' for p in self.search_list).encode()
|
|
34
|
+
|
|
35
|
+
def check_call(self, cmd):
|
|
36
|
+
self.calls.append(cmd)
|
|
37
|
+
|
|
38
|
+
def run(self, cmd, capture_output=False, text=False):
|
|
39
|
+
self.calls.append(cmd)
|
|
40
|
+
assert cmd == ["security", "default-keychain"]
|
|
41
|
+
if not self.default:
|
|
42
|
+
return mock.Mock(returncode=1, stdout="", stderr="no default set")
|
|
43
|
+
return mock.Mock(returncode=0, stdout=f' "{self.default}"\n', stderr="")
|
|
44
|
+
|
|
45
|
+
def patches(self):
|
|
46
|
+
return (
|
|
47
|
+
mock.patch.object(keychain.subprocess, "check_output", self.check_output),
|
|
48
|
+
mock.patch.object(keychain.subprocess, "check_call", self.check_call),
|
|
49
|
+
mock.patch.object(keychain.subprocess, "run", self.run),
|
|
50
|
+
mock.patch.object(keychain.os.path, "exists", self.exists),
|
|
51
|
+
mock.patch.object(
|
|
52
|
+
keychain.os.path, "expanduser", lambda p: p.replace("~", "/Users/ci")
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
LOGIN = "/Users/ci/Library/Keychains/login.keychain-db"
|
|
58
|
+
CI_KEYCHAIN = "/tmp/runner/ci.keychain-db"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _apply(host, fn, *args):
|
|
62
|
+
patches = host.patches()
|
|
63
|
+
for p in patches:
|
|
64
|
+
p.start()
|
|
65
|
+
try:
|
|
66
|
+
return fn(*args)
|
|
67
|
+
finally:
|
|
68
|
+
for p in patches:
|
|
69
|
+
p.stop()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PrependToUserSearchListTests(unittest.TestCase):
|
|
73
|
+
def test_never_claims_the_default_keychain(self):
|
|
74
|
+
host = _SecurityHost(
|
|
75
|
+
existing_files={LOGIN, CI_KEYCHAIN},
|
|
76
|
+
search_list=[LOGIN],
|
|
77
|
+
default=LOGIN,
|
|
78
|
+
)
|
|
79
|
+
_apply(host, keychain._prepend_to_user_search_list, CI_KEYCHAIN)
|
|
80
|
+
claimed = [c for c in host.calls if c[:2] == ["security", "default-keychain"] and "-s" in c]
|
|
81
|
+
self.assertEqual(claimed, [])
|
|
82
|
+
self.assertIn(
|
|
83
|
+
["security", "list-keychains", "-d", "user", "-s", CI_KEYCHAIN, LOGIN],
|
|
84
|
+
host.calls,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def test_prunes_dangling_search_list_entries(self):
|
|
88
|
+
stale = "/private/var/tmp/app-robot-task-1/ci.keychain-db"
|
|
89
|
+
host = _SecurityHost(
|
|
90
|
+
existing_files={LOGIN, CI_KEYCHAIN},
|
|
91
|
+
search_list=[stale, LOGIN],
|
|
92
|
+
default=LOGIN,
|
|
93
|
+
)
|
|
94
|
+
_apply(host, keychain._prepend_to_user_search_list, CI_KEYCHAIN)
|
|
95
|
+
self.assertIn(
|
|
96
|
+
["security", "list-keychains", "-d", "user", "-s", CI_KEYCHAIN, LOGIN],
|
|
97
|
+
host.calls,
|
|
98
|
+
)
|
|
99
|
+
for call in host.calls:
|
|
100
|
+
self.assertNotIn(stale, call)
|
|
101
|
+
|
|
102
|
+
def test_own_path_not_duplicated_when_already_listed(self):
|
|
103
|
+
host = _SecurityHost(
|
|
104
|
+
existing_files={LOGIN, CI_KEYCHAIN},
|
|
105
|
+
search_list=[CI_KEYCHAIN, LOGIN],
|
|
106
|
+
default=LOGIN,
|
|
107
|
+
)
|
|
108
|
+
_apply(host, keychain._prepend_to_user_search_list, CI_KEYCHAIN)
|
|
109
|
+
self.assertIn(
|
|
110
|
+
["security", "list-keychains", "-d", "user", "-s", CI_KEYCHAIN, LOGIN],
|
|
111
|
+
host.calls,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RepairDefaultKeychainTests(unittest.TestCase):
|
|
116
|
+
def test_resets_a_dangling_default(self):
|
|
117
|
+
host = _SecurityHost(
|
|
118
|
+
existing_files={LOGIN},
|
|
119
|
+
search_list=[LOGIN],
|
|
120
|
+
default="/private/var/tmp/app-robot-task-1/ci.keychain-db",
|
|
121
|
+
)
|
|
122
|
+
_apply(host, keychain._repair_default_keychain)
|
|
123
|
+
self.assertIn(["security", "default-keychain", "-s", LOGIN], host.calls)
|
|
124
|
+
|
|
125
|
+
def test_sets_login_when_no_default_exists(self):
|
|
126
|
+
host = _SecurityHost(existing_files={LOGIN}, search_list=[LOGIN], default="")
|
|
127
|
+
_apply(host, keychain._repair_default_keychain)
|
|
128
|
+
self.assertIn(["security", "default-keychain", "-s", LOGIN], host.calls)
|
|
129
|
+
|
|
130
|
+
def test_leaves_a_healthy_default_alone(self):
|
|
131
|
+
other = "/Users/ci/Library/Keychains/custom.keychain-db"
|
|
132
|
+
host = _SecurityHost(
|
|
133
|
+
existing_files={LOGIN, other}, search_list=[LOGIN], default=other
|
|
134
|
+
)
|
|
135
|
+
_apply(host, keychain._repair_default_keychain)
|
|
136
|
+
self.assertNotIn(["security", "default-keychain", "-s", LOGIN], host.calls)
|
|
137
|
+
|
|
138
|
+
def test_noop_when_login_keychain_is_absent(self):
|
|
139
|
+
host = _SecurityHost(existing_files=set(), search_list=[], default="")
|
|
140
|
+
_apply(host, keychain._repair_default_keychain)
|
|
141
|
+
self.assertEqual(host.calls, [])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.52
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.52
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
# which account should authorize a new certificate.
|
|
6
6
|
select_certbot_account() {
|
|
7
7
|
local accounts_dir="${1:-/etc/letsencrypt/accounts}"
|
|
8
|
+
local server_dir="$accounts_dir/acme-v02.api.letsencrypt.org/directory"
|
|
8
9
|
local registration
|
|
9
10
|
|
|
10
11
|
registration="$(
|
|
11
|
-
find "$
|
|
12
|
+
find "$server_dir" -type f -name regr.json -print 2>/dev/null \
|
|
12
13
|
| LC_ALL=C sort \
|
|
13
14
|
| head -1
|
|
14
15
|
)"
|