gowalk-cicd 1.0.71 → 1.0.73

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
@@ -145,12 +145,24 @@ node /path/to/gowalk-cicd/bin/cli.mjs
145
145
  `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
146
146
  consumer autoupdate changes the action directory. The installer delivers this development
147
147
  tooling for native iOS repositories too; it does not require an Android app.
148
+ - Flutter manifest GET recovery is scoped to the two pinned setup invocations through `BASH_ENV`.
149
+ It retains the account proxy, retries transient TLS/transport errors within 60 seconds, and emits
150
+ only complete JSON. Partial responses and private curl stderr never enter action outputs;
151
+ authentication/certificate failures remain terminal. SDK archive calls retain upstream behavior.
148
152
  - Flutter setup scopes `SEGMENT_DOWNLOAD_TIMEOUT_MINS=1` to the composite action's optional
149
153
  SDK/pub cache restores. This bounds each cache segment, not the whole cache or SDK download.
150
154
  A cache timeout becomes a cache miss; normal proxied SDK setup remains mandatory and its
151
155
  errors still fail the job. Never use `continue-on-error` or a timeout around all SDK setup
152
156
  to hide an optional cache failure. Do not alter cache keys or disable caches as part of
153
157
  this timeout handling.
158
+ - GitHub artifact uploads have their own checksum-pinned `artifact_env.cjs` process scope.
159
+ It reuses the cache transport's approved HTTPS request guard, then removes inherited proxy
160
+ variables only inside the official `upload-artifact` process. Unknown programs/origins refuse;
161
+ later Apple/Google clients retain their proxies and artifact failures remain job failures.
162
+ The iOS action reads this helper from its immutable `SWIFT_APP_ACTION` snapshot. Its shared
163
+ `cache_scope.cjs` and `artifact_env.cjs` copies match the Android distribution byte for byte,
164
+ enforced by `test/artifact-wiring.test.mjs`; update both when changing that common guard.
165
+ Update existing consumer workflow artifact steps as well as action directories on adoption.
154
166
  - Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
155
167
  secret. The action writes it to host-side `.runtime.env` with mode 0600 and
156
168
  supplies it to Compose after the generated `.env`; never print its content.
@@ -165,6 +177,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
165
177
  is the only place that decides which; every downstream step branches on the
166
178
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
167
179
  because a Flutter app also carries `android/settings.gradle`.
180
+ - iOS plist stamping reads complete JSON build settings through `resolve_info_plist.py`,
181
+ preserving Xcode's exit status and bounded sanitized stderr. Select exactly the configured
182
+ bundle's application target and resolve paths from that target's source root; never take
183
+ the first dependency plist or stop reading Xcode output early. The encryption step reuses
184
+ the validated source path. Missing/ambiguous source plists fail; positively generated
185
+ plists retain the existing build-setting version path. Queries disable signing and package
186
+ updates, retain inherited proxies, and never enable provisioning network operations.
168
187
  - Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
169
188
  has no equivalent, so `set_gradle_version()` rewrites the literal in the
170
189
  checkout the way the iOS action patches the pbxproj. Never commit that edit.
package/README.md CHANGED
@@ -647,6 +647,15 @@ without the repository secret cannot start provider downloads through these work
647
647
  The Apple uploader retains `apple-upload-<run>-<attempt>` receipts with IPA SHA-256,
648
648
  upload ID and processing readback, and resumes matching bytes after interruption.
649
649
 
650
+ GitHub artifact traffic uses a separate, per-action transport scope. The exact pinned
651
+ `actions/upload-artifact` client can reach approved GitHub Results and artifact-storage
652
+ HTTPS origins without inheriting the store proxy; unknown programs and destinations are
653
+ refused. The scope ends with that Node process. Apple/Google calls retain their assigned
654
+ exit, and a failed artifact creation, transfer or finalization still fails delivery.
655
+ Adoption requires updating workflow upload steps as well as the distributed actions while
656
+ preserving each app's custom checks and artifact retention settings. This fixes the proven
657
+ inherited-proxy path; an isolated `ECONNRESET` does not identify every possible network fault.
658
+
650
659
  1. **Auto-detects** your `.xcodeproj` / `.xcworkspace`, scheme, bundle ID,
651
660
  and `team_id` (from the ASC API key). No `ci.config.yaml` required —
652
661
  override via action inputs only if auto-detection fails.
@@ -1 +1 @@
1
- 1.0.71
1
+ 1.0.73
package/action/action.yml CHANGED
@@ -601,51 +601,14 @@ runs:
601
601
  WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
602
602
  SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
603
603
  CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
604
+ BUNDLE_ID: ${{ inputs.bundle-id || env.CFG_BUNDLE_ID }}
604
605
  run: |
605
606
  set -o pipefail
606
- # Some Xcode projects hardcode CFBundleVersion / CFBundleShortVersionString
607
- # as literal values in Info.plist instead of using $(CURRENT_PROJECT_VERSION)
608
- # and $(MARKETING_VERSION) substitution. When that's the case, passing
609
- # CURRENT_PROJECT_VERSION=N on the xcodebuild CLI has no effect on the
610
- # archived plist and altool rejects the upload with
611
- # `previousBundleVersion`. Stamp the resolved source Info.plist directly
612
- # so the archive ships with the computed values regardless of whether
613
- # the plist uses substitutions or hardcoded literals.
614
- if [ -n "$WORKSPACE" ]; then
615
- PROJ_ARGS=(-workspace "$WORKSPACE")
616
- PROJ_BASE=$(dirname "$WORKSPACE")
617
- else
618
- PROJ_ARGS=(-project "$PROJECT")
619
- PROJ_BASE=$(dirname "$PROJECT")
620
- fi
621
- # Try agvtool first — it's Apple's official tool and handles projects
622
- # using the generic versioning system. Harmless no-op on projects
623
- # without VERSIONING_SYSTEM set. Run from the project directory.
624
- # Marketing version is intentionally NOT stamped here: the project's
625
- # MARKETING_VERSION build setting is the single source of truth and
626
- # was already resolved into env earlier; CI must never mutate it.
627
- (
628
- cd "$PROJ_BASE" 2>/dev/null || exit 0
629
- xcrun agvtool new-version -all "$BUILD_NUMBER" >/dev/null 2>&1 || true
630
- )
631
- # Now resolve the actual Info.plist path and stamp literal values.
632
- # This is idempotent with agvtool and also covers projects where the
633
- # plist has hardcoded numbers (the sample1 failure mode).
634
- INFOPLIST=$(xcodebuild "${PROJ_ARGS[@]}" -scheme "$SCHEME" -configuration "${CONFIGURATION:-Release}" \
635
- -showBuildSettings 2>/dev/null \
636
- | awk -F' = ' '/^[[:space:]]*INFOPLIST_FILE/ {
637
- gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit
638
- }')
639
- if [ -z "$INFOPLIST" ]; then
640
- echo "::warning::INFOPLIST_FILE not set for scheme $SCHEME; skipping version stamping"
641
- exit 0
642
- fi
643
- INFOPLIST_PATH="$PROJ_BASE/$INFOPLIST"
644
- if [ ! -f "$INFOPLIST_PATH" ]; then
645
- INFOPLIST_PATH="$INFOPLIST"
646
- fi
647
- if [ ! -f "$INFOPLIST_PATH" ]; then
648
- echo "::warning::Info.plist not found at $INFOPLIST_PATH; skipping version stamping"
607
+ # Read every target's complete settings and preserve Xcode's actual status/error.
608
+ # The selected application's source root can differ from its containing workspace.
609
+ INFOPLIST_PATH=$(python3 "$SWIFT_APP_ACTION/scripts/resolve_info_plist.py")
610
+ if [ -z "$INFOPLIST_PATH" ]; then
611
+ echo "Generated Info.plist: Archive supplies CURRENT_PROJECT_VERSION and MARKETING_VERSION"
649
612
  exit 0
650
613
  fi
651
614
  /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFOPLIST_PATH" 2>/dev/null \
@@ -665,29 +628,15 @@ runs:
665
628
  VALUE: ${{ inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT }}
666
629
  run: |
667
630
  set -o pipefail
668
- if [ -n "$WORKSPACE" ]; then
669
- PROJ_ARGS=(-workspace "$WORKSPACE")
670
- PROJ_BASE=$(dirname "$WORKSPACE")
671
- else
672
- PROJ_ARGS=(-project "$PROJECT")
673
- PROJ_BASE=$(dirname "$PROJECT")
674
- fi
675
- INFOPLIST=$(xcodebuild "${PROJ_ARGS[@]}" -scheme "$SCHEME" -configuration "${CONFIGURATION:-Release}" \
676
- -showBuildSettings 2>/dev/null \
677
- | awk -F' = ' '/^[[:space:]]*INFOPLIST_FILE/ {
678
- gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit
679
- }')
680
- if [ -z "$INFOPLIST" ]; then
681
- echo "::warning::INFOPLIST_FILE not set for scheme $SCHEME; skipping ITSAppUsesNonExemptEncryption"
631
+ # Reuse the validated application path from version stamping; no second Xcode query.
632
+ INFOPLIST_PATH="${SWIFT_APP_SOURCE_INFOPLIST?Source plist resolution did not complete}"
633
+ if [ -z "$INFOPLIST_PATH" ]; then
634
+ echo "::warning::The application generates Info.plist; no source plist to edit for encryption"
682
635
  exit 0
683
636
  fi
684
- INFOPLIST_PATH="$PROJ_BASE/$INFOPLIST"
685
- if [ ! -f "$INFOPLIST_PATH" ]; then
686
- INFOPLIST_PATH="$INFOPLIST"
687
- fi
688
637
  if [ ! -f "$INFOPLIST_PATH" ]; then
689
- echo "::warning::Info.plist not found at $INFOPLIST_PATH; skipping"
690
- exit 0
638
+ echo "::error::The resolved application Info.plist is no longer present"
639
+ exit 1
691
640
  fi
692
641
  /usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption $VALUE" "$INFOPLIST_PATH" 2>/dev/null \
693
642
  || /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool $VALUE" "$INFOPLIST_PATH"
@@ -815,6 +764,8 @@ runs:
815
764
  - name: Preserve the Firebase console upload artifact
816
765
  if: ${{ always() && inputs.archive == 'true' }}
817
766
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
767
+ env:
768
+ NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
818
769
  with:
819
770
  name: ios-crashlytics-symbols-${{ github.run_id }}-${{ github.run_attempt }}
820
771
  path: ${{ runner.temp }}/firebase-symbols/
@@ -834,6 +785,8 @@ runs:
834
785
  - name: Retain Apple upload receipt
835
786
  if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' }}
836
787
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
788
+ env:
789
+ NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
837
790
  with:
838
791
  name: apple-upload-${{ github.run_id }}-${{ github.run_attempt }}
839
792
  path: ${{ runner.temp }}/apple-build-upload.json
@@ -845,6 +798,8 @@ runs:
845
798
  - name: Retain the exported IPA for upload recovery
846
799
  if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' && steps.upload.outcome != 'success' }}
847
800
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
801
+ env:
802
+ NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
848
803
  with:
849
804
  name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
850
805
  path: ${{ runner.temp }}/export/*.ipa
@@ -0,0 +1,10 @@
1
+ // Only the exact pinned upload-artifact process receives this GitHub-specific transport.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env, 'artifact');
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_artifact_transport_scope_refused\n');
9
+ process.exit(1);
10
+ }
@@ -0,0 +1,81 @@
1
+ // Direct routing belongs only to pinned first-party Actions programs and approved HTTPS origins.
2
+ const {readFileSync, copyFileSync, mkdtempSync, appendFileSync} = require('node:fs');
3
+ const {createHash} = require('node:crypto');
4
+ const {basename, dirname, join} = require('node:path');
5
+ const hashes = new Map([
6
+ ['restore', 'a49301428b4c5477522c00f6464566457ed327d64ae5f80f8c12e762fb2ea9a2'],
7
+ ['save', 'b969065795aaf5c6d20ba158e910be5d353eefb02eb2785263eb32e13090c066'],
8
+ ]);
9
+ const artifactHash = '168c44946cba03564808c19b83a72601e6d1d6f57081dbd2d6e2ea1cedc522f5';
10
+ const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
11
+ const refusal = () => new Error('github_cache_transport_scope_refused');
12
+
13
+ function allowedHost(host) {
14
+ return host === 'api.github.com'
15
+ || /^[a-z0-9.-]+\.actions\.githubusercontent\.com$/.test(host)
16
+ || /^[a-z0-9]{3,24}\.blob\.core\.windows\.net$/.test(host);
17
+ }
18
+
19
+ function requireOrigin(value, service = false) {
20
+ let url;
21
+ try { url = new URL(value); } catch { throw refusal(); }
22
+ if (url.protocol !== 'https:' || url.port || url.username || url.password || !allowedHost(url.hostname)
23
+ || (service && !url.hostname.endsWith('.actions.githubusercontent.com'))) throw refusal();
24
+ return url;
25
+ }
26
+
27
+ function authorize(entrypoint, env, kind = 'cache') {
28
+ if (!entrypoint || basename(entrypoint) !== 'index.js' || basename(dirname(dirname(entrypoint))) !== 'dist') {
29
+ throw refusal();
30
+ }
31
+ if (!['cache', 'artifact'].includes(kind)) throw refusal();
32
+ const program = basename(dirname(entrypoint));
33
+ const expected = kind === 'artifact' ? (program === 'upload' && artifactHash) : hashes.get(program);
34
+ if (!expected || createHash('sha256').update(readFileSync(entrypoint)).digest('hex') !== expected) throw refusal();
35
+ const origins = (kind === 'artifact' ? [env.ACTIONS_RESULTS_URL]
36
+ : [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL]).filter(Boolean);
37
+ if (!origins.length) throw refusal();
38
+ for (const origin of origins) requireOrigin(origin, true);
39
+ }
40
+
41
+ function requireRequest(args, protocol) {
42
+ const [input, extra] = args;
43
+ const options = typeof input === 'string' || input instanceof URL
44
+ ? {protocol, ...require('node:url').urlToHttpOptions(requireOrigin(input)),
45
+ ...(typeof extra === 'object' ? extra : {})}
46
+ : {...input};
47
+ if (!options || options.socketPath || (options.protocol || protocol) !== 'https:') throw refusal();
48
+ for (const host of [options.hostname, options.host].filter(Boolean)) {
49
+ requireOrigin(`https://${host}${options.port ? ':' + options.port : ''}`);
50
+ }
51
+ if (!options.hostname && !options.host) throw refusal();
52
+ }
53
+
54
+ function guardRequests() {
55
+ for (const [name, protocol] of [['node:http', 'http:'], ['node:https', 'https:']]) {
56
+ const client = require(name);
57
+ for (const verb of ['request', 'get']) {
58
+ const original = client[verb];
59
+ client[verb] = function (...args) { requireRequest(args, protocol); return original.apply(this, args); };
60
+ }
61
+ }
62
+ if (globalThis.fetch) {
63
+ const original = globalThis.fetch;
64
+ globalThis.fetch = function (input, init) {
65
+ requireOrigin(typeof input === 'string' || input instanceof URL ? input : input.url);
66
+ return original.call(this, input, {...init, redirect: 'error'});
67
+ };
68
+ }
69
+ }
70
+
71
+ function snapshot() {
72
+ const target = mkdtempSync(join(process.env.RUNNER_TEMP, 'gowalk-cache-scope-'));
73
+ for (const file of ['cache_env.cjs', 'cache_scope.cjs']) copyFileSync(join(__dirname, file), join(target, file));
74
+ appendFileSync(process.env.GITHUB_OUTPUT, `preloader=${join(target, 'cache_env.cjs')}\n`);
75
+ }
76
+
77
+ module.exports = {authorize, guardRequests, proxyKeys, requireOrigin};
78
+ if (require.main === module) {
79
+ try { if (process.argv[2] !== '--snapshot') throw refusal(); snapshot(); }
80
+ catch { process.stderr.write('github_cache_scope_snapshot_failed\n'); process.exitCode = 1; }
81
+ }
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Read complete Xcode settings once and select the requested application's source plist."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import subprocess
10
+ import sys
11
+
12
+ APP_TYPE = "com.apple.product-type.application"
13
+ _VARIABLE = re.compile(r"\$\(([^)]+)\)|\$\{([^}]+)\}")
14
+
15
+
16
+ class SettingsFailed(RuntimeError):
17
+ def __init__(self, message: str, code: int = 1):
18
+ super().__init__(message)
19
+ self.code = code
20
+
21
+
22
+ def diagnostic(text: str) -> str:
23
+ for key, value in os.environ.items():
24
+ if len(value) >= 4 and re.search(r"SECRET|TOKEN|PASSWORD|PROXY|PRIVATE_KEY", key, re.I):
25
+ text = text.replace(value, "[redacted]")
26
+ text = re.sub(r"https?://[^\s]+", "[URL redacted]", text)
27
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
28
+ return "\n".join("xcodebuild: " + line for line in text[-8000:].splitlines()[-60:])
29
+
30
+
31
+ def read_settings(project: str, workspace: str, scheme: str, configuration: str, logs: Path) -> list:
32
+ if not scheme or not (workspace or project):
33
+ raise SettingsFailed("Project/workspace and scheme are required for source plist resolution")
34
+ command = ["xcodebuild", "-workspace" if workspace else "-project", workspace or project,
35
+ "-scheme", scheme, "-configuration", configuration or "Release", "-sdk", "iphoneos",
36
+ "-destination", "generic/platform=iOS", "-disableAutomaticPackageResolution", "-skipPackageUpdates",
37
+ "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "CODE_SIGN_IDENTITY=", "DEVELOPMENT_TEAM=",
38
+ "-showBuildSettings", "-json"]
39
+ logs.mkdir(parents=True, exist_ok=True, mode=0o700)
40
+ os.chmod(logs, 0o700)
41
+ output, errors = logs / "build-settings.json", logs / "build-settings.stderr"
42
+ # File sinks consume the entire producer output. An early pipe reader can hide its actual status.
43
+ with output.open("wb") as stdout, errors.open("wb") as stderr:
44
+ os.chmod(output, 0o600)
45
+ os.chmod(errors, 0o600)
46
+ timed_out = False
47
+ try:
48
+ result = subprocess.run(command, stdout=stdout, stderr=stderr, timeout=300, check=False)
49
+ code = result.returncode if result.returncode >= 0 else 128 - result.returncode
50
+ except subprocess.TimeoutExpired:
51
+ timed_out, code = True, 124
52
+ if code:
53
+ detail = diagnostic(errors.read_text(errors="replace"))
54
+ if detail:
55
+ print(detail, file=sys.stderr)
56
+ reason = "timed out after 300 seconds" if timed_out else f"failed with exit {code}"
57
+ raise SettingsFailed(f"xcodebuild -showBuildSettings {reason}", code)
58
+ try:
59
+ settings = json.loads(output.read_text())
60
+ except (ValueError, UnicodeError):
61
+ raise SettingsFailed("xcodebuild returned invalid build-settings JSON") from None
62
+ if not isinstance(settings, list):
63
+ raise SettingsFailed("xcodebuild build-settings response must be a target list")
64
+ return settings
65
+
66
+
67
+ def application(settings: list, bundle_id: str) -> dict:
68
+ candidates = []
69
+ for row in settings:
70
+ values = row.get("buildSettings") if isinstance(row, dict) else None
71
+ if not isinstance(values, dict) or values.get("PRODUCT_TYPE") != APP_TYPE:
72
+ continue
73
+ if not bundle_id or values.get("PRODUCT_BUNDLE_IDENTIFIER") == bundle_id:
74
+ candidates.append(values)
75
+ if len(candidates) != 1:
76
+ raise SettingsFailed("Expected exactly one application target matching the configured bundle identifier")
77
+ return candidates[0]
78
+
79
+
80
+ def source_plist(settings: dict, checkout: Path) -> Path | None:
81
+ value = str(settings.get("INFOPLIST_FILE") or "").strip()
82
+ if not value:
83
+ if settings.get("GENERATE_INFOPLIST_FILE") == "YES":
84
+ return None
85
+ raise SettingsFailed("The application has no source Info.plist and does not enable generated Info.plist")
86
+ for _ in range(5):
87
+ expanded = _VARIABLE.sub(lambda match: str(settings.get(match[1] or match[2], match[0])), value)
88
+ if expanded == value:
89
+ break
90
+ value = expanded
91
+ if _VARIABLE.search(value) or "\n" in value or "\r" in value:
92
+ raise SettingsFailed("The application's Info.plist path contains an unresolved or invalid setting")
93
+ path = Path(value)
94
+ if not path.is_absolute():
95
+ source = settings.get("SRCROOT") or settings.get("PROJECT_DIR")
96
+ if not source:
97
+ raise SettingsFailed("The application target did not provide its source root")
98
+ path = Path(source) / path
99
+ path = path.resolve()
100
+ if "\n" in str(path) or "\r" in str(path):
101
+ raise SettingsFailed("The resolved source plist cannot be represented in the job environment")
102
+ if not path.is_relative_to(checkout.resolve()) or not path.is_file():
103
+ raise SettingsFailed("The application source Info.plist is missing or outside the checkout")
104
+ return path
105
+
106
+
107
+ def main() -> int:
108
+ try:
109
+ logs = Path(os.environ["RUNNER_TEMP"]) / "swift-app-build-settings"
110
+ rows = read_settings(os.environ.get("PROJECT", ""), os.environ.get("WORKSPACE", ""),
111
+ os.environ.get("SCHEME", ""), os.environ.get("CONFIGURATION", "Release"), logs)
112
+ selected = source_plist(application(rows, os.environ.get("BUNDLE_ID", "")), Path.cwd())
113
+ value = str(selected) if selected else ""
114
+ with Path(os.environ["GITHUB_ENV"]).open("a") as stream:
115
+ stream.write(f"SWIFT_APP_SOURCE_INFOPLIST={value}\n")
116
+ print(value)
117
+ return 0
118
+ except SettingsFailed as exc:
119
+ print(f"::error title=Application build settings::{exc}", file=sys.stderr)
120
+ return exc.code
121
+ except (KeyError, OSError):
122
+ print("::error title=Application build settings::Required settings tooling or private output is unavailable",
123
+ file=sys.stderr)
124
+ return 1
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
@@ -0,0 +1,120 @@
1
+ """Consume real producer output/status, then select only the app's own source plist."""
2
+ from contextlib import redirect_stderr
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import resolve_info_plist as subject
12
+
13
+
14
+ class ResolveInfoPlistTest(unittest.TestCase):
15
+ def setUp(self):
16
+ self.temp = tempfile.TemporaryDirectory()
17
+ self.addCleanup(self.temp.cleanup)
18
+ self.root = Path(self.temp.name)
19
+ self.source = self.root / 'Nested Project' / 'Application Info.plist'
20
+ self.source.parent.mkdir()
21
+ self.source.write_text('<plist/>')
22
+ self.values = {'PRODUCT_TYPE': subject.APP_TYPE, 'PRODUCT_BUNDLE_IDENTIFIER': 'test.local.app',
23
+ 'SRCROOT': str(self.source.parent), 'INFOPLIST_FILE': self.source.name}
24
+
25
+ def producer(self, body):
26
+ tool = self.root / 'xcodebuild'
27
+ tool.write_text('#!/usr/bin/env python3\n' + body)
28
+ tool.chmod(0o700)
29
+ return patch.dict(os.environ, {'PATH': str(self.root) + os.pathsep + os.environ['PATH']})
30
+
31
+ def test_drains_large_output_and_preserves_arguments_and_inherited_proxy(self):
32
+ record = self.root / 'invocation.json'
33
+ rows = [{'buildSettings': dict(self.values, LARGE='x' * 500000)}]
34
+ body = ("import json,os,sys\n"
35
+ f"open({str(record)!r},'w').write(json.dumps([sys.argv[1:],os.getenv('HTTPS_PROXY')]))\n"
36
+ f"sys.stdout.write({json.dumps(rows)!r})\n")
37
+ with self.producer(body), patch.dict(os.environ, {'HTTPS_PROXY': 'http://fixture-proxy.invalid:9000'}):
38
+ result = subject.read_settings('App.xcodeproj', '', 'App', 'Release', self.root / 'logs')
39
+ self.assertEqual(result, rows)
40
+ args, proxy = json.loads(record.read_text())
41
+ self.assertEqual(proxy, 'http://fixture-proxy.invalid:9000')
42
+ self.assertIn('-disableAutomaticPackageResolution', args)
43
+ self.assertIn('-skipPackageUpdates', args)
44
+ self.assertIn('CODE_SIGNING_ALLOWED=NO', args)
45
+ self.assertEqual(args[-2:], ['-showBuildSettings', '-json'])
46
+ self.assertNotIn('-allowProvisioningUpdates', args)
47
+
48
+ def test_exit_74_is_not_an_empty_success_and_error_is_bounded_private(self):
49
+ stderr = io.StringIO()
50
+ error = 'x' * 20000 + '\nCould not resolve package: https://private.invalid/token-secret token-secret\n'
51
+ body = f"import sys\nsys.stderr.write({error!r})\nsys.stdout.write('[]')\nsys.exit(74)\n"
52
+ with self.producer(body), patch.dict(os.environ, {'APP_SECRET': 'token-secret'}), redirect_stderr(stderr):
53
+ with self.assertRaises(subject.SettingsFailed) as raised:
54
+ subject.read_settings('App.xcodeproj', '', 'App', 'Release', self.root / 'logs')
55
+ self.assertEqual(raised.exception.code, 74)
56
+ self.assertIn('Could not resolve package', stderr.getvalue())
57
+ self.assertNotIn('token-secret', stderr.getvalue())
58
+ self.assertNotIn('private.invalid', stderr.getvalue())
59
+ self.assertLess(len(stderr.getvalue()), 8200)
60
+ self.assertTrue((self.root / 'logs' / 'build-settings.stderr').is_file())
61
+ for output in ['not-json', '{}']:
62
+ with self.producer(f"print({output!r})\n"):
63
+ with self.assertRaises(subject.SettingsFailed):
64
+ subject.read_settings('App.xcodeproj', '', 'App', 'Release', self.root / 'logs')
65
+
66
+ def test_selects_bundle_matched_application_not_first_dependency_or_other_app(self):
67
+ dependency = dict(self.values, PRODUCT_TYPE='com.apple.product-type.framework', INFOPLIST_FILE='Other.plist')
68
+ other = dict(self.values, PRODUCT_BUNDLE_IDENTIFIER='test.local.other')
69
+ rows = [{'buildSettings': row} for row in [dependency, other, self.values]]
70
+ chosen = subject.application(rows, 'test.local.app')
71
+ self.assertEqual(subject.source_plist(chosen, self.root), self.source.resolve())
72
+ with self.assertRaises(subject.SettingsFailed):
73
+ subject.application(rows, '')
74
+ with self.assertRaises(subject.SettingsFailed):
75
+ subject.application(rows, 'test.local.absent')
76
+
77
+ def test_ambiguous_same_bundle_and_malformed_targets_refuse(self):
78
+ for rows in [[{'buildSettings': self.values}] * 2, [{'buildSettings': []}], [None]]:
79
+ with self.assertRaises(subject.SettingsFailed):
80
+ subject.application(rows, 'test.local.app')
81
+
82
+ def test_relative_and_variable_plist_paths_use_the_target_root(self):
83
+ values = dict(self.values, INFOPLIST_FILE='$(SRCROOT)/Application Info.plist')
84
+ self.assertEqual(subject.source_plist(values, self.root), self.source.resolve())
85
+ values['INFOPLIST_FILE'] = '${UNKNOWN}/Info.plist'
86
+ with self.assertRaises(subject.SettingsFailed):
87
+ subject.source_plist(values, self.root)
88
+ newline_root = self.root / 'project\nname'
89
+ newline_root.mkdir()
90
+ (newline_root / self.source.name).write_text('<plist/>')
91
+ values = dict(self.values, SRCROOT=str(newline_root))
92
+ with self.assertRaises(subject.SettingsFailed):
93
+ subject.source_plist(values, self.root)
94
+
95
+ def test_missing_outside_and_generated_paths_are_distinct(self):
96
+ for values in [dict(self.values, INFOPLIST_FILE='Missing.plist'),
97
+ dict(self.values, INFOPLIST_FILE='/etc/hosts'),
98
+ dict(self.values, INFOPLIST_FILE='')]:
99
+ with self.assertRaises(subject.SettingsFailed):
100
+ subject.source_plist(values, self.root)
101
+ generated = dict(self.values, INFOPLIST_FILE='', GENERATE_INFOPLIST_FILE='YES')
102
+ self.assertIsNone(subject.source_plist(generated, self.root))
103
+
104
+ def test_timeout_keeps_error_evidence_and_does_not_retry(self):
105
+ import subprocess
106
+
107
+ stderr = io.StringIO()
108
+ def timed_out(command, **kwargs):
109
+ kwargs['stderr'].write(b'package graph was unavailable')
110
+ raise subprocess.TimeoutExpired(command, 300)
111
+ with patch.object(subject.subprocess, 'run', side_effect=timed_out) as run, redirect_stderr(stderr):
112
+ with self.assertRaises(subject.SettingsFailed) as raised:
113
+ subject.read_settings('App.xcodeproj', '', 'App', 'Release', self.root / 'logs')
114
+ self.assertEqual(raised.exception.code, 124)
115
+ self.assertEqual(run.call_count, 1)
116
+ self.assertIn('package graph was unavailable', stderr.getvalue())
117
+
118
+
119
+ if __name__ == '__main__':
120
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.71
1
+ 1.0.73
@@ -191,6 +191,8 @@ runs:
191
191
  - name: Retain Dart symbol files
