gowalk-cicd 1.0.208 → 1.0.209
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/action/action.yml +14 -0
- package/action/scripts/mobile_artifact_files.py +14 -0
- package/action/scripts/native_preparation.py +166 -0
- package/docs/CANDIDATE_ARTIFACTS.md +42 -0
- package/package.json +1 -1
- package/src/config/schema.mjs +5 -0
- package/src/config/settings.mjs +4 -1
- package/templates/history.json.gz +0 -0
package/action/action.yml
CHANGED
|
@@ -10,6 +10,10 @@ description: >
|
|
|
10
10
|
consumers. App Store provisioning profiles are reconciled at runtime.
|
|
11
11
|
|
|
12
12
|
inputs:
|
|
13
|
+
native-preparation-manifest:
|
|
14
|
+
description: Tracked manifest for app-owned native dependency preparation
|
|
15
|
+
required: false
|
|
16
|
+
default: ''
|
|
13
17
|
candidate-binary:
|
|
14
18
|
description: Verified candidate artifact directory supplied by the package workflow
|
|
15
19
|
required: false
|
|
@@ -233,6 +237,16 @@ runs:
|
|
|
233
237
|
sudo xcode-select -s "$XCODE_PATH"
|
|
234
238
|
xcodebuild -version
|
|
235
239
|
|
|
240
|
+
- name: Prepare manifest-bound native dependencies
|
|
241
|
+
if: ${{ inputs.candidate-binary == '' && inputs.native-preparation-manifest != '' }}
|
|
242
|
+
shell: bash
|
|
243
|
+
env:
|
|
244
|
+
NATIVE_PREPARATION_MANIFEST: ${{ inputs.native-preparation-manifest }}
|
|
245
|
+
run: |
|
|
246
|
+
python3 "$SWIFT_APP_ACTION/scripts/native_preparation.py" prepare \
|
|
247
|
+
"$GITHUB_WORKSPACE" "$NATIVE_PREPARATION_MANIFEST" "$RUNNER_TEMP/native-preparation.json"
|
|
248
|
+
printf 'NATIVE_PREPARATION_MANIFEST=%s\n' "$NATIVE_PREPARATION_MANIFEST" >> "$GITHUB_ENV"
|
|
249
|
+
|
|
236
250
|
- name: Describe native dependency cache
|
|
237
251
|
if: ${{ inputs.candidate-binary == '' }}
|
|
238
252
|
id: native_cache_scope
|
|
@@ -95,6 +95,12 @@ def pack(platform: str, directory: Path) -> None:
|
|
|
95
95
|
raise ValueError("mobile_artifact_binary_ambiguous")
|
|
96
96
|
(directory / "export").mkdir()
|
|
97
97
|
shutil.copyfile(binaries[0], directory / "export" / "app.ipa")
|
|
98
|
+
manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
|
|
99
|
+
if manifest:
|
|
100
|
+
import native_preparation
|
|
101
|
+
receipt = temp / "native-preparation.json"
|
|
102
|
+
native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest, receipt, outputs=True)
|
|
103
|
+
shutil.copyfile(receipt, directory / "native-preparation.json")
|
|
98
104
|
symbols = ["firebase-symbols", "dart-symbols/ios"]
|
|
99
105
|
else:
|
|
100
106
|
binary = Path(os.environ["MOBILE_ARTIFACT_BINARY"])
|
|
@@ -120,6 +126,14 @@ def verify(platform: str, directory: Path) -> dict:
|
|
|
120
126
|
or value.get("credential_fingerprints") != credentials(platform)
|
|
121
127
|
or value.get("files") != files(directory)):
|
|
122
128
|
raise ValueError("mobile_artifact_inputs_mismatch")
|
|
129
|
+
if platform == "ios":
|
|
130
|
+
manifest = os.environ.get("NATIVE_PREPARATION_MANIFEST", "")
|
|
131
|
+
if manifest:
|
|
132
|
+
import native_preparation
|
|
133
|
+
native_preparation.verify(Path(os.environ["GITHUB_WORKSPACE"]).resolve(), manifest,
|
|
134
|
+
directory / "native-preparation.json")
|
|
135
|
+
elif "native-preparation.json" in value["files"]:
|
|
136
|
+
raise ValueError("mobile_artifact_inputs_mismatch")
|
|
123
137
|
expected = set(IOS_ENV if platform == "ios" else ANDROID_ENV)
|
|
124
138
|
environment = value.get("environment", {})
|
|
125
139
|
if set(environment) not in (expected, expected - {SOURCE_PLIST}) or any(
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare and attest tracked, app-authored native build dependencies."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
SCHEMA = 'gowalk-cicd/native-preparation.v1'
|
|
13
|
+
RECEIPT = 'gowalk-cicd/native-preparation-receipt.v1'
|
|
14
|
+
MAX_FILES = 10000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def digest(path):
|
|
18
|
+
with path.open('rb') as stream:
|
|
19
|
+
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def command(*args, cwd):
|
|
23
|
+
return subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE,
|
|
24
|
+
stderr=subprocess.DEVNULL, text=True).stdout
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def path_at(root, name):
|
|
28
|
+
if not isinstance(name, str) or len(name) > 500 or not re.fullmatch(r'[A-Za-z0-9_./-]+', name):
|
|
29
|
+
raise ValueError('native_preparation_path_invalid')
|
|
30
|
+
path = PurePosixPath(name)
|
|
31
|
+
if path.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')):
|
|
32
|
+
raise ValueError('native_preparation_path_invalid')
|
|
33
|
+
if 'creds' in path.parts or '.git' in path.parts or any(part.startswith('.env') for part in path.parts):
|
|
34
|
+
raise ValueError('native_preparation_path_invalid')
|
|
35
|
+
result = root.joinpath(*path.parts)
|
|
36
|
+
current = root
|
|
37
|
+
for part in path.parts:
|
|
38
|
+
current = current / part
|
|
39
|
+
if current.is_symlink():
|
|
40
|
+
raise ValueError('native_preparation_symlink')
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracked(root, name):
|
|
45
|
+
path = path_at(root, name)
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
raise ValueError('native_preparation_input_absent')
|
|
48
|
+
command('git', 'ls-files', '--error-unmatch', '--', name, cwd=root)
|
|
49
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', name, cwd=root)
|
|
50
|
+
return digest(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(root, name):
|
|
54
|
+
manifest_hash = tracked(root, name)
|
|
55
|
+
path = path_at(root, name)
|
|
56
|
+
if path.stat().st_size > 65536:
|
|
57
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
58
|
+
data = json.loads(path.read_text())
|
|
59
|
+
if (not isinstance(data, dict) or set(data) != {'schema', 'script', 'inputs', 'outputs'}
|
|
60
|
+
or data['schema'] != SCHEMA or not isinstance(data['inputs'], list)
|
|
61
|
+
or not isinstance(data['outputs'], list) or not data['outputs']
|
|
62
|
+
or len(data['inputs']) > 100 or len(data['outputs']) > 100):
|
|
63
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
64
|
+
inputs = [name, data['script'], *data['inputs']]
|
|
65
|
+
if len(set(inputs)) != len(inputs) or len(set(data['outputs'])) != len(data['outputs']):
|
|
66
|
+
raise ValueError('native_preparation_manifest_invalid')
|
|
67
|
+
hashes = {item: tracked(root, item) for item in inputs}
|
|
68
|
+
if hashes[name] != manifest_hash or not os.access(path_at(root, data['script']), os.X_OK):
|
|
69
|
+
raise ValueError('native_preparation_script_invalid')
|
|
70
|
+
for output in data['outputs']:
|
|
71
|
+
path_at(root, output)
|
|
72
|
+
if command('git', 'ls-files', '-z', '--', output, cwd=root):
|
|
73
|
+
raise ValueError('native_preparation_output_tracked')
|
|
74
|
+
command('git', 'check-ignore', '-q', '--', output, cwd=root)
|
|
75
|
+
return data, hashes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def output_tree(root, name):
|
|
79
|
+
path = path_at(root, name)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
raise ValueError('native_preparation_output_absent')
|
|
82
|
+
paths = [path] if path.is_file() else sorted(path.rglob('*'))
|
|
83
|
+
entries = []
|
|
84
|
+
for item in paths:
|
|
85
|
+
if item.is_symlink():
|
|
86
|
+
raise ValueError('native_preparation_symlink')
|
|
87
|
+
if item.is_file():
|
|
88
|
+
entries.append([item.relative_to(root).as_posix(), digest(item)])
|
|
89
|
+
if len(entries) > MAX_FILES:
|
|
90
|
+
raise ValueError('native_preparation_output_limit')
|
|
91
|
+
elif not item.is_dir():
|
|
92
|
+
raise ValueError('native_preparation_output_invalid')
|
|
93
|
+
if not entries:
|
|
94
|
+
raise ValueError('native_preparation_output_absent')
|
|
95
|
+
return {'path': name, 'files': len(entries), 'sha256': hashlib.sha256(
|
|
96
|
+
json.dumps(entries, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def identity(root, name):
|
|
100
|
+
data, inputs = load(root, name)
|
|
101
|
+
return data, {'schema': RECEIPT, 'manifest': name, 'inputs': inputs}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def prepare(root, manifest, receipt):
|
|
105
|
+
data, result = identity(root, manifest)
|
|
106
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
|
|
107
|
+
allowed = ('PATH', 'HOME', 'TMPDIR', 'RUNNER_TEMP', 'APP_ROBOT_TASK_SCRATCH', 'DEVELOPER_DIR', 'SDKROOT',
|
|
108
|
+
'CI', 'GITHUB_ACTIONS', 'GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GITHUB_JOB')
|
|
109
|
+
environment = {key: os.environ[key] for key in allowed if key in os.environ}
|
|
110
|
+
if environment.get('GITHUB_ACTIONS') == 'true':
|
|
111
|
+
environment['CI'] = 'true'
|
|
112
|
+
environment['GITHUB_WORKSPACE'] = str(root)
|
|
113
|
+
subprocess.run([str(path_at(root, data['script']))], cwd=root, env=environment, check=True)
|
|
114
|
+
command('git', 'diff', '--quiet', 'HEAD', '--', cwd=root)
|
|
115
|
+
_, after = identity(root, manifest)
|
|
116
|
+
if result != after:
|
|
117
|
+
raise ValueError('native_preparation_input_changed')
|
|
118
|
+
result['toolchain'] = command('xcodebuild', '-version', cwd=root).strip()
|
|
119
|
+
result['outputs'] = [output_tree(root, name) for name in data['outputs']]
|
|
120
|
+
receipt.write_text(json.dumps(result, sort_keys=True, indent=2) + '\n')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify(root, manifest, receipt, *, outputs=False):
|
|
124
|
+
data, expected = identity(root, manifest)
|
|
125
|
+
if receipt.is_symlink() or not receipt.is_file() or receipt.stat().st_size > 1024 * 1024:
|
|
126
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
127
|
+
actual = json.loads(receipt.read_text())
|
|
128
|
+
if (not isinstance(actual, dict) or set(actual) != set(expected) | {'toolchain', 'outputs'}
|
|
129
|
+
or any(actual.get(k) != v for k, v in expected.items())
|
|
130
|
+
or not isinstance(actual.get('toolchain'), str) or not actual['toolchain']
|
|
131
|
+
or not isinstance(actual.get('outputs'), list) or len(actual['outputs']) != len(data['outputs'])):
|
|
132
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
133
|
+
for expected_path, item in zip(data['outputs'], actual['outputs']):
|
|
134
|
+
if (not isinstance(item, dict) or set(item) != {'path', 'files', 'sha256'}
|
|
135
|
+
or item['path'] != expected_path or type(item['files']) is not int
|
|
136
|
+
or not 0 < item['files'] <= MAX_FILES or not isinstance(item['sha256'], str)
|
|
137
|
+
or len(item['sha256']) != 64 or any(c not in '0123456789abcdef' for c in item['sha256'])):
|
|
138
|
+
raise ValueError('native_preparation_receipt_invalid')
|
|
139
|
+
if outputs and (actual['outputs'] != [output_tree(root, name) for name in data['outputs']]
|
|
140
|
+
or actual['toolchain'] != command('xcodebuild', '-version', cwd=root).strip()):
|
|
141
|
+
raise ValueError('native_preparation_output_changed')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
parser = argparse.ArgumentParser()
|
|
146
|
+
parser.add_argument('operation', choices=['prepare', 'verify', 'verify-outputs'])
|
|
147
|
+
parser.add_argument('root', type=Path)
|
|
148
|
+
parser.add_argument('manifest')
|
|
149
|
+
parser.add_argument('receipt', type=Path)
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
try:
|
|
152
|
+
root = args.root.resolve(strict=True)
|
|
153
|
+
if args.operation == 'prepare':
|
|
154
|
+
prepare(root, args.manifest, args.receipt)
|
|
155
|
+
else:
|
|
156
|
+
verify(root, args.manifest, args.receipt, outputs=args.operation == 'verify-outputs')
|
|
157
|
+
except (ValueError, OSError, subprocess.SubprocessError, TypeError, KeyError) as error:
|
|
158
|
+
code = str(error) if isinstance(error, ValueError) and str(error).startswith('native_preparation_') else \
|
|
159
|
+
'native_preparation_failed'
|
|
160
|
+
print(code, file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
sys.exit(main())
|
|
@@ -70,3 +70,45 @@ and pinned cache programs as release builds. Cache Pods and `.factory/toolchain/
|
|
|
70
70
|
helper's lockfile/Ruby/Xcode/architecture identity; `pod install --deployment` and committed locks
|
|
71
71
|
remain required. Beyond that conditional dependency priming, no compile warm-up, machine affinity, reservation
|
|
72
72
|
or factory wait protocol is added.
|
|
73
|
+
|
|
74
|
+
## App-owned native preparation
|
|
75
|
+
|
|
76
|
+
A native iOS app whose Xcode graph consumes generated, lockfile-pinned local frameworks may set
|
|
77
|
+
`mobile.ios.native-preparation-manifest` in `.gowalk-cicd.yml` to a tracked JSON file:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"schema": "gowalk-cicd/native-preparation.v1",
|
|
82
|
+
"script": "tools/ios/prepare-libraries.sh",
|
|
83
|
+
"inputs": ["tools/ios/library.lock.json"],
|
|
84
|
+
"outputs": [".factory/cache/library/ios/Example.xcframework"]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
All names are repository-relative. The manifest, executable script and declared inputs must be
|
|
89
|
+
regular tracked files matching the frozen checkout. The tracked tree must remain unchanged through
|
|
90
|
+
preparation. Output trees must be gitignored, contain no tracked files and cannot contain symlinks.
|
|
91
|
+
The script itself must pin and verify its downloaded inputs, must not run tests, and must
|
|
92
|
+
produce all declared outputs with the active Xcode toolchain. The helper executes it as an argument,
|
|
93
|
+
not as an interpolated shell command. App-authored source and this build recipe are subject to the
|
|
94
|
+
same review and trusted-candidate policy as Xcode build phases.
|
|
95
|
+
The child receives a narrow environment, including the nonsecret GitHub run, attempt and job
|
|
96
|
+
identity so a recipe can distinguish a fresh build from same-job cache reuse; credentials and
|
|
97
|
+
unrelated workflow variables are not forwarded.
|
|
98
|
+
|
|
99
|
+
The action runs preparation after selecting Xcode and before Xcode project detection on an actual
|
|
100
|
+
macOS build. It records the source and output hashes and Xcode version, then checks those outputs
|
|
101
|
+
again before packing a candidate. The preparation receipt travels as an authenticated candidate
|
|
102
|
+
artifact file. Promotion verifies its manifest and source hashes along with the binary's existing
|
|
103
|
+
source, credential and artifact checks, and does not rebuild or require generated frameworks on
|
|
104
|
+
Linux. Missing, stale or malformed evidence fails closed. The full tracked mobile source, including
|
|
105
|
+
all scripts, lockfiles and configuration, remains part of the candidate fingerprint; the manifest
|
|
106
|
+
is not a substitute for reviewing a dependency recipe or testing the produced software.
|
|
107
|
+
|
|
108
|
+
Custom validation workflows that need the same graph can invoke the shipped helper before their
|
|
109
|
+
native dependency guard:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
python3 .github/actions/swift-app/scripts/native_preparation.py prepare \
|
|
113
|
+
"$GITHUB_WORKSPACE" tools/ios/native-deps.json "$RUNNER_TEMP/native-preparation.json"
|
|
114
|
+
```
|
package/package.json
CHANGED
package/src/config/schema.mjs
CHANGED
|
@@ -44,6 +44,11 @@ function validateNamedSettings(config) {
|
|
|
44
44
|
if (candidate !== undefined && typeof candidate !== 'boolean') {
|
|
45
45
|
throw new MigrationRepair('candidate_setting_requires_boolean', '.gowalk-cicd.yml');
|
|
46
46
|
}
|
|
47
|
+
const manifest = config.mobile?.ios?.['native-preparation-manifest'];
|
|
48
|
+
if (manifest !== undefined && (typeof manifest !== 'string' || !/^[A-Za-z0-9_.\/-]+$/.test(manifest) ||
|
|
49
|
+
manifest.startsWith('/') || manifest.split('/').some(part => !part || part === '.' || part === '..'))) {
|
|
50
|
+
throw new MigrationRepair('native_preparation_manifest_path_invalid', '.gowalk-cicd.yml');
|
|
51
|
+
}
|
|
47
52
|
for (const platform of ['ios', 'android']) {
|
|
48
53
|
const value = config.mobile?.[platform];
|
|
49
54
|
const allowed = fields.filter(path => path[1] === platform).map(path => path[2]);
|
package/src/config/settings.mjs
CHANGED
|
@@ -6,7 +6,8 @@ const ios = ['jobs', 'ios', 'steps', 'items', 'id:ios', 'with'];
|
|
|
6
6
|
const android = ['jobs', 'android', 'steps', 'items', 'id:android', 'with'];
|
|
7
7
|
const backend = ['jobs', 'deploy', 'steps', 'items', 'name:Deploy backend', 'with'];
|
|
8
8
|
const inputs = {
|
|
9
|
-
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id'
|
|
9
|
+
ios: ['project', 'workspace', 'scheme', 'configuration', 'bundle-id', 'team-id', 'firebase-app-id',
|
|
10
|
+
'native-preparation-manifest'],
|
|
10
11
|
android: ['project-dir', 'module', 'variant', 'firebase-app-id', 'dart-defines'],
|
|
11
12
|
backend: ['ssh-key', 'host', 'app-name', 'api-domain', 'backend-dir', 'compose-file', 'health-path', 'runtime-env',
|
|
12
13
|
'public-health-url', 'public-base-url', 'expected-build-sha', 'ingress-required', 'ingress-token'],
|
|
@@ -16,6 +17,8 @@ export function settingsPaths(file) {
|
|
|
16
17
|
if (file === 'deploy.yml') return [
|
|
17
18
|
{ name: ['mobile', 'platforms'], path: mobile },
|
|
18
19
|
...inputs.ios.map(key => ({ name: ['mobile', 'ios', key], path: [...ios, key] })),
|
|
20
|
+
{ name: ['mobile', 'ios', 'native-preparation-manifest'],
|
|
21
|
+
path: ['jobs', 'ios', 'env', 'NATIVE_PREPARATION_MANIFEST'] },
|
|
19
22
|
...inputs.android.map(key => ({ name: ['mobile', 'android', key], path: [...android, key] })),
|
|
20
23
|
];
|
|
21
24
|
if (file === 'deploy-backend.yml') return inputs.backend.map(key => ({
|
|
Binary file
|