gowalk-cicd 1.0.104 → 1.0.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -12,8 +12,8 @@ Consumer runs `npx gowalk-cicd` and gets:
12
12
  - `.github/workflows/deploy-web.yml` — Flutter web release artifact workflow
13
13
  (only when `web/index.html` exists)
14
14
 
15
- Re-running the command is both install and update. No scope flag, no
16
- uninstall. Keep it simple.
15
+ Re-running the installer updates it. `gowalk-cicd release [ios|android|both]` creates the
16
+ explicit source-and-configuration intent; it does not build or upload. Keep this surface small.
17
17
 
18
18
  ## Structure
19
19
 
@@ -264,6 +264,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
264
264
  Both Flutter configuration and the Pods helper use that bundle in their child process,
265
265
  preserving Podfile.lock and the parent job environment. Deliver the workflow wrapper and
266
266
  action helper together; action-only updates cannot repair earlier custom configuration steps.
267
+ Flutter `--config-only` can query Xcode and install Pods before returning. In the workflow,
268
+ generate Flutter inputs with `pub get`, run the locked `native_pods.py` preparation and
269
+ completed graph guard, then configure the release with its existing obfuscation/define flags.
270
+ The graph guard still applies when there is no Podfile. Preserve this order in custom CI.
267
271
  - Locked Pod installation retries once only when its own attempt reports curl 18 and a partial-transfer
268
272
  symptom. Both attempts share the existing 900-second budget and recheck Podfile, Podfile.lock and
269
273
  selected Ruby bundle inputs. TLS/auth/permission, checksum, version and deployment errors never retry.
@@ -385,3 +389,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
385
389
  refuse before provider work; only exact terminal run/attempt/source proof permits recovery.
386
390
  Other packages stay parallel. The GitHub coordination request is fixed-origin and does not
387
391
  change the Google account proxy. Update workflow wiring as well as action directories.
392
+
393
+ - Mobile Deploy watches `.factory/releases/mobile.json`, produced from committed task source and
394
+ repository variables. Unknown inputs and embedded editors remain in its source proof; native trees
395
+ are platform-scoped. Backend/web deployments remain independent. Confirmed store uploads are
396
+ recorded immediately in repository deployment receipts; retries reuse their prior-run evidence.
397
+ An uncertain upload never starts another binary automatically. Preserve its signed artifact,
398
+ complete exact provider readback/recovery through scoped tooling, then reconcile its receipt.
399
+ Actions without `MOBILE_RELEASE_DEPLOYMENT_ID` preserve legacy consumer behavior. All new
400
+ receipt helper reads are fixed-origin GitHub requests; Apple/Google still use their assigned exits.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  One installer for iOS TestFlight, Android Google Play, and Flutter web release
4
4
  artifacts. It supports native Swift/SwiftUI iOS projects, native Kotlin/Gradle
5
- Android projects, and Flutter apps. Put the platform keys under `creds/`, push
6
- to `main`, and the applicable builds run only on GitHub Actions.
5
+ Android projects, and Flutter apps. Provision encrypted repository secrets, validate a
6
+ task candidate, and promote its release intent. Store builds and uploads run only in GitHub Actions.
7
7
 
8
8
  ## Install
9
9
 
@@ -26,19 +26,46 @@ are unaffected.
26
26
 
27
27
  Re-run the same command anytime to pull the latest version.
28
28
 
29
- Mobile releases run for changes to the app or its release inputs. Documentation,
30
- marketing, `tools/recovery/`, tests, `.factory/progress/`, backend-only changes and
31
- the standard validation/web workflows do not upload another mobile binary. Mixed
32
- commits still release when they change any app input, including embedded editors,
33
- assets and build scripts. Backend action changes deploy the backend; updating only
34
- its `.daemux-version` marker does not. Keep runtime assets outside these excluded
35
- directories, or customize the filters if your build deliberately includes them.
29
+ Mobile delivery requires an explicit frozen release intent. After source, signing, account transport,
30
+ and integration readiness pass, run on the task branch:
36
31
 