192
192
  if: ${{ steps.config.outputs.project_kind == 'flutter' }}
193
193
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
194
+ env:
195
+ NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
194
196
  with:
195
197
  name: android-symbols-${{ steps.config.outputs.package_name }}-${{ steps.config.outputs.build_number }}
196
198
  path: ${{ steps.flutter_build.outputs.symbols_path }}
@@ -84,9 +84,13 @@ runs:
84
84
  using: composite
85
85
  steps:
86
86
  - name: Require provider transport
87
+ id: transport
87
88
  shell: bash
88
89
  run: |
89
90
  python3 "$GITHUB_ACTION_PATH/../scripts/play_store_proxy.py" --bootstrap-env
91
+ printf 'manifest_env=%s\nmanifest_helper=%s\n' \
92
+ "$GITHUB_ACTION_PATH/../scripts/flutter_manifest_env.sh" \
93
+ "$GITHUB_ACTION_PATH/../scripts/flutter_manifest.py" >> "$GITHUB_OUTPUT"
90
94
  - name: Retain the cache process scope
91
95
  id: scope
92
96
  if: ${{ inputs.cache == 'true' || inputs.pub-cache == 'true' }}
@@ -96,6 +100,9 @@ runs:
96
100
  - name: Resolve Flutter release and cache identities
