gowalk-cicd 1.0.70 → 1.0.72

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
@@ -135,12 +135,30 @@ node /path/to/gowalk-cicd/bin/cli.mjs
135
135
  localization, global activation and builds run through the existing owned relay.
136
136
  The helper's bootstrap and command modes require only Python's standard library;
137
137
  importing the Google API client must not become an unprotected bootstrap prerequisite.
138
+ - `android-action/flutter-setup/` owns Flutter setup. It forwards the official action's
139
+ inputs/cache identities while isolating restore/save from provider transport. Its step-local
140
+ `NODE_OPTIONS` preloader (`android-action/scripts/cache_*.cjs`) also protects CocoaPods cache
141
+ steps and verifies the exact pinned Actions cache entrypoint bytes and HTTPS
142
+ GitHub cache/Azure blob destinations before removing proxy variables in that cache process.
143
+ Unknown code or origins refuse; SDK setup, non-cache children and later steps retain their
144
+ proxy requirement. Never export this scope through `GITHUB_ENV` or add cache domains to
145
+ `NO_PROXY`. The preloader snapshot under `RUNNER_TEMP` also covers official post/save after
146
+ consumer autoupdate changes the action directory. The installer delivers this development
147
+ tooling for native iOS repositories too; it does not require an Android app.
138
148
  - Flutter setup scopes `SEGMENT_DOWNLOAD_TIMEOUT_MINS=1` to the composite action's optional
139
149
  SDK/pub cache restores. This bounds each cache segment, not the whole cache or SDK download.
140
150
  A cache timeout becomes a cache miss; normal proxied SDK setup remains mandatory and its
141
151
  errors still fail the job. Never use `continue-on-error` or a timeout around all SDK setup
142
152
  to hide an optional cache failure. Do not alter cache keys or disable caches as part of
143
153
  this timeout handling.
154
+ - GitHub artifact uploads have their own checksum-pinned `artifact_env.cjs` process scope.
155
+ It reuses the cache transport's approved HTTPS request guard, then removes inherited proxy
156
+ variables only inside the official `upload-artifact` process. Unknown programs/origins refuse;
157
+ later Apple/Google clients retain their proxies and artifact failures remain job failures.
158
+ The iOS action reads this helper from its immutable `SWIFT_APP_ACTION` snapshot. Its shared
159
+ `cache_scope.cjs` and `artifact_env.cjs` copies match the Android distribution byte for byte,
160
+ enforced by `test/artifact-wiring.test.mjs`; update both when changing that common guard.
161
+ Update existing consumer workflow artifact steps as well as action directories on adoption.
144
162
  - Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
145
163
  secret. The action writes it to host-side `.runtime.env` with mode 0600 and
146
164
  supplies it to Compose after the generated `.env`; never print its content.
@@ -155,6 +173,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
155
173
  is the only place that decides which; every downstream step branches on the
156
174
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
157
175
  because a Flutter app also carries `android/settings.gradle`.
176
+ - iOS plist stamping reads complete JSON build settings through `resolve_info_plist.py`,
177
+ preserving Xcode's exit status and bounded sanitized stderr. Select exactly the configured
178
+ bundle's application target and resolve paths from that target's source root; never take
179
+ the first dependency plist or stop reading Xcode output early. The encryption step reuses
180
+ the validated source path. Missing/ambiguous source plists fail; positively generated
181
+ plists retain the existing build-setting version path. Queries disable signing and package
182
+ updates, retain inherited proxies, and never enable provisioning network operations.
158
183
  - Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
159
184
  has no equivalent, so `set_gradle_version()` rewrites the literal in the
160
185
  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.70
