gowalk-cicd 1.0.59 → 1.0.61

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
@@ -96,6 +96,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
96
96
  - Keep the CLI surface minimal: `--dry-run`, `-v`, `-h`. Resist adding flags.
97
97
  - `.gitignore` in the consumer repo is git-tracked. Never auto-edit it.
98
98
  Detect and warn, tell the user to remove lines manually.
99
+ - Never repeat an `env:` key in another case (`NO_PROXY` beside `no_proxy`): GitHub
100
+ compares env keys case-insensitively and rejects the whole workflow or action file.
101
+ `test/env-keys.test.mjs` scans the templates and actions. Set lower-case spellings
102
+ or clear a lower-case bypass through `play_store_proxy.py -- <command>` (child
103
+ process) or `play_store_proxy.py --github-env` (later steps), never through YAML.
99
104
  - Store API calls belong in GitHub Actions. Local tests may build/sign but must
100
105
  never connect to App Store Connect or Google Play.
101
106
  - Every Apple/Google store API call, OAuth exchange and binary upload requires its
@@ -110,6 +115,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
110
115
  `IOS_DISTRIBUTION_CERT_REGISTRY_BASE64`), and the Android keystore, properties
111
116
  and Play service account into an ephemeral runner checkout.
112
117
  Tracked `creds/` files remain a backward-compatible fallback.
118
+ - Never revoke a distribution certificate automatically, including legacy caches.
119
+ `certificate-cap-policy` accepts only `fail`; a full cap requires registry reconciliation.
120
+ - Keep preinstalled Android SDK/NDKs. Every Gradle test/build and SDK download runs
121
+ under `play_store_proxy.py --`, whose owned loopback relay gives Java an explicit
122
+ authenticated account exit without credentials in JVM options. HTTPS is tunneled
123
+ without TLS interception; no upstream failure may fall back to the destination.
124
+ The wrapper disables Gradle daemons and closes its relay when the command ends.
113
125
  - Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
114
126
  secret. The action writes it to host-side `.runtime.env` with mode 0600 and
115
127
  supplies it to Compose after the generated `.env`; never print its content.
@@ -163,7 +175,7 @@ node /path/to/gowalk-cicd/bin/cli.mjs
163
175
  can be raised to cover a release's life.
164
176
  - Firebase Crashlytics traffic follows the Google account proxy. Android builds and
165
177
  the pinned Firebase CLI receive the explicit proxy environment; Crashlytics Gradle
166
- 2.9.2+ and the pinned buildtools support it (generic JVM networking does not).
178
+ 2.9.2+ and the pinned buildtools support it; generic JVM networking uses the relay.
167
179
  Native iOS upload-symbols uses NSURLSession, so it is never executed. Before
168
180
  archiving, dedicated Crashlytics upload phases in the ephemeral Xcode projects
169
181
  are deferred; mixed/unidentified upload phases refuse the archive for repair.