37
- These filters take effect after updating and committing the consumer workflow
38
- files with this installer; CI self-update refreshes action directories only.
39
- Follow an existing push-triggered release before dispatching another one. Manual
40
- dispatch remains available after an actual release-input or credential repair;
41
- it bypasses push path filters and can repeat a successful release.
32
+ ```bash
33
+ npx --yes gowalk-cicd release both # or ios / android
34
+ # Inspect and commit this deterministic manifest with the candidate.
35
+ git add -f .factory/releases/mobile.json
36
+ ```
37
+
38
+ Merge the exact validated candidate through the repository's protected PR. Only a change to
39
+ `.factory/releases/mobile.json` triggers Mobile Deploy on main. Ordinary checkpoint, backend,
40
+ web, documentation and tooling landings do not trigger a store build. Backend and web retain
41
+ their own workflows. Web compilation no longer starts just because a plugin version marker changed.
42
+
43
+ The intent binds committed source and a digest of repository variables. A changed source input
44
+ or compiled variable refuses before builds; prepare a new intent after the repair. Native iOS
45
+ and Android directories are scoped to their own platform. Unknown inputs, embedded editors,
46
+ assets, scripts, dependency locks and backend-generated resources remain in the conservative
47
+ source fingerprint. There is no blanket editor/backend exclusion from the input proof.
48
+
49
+ The gate records each platform in GitHub deployment receipts, scoped to that input fingerprint.
50
+ A confirmed upload is reused on subsequent attempts and emits `mobile_upload_reused` with the
51
+ original run/attempt/source. Read that run's annotations and current store state: upload reuse
52
+ still requires unfinished metadata, symbols, policy replies and review submission to be completed
53
+ through the existing scoped commands. It never proves store review approval.
54
+
55
+ Store-upload intent is persisted before provider writes, and success immediately after the uploader
56
+ confirms. An interrupted upload refuses another build: inspect the retained signed binary/upload
57
+ receipt and read back its exact version with the account-pinned store tooling. Resume that same
58
+ upload when necessary. After positive upload readback, reconcile the named GitHub deployment to
59
+ `success` with description `uploaded`, retaining the provider evidence in the app task. Never clear
60
+ an uncertain receipt based on elapsed time or a missing response. A terminal attempt that failed
61
+ before the upload marker may retry. Rerun the whole workflow so admission and platform jobs share
62
+ one run attempt; a failed-jobs-only rerun with an older admission receipt refuses.
63
+
64
+ Mobile runs serialize without cancelling an in-flight upload. Manual dispatch obeys the same
65
+ intent and receipts; it cannot silently rebuild an already-uploaded input. Feature-branch dispatch
66
+ retains backend preview, while store delivery requires the default branch's validated intent.
67
+ Existing consumer workflows keep their behavior until deliberately adopting this template;
68
+ action-only updates do not change triggers. Preserve custom build inputs and readiness when adopting.
42
69
 
43
70
  Flutter's optional SDK and pub cache restores have a one-minute timeout per
44
71
  download segment. A slow segment becomes a cache miss and normal SDK setup
@@ -58,6 +85,12 @@ When adopting this update, retain the `native_bundle.py` configuration wrapper i
58
85
  custom workflows as well as updating the action files. Apps without a Gemfile keep
59
86
  their existing CocoaPods runtime.
60
87
 
88
+ Flutter `--config-only` can query Xcode and invoke `pod install`. The workflow first
89
+ runs `flutter pub get` to generate the Podfile's Flutter inputs, then installs locked
90
+ Pods and verifies the selected workspace before release configuration. Preserve this
91
+ order in customized validation and deployment workflows; updating action directories
92
+ alone cannot repair a configuration step that runs before the action.
93
+
61
94
  ## Flutter web release
62
95
 
63
96
  The conditional `deploy-web.yml` workflow runs on changes to Flutter source,
@@ -321,6 +354,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
321
354
  | `play_upload_result` | `gowalk-cicd/play-upload.v1` | `ok`, `code`, `attempts`, `review_pending`, upstream identity |
322
355
  | `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
323
356
  | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
357
+ | `mobile_upload_reused` | `gowalk-cicd/mobile-upload-reused.v1` | `platform`, `fingerprint`, `run_id`, `run_attempt`, `source_sha`, `deployment_id`, `next_action` |
324
358
  | `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
325
359
  | `apple_build_upload_failed` (error) | `gowalk-cicd/apple-build-upload-failed.v2` | `category`, `stage`, `receipt_available`, receipt identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`, `provider_status`, `provider_code`, `provider_pointer`), `action` |
326
360
  | `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