1
+ 1.0.72
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.70
1
+ 1.0.72
@@ -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 }}
@@ -0,0 +1,149 @@
1
+ name: Set up proxied Flutter
2
+ description: Set up Flutter with protected provider transport and isolated official GitHub caches
3
+ inputs:
4
+ channel:
5
+ description: The Flutter build release channel
6
+ required: false
7
+ default: stable
8
+ flutter-version:
9
+ description: The Flutter version to make available on the path
10
+ required: false
11
+ default: ''
12
+ flutter-version-file:
13
+ description: The pubspec.yaml or FVM config file with exact Flutter version defined
14
+ required: false
15
+ default: ''
16
+ architecture:
17
+ description: The architecture of Flutter SDK executable (x64 or arm64)
18
+ required: false
19
+ default: ${{ runner.arch }}
20
+ cache:
21
+ description: Cache the Flutter SDK
22
+ required: false
23
+ default: 'false'
24
+ cache-key:
25
+ description: Identifier for the Flutter SDK cache
26
+ required: false
27
+ default: ''
28
+ cache-path:
29
+ description: Flutter SDK cache path
30
+ required: false
31
+ default: ''
32
+ pub-cache:
33
+ description: Cache the Dart pub dependencies
34
+ required: false
35
+ default: ''
36
+ pub-cache-key:
37
+ description: Identifier for the Dart .pub-cache cache
38
+ required: false
39
+ default: ''
40
+ pub-cache-path:
41
+ description: Flutter pub cache path
42
+ required: false
43
+ default: default
44
+ dry-run:
45
+ description: If true, get outputs but do not install Flutter
46
+ required: false
47
+ default: 'false'
48
+ git-source:
49
+ description: Git clone source
50
+ required: false
51
+ default: https://github.com/flutter/flutter.git
52
+ outputs:
53
+ CHANNEL:
54
+ value: ${{ steps.flutter-action.outputs.CHANNEL }}
55
+ description: The selected Flutter release channel
56
+ VERSION:
57
+ value: ${{ steps.flutter-action.outputs.VERSION }}
58
+ description: The selected Flutter version
59
+ ARCHITECTURE:
60
+ value: ${{ steps.flutter-action.outputs.ARCHITECTURE }}
61
+ description: The selected Flutter CPU architecture
62
+ CACHE-KEY:
63
+ value: ${{ steps.flutter-action.outputs.CACHE-KEY }}
64
+ description: Key used to cache the Flutter SDK
65
+ CACHE-PATH:
66
+ value: ${{ steps.flutter-action.outputs.CACHE-PATH }}
67
+ description: Path to Flutter SDK
68
+ PUB-CACHE-KEY:
69
+ value: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}
70
+ description: Key used to cache the pub dependencies
71
+ PUB-CACHE-PATH:
72
+ value: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
73
+ description: Path to pub cache
74
+ GIT_SOURCE:
75
+ value: ${{ steps.flutter-action.outputs.GIT_SOURCE }}
76
+ description: Git source of Flutter SDK repository to clone
77
+ CACHE-HIT:
78
+ value: ${{ steps.cache-flutter.outputs.cache-hit }}
79
+ description: '`true` if the flutter cache was a hit'
80
+ PUB-CACHE-HIT:
81
+ value: ${{ steps.cache-pub.outputs.cache-hit }}
82
+ description: '`true` if the pub cache was a hit'
83
+ runs:
84
+ using: composite
85
+ steps:
86
+ - name: Require provider transport
87
+ shell: bash
88
+ run: |
89
+ python3 "$GITHUB_ACTION_PATH/../scripts/play_store_proxy.py" --bootstrap-env
90
+ - name: Retain the cache process scope
91
+ id: scope
92
+ if: ${{ inputs.cache == 'true' || inputs.pub-cache == 'true' }}
93
+ continue-on-error: true
94
+ shell: bash
95
+ run: node "$GITHUB_ACTION_PATH/../scripts/cache_scope.cjs" --snapshot
96
+ - name: Resolve Flutter release and cache identities
97
+ id: flutter-action
98
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
99
+ with:
100
+ channel: ${{ inputs.channel }}
101
+ flutter-version: ${{ inputs.flutter-version }}
102
+ flutter-version-file: ${{ inputs.flutter-version-file }}
103
+ architecture: ${{ inputs.architecture }}
104
+ cache: 'false'
105
+ cache-key: ${{ inputs.cache-key }}
106
+ cache-path: ${{ inputs.cache-path }}
107
+ pub-cache: 'false'
108
+ pub-cache-key: ${{ inputs.pub-cache-key }}
109
+ pub-cache-path: ${{ inputs.pub-cache-path }}
110
+ dry-run: 'true'
111
+ git-source: ${{ inputs.git-source }}
112
+ - name: Cache Flutter
113
+ id: cache-flutter
114
+ if: ${{ inputs.cache == 'true' }}
115
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
116
+ continue-on-error: true
117
+ env:
118
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
119
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
120
+ with:
121
+ path: ${{ steps.flutter-action.outputs.CACHE-PATH }}
122
+ key: ${{ steps.flutter-action.outputs.CACHE-KEY }}
123
+ - name: Cache pub dependencies
124
+ id: cache-pub
125
+ if: ${{ (inputs.pub-cache == '' && inputs.cache == 'true') || inputs.pub-cache == 'true' }}
126
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
127
+ continue-on-error: true
128
+ env:
129
+ NODE_OPTIONS: --require "${{ steps.scope.outputs.preloader }}"
130
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
131
+ with:
132
+ path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
133
+ key: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
134
+ - name: Install or reuse the resolved Flutter SDK
135
+ if: ${{ inputs.dry-run != 'true' }}
136
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
137
+ with:
138
+ channel: ${{ steps.flutter-action.outputs.CHANNEL }}
139
+ flutter-version: ${{ steps.flutter-action.outputs.VERSION }}
140
+ flutter-version-file: ''
141
+ architecture: ${{ steps.flutter-action.outputs.ARCHITECTURE }}
142
+ cache: 'false'
143
+ cache-key: ${{ inputs.cache-key }}
144
+ cache-path: ${{ steps.flutter-action.outputs.CACHE-PATH }}
145
+ pub-cache: 'false'
146
+ pub-cache-key: ${{ inputs.pub-cache-key }}
147
+ pub-cache-path: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
148
+ dry-run: 'false'
149
+ git-source: ${{ inputs.git-source }}
@@ -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,10 @@
1
+ // NODE_OPTIONS applies this only to the cache step, including its official post/save action.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env);
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_cache_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,125 @@
1
+ """Real pinned SDK setup reuses restored bytes; cold/missed cache stays on the owned proxy."""
2
+ import hashlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ import threading
12
+ import unittest
13
+ import urllib.request
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+
16
+ SCRIPT = Path(__file__).with_name("play_store_proxy.py")
17
+ SETUP_SHA = "35f0cd9e9c1d7a643448223573b4a03879e63676fdc27872de3d0104d5bf89f9"
18
+ SETUP_URL = ("https://raw.githubusercontent.com/subosito/flutter-action/"
19
+ "1a449444c387b1966244ae4d4f8c696479add0b2/setup.sh")
20
+
21
+
22
+ class SdkSetupTests(unittest.TestCase):
23
+ @classmethod
24
+ def setUpClass(cls):
25
+ fixture = os.environ.get("GOWALK_CACHE_ACTION_FIXTURES")
26
+ if fixture:
27
+ cls.source = (Path(fixture) / "flutter-setup.sh").read_bytes()
28
+ else:
29
+ with urllib.request.urlopen(SETUP_URL, timeout=30) as response:
30
+ cls.source = response.read(128 * 1024)
31
+ assert hashlib.sha256(cls.source).hexdigest() == SETUP_SHA
32
+
33
+ def setUp(self):
34
+ self.directory = tempfile.TemporaryDirectory()
35
+ self.addCleanup(self.directory.cleanup)
36
+ self.path = Path(self.directory.name)
37
+ self.setup = self.path / "setup.sh"
38
+ self.setup.write_bytes(self.source)
39
+ self.sdk = self.path / "sdk"
40
+ self.env = {**os.environ, "RUNNER_TEMP": str(self.path), "RUNNER_OS": "Linux", "RUNNER_ARCH": "X64",
41
+ "RUNNER_TOOL_CACHE": str(self.path / "tools"), "PUB_CACHE": str(self.path / "pub"),
42
+ "FLUTTER_STORAGE_BASE_URL": "http://provider.invalid", "GITHUB_ENV": str(self.path / "env"),
43
+ "GITHUB_OUTPUT": str(self.path / "output"), "GITHUB_PATH": str(self.path / "path")}
44
+ self.calls = []
45
+ body = io.BytesIO()
46
+ with tarfile.open(fileobj=body, mode="w:gz") as archive:
47
+ entry = tarfile.TarInfo("flutter/bin/flutter")
48
+ content = b"#!/bin/sh\nexit 0\n"
49
+ entry.size, entry.mode = len(content), 0o755
50
+ archive.addfile(entry, io.BytesIO(content))
51
+ self.archive = body.getvalue()
52
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), self.handler())
53
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
54
+ self.addCleanup(self.server.server_close)
55
+ self.addCleanup(self.server.shutdown)
56
+ self.env["GOOGLE_STORE_PROXY_URL"] = f"http://127.0.0.1:{self.server.server_port}"
57
+
58
+ def handler(self):
59
+ owner = self
60
+
61
+ class Handler(BaseHTTPRequestHandler):
62
+ def do_GET(self):
63
+ owner.calls.append(self.path)
64
+ if self.path.endswith(".json"):
65
+ data = json.dumps({"releases": [{"channel": "stable", "version": "3.44.0",
66
+ "dart_sdk_arch": "x64", "hash": "fixture", "sha256": "fixture",
67
+ "archive": "sdk.tar.gz"}]}).encode()
68
+ else:
69
+ data = owner.archive
70
+ self.send_response(200)
71
+ self.send_header("Content-Length", str(len(data)))
72
+ self.end_headers()
73
+ self.wfile.write(data)
74
+
75
+ def log_message(self, *_args):
76
+ pass
77
+
78
+ return Handler
79
+
80
+ def bootstrap(self):
81
+ result = subprocess.run([sys.executable, "-S", str(SCRIPT), "--bootstrap-env"],
82
+ env=self.env, capture_output=True, text=True, timeout=10)
83
+ if result.returncode == 0:
84
+ self.env.update(line.split("=", 1) for line in (self.path / "env").read_text().splitlines())
85
+ return result
86
+
87
+ def install(self):
88
+ return subprocess.run(["bash", str(self.setup), "-n", "3.44.0", "-a", "x64", "-c", str(self.sdk),
89
+ "stable"], env=self.env, capture_output=True, text=True, timeout=10)
90
+
91
+ def test_restored_sdk_fetches_only_manifest_through_proxy(self):
92
+ target = self.sdk / "flutter/bin/flutter"
93
+ target.parent.mkdir(parents=True)
94
+ target.write_text("#!/bin/sh\nexit 0\n")
95
+ target.chmod(0o755)
96
+ self.assertEqual(self.bootstrap().returncode, 0)
97
+ result = self.install()
98
+ self.assertEqual(result.returncode, 0, result.stderr)
99
+ self.assertEqual(self.calls, ["http://provider.invalid/flutter_infra_release/releases/releases_linux.json"])
100
+
101
+ def test_cold_cache_downloads_and_installs_sdk_through_proxy(self):
102
+ self.assertEqual(self.bootstrap().returncode, 0)
103
+ result = self.install()
104
+ self.assertEqual(result.returncode, 0, result.stderr)
105
+ self.assertEqual([url.rsplit("/", 1)[-1] for url in self.calls], ["releases_linux.json", "sdk.tar.gz"])
106
+ self.assertTrue(os.access(self.sdk / "flutter/bin/flutter", os.X_OK))
107
+
108
+ def test_provider_failure_after_cache_miss_still_fails_setup(self):
109
+ self.archive = b"not-a-valid-sdk-archive"
110
+ self.assertEqual(self.bootstrap().returncode, 0)
111
+ result = self.install()
112
+ self.assertNotEqual(result.returncode, 0)
113
+ self.assertFalse((self.sdk / "flutter/bin/flutter").exists())
114
+ self.assertEqual(len(self.calls), 2)
115
+
116
+ def test_missing_pin_refuses_before_manifest_even_with_restored_sdk(self):
117
+ self.env.pop("GOOGLE_STORE_PROXY_URL")
118
+ result = self.bootstrap()
119
+ self.assertNotEqual(result.returncode, 0)
120
+ self.assertFalse((self.path / "env").exists())
121
+ self.assertEqual(self.calls, [])
122
+
123
+
124
+ if __name__ == "__main__":
125
+ unittest.main()
@@ -16,16 +16,55 @@ class BootstrapWiringTests(unittest.TestCase):
16
16
  for job in workflow["jobs"].values():
