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 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
 
@@ -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. 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,47 @@ 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 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` template already includes the required
1071
- `paths-ignore` entries for both action directories. If you have an
1072
- older `deploy.yml` checked in, add both entries to prevent the
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
 
@@ -1 +1 @@
1
- 1.0.105
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.105
1
+ 1.0.107
@@ -1 +1 @@
1
- 1.0.105
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. 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.105",
3
+ "version": "1.0.107",
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,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
+ }
@@ -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:
@@ -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: ${{ vars.DEPLOY_PLATFORMS != 'ios' }}
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