97
101
  id: flutter-action
98
102
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
103
+ env:
104
+ BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
105
+ GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
99
106
  with:
100
107
  channel: ${{ inputs.channel }}
101
108
  flutter-version: ${{ inputs.flutter-version }}
@@ -134,6 +141,9 @@ runs:
134
141
  - name: Install or reuse the resolved Flutter SDK
135
142
  if: ${{ inputs.dry-run != 'true' }}
136
143
  uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
144
+ env:
145
+ BASH_ENV: ${{ steps.transport.outputs.manifest_env }}
146
+ GOWALK_FLUTTER_MANIFEST_HELPER: ${{ steps.transport.outputs.manifest_helper }}
137
147
  with:
138
148
  channel: ${{ steps.flutter-action.outputs.CHANNEL }}
139
149
  flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
@@ -0,0 +1,10 @@
1
+ // Only the exact pinned upload-artifact process receives this GitHub-specific transport.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env, 'artifact');
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_artifact_transport_scope_refused\n');
9
+ process.exit(1);
10
+ }
@@ -1,4 +1,4 @@
1
- // Direct routing belongs only to these pinned first-party cache programs and HTTPS origins.
1
+ // Direct routing belongs only to pinned first-party Actions programs and approved HTTPS origins.
2
2
  const {readFileSync, copyFileSync, mkdtempSync, appendFileSync} = require('node:fs');