17
17
  steps = job.get("steps", [])
18
18
  for index, step in enumerate(steps):
19
- if not step.get("uses", "").startswith("subosito/flutter-action@"):
19
+ if not step.get("uses", "").endswith("/android-app/flutter-setup"):
20
20
  continue
21
21
  checked += 1
22
- guard = steps[index - 1]
23
- self.assertEqual(guard.get("if"), step.get("if"))
24
22
  self.assertEqual(job["env"]["GOOGLE_STORE_PROXY_URL"],
25
23
  "${{ secrets.GOOGLE_STORE_PROXY_URL }}")
26
- self.assertTrue(guard["run"].endswith("play_store_proxy.py --bootstrap-env"))
27
- self.assertNotIn("continue-on-error", guard)
24
+ self.assertNotIn("continue-on-error", step)
28
25
  self.assertEqual(checked, 3)
26
+ setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
27
+ guard, scope, resolve, sdk_cache, pub_cache, install = setup["runs"]["steps"]
28
+ self.assertIn('play_store_proxy.py" --bootstrap-env', guard["run"])
29
+ self.assertNotIn("continue-on-error", guard)
30
+ self.assertTrue(scope["continue-on-error"])
31
+ self.assertIn("--snapshot", scope["run"])
32
+ self.assertEqual(resolve["with"]["dry-run"], "true")
33
+ for step in (resolve, install):
34
+ self.assertNotIn("env", step)
35
+ self.assertEqual(step["with"]["cache"], "false")
36
+ self.assertEqual(step["with"]["pub-cache"], "false")
37
+ self.assertNotIn("continue-on-error", step)
38
+ for step in (sdk_cache, pub_cache):
39
+ self.assertTrue(step["continue-on-error"])
40
+ self.assertIn("steps.scope.outputs.preloader", step["env"]["NODE_OPTIONS"])
41
+ self.assertEqual(step["env"]["SEGMENT_DOWNLOAD_TIMEOUT_MINS"], "1")
42
+
43
+ def test_official_flutter_custom_input_and_output_contract_is_preserved(self):
44
+ setup = yaml.safe_load((ROOT / "android-action/flutter-setup/action.yml").read_text())
45
+ inputs = {"channel", "flutter-version", "flutter-version-file", "architecture", "cache", "cache-key",
46
+ "cache-path", "pub-cache", "pub-cache-key", "pub-cache-path", "dry-run", "git-source"}
47
+ self.assertEqual(set(setup["inputs"]), inputs)
48
+ resolve = next(step for step in setup["runs"]["steps"] if step.get("id") == "flutter-action")
49
+ for name in inputs - {"cache", "pub-cache", "dry-run"}:
50
+ self.assertEqual(resolve["with"][name], "${{ inputs." + name + " }}")
51
+ self.assertEqual(setup["outputs"]["CACHE-HIT"]["value"], "${{ steps.cache-flutter.outputs.cache-hit }}")
52
+ self.assertEqual(setup["outputs"]["PUB-CACHE-HIT"]["value"], "${{ steps.cache-pub.outputs.cache-hit }}")
53
+
54
+ def test_pods_cache_keeps_identity_and_uses_the_same_optional_scope(self):
55
+ workflow = yaml.safe_load((ROOT / "templates/deploy.yml").read_text())
56
+ steps = workflow["jobs"]["ios"]["steps"]
57
+ cache = next(step for step in steps if step.get("name") == "Cache CocoaPods")
58
+ scope = next(step for step in steps if step.get("id") == "pods-cache-scope")
59
+ self.assertTrue(scope["continue-on-error"])
60
+ self.assertTrue(cache["continue-on-error"])
61
+ self.assertEqual(scope["if"], cache["if"])
62
+ self.assertIn("scripts/cache_scope.cjs --snapshot", scope["run"])
63
+ self.assertIn("steps.pods-cache-scope.outputs.preloader", cache["env"]["NODE_OPTIONS"])
64
+ self.assertEqual(cache["uses"], "actions/cache@caa296126883cff596d87d8935842f9db880ef25")
65
+ self.assertEqual(cache["with"]["path"], "ios/Pods\n~/Library/Caches/CocoaPods\n")
66
+ self.assertEqual(cache["with"]["key"],
67
+ "pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock', 'pubspec.lock') }}")
29
68
 