@@ -1061,10 +1095,9 @@ on a verified task branch (under both `.github/actions/swift-app/` and
1061
1095
  | Candidates | Action updates and later version changes have separate verified receipts |
1062
1096
  | Failure mode | npm discovery remains optional; unconfirmed source preservation fails the job |
1063
1097
 
1064
- The shipped `deploy.yml` template already includes the required
1065
- `paths-ignore` entries for both action directories. If you have an
1066
- older `deploy.yml` checked in, add both entries to prevent the
1067
- tooling-only source landing from re-triggering the mobile workflow.
1098
+ The shipped `deploy.yml` watches only the frozen release intent. Adopting an action-directory
1099
+ update on its own therefore does not launch another mobile upload. Review and adopt the template
1100
+ as well as the action directories to enable this behavior in an existing consumer.
1068
1101
 
1069
1102
  ### deploy.yml is not auto-updated
1070
1103
 
@@ -1 +1 @@
1
- 1.0.104
1
+ 1.0.106
package/action/action.yml CHANGED
@@ -718,6 +718,11 @@ runs:
718
718
  path: ${{ runner.temp }}/firebase-symbols/
719
719
  if-no-files-found: ignore
720
720
 
721
+ - name: Record iOS upload intent
722
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
723
+ shell: bash
724
+ run: node "$SWIFT_APP_ACTION/scripts/mobile_release.cjs" begin-upload ios
725
+
721
726
  - name: Upload to TestFlight
722
727
  id: upload
723
728
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
@@ -729,6 +734,11 @@ runs:
729
734
  python3 "$SWIFT_APP_ACTION/scripts/upload_build.py" "$IPA" \
730
735
  --report "$RUNNER_TEMP/apple-build-upload.json"
731
736
 
737
+ - name: Record confirmed iOS upload
738
+ if: ${{ steps.upload.outcome == 'success' }}
739
+ shell: bash
740
+ run: node "$SWIFT_APP_ACTION/scripts/mobile_release.cjs" finish-upload ios
741
+
732
742
  - name: Retain Apple upload receipt
733
743
  if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' }}
734
744
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
@@ -0,0 +1,55 @@
1
+ /* Frozen mobile build inputs. Unknown source paths remain inputs, including embedded editors. */
2
+ 'use strict';
3
+ const {execFileSync} = require('node:child_process');
4
+ const {createHash} = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const INTENT = '.factory/releases/mobile.json';
7
+ const SCHEMA = 'gowalk-cicd/mobile-release.v1';
8
+ const git = (...args) => execFileSync('git', args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']});
9
+ const digest = value => createHash('sha256').update(value).digest('hex');
10
+
11
+ function input(name, platform) {
12
+ if (name === INTENT || name.startsWith('.factory/progress/') || name.startsWith('creds/')) return false;
13
+ if (/^(README|CHANGELOG|AGENTS|CLAUDE)\.md$/.test(name) || /^docs\/.*\.md$/.test(name)) return false;
14
+ if (platform === 'ios' && name.startsWith('android/')) return false;
15
+ if (platform === 'android' && name.startsWith('ios/')) return false;
16
+ // Backend and web deployment alone never promote a mobile release. Their source
17
+ // remains in this conservative fingerprint in case the app embeds or generates it.
18
+ return true;
19
+ }
20
+
21
+ function configuration(value = {}) {
22
+ return digest(JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))));
23
+ }
24
+
25
+ function fingerprint(platform, ref = 'HEAD') {
26
+ if (!['ios', 'android'].includes(platform)) throw Error('mobile_release_platform_invalid');
27
+ return digest(git('ls-tree', '-r', '-z', ref).split('\0').filter(row =>
28
+ row && input(row.slice(row.indexOf('\t') + 1), platform)).sort().join('\0'));
29
+ }
30
+
31
+ function create(platforms = ['ios', 'android'], variables = {}) {
32
+ if (['main', 'master', ''].includes(git('branch', '--show-current').trim())) {
33
+ throw Error('mobile_release_task_branch_required');
34
+ }
35
+ const dirty = git('status', '--porcelain', '-z', '--untracked-files=all').split('\0').filter(Boolean);
36
+ if (dirty.some(row => row.slice(3) !== INTENT)) throw Error('mobile_release_source_not_committed');
37
+ const manifest = {schema: SCHEMA, configuration_sha256: configuration(variables),
38
+ platforms: Object.fromEntries(platforms.map(p => [p, fingerprint(p)]))};
39
+ fs.mkdirSync('.factory/releases', {recursive: true});
40
+ fs.writeFileSync(INTENT, JSON.stringify(manifest, null, 2) + '\n');
41
+ return {ok: true, file: INTENT, ...manifest};
42
+ }
43
+
44
+ function read(variables = {}) {
45
+ const manifest = JSON.parse(fs.readFileSync(INTENT, 'utf8'));
46
+ if (manifest.configuration_sha256 !== configuration(variables)) throw Error('mobile_release_configuration_changed');
47
+ if (manifest.schema !== SCHEMA || !manifest.platforms || !Object.keys(manifest.platforms).length ||
48
+ Object.entries(manifest.platforms).some(([p, sha]) =>
49
+ !['ios', 'android'].includes(p) || !/^[a-f0-9]{64}$/.test(sha))) throw Error('mobile_release_intent_invalid');
50
+ for (const [p, sha] of Object.entries(manifest.platforms)) {
51
+ if (fingerprint(p) !== sha) throw Error('mobile_release_intent_stale');
52
+ }
53
+ return manifest;
54
+ }
55
+ module.exports = {INTENT, SCHEMA, input, fingerprint, create, read, configuration};
@@ -0,0 +1,97 @@
1
+ /* An explicit source intent admits each platform once; uncertain uploads never replay automatically. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const inputs = require('./mobile_inputs.cjs');
5
+ const api = require('./mobile_release_api.cjs');
6
+ const SCHEMA = 'gowalk-cicd/mobile-upload-owner.v1';
7
+
8
+ function owner(platform, fingerprint) {
9
+ return {schema: SCHEMA, platform, fingerprint, run_id: Number(process.env.GITHUB_RUN_ID),
10
+ run_attempt: Number(process.env.GITHUB_RUN_ATTEMPT), source_sha: process.env.GITHUB_SHA};
11
+ }
12
+
13
+ async function reusable(record, expected) {
14
+ const payload = record.payload;
15
+ if (payload?.schema !== SCHEMA || payload.platform !== expected.platform ||
16
+ payload.fingerprint !== expected.fingerprint || !Number.isSafeInteger(payload.run_id) ||
17
+ !Number.isSafeInteger(payload.run_attempt) || !/^[a-f0-9]{40}$/.test(payload.source_sha)) {
18
+ throw Error('mobile_release_previous_owner_invalid');
19
+ }
20
+ const run = await api.request('GET', `/actions/runs/${payload.run_id}/attempts/${payload.run_attempt}`);
21
+ if (run.head_sha !== payload.source_sha || run.path !== '.github/workflows/deploy.yml') {
22
+ throw Error('mobile_release_previous_run_unverified');
23
+ }
24
+ const statuses = await api.request('GET', `/deployments/${record.id}/statuses?per_page=1`);
25
+ const last = statuses[0];
26
+ if (last?.state === 'success' && last.description === 'uploaded') {
27
+ console.log('::notice title=mobile_upload_reused::' + JSON.stringify({schema: 'gowalk-cicd/mobile-upload-reused.v1',
28
+ platform: expected.platform, fingerprint: expected.fingerprint, run_id: payload.run_id,
29
+ run_attempt: payload.run_attempt, source_sha: payload.source_sha, deployment_id: record.id,
30
+ next_action: 'Read prior run annotations and current store state; finish pending metadata and review work'}));
31
+ return true;
32
+ }
33
+ if (run.status !== 'completed') throw Error('mobile_release_owner_busy_or_unverified');
34
+ // No upload marker means that terminal attempt never crossed the store-write boundary.
35
+ if (!last || last.description === 'building') return false;
36
+ throw Error('mobile_release_upload_unconfirmed_read_back_retained_binary_before_retry');
37
+ }
38
+
39
+ async function admit(platforms = 'both') {
40
+ if (!['both', 'ios', 'android'].includes(platforms)) throw Error('mobile_release_platform_invalid');
41
+ const manifest = inputs.read(JSON.parse(process.env.MOBILE_RELEASE_VARIABLES || '{}'));
42
+ const selected = {};
43
+ for (const [platform, source] of Object.entries(manifest.platforms)) {
44
+ const fingerprint = require('node:crypto').createHash('sha256')
45
+ .update(source + manifest.configuration_sha256).digest('hex');
46
+ if (platforms !== 'both' && platforms !== '' && platforms !== platform) continue;
47
+ const expected = owner(platform, fingerprint);
48
+ const environment = `mobile-${platform}-${fingerprint}`;
49
+ const records = await api.request('GET', `/deployments?environment=${environment}&per_page=100`);
50
+ if (!Array.isArray(records) || records.length >= 100) throw Error('mobile_release_inventory_unbounded');
51
+ if (records.length && await reusable(records[0], expected)) continue;
52
+ const record = await api.request('POST', '/deployments', {ref: process.env.GITHUB_SHA,
53
+ auto_merge: false, required_contexts: [], environment, task: 'mobile-upload', payload: expected,
54
+ transient_environment: false, production_environment: true});
55
+ if (!Number.isSafeInteger(record.id) || record.sha !== expected.source_sha) {
56
+ throw Error('mobile_release_owner_write_unconfirmed');
57
+ }
58
+ await api.status(record.id, 'building');
59
+ selected[platform] = record.id;
60
+ }
61
+ return selected;
62
+ }
63
+
64
+ async function phase(action, platform) {
65
+ const id = Number(process.env.MOBILE_RELEASE_DEPLOYMENT_ID);
66
+ if (!id) return; // Existing consumers without release-intent wiring keep their original behavior.
67
+ const record = await api.request('GET', `/deployments/${id}`);
68
+ const expected = owner(platform, record.payload?.fingerprint);
69
+ if (record.payload?.schema !== SCHEMA || record.payload.run_id !== expected.run_id ||
70
+ record.payload.run_attempt !== expected.run_attempt || record.sha !== expected.source_sha ||
71
+ record.payload.platform !== platform) throw Error('mobile_release_phase_owner_mismatch');
72
+ const statuses = await api.request('GET', `/deployments/${id}/statuses?per_page=1`);
73
+ const required = action === 'begin-upload' ? 'building' : 'uploading';
74
+ if (statuses[0]?.description !== required) throw Error('mobile_release_upload_phase_unconfirmed');
75
+ await api.status(id, action === 'begin-upload' ? 'uploading' : 'uploaded',
76
+ action === 'begin-upload' ? 'in_progress' : 'success');
77
+ }
78
+
79
+ async function main(args) {
80
+ if (args[0] === 'admit') {
81
+ const selected = await admit(args[1] || 'both');
82
+ for (const platform of ['ios', 'android']) {
83
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `${platform}=${selected[platform] || ''}\n`);
84
+ }
85
+ return;
86
+ }
87
+ if (['begin-upload', 'finish-upload'].includes(args[0]) && ['ios', 'android'].includes(args[1])) {
88
+ return phase(args[0], args[1]);
89
+ }
90
+ throw Error('mobile_release_command_invalid');
91
+ }
92
+ if (require.main === module) main(process.argv.slice(2)).catch(error => {
93
+ const code = /^mobile_release_[a-z0-9_]+$/.test(error.message) ? error.message : 'mobile_release_unavailable';
94
+ console.error(`::error title=mobile_release_refused::${code}`);
95
+ process.exitCode = 1;
96
+ });
97
+ module.exports = {owner, reusable, admit, phase};
@@ -0,0 +1,38 @@
1
+ /* Repository-local GitHub deployment receipts; never a store credential or transport. */
2
+ 'use strict';
3
+ const https = require('node:https');
4
+
5
+ function request(method, path, body) {
6
+ const repo = process.env.GITHUB_REPOSITORY;
7
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo || '') || !path.startsWith('/') || path.includes('..')) {
8
+ throw Error('mobile_release_repository_invalid');
9
+ }
10
+ const data = body === undefined ? null : JSON.stringify(body);
11
+ return new Promise((resolve, reject) => {
12
+ const req = https.request({hostname: 'api.github.com', path: `/repos/${repo}${path}`, method,
13
+ headers: {'User-Agent': 'gowalk-cicd', Accept: 'application/vnd.github+json',
14
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN || ''}`, 'X-GitHub-Api-Version': '2022-11-28',
15
+ ...(data ? {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data)} : {})}}, res => {
16
+ let text = '';
17
+ res.on('data', chunk => { text += chunk; if (text.length > 4 * 1024 * 1024) res.destroy(); });
18
+ res.on('error', () => reject(Error('mobile_release_receipt_read_unavailable')));
19
+ res.on('end', () => {
20
+ if (res.statusCode < 200 || res.statusCode >= 300) {
21
+ return reject(Error(`mobile_release_http_${res.statusCode}`));
22
+ }
23
+ try { resolve(JSON.parse(text)); } catch { reject(Error('mobile_release_receipt_invalid')); }
24
+ });
25
+ });
26
+ req.setTimeout(30000, () => req.destroy(Error('timeout')));
27
+ req.on('error', () => reject(Error('mobile_release_receipt_transport_unconfirmed')));
28
+ req.end(data);
29
+ });
30
+ }
31
+
32
+ async function status(id, phase, state = 'in_progress') {
33
+ const result = await request('POST', `/deployments/${id}/statuses`, {state, description: phase, auto_inactive: false,
34
+ log_url: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`});
35
+ if (result.state !== state || result.description !== phase) throw Error('mobile_release_receipt_unconfirmed');
36
+ return result;
37
+ }
38
+ module.exports = {request, status};
@@ -0,0 +1,107 @@
1
+ """Execute the shipped Flutter preparation shell with real guards and offline tool fixtures."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import textwrap
9
+ import unittest
10
+
11
+ import native_bundle
12
+ from test_native_dependency_guard import project
13
+
14
+ REPO = Path(__file__).resolve().parents[2]
15
+
16
+
17
+ class FlutterPreparationTests(unittest.TestCase):
18
+ def setUp(self):
19
+ temporary = tempfile.TemporaryDirectory(prefix="flutter-native-preparation-")
20
+ self.addCleanup(temporary.cleanup)
21
+ self.root = Path(temporary.name).resolve()
22
+ project(self.root, "ios/Runner.xcodeproj")
23
+ workspace = self.root / "ios/Runner.xcworkspace"
24
+ workspace.mkdir()
25
+ (workspace / "contents.xcworkspacedata").write_text(
26
+ '<Workspace><FileRef location="group:Runner.xcodeproj"/></Workspace>')
27
+ for name in ("Podfile", "Podfile.lock"):
28
+ (self.root / "ios" / name).write_text("fixture lock\n")
29
+ (self.root / "pubspec.yaml").write_text("name: fixture\n")
30
+ for argv in (["git", "init", "-q"], ["git", "add", "."],
31
+ ["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
32
+ "commit", "-qm", "Freeze native fixture"]):
33
+ subprocess.run(argv, cwd=self.root, capture_output=True, check=True)
34
+ actions = self.root / ".github/actions"
35
+ actions.mkdir(parents=True)
36
+ (actions / "swift-app").symlink_to(REPO / "action", target_is_directory=True)
37
+ (actions / "android-app").symlink_to(REPO / "android-action", target_is_directory=True)
38
+ self.env = {**os.environ, "PATH": str(self.root) + os.pathsep + os.environ["PATH"],
39
+ "GITHUB_WORKSPACE": str(self.root), "RUNNER_TEMP": str(self.root / "scratch"),
40
+ "ONBOARDING_PUBLIC_ORIGIN": "https://flow.example.invalid",
41
+ "GOOGLE_STORE_PROXY_URL": "http://fixture:PRIVATE_SENTINEL@127.0.0.1:9"}
42
+ self.install_tools()
43
+
44
+ def executable(self, name, source):
45
+ path = self.root / name
46
+ path.write_text("#!" + sys.executable + "\n" + textwrap.dedent(source))
47
+ path.chmod(0o700)
48
+
49
+ def install_tools(self):
50
+ self.executable("flutter", '''
51
+ import json,sys
52
+ from pathlib import Path
53
+ if sys.argv[1:3] == ["pub", "get"]:
54
+ folder=Path("ios/Flutter");folder.mkdir()
55
+ (folder/"Generated.xcconfig").write_text("generated")
56
+ elif sys.argv[1:3] == ["build", "ios"]:
57
+ Path("configuration.json").write_text(json.dumps(sys.argv[1:]))
58
+ assert Path("ios/pods-ready").is_file(), "Configuration reached before locked Pods"
59
+ else:
60
+ raise SystemExit(9)
61
+ ''')
62
+ self.executable("pod", '''
63
+ import json,os,sys
64
+ from pathlib import Path
65
+ assert Path("Flutter/Generated.xcconfig").is_file()
66
+ assert sys.argv[1:] == ["install", "--deployment", "--no-repo-update"]
67
+ assert os.environ["HTTPS_PROXY"].startswith("http://127.0.0.1:")
68
+ Path("pod-called").touch()
69
+ if os.environ.get("FAIL_PODS"):
70
+ raise SystemExit(7)
71
+ Path("pods-ready").touch()
72
+ ''')
73
+
74
+ def run_preparation(self):
75
+ body = (REPO / "templates/deploy.yml").read_text().split(
76
+ "- name: Prepare Flutter iOS project\n", 1)[1].split("\n - ", 1)[0]
77
+ script = textwrap.dedent(body.split(" run: |\n", 1)[1])
78
+ result = subprocess.run([sys.executable, native_bundle.__file__, "--folder", "ios",
79
+ "--proxy-helper", str(REPO / "android-action/scripts/play_store_proxy.py"),
80
+ "--", "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script],
81
+ cwd=self.root, env=self.env, capture_output=True, text=True, timeout=20)
82
+ self.assertNotIn("PRIVATE_SENTINEL", result.stdout + result.stderr)
83
+ return result
84
+
85
+ def test_locked_pods_precede_configuration_without_losing_release_arguments(self):
86
+ result = self.run_preparation()
87
+ self.assertEqual(result.returncode, 0, result.stdout)
88
+ self.assertTrue((self.root / "ios/pod-called").exists())
89
+ args = json.loads((self.root / "configuration.json").read_text())
90
+ for arg in ("--release", "--no-codesign", "--config-only", "--obfuscate",
91
+ "--split-debug-info=" + str(self.root / "scratch/dart-symbols/ios"),
92
+ "ONBOARDING_PUBLIC_ORIGIN=https://flow.example.invalid"):
93
+ self.assertIn(arg, args)
94
+ self.assertEqual((self.root / "ios/Podfile.lock").read_text(), "fixture lock\n")
95
+
96
+ def test_failed_locked_install_stops_before_flutter_configuration(self):
97
+ self.env["FAIL_PODS"] = "1"
98
+ self.assertNotEqual(self.run_preparation().returncode, 0)
99
+ self.assertTrue((self.root / "ios/pod-called").exists())
100
+ self.assertFalse((self.root / "configuration.json").exists())
101
+
102
+ def test_remote_package_graph_refuses_before_configuration_without_podfile(self):
103
+ (self.root / "ios/Podfile").unlink()
104
+ project(self.root, "ios/Runner.xcodeproj", {"remote": {"isa": "XCRemoteSwiftPackageReference"}})
105
+ self.assertNotEqual(self.run_preparation().returncode, 0)
106
+ self.assertFalse((self.root / "ios/pod-called").exists())
107
+ self.assertFalse((self.root / "configuration.json").exists())
@@ -1 +1 @@
1
- 1.0.104
1
+ 1.0.106
@@ -1 +1 @@
1
- 1.0.104
1
+ 1.0.106
package/bin/cli.mjs CHANGED
@@ -21,6 +21,22 @@ async function loadNotifier() {
21
21
  }
22
22
 
23
23
  const args = process.argv.slice(2);
24
+ if (args[0] === 'release') {
25
+ const platform = args[1] || 'both';
26
+ if (args.length > 2 || !['ios', 'android', 'both'].includes(platform)) {
27
+ console.error('Usage: gowalk-cicd release [ios|android|both]');
28
+ process.exit(2);
29
+ }
30
+ const {release} = await import('../src/release.mjs');
31
+ try {
32
+ console.log(JSON.stringify(release(platform)));
33
+ process.exit(0);
34
+ } catch (error) {
35
+ const code = /^mobile_release_[a-z_]+$/.test(error.message) ? error.message : 'mobile_release_prepare_unavailable';
36
+ console.error(code);
37
+ process.exit(1);
38
+ }
39
+ }
24
40
  let dryRun = false;
25
41
 
26
42
  for (const arg of args) {
@@ -44,6 +60,7 @@ What it writes:
44
60
  .github/workflows/deploy-web.yml - Flutter web release workflow when web/ exists
45
61
 
46
62
  Options:
63
+ release [ios|android|both] Prepare a frozen mobile release intent on the task branch
47
64
  --dry-run Print what would happen without writing files
48
65
  -v, --version Show version number
49
66
  -h, --help Show this help message
@@ -53,10 +70,10 @@ Examples:
53
70
  npx --yes gowalk-cicd --dry-run
54
71
 
55
72
  After install:
56
- 1. Place your App Store Connect API key at creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
57
- 2. Add the Android upload keystore, signing properties, and Play service account
58
- 3. Make the repo PRIVATE (credentials are read from creds/)
59
- 4. Commit + push to main -- CI builds both platforms`);
73
+ 1. Provision repository secrets through your account-scoped tooling
74
+ 2. Finish source and readiness checks on the protected task branch
75
+ 3. Run gowalk-cicd release, then commit .factory/releases/mobile.json
76
+ 4. Merge the validated candidate; its release intent admits the selected stores`);
60
77
  process.exit(0);
61
78
  case '--dry-run':
62
79
  dryRun = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.104",
3
+ "version": "1.0.106",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/install.mjs CHANGED
@@ -219,7 +219,7 @@ function printSummary() {
219
219
  console.log(' GOOGLE_PLAY_SERVICE_ACCOUNT_JSON');
220
220
  console.log(' The workflow materializes them with mode 0600 only on its ephemeral runner.');
221
221
  console.log(' Existing checkout credentials remain a backward-compatible fallback.');
222
- console.log(' Push to main after configuring the secrets; GitHub Actions builds both platforms.');
222
+ console.log(' Validate committed task source, run gowalk-cicd release, and merge its frozen intent through a PR.');
223
223
  console.log('');
224
224
  console.log('Re-run this installer anytime to update the vendored action:');
225
225
  console.log(' npx --yes gowalk-cicd');
@@ -0,0 +1,16 @@
1
+ // Freeze committed source and the repository variables the workflow will actually read.
2
+ import {execFileSync} from 'node:child_process';
3
+ import inputs from '../action/scripts/mobile_inputs.cjs';
4
+
5
+ export function release(platform) {
6
+ const execute = (command, args) => execFileSync(command, args, {
7
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000,
8
+ }).trim();
9
+ const remote = execute('git', ['remote', 'get-url', 'origin']);
10
+ const match = /^(?:git@github\.com:|https:\/\/github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(remote);
11
+ if (!match) throw Error('mobile_release_repository_unverified');
12
+ const pages = JSON.parse(execute('gh', ['api', `repos/${match[1]}/actions/variables?per_page=100`,
13
+ '--paginate', '--slurp']));
14
+ const variables = Object.fromEntries(pages.flatMap(page => page.variables).map(row => [row.name, row.value]));
15
+ return inputs.create(platform === 'both' ? ['ios', 'android'] : [platform], variables);
16
+ }
@@ -10,7 +10,6 @@ on:
10
10
  - 'pubspec.lock'
11
11
  - 'l10n.yaml'
12
12
  - 'analysis_options.yaml'
13
- - '.github/actions/*/.daemux-version'
14
13
  - '.github/workflows/deploy-web.yml'
15
14
  workflow_dispatch:
16
15
 
@@ -2,41 +2,38 @@ name: Mobile Deploy
2
2
  on:
3
3
  push:
4
4
  branches: [main]
5
- paths-ignore:
6
- - '**/*.md'
7
- - '.gitignore'
8
- - 'creds/**'
9
- # Durable task records and independently deployed backend sources do not
10
- # change a native binary. Mixed commits still run when any mobile input changes.
11
- - '.factory/progress/**'
12
- - 'docs/**'
13
- - 'marketing/**'
14
- - 'tools/recovery/**'
15
- - 'test/**'
16
- - 'tests/**'
17
- - 'integration_test/**'
18
- - 'backend/**'
19
- - 'server/**'
20
- - 'docker-compose.yml'
21
- - '.github/workflows/deploy-backend.yml'
22
- - '.github/workflows/deploy-web.yml'
23
- - '.github/workflows/flutter-ci.yml'
24
- - '.github/workflows/ios-ci.yml'
25
- - '.github/actions/swift-app/**'
26
- - '.github/actions/android-app/**'
27
- - '.github/actions/backend-app/**'
5
+ paths:
6
+ - '.factory/releases/mobile.json'
28
7
  workflow_dispatch:
29
8
 
30
9
  permissions:
31
10
  contents: write
32
11
  actions: read
33
12
  models: read
13
+ deployments: write
34
14
 
35
15
  concurrency:
36
16
  group: mobile-deploy-${{ github.ref }}
37
- cancel-in-progress: true
17
+ cancel-in-progress: false
38
18
 
39
19
  jobs:
20
+ release:
21
+ name: Admit frozen mobile release
22
+ if: ${{ github.ref_name == github.event.repository.default_branch }}
23
+ runs-on: ubuntu-24.04
24
+ timeout-minutes: 5
25
+ outputs:
26
+ ios: ${{ steps.intent.outputs.ios }}
27
+ android: ${{ steps.intent.outputs.android }}
28
+ steps:
29
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
30
+ - name: Verify release intent and prior upload receipts
31
+ id: intent
32
+ env:
33
+ GITHUB_TOKEN: ${{ github.token }}
34
+ DEPLOY_PLATFORMS: ${{ vars.DEPLOY_PLATFORMS || 'both' }}
35
+ MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
36
+ run: node .github/actions/swift-app/scripts/mobile_release.cjs admit "$DEPLOY_PLATFORMS"
40
37
  # Repository variable DEPLOY_PLATFORMS selects which stores this repo ships to:
41
38
  # unset / 'both' — iOS and Android (the default)
42
39
  # 'android' — Android only; skip the iOS job entirely
@@ -44,11 +41,14 @@ jobs:
44
41
  # Use it when one platform is intentionally out of scope for a repo, so an
45
42
  # unrelated failure on the other platform cannot block the release you want.
46
43
  ios:
44
+ needs: release
47
45
  name: iOS TestFlight
48
46
  env:
47
+ MOBILE_RELEASE_DEPLOYMENT_ID: ${{ needs.release.outputs.ios }}
48
+ GITHUB_TOKEN: ${{ github.token }}
49
49
  APPLE_STORE_PROXY_URL: ${{ secrets.APPLE_STORE_PROXY_URL }}
50
50
  GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
51
- if: ${{ vars.DEPLOY_PLATFORMS != 'android' }}
51
+ if: ${{ needs.release.outputs.ios != '' }}
52
52
  runs-on: macos-15
53
53
  timeout-minutes: 60
54
54
  steps:
@@ -149,6 +149,13 @@ jobs:
149
149
  ONBOARDING_PUBLIC_ORIGIN: ${{ vars.ONBOARDING_PUBLIC_ORIGIN }}
150
150
  run: |
151
151
  flutter pub get
152
+ # Even --config-only can query Xcode and run pod install. Pub generates
153
+ # the Flutter configuration needed by the Podfile without resolving Pods.
154
+ python3 .github/actions/swift-app/scripts/native_pods.py "$GITHUB_WORKSPACE" \
155
+ --container ios/Runner.xcworkspace \
156
+ --proxy-helper .github/actions/android-app/scripts/play_store_proxy.py
157
+ python3 .github/actions/swift-app/scripts/native_dependency_guard.py "$GITHUB_WORKSPACE" \
158
+ --container ios/Runner.xcworkspace
152
159
  # Flutter removed the synthetic `package:flutter_gen`, so apps that use
153
160
  # AppLocalizations now generate real sources. Nothing else generates them,
154
161
  # and analyze/compile fail on the missing import if we skip this.
@@ -240,11 +247,14 @@ jobs:
240
247
  if-no-files-found: error
241
248
 
242
249
  android:
250
+ needs: release
243
251
  name: Android Google Play
244
252
  env:
253
+ MOBILE_RELEASE_DEPLOYMENT_ID: ${{ needs.release.outputs.android }}
254
+ GITHUB_TOKEN: ${{ github.token }}
245
255
  GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
246
256
  PLAY_EDIT_BINDING: ${{ vars.APP_ROBOT_PLAY_EDIT_BINDING }}
247
- if: ${{ vars.DEPLOY_PLATFORMS != 'ios' }}
257
+ if: ${{ needs.release.outputs.android != '' }}
248
258
  runs-on: ubuntu-24.04
249
259
  timeout-minutes: 45
250
260
  steps:
@@ -286,12 +296,19 @@ jobs:
286
296
  fi
287
297
 
288
298
  # --- Bitrise mode ---------------------------------------------------
299
+ - name: Record Bitrise upload intent
300
+ if: ${{ steps.mode.outputs.mode == 'bitrise' }}
301
+ run: node .github/actions/swift-app/scripts/mobile_release.cjs begin-upload android
289
302
  - name: Deploy Android via Bitrise
303
+ id: bitrise
290
304
  if: ${{ steps.mode.outputs.mode == 'bitrise' }}
291
305
  shell: bash
292
306
  env:
293
307
  BITRISE_API_TOKEN: ${{ secrets.BITRISE_API_TOKEN }}
294
308
  run: python3 .github/actions/android-app/scripts/bitrise_deploy.py
309
+ - name: Record confirmed Bitrise delivery
310
+ if: ${{ steps.bitrise.outcome == 'success' }}
311
+ run: node .github/actions/swift-app/scripts/mobile_release.cjs finish-upload android
295
312
 
296
313
  # --- Local mode -----------------------------------------------------
297
314
  # Native Gradle apps must not pay for a Flutter install, and their AGP
@@ -522,6 +539,9 @@ jobs:
522
539
  # The wrapper verifies the unchanged pinned official uploader and classifies
523
540
  # its immediate result. Only explicit expired/deleted edits retry normally;
524
541
  # only Play's explicit review-hold refusal changes the review flag.
542
+ - name: Record Android upload intent
543
+ if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
544
+ run: node .github/actions/swift-app/scripts/mobile_release.cjs begin-upload android
525
545
  - name: Upload Android release to Google Play
526
546
  id: play-upload
527
547
  if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
@@ -540,6 +560,10 @@ jobs:
540
560
  whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
541
561
 
542
562
  # The retry landed the bundle without submitting it. That is a fact for
563
+ - name: Record confirmed Android upload
564
+ if: ${{ steps.play-upload.outcome == 'success' }}
565
+ run: node .github/actions/swift-app/scripts/mobile_release.cjs finish-upload android
566
+
543
567
  # the panel that merged this commit, not a chore for a person: the panel
544
568
  # reads this typed check-run annotation off the job (title + JSON with a
545
569
  # versioned schema, never the log text) and opens its own Play console