gowalk-cicd 1.0.71 → 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 +15 -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/scripts/artifact_env.cjs +10 -0
- package/android-action/scripts/cache_scope.cjs +8 -4
- 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
|
@@ -151,6 +151,14 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
151
151
|
errors still fail the job. Never use `continue-on-error` or a timeout around all SDK setup
|
|
152
152
|
to hide an optional cache failure. Do not alter cache keys or disable caches as part of
|
|
153
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.
|
|
154
162
|
- Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
|
|
155
163
|
secret. The action writes it to host-side `.runtime.env` with mode 0600 and
|
|
156
164
|
supplies it to Compose after the generated `.env`; never print its content.
|
|
@@ -165,6 +173,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
165
173
|
is the only place that decides which; every downstream step branches on the
|
|
166
174
|
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
|
167
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.
|
|
168
183
|
- Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
|
|
169
184
|
has no equivalent, so `set_gradle_version()` rewrites the literal in the
|
|
170
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.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
#
|
|
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.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,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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.72
|
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 }}
|