30
69
  def test_remaining_flutter_commands_run_inside_the_owned_relay(self):
31
70
  groups = []
@@ -1 +1 @@
1
- 1.0.70
1
+ 1.0.72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.70",
3
+ "version": "1.0.72",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,12 +30,7 @@ jobs:
30
30
  GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
31
31
  steps:
32
32
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
33
- - name: Require the Google proxy before Flutter SDK setup
34
- shell: bash
35
- run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
36
- - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
37
- env:
38
- SEGMENT_DOWNLOAD_TIMEOUT_MINS: "1"
33
+ - uses: ./.github/actions/android-app/flutter-setup
39
34
  with:
40
35
  channel: stable
41
36
  cache: true
@@ -61,6 +56,8 @@ jobs:
61
56
  flutter build web --release --build-number="${GITHUB_RUN_NUMBER}"
62
57
  - name: Retain Flutter web release
63
58
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
59
+ env:
60
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
64
61
  with:
65
62
  name: web-${{ github.event.repository.name }}-${{ github.run_number }}
66
63
  path: build/web
@@ -77,22 +77,25 @@ jobs:
77
77
  else
78
78
  echo "enabled=false" >> "$GITHUB_OUTPUT"
79
79
  fi
80
- - name: Require the Google proxy before Flutter SDK setup
81
- if: ${{ steps.flutter.outputs.enabled == 'true' }}
82
- shell: bash
83
- run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
84
80
  - name: Set up Flutter
85
81
  if: ${{ steps.flutter.outputs.enabled == 'true' }}
86
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
87
- env:
88
- # Optional SDK/pub cache segments must finish promptly; normal SDK setup still fails on errors.
89
- SEGMENT_DOWNLOAD_TIMEOUT_MINS: "1"
82
+ uses: ./.github/actions/android-app/flutter-setup
90
83
  with:
91
84
  channel: stable
92
85
  cache: true
86
+ - name: Retain CocoaPods cache process scope
87
+ id: pods-cache-scope
88
+ if: ${{ steps.flutter.outputs.enabled == 'true' }}
89
+ continue-on-error: true
90
+ shell: bash
91
+ run: node .github/actions/android-app/scripts/cache_scope.cjs --snapshot
93
92
  - name: Cache CocoaPods
94
93
  if: ${{ steps.flutter.outputs.enabled == 'true' }}
95
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
94
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5, same pinned cache process as Flutter
95
+ continue-on-error: true
96
+ env:
97
+ NODE_OPTIONS: --require "${{ steps.pods-cache-scope.outputs.preloader }}"
98
+ SEGMENT_DOWNLOAD_TIMEOUT_MINS: "1"
96
99
  with:
97
100
  path: |
98
101
  ios/Pods
@@ -212,6 +215,8 @@ jobs:
212
215
  - name: Retain Dart symbol files