3
3
  const {createHash} = require('node:crypto');
4
4
  const {basename, dirname, join} = require('node:path');
@@ -6,6 +6,7 @@ const hashes = new Map([
6
6
  ['restore', 'a49301428b4c5477522c00f6464566457ed327d64ae5f80f8c12e762fb2ea9a2'],
7
7
  ['save', 'b969065795aaf5c6d20ba158e910be5d353eefb02eb2785263eb32e13090c066'],
8
8
  ]);
9
+ const artifactHash = '168c44946cba03564808c19b83a72601e6d1d6f57081dbd2d6e2ea1cedc522f5';
9
10
  const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
10
11
  const refusal = () => new Error('github_cache_transport_scope_refused');
11
12
 
@@ -23,13 +24,16 @@ function requireOrigin(value, service = false) {
23
24
  return url;
24
25
  }
25
26
 
26
- function authorize(entrypoint, env) {
27
+ function authorize(entrypoint, env, kind = 'cache') {
27
28
  if (!entrypoint || basename(entrypoint) !== 'index.js' || basename(dirname(dirname(entrypoint))) !== 'dist') {
28
29
  throw refusal();
29
30
  }
30
- const expected = hashes.get(basename(dirname(entrypoint)));
31
+ if (!['cache', 'artifact'].includes(kind)) throw refusal();
32
+ const program = basename(dirname(entrypoint));
33
+ const expected = kind === 'artifact' ? (program === 'upload' && artifactHash) : hashes.get(program);
31
34
  if (!expected || createHash('sha256').update(readFileSync(entrypoint)).digest('hex') !== expected) throw refusal();
32
- const origins = [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL].filter(Boolean);
35
+ const origins = (kind === 'artifact' ? [env.ACTIONS_RESULTS_URL]
36
+ : [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL]).filter(Boolean);
33
37
  if (!origins.length) throw refusal();
34
38
  for (const origin of origins) requireOrigin(origin, true);
35
39
  }
@@ -0,0 +1,76 @@
1
+ """Retry the official Flutter manifest GET without exposing partial JSON or proxy secrets."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from urllib.parse import urlsplit
13
+
14
+ RETRY_CODES = {5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95}
15
+ RETRY_HTTP = {408, 429, 500, 502, 503, 504}
16
+
17
+
18
+ def download(arguments: list[str], directory: Path) -> int:
19
+ # This is the exact GET made by the pinned upstream setup.sh. Refuse new semantics.
20
+ if arguments[:-1] != ["--silent", "--connect-timeout", "15", "--retry", "5"]:
21
+ raise ValueError("flutter_manifest_arguments_unrecognized")
22
+ url = urlsplit(arguments[-1])
23
+ if (url.scheme != "https" or not url.hostname or url.username or url.password
24
+ or url.query or url.fragment or not re.fullmatch(
25
+ r"/flutter_infra_release/releases/releases_(linux|macos|windows)\.json", url.path)):
26
+ raise ValueError("flutter_manifest_url_unrecognized")
27
+ if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
28
+ raise ValueError("flutter_manifest_proxy_required")
29
+ child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
30
+ deadline, code, http, proxy_http = time.monotonic() + 60, 1, 0, 0
31
+ for attempt in range(1, 5):
32
+ remaining = deadline - time.monotonic()
33
+ if remaining <= 0:
34
+ break
35
+ destination = directory / f"manifest-{attempt}.json"
36
+ destination.touch(mode=0o600, exist_ok=False)
37
+ destination.chmod(0o600)
38
+ try:
39
+ result = subprocess.run([
40
+ "curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
41
+ "--connect-timeout", "15", "--max-time", str(min(20, remaining)),
42
+ "--max-filesize", "16777216", "--output", str(destination),
43
+ "--write-out", "%{http_code} %{http_connect}", arguments[-1],
44
+ ], capture_output=True, timeout=remaining, env=child_env)
45
+ code = result.returncode
46
+ status = result.stdout.split()
47
+ http, proxy_http = (map(int, status) if len(status) == 2 and all(part.isdigit() for part in status)
48
+ else (0, 0))
49
+ except subprocess.TimeoutExpired:
50
+ code, http, proxy_http = 28, 0, 0
51
+ if code == 0 and http == 200:
52
+ body = destination.read_bytes()
53
+ json.loads(body) # A successful transport must deliver complete JSON.
54
+ sys.stdout.buffer.write(body)
55
+ if attempt > 1:
56
+ print(f"flutter_manifest_recovered attempts={attempt}", file=sys.stderr)
57
+ return 0
58
+ if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
59
+ break
60
+ time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
61
+ print(f"flutter_manifest_transport_failed curl_exit={code} http_status={http} "
62
+ f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
63
+ return code or 1
64
+
65
+
66
+ def main() -> int:
67
+ try:
68
+ with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
69
+ return download(sys.argv[1:], Path(scratch))
70
+ except (ValueError, OSError, KeyError):
71
+ print("flutter_manifest_invalid_request_or_response", file=sys.stderr)
72
+ return 1
73
+
74
+
75
+ if __name__ == "__main__":
76
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ # Loaded only by the two pinned Flutter setup invocations, never the whole job.
2
+ curl() {
3
+ case "${!#}" in
4
+ */flutter_infra_release/releases/releases_*.json)
5
+ python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
6
+ ;;
7
+ *) command curl "$@" ;;
8
+ esac
9
+ }
@@ -0,0 +1,85 @@
1
+ """An owned CONNECT proxy terminates only fixture TLS; it never dials a destination."""
2
+ from contextlib import contextmanager
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import socket
8
+ import socketserver
9
+ import ssl
10
+ import struct
11
+ import subprocess
12
+ import threading
13
+ import urllib.request
14
+
15
+ from test_flutter_cache_setup import SETUP_SHA, SETUP_URL
16
+
17
+ HOST = "manifest.fixture.invalid"
18
+ MANIFEST = json.dumps({"releases": [{"channel": "stable", "version": "3.44.0", "dart_sdk_arch": "x64",
19
+ "hash": "a" * 40, "sha256": "b" * 64, "archive": "owned-sdk.tar.xz"}]}).encode()
20
+
21
+
22
+ def official_setup() -> bytes:
23
+ fixture = os.environ.get("GOWALK_CACHE_ACTION_FIXTURES")
24
+ if fixture:
25
+ source = (Path(fixture) / "flutter-setup.sh").read_bytes()
26
+ else:
27
+ with urllib.request.urlopen(SETUP_URL, timeout=30) as response:
28
+ source = response.read(128 * 1024)
29
+ assert hashlib.sha256(source).hexdigest() == SETUP_SHA
30
+ return source
31
+
32
+
33
+ class Proxy(socketserver.StreamRequestHandler):
34
+ def handle(self):
35
+ first = self.rfile.readline(4096).split()
36
+ while self.rfile.readline(4096) not in (b"\r\n", b"\n", b""):
37
+ pass
38
+ if first != [b"CONNECT", (HOST + ":443").encode(), b"HTTP/1.1"]:
39
+ return
40
+ with self.server.lock:
41
+ self.server.connects += 1
42
+ mode = self.server.modes.pop(0)
43
+ if mode == "proxy_auth":
44
+ self.wfile.write(b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n")
45
+ return
46
+ self.wfile.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
47
+ self.wfile.flush()
48
+ if mode == "reset":
49
+ self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
50
+ return
51
+ try:
52
+ with self.server.context.wrap_socket(self.connection, server_side=True) as stream:
53
+ stream.settimeout(3)
54
+ with stream.makefile("rb") as reader:
55
+ request = reader.readline(4096).split()
56
+ while reader.readline(4096) not in (b"\r\n", b"\n", b""):
57
+ pass
58
+ self.server.requests.append(request[1].decode())
59
+ status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
60
+ body = MANIFEST[:31] if mode == "partial" else MANIFEST
61
+ stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
62
+ + str(len(MANIFEST)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
63
+ except (OSError, ssl.SSLError):
64
+ pass # An untrusted fixture certificate must be rejected by the real client.
65
+
66
+
67
+ @contextmanager
68
+ def tls_proxy(root: Path, modes: list[str]):
69
+ cert, key = root / "cert.pem", root / "key.pem"
70
+ command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
71
+ "-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
72
+ "-addext", "subjectAltName=DNS:" + HOST]
73
+ subprocess.run(command, check=True, capture_output=True, timeout=10)
74
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
75
+ context.load_cert_chain(cert, key)
76
+ server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Proxy)
77
+ server.daemon_threads = True
78
+ server.context, server.modes = context, list(modes)
79
+ server.connects, server.requests, server.lock = 0, [], threading.Lock()
80
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
81
+ thread.start()
82
+ try:
83
+ yield server, cert
84
+ finally:
85
+ server.shutdown(); server.server_close(); thread.join(3)
@@ -0,0 +1,119 @@
1
+ """Real curl and jq execute the byte-identical official setup through owned TLS failures."""
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+
8
+ from flutter_manifest_fixture import HOST, MANIFEST, official_setup, tls_proxy
9
+
10
+ ROOT = Path(__file__).parent
11
+
12
+
13
+ class ManifestRecoveryTests(unittest.TestCase):
14
+ @classmethod
15
+ def setUpClass(cls):
16
+ cls.source = official_setup()
17
+
18
+ def setUp(self):
19
+ self.directory = tempfile.TemporaryDirectory()
20
+ self.addCleanup(self.directory.cleanup)
21
+ self.root = Path(self.directory.name)
22
+ self.setup = self.root / "setup.sh"
23
+ self.setup.write_bytes(self.source)
24
+ (self.root / "test").mkdir()
25
+ (self.root / "test/releases_linux.json").write_bytes(MANIFEST)
26
+ # Local curl preferences may never disable the assigned proxy or certificate checks.
27
+ (self.root / ".curlrc").write_text('noproxy = "*"\ninsecure\n')
28
+
29
+ def invoke(self, server, cert, *, hooked=True, trusted=True, test_mode=False, bound=True):
30
+ proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
31
+ env = {**os.environ, "HOME": str(self.root), "RUNNER_TEMP": str(self.root), "RUNNER_OS": "Linux",
32
+ "RUNNER_ARCH": "X64", "RUNNER_TOOL_CACHE": str(self.root / "tools"),
33
+ "PUB_CACHE": str(self.root / "pub"), "FLUTTER_STORAGE_BASE_URL": "https://" + HOST,
34
+ "GITHUB_OUTPUT": str(self.root / "output"), "HTTPS_PROXY": proxy, "https_proxy": proxy,
35
+ "NO_PROXY": "*", "no_proxy": "*"}
36
+ for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
37
+ env.pop(key, None)
38
+ if not bound:
39
+ env.pop("HTTPS_PROXY"); env.pop("https_proxy")
40
+ if trusted:
41
+ env["CURL_CA_BUNDLE"] = str(cert)
42
+ if hooked:
43
+ env.update(BASH_ENV=str(ROOT / "flutter_manifest_env.sh"),
44
+ GOWALK_FLUTTER_MANIFEST_HELPER=str(ROOT / "flutter_manifest.py"))
45
+ else:
46
+ # The baseline deliberately runs the original curl defaults, without stale local bypass settings.
47
+ env.update(HOME=str(self.root / "absent-home"), NO_PROXY="", no_proxy="")
48
+ args = ["bash", str(self.setup), "-p", "-n", "3.44.0", "-a", "x64", "-k", "custom-:version:-:hash:"]
49
+ result = subprocess.run([*args, *(["-t"] if test_mode else []), "stable"], env=env,
50
+ capture_output=True, text=True, timeout=20)
51
+ self.assertNotIn("fixture-secret", result.stdout + result.stderr)
52
+ self.assertFalse(list(self.root.glob("flutter-manifest-*")))
53
+ return result
54
+
55
+ def test_original_tls_exit35_recovers_without_changing_official_cache_identity(self):
56
+ with tls_proxy(self.root, ["reset"]) as (server, cert):
57
+ original = self.invoke(server, cert, hooked=False)
58
+ self.assertEqual(original.returncode, 35)
59
+ self.assertEqual((server.connects, server.requests), (1, []))
60
+ with tls_proxy(self.root, ["reset", "ok"]) as (server, cert):
61
+ result = self.invoke(server, cert)
62
+ self.assertEqual(result.returncode, 0, result.stderr)
63
+ self.assertEqual((server.connects, len(server.requests)), (2, 1))
64
+ self.assertIn("flutter_manifest_recovered attempts=2", result.stderr)
65
+ baseline = self.invoke(server, cert, test_mode=True)
66
+ self.assertEqual((self.root / "output").read_text(), baseline.stdout)
67
+ self.assertEqual(result.stdout, "")
68
+
69
+ def test_partial_body_is_discarded_before_the_next_complete_json(self):
70
+ with tls_proxy(self.root, ["partial", "ok"]) as (server, cert):
71
+ result = self.invoke(server, cert)
72
+ self.assertEqual(result.returncode, 0, result.stderr)
73
+ self.assertEqual((server.connects, len(server.requests)), (2, 2))
74
+ self.assertIn("VERSION=3.44.0", (self.root / "output").read_text())
75
+ self.assertEqual(result.stdout, "")
76
+
77
+ def test_proxy_auth_refuses_once_without_any_destination_request(self):
78
+ with tls_proxy(self.root, ["proxy_auth"]) as (server, cert):
79
+ result = self.invoke(server, cert)
80
+ self.assertNotEqual(result.returncode, 0)
81
+ self.assertEqual((server.connects, server.requests), (1, []))
82
+ self.assertIn("proxy_status=407 attempts=1", result.stderr)
83
+ self.assertEqual(result.stdout, "")
84
+
85
+ def test_untrusted_certificate_never_retries_or_uses_curlrc_insecure(self):
86
+ with tls_proxy(self.root, ["ok"]) as (server, cert):
87
+ result = self.invoke(server, cert, trusted=False)
88
+ self.assertEqual(result.returncode, 60)
89
+ self.assertEqual((server.connects, server.requests), (1, []))
90
+ self.assertIn("attempts=1", result.stderr)
91
+ self.assertEqual(result.stdout, "")
92
+
93
+ def test_origin_auth_is_terminal_without_exposing_its_body(self):
94
+ with tls_proxy(self.root, ["origin_auth"]) as (server, cert):
95
+ result = self.invoke(server, cert)
96
+ self.assertEqual(result.returncode, 22)
97
+ self.assertEqual((server.connects, len(server.requests)), (1, 1))
98
+ self.assertIn("http_status=401", result.stderr)
99
+ self.assertIn("attempts=1", result.stderr)
100
+ self.assertEqual(result.stdout, "")
101
+
102
+ def test_repeated_tls_resets_stop_after_four_private_attempts(self):
103
+ with tls_proxy(self.root, ["reset"] * 4) as (server, cert):
104
+ result = self.invoke(server, cert)
105
+ self.assertEqual(result.returncode, 35)
106
+ self.assertEqual((server.connects, server.requests), (4, []))
107
+ self.assertIn("attempts=4", result.stderr)
108
+ self.assertEqual(result.stdout, "")
109
+
110
+ def test_missing_proxy_refuses_before_any_connection(self):
111
+ with tls_proxy(self.root, []) as (server, cert):
112
+ result = self.invoke(server, cert, bound=False)
113
+ self.assertEqual(result.returncode, 1)
114
+ self.assertEqual((server.connects, server.requests), (0, []))
115
+ self.assertEqual(result.stdout, "")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,41 @@
1
+ """The parent composite freezes helper paths before a nested action changes its context."""
2
+ import os
3
+ from pathlib import Path
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ import yaml
10
+
11
+ ROOT = Path(__file__).resolve().parents[2]
12
+
13
+
14
+ class ManifestPathScopeTests(unittest.TestCase):
15
+ def test_real_transport_step_captures_parent_paths_without_exporting_a_job_hook(self):
16
+ action = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
17
+ guard = action["runs"]["steps"][0]
18
+ with tempfile.TemporaryDirectory(prefix="manifest parent ") as directory:
19
+ root = Path(directory)
20
+ parent = root / "parent/flutter-setup"
21
+ scripts = parent.parent / "scripts"
22
+ parent.mkdir(parents=True); scripts.mkdir()
23
+ for name in ("play_store_proxy.py", "flutter_manifest.py", "flutter_manifest_env.sh"):
24
+ shutil.copyfile(ROOT / "android-action/scripts" / name, scripts / name)
25
+ env_file, output = root / "env", root / "output"
26
+ env = {**os.environ, "GITHUB_ACTION_PATH": str(parent), "GITHUB_ENV": str(env_file),
27
+ "GITHUB_OUTPUT": str(output), "RUNNER_TEMP": str(root),
28
+ "GOOGLE_STORE_PROXY_URL": "http://fixture-user:fixture-secret@127.0.0.1:9"}
29
+ result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", guard["run"]], env=env,
30
+ capture_output=True, text=True, timeout=10)
31
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", ""))
32
+ paths = dict(line.split("=", 1) for line in output.read_text().splitlines())
33
+ self.assertEqual(Path(paths["manifest_env"]).resolve(), (scripts / "flutter_manifest_env.sh").resolve())
34
+ self.assertEqual(Path(paths["manifest_helper"]).resolve(), (scripts / "flutter_manifest.py").resolve())
35
+ self.assertNotIn("BASH_ENV", env_file.read_text())
36
+ self.assertNotIn("GOWALK_FLUTTER_MANIFEST_HELPER", env_file.read_text())
37
+ self.assertNotIn("fixture-secret", output.read_text())
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
@@ -31,10 +31,18 @@ class BootstrapWiringTests(unittest.TestCase):
31
31
  self.assertIn("--snapshot", scope["run"])
32
32
  self.assertEqual(resolve["with"]["dry-run"], "true")
33
33
  for step in (resolve, install):
34
- self.assertNotIn("env", step)
34
+ self.assertEqual(step["uses"], "subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2")
35
+ self.assertEqual(step["env"], {
36
+ "BASH_ENV": "${{ steps.transport.outputs.manifest_env }}",
37
+ "GOWALK_FLUTTER_MANIFEST_HELPER": "${{ steps.transport.outputs.manifest_helper }}"})
35
38
  self.assertEqual(step["with"]["cache"], "false")
36
39
  self.assertEqual(step["with"]["pub-cache"], "false")
37
40
  self.assertNotIn("continue-on-error", step)
41
+ self.assertNotIn("BASH_ENV", setup.get("env", {}))
42
+ for step in (guard, scope, sdk_cache, pub_cache):
43
+ self.assertNotIn("BASH_ENV", step.get("env", {}))
44
+ self.assertEqual(guard["id"], "transport")
45
+ self.assertIn('>> "$GITHUB_OUTPUT"', guard["run"])
38
46
  for step in (sdk_cache, pub_cache):
39
47
  self.assertTrue(step["continue-on-error"])
40
48
  self.assertIn("steps.scope.outputs.preloader", step["env"]["NODE_OPTIONS"])
@@ -1 +1 @@
1
- 1.0.71
1
+ 1.0.73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.71",
3
+ "version": "1.0.73",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,8 @@ jobs:
56
56
  flutter build web --release --build-number="${GITHUB_RUN_NUMBER}"
57
57
  - name: Retain Flutter web release
58
58
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
59
+ env:
60
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
59
61
  with:
60
62
  name: web-${{ github.event.repository.name }}-${{ github.run_number }}
61
63
  path: build/web
@@ -215,6 +215,8 @@ jobs:
215
215
  - name: Retain Dart symbol files
216
216
  if: ${{ !cancelled() && steps.ios_symbols.outputs.present == 'true' }}
217
217
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
218
+ env:
219
+ NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
218
220
  with:
219
221
  name: ios-symbols-${{ github.event.repository.name }}-${{ env.BUILD_NUMBER || github.run_number }}
220
222
  path: ${{ runner.temp }}/dart-symbols/ios
@@ -435,6 +437,8 @@ jobs:
435
437
  - name: Retain signed Android App Bundle
436
438
  if: ${{ steps.mode.outputs.mode == 'local' }}
437
439
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
440
+ env:
441
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
438
442
  with:
439
443
  name: android-${{ steps.android.outputs.package-name }}-${{ steps.android.outputs.build-number }}
440
444
  path: ${{ steps.android.outputs.bundle-path }}