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 +15 -2
- package/README.md +51 -18
- package/action/.daemux-version +1 -1
- package/action/action.yml +10 -0
- package/action/scripts/mobile_inputs.cjs +55 -0
- package/action/scripts/mobile_release.cjs +97 -0
- package/action/scripts/mobile_release_api.cjs +38 -0
- package/action/scripts/test_flutter_native_preparation.py +107 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/bin/cli.mjs +21 -4
- package/package.json +1 -1
- package/src/install.mjs +1 -1
- package/src/release.mjs +16 -0
- package/templates/deploy-web.yml +0 -1
- package/templates/deploy.yml +50 -26
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
|
|
16
|
-
|
|
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.
|
|
6
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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`
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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.
|
|
1
|
+
1.0.106
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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.
|
|
57
|
-
2.
|
|
58
|
-
3.
|
|
59
|
-
4.
|
|
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
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('
|
|
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');
|
package/src/release.mjs
ADDED
|
@@ -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
|
+
}
|
package/templates/deploy-web.yml
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -2,41 +2,38 @@ name: Mobile Deploy
|
|
|
2
2
|
on:
|
|
3
3
|
push:
|
|
4
4
|
branches: [main]
|
|
5
|
-
paths
|
|
6
|
-
- '
|
|
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:
|
|
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: ${{
|
|
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: ${{
|
|
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
|