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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.208",
3
+ "version": "1.0.209",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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]);
@@ -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