gowalk-cicd 1.0.105 → 1.0.107
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 +11 -2
- package/README.md +46 -18
- package/action/.daemux-version +1 -1
- package/action/action.yml +10 -0
- package/action/scripts/mobile_inputs.cjs +56 -0
- package/action/scripts/mobile_release.cjs +97 -0
- package/action/scripts/mobile_release_api.cjs +38 -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 +36 -0
- package/templates/deploy-web.yml +0 -1
- package/templates/deploy.yml +43 -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
|
|
|
@@ -389,3 +389,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
389
389
|
refuse before provider work; only exact terminal run/attempt/source proof permits recovery.
|
|
390
390
|
Other packages stay parallel. The GitHub coordination request is fixed-origin and does not
|
|
391
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,47 @@ 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 effective organization/repository variables, with repository
|
|
44
|
+
precedence. A changed source input 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. Documents remain inputs because an app may bundle them. There is no blanket
|
|
48
|
+
editor/backend exclusion from the input proof. These conservative hashes do not add push triggers.
|
|
49
|
+
|
|
50
|
+
The gate records each platform in GitHub deployment receipts, scoped to that input fingerprint.
|
|
51
|
+
A confirmed upload is reused on subsequent attempts and emits `mobile_upload_reused` with the
|
|
52
|
+
original run/attempt/source. Read that run's annotations and current store state: upload reuse
|
|
53
|
+
still requires unfinished metadata, symbols, policy replies and review submission to be completed
|
|
54
|
+
through the existing scoped commands. It never proves store review approval.
|
|
55
|
+
|
|
56
|
+
Store-upload intent is persisted before provider writes, and success immediately after the uploader
|
|
57
|
+
confirms. An interrupted upload refuses another build: inspect the retained signed binary/upload
|
|
58
|
+
receipt and read back its exact version with the account-pinned store tooling. Resume that same
|
|
59
|
+
upload when necessary. After positive upload readback, reconcile the named GitHub deployment to
|
|
60
|
+
`success` with description `uploaded`, retaining the provider evidence in the app task. Never clear
|
|
61
|
+
an uncertain receipt based on elapsed time or a missing response. A terminal attempt that failed
|
|
62
|
+
before the upload marker may retry. Rerun the whole workflow so admission and platform jobs share
|
|
63
|
+
one run attempt; a failed-jobs-only rerun with an older admission receipt refuses.
|
|
64
|
+
|
|
65
|
+
Mobile runs serialize without cancelling an in-flight upload. Manual dispatch obeys the same
|
|
66
|
+
intent and receipts; it cannot silently rebuild an already-uploaded input. Feature-branch dispatch
|
|
67
|
+
retains backend preview, while store delivery requires the default branch's validated intent.
|
|
68
|
+
Existing consumer workflows keep their behavior until deliberately adopting this template;
|
|
69
|
+
action-only updates do not change triggers. Preserve custom build inputs and readiness when adopting.
|
|
42
70
|
|
|
43
71
|
Flutter's optional SDK and pub cache restores have a one-minute timeout per
|
|
44
72
|
download segment. A slow segment becomes a cache miss and normal SDK setup
|
|
@@ -327,6 +355,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
|
|
|
327
355
|
| `play_upload_result` | `gowalk-cicd/play-upload.v1` | `ok`, `code`, `attempts`, `review_pending`, upstream identity |
|
|
328
356
|
| `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
|
|
329
357
|
| `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
|
|
358
|
+
| `mobile_upload_reused` | `gowalk-cicd/mobile-upload-reused.v1` | `platform`, `fingerprint`, `run_id`, `run_attempt`, `source_sha`, `deployment_id`, `next_action` |
|
|
330
359
|
| `source_maintenance_pending` | `gowalk-cicd/source-maintenance-pending.v1` | `repo`, `base_sha`, `head_sha`, `branch`, `run_id` |
|
|
331
360
|
| `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` |
|
|
332
361
|
| `flutter_download_failed` (error) | `gowalk-cicd/flutter-download-failed.v1` | `phase`, `curl_exit`, `http_status`, `proxy_status`, `attempts`, `retained_bytes` |
|
|
@@ -1067,10 +1096,9 @@ on a verified task branch (under both `.github/actions/swift-app/` and
|
|
|
1067
1096
|
| Candidates | Action updates and later version changes have separate verified receipts |
|
|
1068
1097
|
| Failure mode | npm discovery remains optional; unconfirmed source preservation fails the job |
|
|
1069
1098
|
|
|
1070
|
-
The shipped `deploy.yml`
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
tooling-only source landing from re-triggering the mobile workflow.
|
|
1099
|
+
The shipped `deploy.yml` watches only the frozen release intent. Adopting an action-directory
|
|
1100
|
+
update on its own therefore does not launch another mobile upload. Review and adopt the template
|
|
1101
|
+
as well as the action directories to enable this behavior in an existing consumer.
|
|
1074
1102
|
|
|
1075
1103
|
### deploy.yml is not auto-updated
|
|
1076
1104
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.107
|
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,56 @@
|
|
|
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
|
+
// Documents may be bundled resources or test inputs. Promotion is explicit, so
|
|
14
|
+
// retaining them here cannot make an ordinary documentation push deploy mobile.
|
|
15
|
+
if (platform === 'ios' && name.startsWith('android/')) return false;
|
|
16
|
+
if (platform === 'android' && name.startsWith('ios/')) return false;
|
|
17
|
+
// Backend and web deployment alone never promote a mobile release. Their source
|
|
18
|
+
// remains in this conservative fingerprint in case the app embeds or generates it.
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function configuration(value = {}) {
|
|
23
|
+
return digest(JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fingerprint(platform, ref = 'HEAD') {
|
|
27
|
+
if (!['ios', 'android'].includes(platform)) throw Error('mobile_release_platform_invalid');
|
|
28
|
+
return digest(git('ls-tree', '-r', '-z', ref).split('\0').filter(row =>
|
|
29
|
+
row && input(row.slice(row.indexOf('\t') + 1), platform)).sort().join('\0'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function create(platforms = ['ios', 'android'], variables = {}) {
|
|
33
|
+
if (['main', 'master', ''].includes(git('branch', '--show-current').trim())) {
|
|
34
|
+
throw Error('mobile_release_task_branch_required');
|
|
35
|
+
}
|
|
36
|
+
const dirty = git('status', '--porcelain', '-z', '--untracked-files=all').split('\0').filter(Boolean);
|
|
37
|
+
if (dirty.some(row => row.slice(3) !== INTENT)) throw Error('mobile_release_source_not_committed');
|
|
38
|
+
const manifest = {schema: SCHEMA, configuration_sha256: configuration(variables),
|
|
39
|
+
platforms: Object.fromEntries(platforms.map(p => [p, fingerprint(p)]))};
|
|
40
|
+
fs.mkdirSync('.factory/releases', {recursive: true});
|
|
41
|
+
fs.writeFileSync(INTENT, JSON.stringify(manifest, null, 2) + '\n');
|
|
42
|
+
return {ok: true, file: INTENT, ...manifest};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function read(variables = {}) {
|
|
46
|
+
const manifest = JSON.parse(fs.readFileSync(INTENT, 'utf8'));
|
|
47
|
+
if (manifest.configuration_sha256 !== configuration(variables)) throw Error('mobile_release_configuration_changed');
|
|
48
|
+
if (manifest.schema !== SCHEMA || !manifest.platforms || !Object.keys(manifest.platforms).length ||
|
|
49
|
+
Object.entries(manifest.platforms).some(([p, sha]) =>
|
|
50
|
+
!['ios', 'android'].includes(p) || !/^[a-f0-9]{64}$/.test(sha))) throw Error('mobile_release_intent_invalid');
|
|
51
|
+
for (const [p, sha] of Object.entries(manifest.platforms)) {
|
|
52
|
+
if (fingerprint(p) !== sha) throw Error('mobile_release_intent_stale');
|
|
53
|
+
}
|
|
54
|
+
return manifest;
|
|
55
|
+
}
|
|
56
|
+
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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.107
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.107
|
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,36 @@
|
|
|
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 variables(repository, execute) {
|
|
6
|
+
const info = JSON.parse(execute('gh', ['api', `repos/${repository}`]));
|
|
7
|
+
if (info.full_name !== repository || !['User', 'Organization'].includes(info.owner?.type)) {
|
|
8
|
+
throw Error('mobile_release_repository_unverified');
|
|
9
|
+
}
|
|
10
|
+
const scopes = info.owner.type === 'Organization' ? ['organization-variables', 'variables'] : ['variables'];
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const scope of scopes) {
|
|
13
|
+
const pages = JSON.parse(execute('gh', ['api', `repos/${repository}/actions/${scope}?per_page=100`,
|
|
14
|
+
'--paginate', '--slurp']));
|
|
15
|
+
if (!Array.isArray(pages) || pages.some(page => !Array.isArray(page.variables))) {
|
|
16
|
+
throw Error('mobile_release_configuration_unverified');
|
|
17
|
+
}
|
|
18
|
+
for (const row of pages.flatMap(page => page.variables)) {
|
|
19
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(row.name) || typeof row.value !== 'string') {
|
|
20
|
+
throw Error('mobile_release_configuration_unverified');
|
|
21
|
+
}
|
|
22
|
+
result[row.name] = row.value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function release(platform) {
|
|
29
|
+
const execute = (command, args) => execFileSync(command, args, {
|
|
30
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000,
|
|
31
|
+
}).trim();
|
|
32
|
+
const remote = execute('git', ['remote', 'get-url', 'origin']);
|
|
33
|
+
const match = /^(?:git@github\.com:|https:\/\/github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(remote);
|
|
34
|
+
if (!match) throw Error('mobile_release_repository_unverified');
|
|
35
|
+
return inputs.create(platform === 'both' ? ['ios', 'android'] : [platform], variables(match[1], execute));
|
|
36
|
+
}
|
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:
|
|
@@ -247,11 +247,14 @@ jobs:
|
|
|
247
247
|
if-no-files-found: error
|
|
248
248
|
|
|
249
249
|
android:
|
|
250
|
+
needs: release
|
|
250
251
|
name: Android Google Play
|
|
251
252
|
env:
|
|
253
|
+
MOBILE_RELEASE_DEPLOYMENT_ID: ${{ needs.release.outputs.android }}
|
|
254
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
252
255
|
GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
253
256
|
PLAY_EDIT_BINDING: ${{ vars.APP_ROBOT_PLAY_EDIT_BINDING }}
|
|
254
|
-
if: ${{
|
|
257
|
+
if: ${{ needs.release.outputs.android != '' }}
|
|
255
258
|
runs-on: ubuntu-24.04
|
|
256
259
|
timeout-minutes: 45
|
|
257
260
|
steps:
|
|
@@ -293,12 +296,19 @@ jobs:
|
|
|
293
296
|
fi
|
|
294
297
|
|
|
295
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
|
|
296
302
|
- name: Deploy Android via Bitrise
|
|
303
|
+
id: bitrise
|
|
297
304
|
if: ${{ steps.mode.outputs.mode == 'bitrise' }}
|
|
298
305
|
shell: bash
|
|
299
306
|
env:
|
|
300
307
|
BITRISE_API_TOKEN: ${{ secrets.BITRISE_API_TOKEN }}
|
|
301
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
|
|
302
312
|
|
|
303
313
|
# --- Local mode -----------------------------------------------------
|
|
304
314
|
# Native Gradle apps must not pay for a Flutter install, and their AGP
|
|
@@ -529,6 +539,9 @@ jobs:
|
|
|
529
539
|
# The wrapper verifies the unchanged pinned official uploader and classifies
|
|
530
540
|
# its immediate result. Only explicit expired/deleted edits retry normally;
|
|
531
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
|
|
532
545
|
- name: Upload Android release to Google Play
|
|
533
546
|
id: play-upload
|
|
534
547
|
if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
|
|
@@ -547,6 +560,10 @@ jobs:
|
|
|
547
560
|
whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
|
|
548
561
|
|
|
549
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
|
+
|
|
550
567
|
# the panel that merged this commit, not a chore for a person: the panel
|
|
551
568
|
# reads this typed check-run annotation off the job (title + JSON with a
|
|
552
569
|
# versioned schema, never the log text) and opens its own Play console
|