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 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 non-interactive.
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
@@ -1 +1 @@
1
- 1.0.50
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 if k != keychain_path
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
- subprocess.check_call(
87
- ["security", "default-keychain", "-s", keychain_path]
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.50
1
+ 1.0.52
@@ -1 +1 @@
1
- 1.0.50
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 "$accounts_dir" -type f -name regr.json -print 2>/dev/null \
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
  )"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.50",
3
+ "version": "1.0.52",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {