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 +19 -0
- package/README.md +9 -0
- package/action/.daemux-version +1 -1
- package/action/action.yml +18 -63
- package/action/scripts/artifact_env.cjs +10 -0
- package/action/scripts/cache_scope.cjs +81 -0
- package/action/scripts/resolve_info_plist.py +128 -0
- package/action/scripts/test_resolve_info_plist.py +120 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +2 -0
- package/android-action/flutter-setup/action.yml +10 -0
- package/android-action/scripts/artifact_env.cjs +10 -0
- package/android-action/scripts/cache_scope.cjs +8 -4
- package/android-action/scripts/flutter_manifest.py +76 -0
- package/android-action/scripts/flutter_manifest_env.sh +9 -0
- package/android-action/scripts/flutter_manifest_fixture.py +85 -0
- package/android-action/scripts/test_flutter_manifest.py +119 -0
- package/android-action/scripts/test_flutter_manifest_scope.py +41 -0
- package/android-action/scripts/test_google_bootstrap_wiring.py +9 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy-web.yml +2 -0
- package/templates/deploy.yml +4 -0
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.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
#
|
|
607
|
-
#
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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 "::
|
|
690
|
-
exit
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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,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.
|
|
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.
|
|
1
|
+
1.0.73
|
package/package.json
CHANGED
package/templates/deploy-web.yml
CHANGED
|
@@ -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
|
package/templates/deploy.yml
CHANGED
|
@@ -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 }}
|