package/README.md CHANGED
@@ -327,12 +327,19 @@ zip I/O error: No space left on device
327
327
  ```
328
328
 
329
329
  The Android job removes the preinstalled toolchains a Flutter build never uses
330
- (.NET, the Android NDK, GHC, PowerShell, Swift, Chromium) and prunes Docker
330
+ (.NET, GHC, PowerShell, Swift, Chromium) and prunes Docker
331
331
  images, reclaiming roughly 25 GB in a few seconds. It prints `df -h /` before
332
332
  and after. Linux only; skipped in Bitrise mode. npm survives on purpose: the
333
333
  [Crashlytics symbol upload](#crashlytics-symbol-delivery-firebase_app_id) runs
334
334
  the Firebase CLI through `npx`.
335
335
 
336
+ The preinstalled Android SDK and NDKs are retained. Gradle and SDK-manager Java
337
+ networking runs through an owned loopback relay to `GOOGLE_STORE_PROXY_URL`.
338
+ The relay authenticates to that exit without putting credentials in JVM options;
339
+ HTTPS remains an end-to-end TLS tunnel. An unavailable proxy fails the request,
340
+ and the relay shuts down with its child command. Gradle daemons are disabled for
341
+ these commands so they cannot retain an expired relay port.
342
+
336
343
  ### Private git dependencies (Flutter)
337
344
 
338
345
  A Flutter app can depend on private git packages:
@@ -441,7 +448,8 @@ identity and create per-app provisioning profiles, but it never creates,
441
448
  replaces, or revokes a registry-managed distribution certificate. Missing,
442
449
  partially written, corrupt, expired, Apple-revoked, or resource-ID/P12-mismatched
443
450
  managed material aborts with a reconciliation error. Legacy repos without the
444
- marker keep the historical cache lifecycle.
451
+ marker may create a certificate in an available slot, but never revoke another
452
+ identity. A full certificate cap stops for account-registry reconciliation.
445
453
 
446
454
  ## How it works
447
455
 
@@ -773,7 +781,7 @@ common ones:
773
781
  | `bundle-id` | Override the auto-detected bundle identifier |
774
782
  | `team-id` | Override the auto-detected team ID |
775
783
  | `app-store-apple-id` | Numeric ASC app ID (override auto-lookup) |
776
- | `certificate-cap-policy` | Full-cap behavior: `fail` preserves identities; default `revoke-oldest` rotates. |
784
+ | `certificate-cap-policy` | Only `fail` is accepted (the default); existing identities are always preserved. |
777
785
  | `run-tests` | `false` to skip the simulator test stage |
778
786
  | `uses-non-exempt-encryption` | Value for `ITSAppUsesNonExemptEncryption` |
779
787
  | `archive` | `false` to build-only (PR runs without secrets) |
@@ -793,8 +801,7 @@ inside GitHub Actions.
793
801
  Once the app exists, all subsequent builds and uploads are fully automated via
794
802
  the ASC API key.
795
803
 
796
- For accounts where automatic certificate revocation is not acceptable, pass
797
- `certificate-cap-policy: 'fail'`. The action may add a certificate when Apple has a free
804
+ Automatic certificate revocation is disabled for every account. The action may add a certificate when Apple has a free
798
805
  slot, but a full-cap response aborts without listing or revoking existing identities. If
799
806
  signing preparation fails after creating a certificate, the default-branch workflow first
800
807
  commits any completed cache files, then re-raises the failure so the private key is not lost.
@@ -1 +1 @@
1
- 1.0.59
1
+ 1.0.61
package/action/action.yml CHANGED
@@ -53,10 +53,10 @@ inputs:
53
53
  certificate-cap-policy:
54
54
  description: >
55
55
  Behavior when Apple reports the two-certificate Distribution cap.
56
- 'revoke-oldest' preserves historical behavior; 'fail' aborts without
57
- listing or revoking any existing certificate.
56
+ Only 'fail' is supported: abort without revoking any existing certificate.
57
+ Reconcile the account registry when no live signing identity is available.
58
58
  required: false
59
- default: "revoke-oldest"
59
+ default: "fail"
60
60
  persist-signing-cache:
61
61
  description: >
62
62
  Commit refreshed signing files back to the default branch. Set to
@@ -3,7 +3,7 @@
3
3
  Cert-creation primitives for the iOS native TestFlight action.
4
4
 
5
5
  Owns the cryptographic legwork (RSA key + CSR), Apple's per-team cert
6
- cap policy (fail closed or revoke OLDEST on 409), and PKCS12 serialisation. Split
6
+ cap refusal (never automatic revocation), and PKCS12 serialisation. Split
7
7
  from ``prepare_signing.py`` so the orchestrator there stays focused on
8
8
  the load-or-regen control flow and the file count stays under the
9
9
  project's per-file limits.
@@ -16,35 +16,20 @@ from __future__ import annotations
16
16
 
17
17
  import base64
18
18
  import os
19
- import time
20
19
 
21
- from asc_common import get_json, request
20
+ from asc_common import request
22
21
  from cryptography import x509
23
22
  from cryptography.hazmat.primitives import hashes, serialization
24
23
  from cryptography.hazmat.primitives.asymmetric import rsa
25
24
  from cryptography.hazmat.primitives.serialization import pkcs12
26
25
  from cryptography.x509.oid import NameOID
27
26
 
28
- # Apple's cert revoke -> create pipeline is eventually consistent: a
29
- # DELETE on the per-team cap-blocking cert sometimes still shows up as
30
- # "active" to a follow-up POST for a second or two, returning a fresh
31
- # 409 even though we just freed a slot. Sleep then retry-with-backoff
32
- # rather than failing the whole CI run on a known-transient race.
33
- CERT_REVOKE_PROPAGATION_DELAY_SEC = float(
34
- os.getenv("CERT_REVOKE_PROPAGATION_DELAY_SEC", "2.0")
35
- )
36
- CERT_POST_RETRIES_AFTER_REVOKE = int(
37
- os.getenv("CERT_POST_RETRIES_AFTER_REVOKE", "2")
38
- )
39
- CERTIFICATE_CAP_POLICIES = frozenset({"revoke-oldest", "fail"})
40
-
41
27
 
42
28
  def certificate_cap_policy() -> str:
43
- """Return the configured full-cap behavior before any provider call."""
44
- policy = os.getenv("CERTIFICATE_CAP_POLICY", "revoke-oldest").strip().lower()
45
- if policy not in CERTIFICATE_CAP_POLICIES:
46
- allowed = ", ".join(sorted(CERTIFICATE_CAP_POLICIES))
47
- raise SystemExit(f"invalid CERTIFICATE_CAP_POLICY {policy!r}; use {allowed}")
29
+ """Refuse destructive legacy policy before any provider call."""
30
+ policy = os.getenv("CERTIFICATE_CAP_POLICY", "fail").strip().lower()
31
+ if policy != "fail":
32
+ raise SystemExit("invalid CERTIFICATE_CAP_POLICY; use fail. Automatic certificate revocation is disabled")
48
33
  return policy
49
34
 
50
35
 
@@ -75,79 +60,9 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
75
60
  return private_key, csr_payload
76
61
 
77
62
 
78
- def oldest_distribution_cert_id(token: str) -> str | None:
79
- """Return the DISTRIBUTION cert with the EARLIEST expirationDate.
80
-
81
- Apple caps each team at 2 distribution certs; when CI hits the cap
82
- we must revoke one. Picking the OLDEST is the safest choice — the
83
- NEWEST cert signed the most recent build that may still be in ASC
84
- processing, and revoking that mid-processing produces ITMS-90035
85
- ("signed with an ad-hoc certificate, not a distribution
86
- certificate") on the prior run. Older certs have long since cleared
87
- processing.
88
- """
89
- data = get_json(
90
- "/certificates",
91
- token,
92
- params={
93
- "limit": "200",
94
- "sort": "-id",
95
- "filter[certificateType]": "DISTRIBUTION",
96
- },
97
- )
98
- oldest_id = None
99
- oldest_exp: str | None = None
100
- for cert in data.get("data", []):
101
- attrs = cert.get("attributes") or {}
102
- exp = attrs.get("expirationDate") or ""
103
- if not exp:
104
- continue
105
- if oldest_exp is None or exp < oldest_exp:
106
- oldest_exp = exp
107
- oldest_id = cert["id"]
108
- return oldest_id
109
-
110
-
111
- def _post_with_revoke_backoff(token: str, body: dict):
112
- """POST a cert create after a revoke, tolerating ASC propagation lag.
113
-
114
- Apple's DELETE -> POST pipeline is eventually consistent: a freshly
115
- revoked cert may still count against the per-team cap for a brief
116
- window, producing a spurious 409 on the immediate re-POST. Sleep
117
- once for ``CERT_REVOKE_PROPAGATION_DELAY_SEC`` then retry up to
118
- ``CERT_POST_RETRIES_AFTER_REVOKE`` times with exponential backoff.
119
- The final attempt drops ``allow_status`` so a real 409 surfaces as
120
- Apple's full body via ``request``'s SystemExit.
121
- """
122
- time.sleep(CERT_REVOKE_PROPAGATION_DELAY_SEC)
123
- delay = CERT_REVOKE_PROPAGATION_DELAY_SEC
124
- for attempt in range(CERT_POST_RETRIES_AFTER_REVOKE):
125
- resp = request(
126
- "POST", "/certificates", token, json_body=body, allow_status={409}
127
- )
128
- if resp.status_code != 409:
129
- return resp
130
- print(
131
- f"Post-revoke 409 on attempt {attempt + 1}/"
132
- f"{CERT_POST_RETRIES_AFTER_REVOKE}; "
133
- f"sleeping {delay}s before final retry"
134
- )
135
- time.sleep(delay)
136
- delay *= 2
137
- # Final attempt without allow_status — let Apple's body surface via
138
- # request()'s SystemExit if the cap is genuinely still hit.
139
- return request("POST", "/certificates", token, json_body=body)
140
-
141
-
142
63
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
143
- """POST a CSR to ASC; return ``(cert_id, cert_der)``.
144
-
145
- On 409 (per-team cap of 2 hit) revoke the OLDEST existing cert
146
- (see :func:`oldest_distribution_cert_id` for why) and retry with
147
- backoff to absorb Apple's revoke -> create propagation lag (see
148
- :func:`_post_with_revoke_backoff`).
149
- """
150
- cap_policy = certificate_cap_policy()
64
+ """POST one CSR; a full team cap preserves every existing certificate."""
65
+ certificate_cap_policy()
151
66
  body = {
152
67
  "data": {
153
68
  "type": "certificates",
@@ -161,24 +76,10 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
161
76
  "POST", "/certificates", token, json_body=body, allow_status={409}
162
77
  )
163
78
  if resp.status_code == 409:
164
- if cap_policy == "fail":
165
- raise SystemExit(
166
- "distribution certificate cap reached; policy=fail preserves all "
167
- "existing certificates and refuses automatic revocation"
168
- )
169
- # Revoke the OLDEST cert, NOT the newest. The newest cert signed
170
- # the previous build that may still be in ASC processing — revoking
171
- # it during processing yields ITMS-90035 on the prior build.
172
- # See oldest_distribution_cert_id() for the full rationale.
173
- print("Distribution cert cap hit; revoking oldest existing cert")
174
- target = oldest_distribution_cert_id(token)
175
- if not target:
176
- raise SystemExit(
177
- "409 from cert create but no existing DISTRIBUTION cert "
178
- "found to revoke"
179
- )
180
- request("DELETE", f"/certificates/{target}", token)
181
- resp = _post_with_revoke_backoff(token, body)
79
+ raise SystemExit(
80
+ "distribution certificate cap reached; policy=fail preserves all "
81
+ "existing certificates and refuses automatic revocation; reconcile the account registry"
82
+ )
182
83
  data = resp.json()["data"]
183
84
  cert_id = data["id"]
184
85
  cert_der = base64.b64decode(data["attributes"]["certificateContent"])
@@ -220,7 +220,7 @@ def verify_cert_alive(token: str, cert_id: str,
220
220
  """Return True iff GET /certificates/{cert_id} returns 200.
221
221
 
222
222
  A 404 means Apple revoked it (manually or via the cap-rotation done
223
- by ``oldest_distribution_cert_id``); any other non-200 is treated
223
+ by another signing client); any other non-200 is treated
224
224
  conservatively as not-alive so we regenerate.
225
225
  """
226
226
  resp = request(
@@ -28,27 +28,25 @@ class CertificateCapPolicyTests(unittest.TestCase):
28
28
  response = mock.MagicMock(status_code=409)
29
29
  with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "fail"}, clear=True):
30
30
  with mock.patch.object(cert_factory, "request", return_value=response) as request:
31
- with mock.patch.object(cert_factory, "oldest_distribution_cert_id") as oldest:
32
- with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
33
- cert_factory.create_distribution_cert("token", "csr")
31
+ with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
32
+ cert_factory.create_distribution_cert("token", "csr")
34
33
  request.assert_called_once()
35
- oldest.assert_not_called()
34
+ self.assertEqual(request.call_args.args[:2], ("POST", "/certificates"))
36
35
 
37
- def test_default_policy_keeps_backward_compatible_rotation(self):
36
+ def test_default_policy_never_revokes_or_retries_a_full_cap(self):
38
37
  capped = mock.MagicMock(status_code=409)
39
- created = mock.MagicMock()
40
- created.json.return_value = {
41
- "data": {"id": "NEW", "attributes": {"certificateContent": "Y2VydA=="}}
42
- }
43
38
  with mock.patch.dict(os.environ, {}, clear=True):
44
- with mock.patch.object(
45
- cert_factory, "request", side_effect=[capped, mock.MagicMock()]
46
- ) as request:
47
- with mock.patch.object(cert_factory, "oldest_distribution_cert_id", return_value="OLD"):
48
- with mock.patch.object(cert_factory, "_post_with_revoke_backoff", return_value=created):
49
- cert_id, cert_der = cert_factory.create_distribution_cert("token", "csr")
50
- self.assertEqual((cert_id, cert_der), ("NEW", b"cert"))
51
- request.assert_any_call("DELETE", "/certificates/OLD", "token")
39
+ with mock.patch.object(cert_factory, "request", return_value=capped) as request:
40
+ with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
41
+ cert_factory.create_distribution_cert("token", "csr")
42
+ request.assert_called_once()
43
+
44
+ def test_historical_destructive_setting_is_refused_before_contacting_apple(self):
45
+ with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "revoke-oldest"}, clear=True):
46
+ with mock.patch.object(cert_factory, "request") as request:
47
+ with self.assertRaisesRegex(SystemExit, "Automatic certificate revocation is disabled"):
48
+ cert_factory.create_distribution_cert("token", "csr")
49
+ request.assert_not_called()
52
50
 
53
51
 
54
52
  class CertificateCapActionWiringTests(unittest.TestCase):
@@ -56,7 +54,7 @@ class CertificateCapActionWiringTests(unittest.TestCase):
56
54
  source = ACTION_YAML.read_text(encoding="utf-8")
57
55
  self.assertRegex(
58
56
  source,
59
- r"(?m)^ certificate-cap-policy:\n(?: .*\n)*? default: \"revoke-oldest\"$",
57
+ r"(?m)^ certificate-cap-policy:\n(?: .*\n)*? default: \"fail\"$",
60
58
  )
61
59
  self.assertIn("CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}", source)
62
60
  self.assertIn("SIGNING_PREP_EXIT=$signing_exit", source)
@@ -1 +1 @@
1
- 1.0.59
1
+ 1.0.61
@@ -128,17 +128,16 @@ runs:
128
128
  if: ${{ steps.config.outputs.project_kind == 'flutter' }}
129
129
  shell: bash
130
130
  env:
131
+ # Only one spelling per key: GitHub compares env keys case-insensitively and
132
+ # rejects the file otherwise. play_store_proxy.py -- runs the build with both
133
+ # spellings set and both NO_PROXY spellings cleared for the child process.
131
134
  HTTPS_PROXY: ${{ env.GOOGLE_STORE_PROXY_URL }}
132
135
  HTTP_PROXY: ${{ env.GOOGLE_STORE_PROXY_URL }}
133
- https_proxy: ${{ env.GOOGLE_STORE_PROXY_URL }}
134
- http_proxy: ${{ env.GOOGLE_STORE_PROXY_URL }}
135
136
  NO_PROXY: ''
136
- no_proxy: ''
137
137
  BUILD_NAME: ${{ inputs.build-name }}
138
138
  DART_DEFINES: ${{ inputs.dart-defines }}
139
139
  DART_SYMBOLS_DIR: ${{ runner.temp }}/dart-symbols/android
140
140
  run: |
141
- python3 "${{ github.action_path }}/scripts/play_store_proxy.py"
142
141
  args=(
143
142
  build appbundle
144
143
  --release
@@ -158,7 +157,7 @@ runs:
158
157
  *) echo "::error::dart-defines entry '$define' is not KEY=VALUE"; exit 1 ;;
159
158
  esac
160
159
  done
161
- flutter "${args[@]}"
160
+ python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- flutter "${args[@]}"
162
161
  if ! ls "$DART_SYMBOLS_DIR"/*.symbols >/dev/null 2>&1; then
163
162
  echo "::error::flutter build wrote no Dart symbol files to $DART_SYMBOLS_DIR;" \
164
163
  "a crash from this obfuscated build could never be read. Refusing to ship it."
@@ -223,7 +222,8 @@ runs:
223
222
  working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
224
223
  run: |
225
224
  chmod +x ./gradlew
226
- ./gradlew test --console=plain --stacktrace
225
+ python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- \
226
+ ./gradlew test --console=plain --stacktrace
227
227
 
228
228
  # ANDROID_BUILD_NUMBER is already in the environment: resolve_android.py
229
229
  # exported it through GITHUB_ENV. ANDROID_BUILD_NAME is only set when the
@@ -234,17 +234,17 @@ runs:
234
234
  shell: bash
235
235
  working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
236
236
  env:
237
+ # Only one spelling per key: GitHub compares env keys case-insensitively and
238
+ # rejects the file otherwise. play_store_proxy.py -- runs the build with both
239
+ # spellings set and both NO_PROXY spellings cleared for the child process.
237
240
  HTTPS_PROXY: ${{ env.GOOGLE_STORE_PROXY_URL }}
238
241
  HTTP_PROXY: ${{ env.GOOGLE_STORE_PROXY_URL }}
239
- https_proxy: ${{ env.GOOGLE_STORE_PROXY_URL }}
240
- http_proxy: ${{ env.GOOGLE_STORE_PROXY_URL }}
241
242
  NO_PROXY: ''
242
- no_proxy: ''
243
243
  ANDROID_BUILD_NAME: ${{ inputs.build-name }}
244
244
  run: |
245
245
  chmod +x ./gradlew
246
- python3 "${{ github.action_path }}/scripts/play_store_proxy.py"
247
- ./gradlew "${ANDROID_GRADLE_MODULE}:bundleRelease" \
246
+ python3 "${{ github.action_path }}/scripts/play_store_proxy.py" -- \
247
+ ./gradlew "${ANDROID_GRADLE_MODULE}:bundleRelease" \
248
248
  --init-script "${{ github.action_path }}/scripts/version_override.init.gradle" \
249
249
  --console=plain --stacktrace
250
250
 
@@ -0,0 +1,119 @@
1
+ """An owned loopback relay for JVMs that cannot authenticate to the assigned HTTP proxy.
2
+
3
+ Only the configured upstream is ever dialled. HTTPS remains an opaque CONNECT
4
+ tunnel; Java still verifies the provider's TLS certificate end to end.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import base64
10
+ from contextlib import contextmanager
11
+ import ssl
12
+ import threading
13
+ from urllib.parse import unquote, urlsplit
14
+
15
+
16
+ class Relay:
17
+ def __init__(self, proxy: str):
18
+ self.proxy = urlsplit(proxy)
19
+ self.loop = asyncio.new_event_loop()
20
+ self.ready = threading.Event()
21
+ self.thread = threading.Thread(target=self._serve, daemon=True)
22
+ self.server = None
23
+ self.port = None
24
+
25
+ def _serve(self):
26
+ asyncio.set_event_loop(self.loop)
27
+ try:
28
+ self.server = self.loop.run_until_complete(
29
+ asyncio.start_server(self._handle, "127.0.0.1", 0, limit=65536))
30
+ self.port = self.server.sockets[0].getsockname()[1]
31
+ self.ready.set()
32
+ self.loop.run_forever()
33
+ finally:
34
+ self.ready.set()
35
+ if self.server:
36
+ self.server.close()
37
+ self.loop.run_until_complete(self.server.wait_closed())
38
+ tasks = asyncio.all_tasks(self.loop)
39
+ for task in tasks:
40
+ task.cancel()
41
+ self.loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
42
+ self.loop.close()
43
+
44
+ async def _upstream(self, request: bytes):
45
+ secure = ssl.create_default_context() if self.proxy.scheme == "https" else None
46
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(
47
+ self.proxy.hostname, self.proxy.port, ssl=secure), timeout=30)
48
+ lines = request.decode("iso-8859-1").split("\r\n")
49
+ lines = [line for line in lines if line and not line.lower().startswith("proxy-authorization:")]
50
+ if self.proxy.username is not None:
51
+ login = f"{unquote(self.proxy.username)}:{unquote(self.proxy.password or '')}"
52
+ encoded = base64.b64encode(login.encode()).decode("ascii")
53
+ lines.append("Proxy-Authorization: Basic " + encoded)
54
+ writer.write(("\r\n".join(lines) + "\r\n\r\n").encode("iso-8859-1"))
55
+ await writer.drain()
56
+ return reader, writer
57
+
58
+ async def _pump(self, reader, writer):
59
+ while data := await asyncio.wait_for(reader.read(65536), timeout=120):
60
+ writer.write(data)
61
+ await writer.drain()
62
+
63
+ async def _handle(self, reader, writer):
64
+ upstream = None
65
+ pumps = []
66
+ try:
67
+ request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=15)
68
+ upstream_reader, upstream = await self._upstream(request)
69
+ if request.startswith(b"CONNECT "):
70
+ answer = await asyncio.wait_for(upstream_reader.readuntil(b"\r\n\r\n"), timeout=30)
71
+ if answer.split(b" ", 2)[1] != b"200":
72
+ raise ConnectionError("upstream tunnel refused")
73
+ writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
74
+ await writer.drain()
75
+ pumps = [asyncio.create_task(self._pump(reader, upstream)),
76
+ asyncio.create_task(self._pump(upstream_reader, writer))]
77
+ await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
78
+ except (OSError, ValueError, IndexError, asyncio.TimeoutError,
79
+ asyncio.IncompleteReadError, asyncio.LimitOverrunError):
80
+ writer.write(b"HTTP/1.1 502 Proxy unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
81
+ finally:
82
+ for task in pumps:
83
+ task.cancel()
84
+ if pumps:
85
+ await asyncio.gather(*pumps, return_exceptions=True)
86
+ for connection in (writer, upstream):
87
+ if connection:
88
+ connection.close()
89
+ try:
90
+ await connection.wait_closed()
91
+ except OSError:
92
+ pass
93
+
94
+
95
+ @contextmanager
96
+ def running(proxy: str):
97
+ relay = Relay(proxy)
98
+ relay.thread.start()
99
+ try:
100
+ if not relay.ready.wait(10) or relay.port is None:
101
+ raise RuntimeError("Could not start the owned JVM proxy relay")
102
+ yield relay.port
103
+ finally:
104
+ if relay.loop.is_running():
105
+ relay.loop.call_soon_threadsafe(relay.loop.stop)
106
+ relay.thread.join(timeout=10)
107
+ if relay.thread.is_alive():
108
+ raise RuntimeError("Owned JVM proxy relay did not finish cleanup")
109
+
110
+
111
+ def environment(env: dict, port: int) -> dict:
112
+ options = (f"-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort={port} "
113
+ f"-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort={port} "
114
+ "-Dhttp.nonProxyHosts= -Dhttps.nonProxyHosts= -Djava.net.useSystemProxies=false")
115
+ # Java prints these options at startup; they contain no proxy credentials.
116
+ for name in ("JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS", "GRADLE_OPTS"):
117
+ env[name] = (env.get(name, "") + " " + options).strip()
118
+ env["GRADLE_OPTS"] += " -Dorg.gradle.daemon=false"
119
+ return env
@@ -1,11 +1,27 @@
1
- """Google CI transport uses only the account-pinned proxy, including OAuth exchanges."""
1
+ """Google CI transport uses only the account-pinned proxy, including OAuth exchanges.
2
+
3
+ Three entry points, all from the one validated ``GOOGLE_STORE_PROXY_URL``:
4
+
5
+ * ``python3 play_store_proxy.py`` refuses a missing or malformed proxy.
6
+ * ``python3 play_store_proxy.py -- <command …>`` runs a build tool with the explicit
7
+ proxy in both spellings and both ``NO_PROXY`` spellings cleared. Setting the pairs
8
+ as YAML ``env`` keys is not an option: GitHub compares env keys case-insensitively
9
+ and rejects the workflow or action file as invalid.
10
+ * ``python3 play_store_proxy.py --github-env`` clears the lower-case ambient bypass
11
+ (``no_proxy``) for the rest of the job, so a JavaScript upload step that can only
12
+ carry upper-case ``env`` keys cannot be bypassed by a runner's ambient value.
13
+ """
2
14
  from __future__ import annotations
3
15
 
4
16
  import os
17
+ import subprocess
18
+ import sys
5
19
  from urllib.parse import urlsplit
6
20
 
7
21
  import requests
8
22
 
23
+ LOWERCASE_BYPASS = ("no_proxy",)
24
+
9
25
 
10
26
  def proxy_url() -> str:
11
27
  value = os.environ.get("GOOGLE_STORE_PROXY_URL", "").strip()
@@ -33,5 +49,40 @@ def session() -> requests.Session:
33
49
  return client
34
50
 
35
51
 
36
- if __name__ == "__main__":
52
+ def run(argv: list[str]) -> int:
53
+ """Run ``argv`` with the explicit proxy environment; the parent process is untouched."""
54
+ if not argv:
55
+ raise SystemExit("usage: play_store_proxy.py -- <command …>")
56
+ from jvm_proxy_relay import environment as jvm_environment, running
57
+
58
+ env = environment()
59
+ with running(proxy_url()) as port:
60
+ return subprocess.run(argv, env=jvm_environment(env, port), check=False).returncode
61
+
62
+
63
+ def clear_github_env_bypass(path: str | None = None) -> list[str]:
64
+ """Append empty lower-case bypass assignments to ``$GITHUB_ENV`` for later steps."""
37
65
  proxy_url()
66
+ target = path or os.environ.get("GITHUB_ENV", "")
67
+ if not target:
68
+ raise SystemExit("GITHUB_ENV is not set; run this inside a GitHub Actions step")
69
+ lines = [f"{name}=" for name in LOWERCASE_BYPASS]
70
+ with open(target, "a", encoding="utf-8") as handle:
71
+ handle.write("".join(line + "\n" for line in lines))
72
+ return lines
73
+
74
+
75
+ def main(args: list[str]) -> int:
76
+ if args[:1] == ["--github-env"]:
77
+ clear_github_env_bypass()
78
+ return 0
79
+ if args[:1] == ["--"]:
80
+ return run(args[1:])
81
+ if args:
82
+ raise SystemExit("usage: play_store_proxy.py [--github-env | -- <command …>]")
83
+ proxy_url()
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,99 @@
1
+ """Real socket and Java proofs: authenticated upstream only, no target fallback."""
2
+ import base64
3
+ from contextlib import closing, contextmanager
4
+ import http.client
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ import unittest
14
+
15
+ import jvm_proxy_relay as relay
16
+
17
+
18
+ @contextmanager
19
+ def upstream():
20
+ seen = []
21
+
22
+ class Proxy(BaseHTTPRequestHandler):
23
+ def do_GET(self):
24
+ seen.append((self.command, self.path, self.headers.get("Proxy-Authorization")))
25
+ self.send_response(200)
26
+ self.end_headers()
27
+ self.wfile.write(b"through pinned proxy")
28
+
29
+ def do_CONNECT(self):
30
+ seen.append((self.command, self.path, self.headers.get("Proxy-Authorization")))
31
+ self.send_response(200)
32
+ self.end_headers()
33
+ value = self.rfile.read(4)
34
+ self.wfile.write(value)
35
+
36
+ def log_message(self, *_):
37
+ pass
38
+
39
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
40
+ server.daemon_threads = True
41
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
42
+ thread.start()
43
+ try:
44
+ yield f"http://fixture-user:fixture-pass@127.0.0.1:{server.server_port}", seen
45
+ finally:
46
+ server.shutdown()
47
+ server.server_close()
48
+ thread.join(timeout=5)
49
+
50
+
51
+ class JvmProxyTests(unittest.TestCase):
52
+ def test_http_and_connect_only_reach_the_authenticated_upstream_and_cleanup(self):
53
+ with upstream() as (proxy, seen):
54
+ with relay.running(proxy) as port:
55
+ with closing(http.client.HTTPConnection("127.0.0.1", port, timeout=5)) as client:
56
+ client.request("GET", "http://unresolvable.invalid/sdk")
57
+ self.assertEqual(client.getresponse().read(), b"through pinned proxy")
58
+ with socket.create_connection(("127.0.0.1", port), timeout=5) as client:
59
+ client.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
60
+ self.assertIn(b"200", client.recv(4096))
61
+ client.sendall(b"test")
62
+ self.assertEqual(client.recv(4), b"test")
63
+ with self.assertRaises(OSError):
64
+ socket.create_connection(("127.0.0.1", port), timeout=0.2)
65
+ auth = "Basic " + base64.b64encode(b"fixture-user:fixture-pass").decode()
66
+ self.assertEqual(seen, [("GET", "http://unresolvable.invalid/sdk", auth),
67
+ ("CONNECT", "unresolvable.invalid:443", auth)])
68
+
69
+ def test_upstream_failure_returns_bounded_502_without_a_direct_request(self):
70
+ with socket.socket() as unused:
71
+ unused.bind(("127.0.0.1", 0))
72
+ closed_port = unused.getsockname()[1]
73
+ with relay.running(f"http://127.0.0.1:{closed_port}") as port:
74
+ with closing(http.client.HTTPConnection("127.0.0.1", port, timeout=5)) as client:
75
+ client.request("CONNECT", "unresolvable.invalid:443")
76
+ response = client.getresponse()
77
+ self.assertEqual((response.status, response.read()), (502, b""))
78
+
79
+ @unittest.skipUnless(shutil.which("java"), "Java runtime unavailable")
80
+ def test_actual_java_urlconnection_uses_explicit_proxy_with_no_credentials_in_options(self):
81
+ source = ('import java.net.*; public class Probe { public static void main(String[] args) '
82
+ 'throws Exception { var c = new URL("http://unresolvable.invalid/sdk").openConnection(); '
83
+ 'c.setConnectTimeout(3000); c.setReadTimeout(3000); '
84
+ 'System.out.print(new String(c.getInputStream().readAllBytes())); }}')
85
+ with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
86
+ target = Path(directory) / "Probe.java"
87
+ target.write_text(source)
88
+ with relay.running(proxy) as port:
89
+ env = relay.environment({**os.environ, "JAVA_TOOL_OPTIONS": "-Dhttp.proxyHost=wrong.invalid"}, port)
90
+ result = subprocess.run(["java", str(target)], env=env, capture_output=True, text=True, timeout=20)
91
+ self.assertEqual(result.returncode, 0, result.stderr)
92
+ self.assertEqual(result.stdout, "through pinned proxy")
93
+ self.assertEqual(seen[0][1], "http://unresolvable.invalid/sdk")
94
+ self.assertNotIn("fixture-pass", result.stderr)
95
+ self.assertIn("-Dorg.gradle.daemon=false", env["GRADLE_OPTS"])
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
@@ -35,6 +35,62 @@ class PlayProxyTests(unittest.TestCase):
35
35
  self.assertFalse(client.trust_env)
36
36
  self.assertEqual(client.proxies, {"http": value, "https": value})
37
37
 
38
+ def test_exec_form_gives_the_child_both_spellings_and_no_bypass(self):
39
+ value = "http://assigned.proxy.test:1234"
40
+ probe = ("import os, json; print(json.dumps({k: os.environ.get(k) for k in "
41
+ "['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']}))")
42
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": value, "no_proxy": "*", "NO_PROXY": "*"}), \
43
+ mock.patch.object(play_store_proxy.subprocess, "run") as run:
44
+ run.return_value.returncode = 0
45
+ self.assertEqual(play_store_proxy.run([sys.executable, "-c", probe]), 0)
46
+ child = run.call_args.kwargs["env"]
47
+ self.assertEqual(child["https_proxy"], value)
48
+ self.assertEqual(child["HTTP_PROXY"], value)
49
+ self.assertEqual(child["no_proxy"], "")
50
+ self.assertEqual(child["NO_PROXY"], "")
51
+ self.assertEqual(os.environ["no_proxy"], "*")
52
+
53
+ def test_exec_form_refuses_without_a_proxy_before_running_anything(self):
54
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": ""}), \
55
+ mock.patch.object(play_store_proxy.subprocess, "run") as run:
56
+ with self.assertRaises(SystemExit):
57
+ play_store_proxy.run(["true"])
58
+ run.assert_not_called()
59
+
60
+ def test_failed_child_preserves_exit_code_and_closes_its_relay(self):
61
+ import json
62
+ import socket
63
+ import tempfile
64
+ with tempfile.TemporaryDirectory() as directory:
65
+ path = Path(directory) / "jvm-options.json"
66
+ probe = ("import json, os, sys; "
67
+ "open(sys.argv[1], 'w').write(json.dumps(os.environ['JAVA_TOOL_OPTIONS'])); sys.exit(7)")
68
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": "http://127.0.0.1:1"}):
69
+ self.assertEqual(play_store_proxy.run([sys.executable, "-c", probe, str(path)]), 7)
70
+ options = json.loads(path.read_text())
71
+ port = int(options.split("-Dhttp.proxyPort=", 1)[1].split()[0])
72
+ with self.assertRaises(OSError):
73
+ socket.create_connection(("127.0.0.1", port), timeout=0.2)
74
+
75
+ def test_github_env_mode_clears_only_the_lowercase_bypass_for_later_steps(self):
76
+ import tempfile
77
+ value = "http://assigned.proxy.test:1234"
78
+ with tempfile.TemporaryDirectory() as tmp:
79
+ target = os.path.join(tmp, "github.env")
80
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": value, "GITHUB_ENV": target, "no_proxy": "*"}):
81
+ self.assertEqual(play_store_proxy.clear_github_env_bypass(), ["no_proxy="])
82
+ self.assertEqual(os.environ["no_proxy"], "*")
83
+ with open(target, encoding="utf-8") as handle:
84
+ self.assertEqual(handle.read(), "no_proxy=\n")
85
+
86
+ def test_github_env_mode_refuses_without_a_proxy_or_target(self):
87
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": "", "GITHUB_ENV": "/nonexistent"}):
88
+ with self.assertRaises(SystemExit):
89
+ play_store_proxy.clear_github_env_bypass()
90
+ with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": "http://assigned.proxy.test:1234", "GITHUB_ENV": ""}):
91
+ with self.assertRaises(SystemExit):
92
+ play_store_proxy.clear_github_env_bypass()
93
+
38
94
 
39
95
  if __name__ == "__main__":
40
96
  unittest.main()
@@ -1 +1 @@
1
- 1.0.59
1
+ 1.0.61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.59",
3
+ "version": "1.0.61",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -286,7 +286,8 @@ jobs:
286
286
  echo "Before:"; df -h / | tail -1
287
287
  # NOT /usr/local/lib/node_modules: npm and npx live there, and the
288
288
  # Android action's Crashlytics symbol upload shells out to npx.
289
- sudo rm -rf /usr/share/dotnet /usr/local/lib/android/sdk/ndk \
289
+ # Keep installed Android SDK/NDKs: removing them makes AGP download them again.
290
+ sudo rm -rf /usr/share/dotnet \
290
291
  /opt/ghc /usr/local/.ghcup /usr/local/share/powershell \
291
292
  /usr/share/swift /usr/local/share/chromium 2>/dev/null || true
292
293
  sudo docker image prune --all --force >/dev/null 2>&1 || true
@@ -453,6 +454,14 @@ jobs:
453
454
  # Note this also means nothing else should be creating edits against these
454
455
  # packages while a deploy runs — an external poller that opens and deletes an
455
456
  # edit will invalidate the one this step is holding.
457
+ # The upload action is JavaScript, so its proxy can only be the step's own
458
+ # upper-case env keys below. Node clients fall back to a lower-case no_proxy
459
+ # when NO_PROXY is empty, and GitHub refuses both spellings in one env map, so
460
+ # the ambient lower-case bypass is cleared through GITHUB_ENV instead.
461
+ - name: Clear ambient lower-case proxy bypass
462
+ if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
463
+ shell: bash
464
+ run: python3 .github/actions/android-app/scripts/play_store_proxy.py --github-env
456
465
  - name: Upload Android release to Google Play
457
466
  id: play-upload
458
467
  continue-on-error: true
@@ -461,8 +470,7 @@ jobs:
461
470
  env:
462
471
  HTTPS_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
463
472
  HTTP_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
464
- NO_PROXY: ''
465
- no_proxy: ''
473
+ NO_PROXY: '' # one spelling only: GitHub rejects env keys that differ by case
466
474
  with:
467
475
  serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
468
476
  packageName: ${{ steps.android.outputs.package-name }}
@@ -490,8 +498,7 @@ jobs:
490
498
  env:
491
499
  HTTPS_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
492
500
  HTTP_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
493
- NO_PROXY: ''
494
- no_proxy: ''
501
+ NO_PROXY: '' # one spelling only: GitHub rejects env keys that differ by case
495
502
  with:
496
503
  serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
497
504
  packageName: ${{ steps.android.outputs.package-name }}