213
216
  if: ${{ !cancelled() && steps.ios_symbols.outputs.present == 'true' }}
214
217
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
218
+ env:
219
+ NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
215
220
  with:
216
221
  name: ios-symbols-${{ github.event.repository.name }}-${{ env.BUILD_NUMBER || github.run_number }}
217
222
  path: ${{ runner.temp }}/dart-symbols/ios
@@ -335,15 +340,9 @@ jobs:
335
340
  distribution: temurin
336
341
  java-version: ${{ steps.jdk.outputs.version }}
337
342
  cache: gradle
338
- - name: Require the Google proxy before Flutter SDK setup
339
- if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
340
- shell: bash
341
- run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
342
343
  - name: Set up Flutter
343
344
  if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
344
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
345
- env:
346
- SEGMENT_DOWNLOAD_TIMEOUT_MINS: "1"
345
+ uses: ./.github/actions/android-app/flutter-setup
347
346
  with:
348
347
  channel: stable
349
348
  cache: true
@@ -438,6 +437,8 @@ jobs:
438
437
  - name: Retain signed Android App Bundle
439
438
  if: ${{ steps.mode.outputs.mode == 'local' }}
440
439
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
440
+ env:
441
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
441
442
  with:
442
443
  name: android-${{ steps.android.outputs.package-name }}-${{ steps.android.outputs.build-number }}
443
444
  path: ${{ steps.android.outputs.bundle-path }}