gowalk-cicd 1.0.29 → 1.0.31

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
@@ -92,6 +92,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
92
92
  Detect and warn, tell the user to remove lines manually.
93
93
  - Store API calls belong in GitHub Actions. Local tests may build/sign but must
94
94
  never connect to App Store Connect or Google Play.
95
+ - Signing material should use encrypted Actions secrets. `deploy.yml`
96
+ materializes `ASC_KEY_P8` + its ID/issuer and the Android keystore,
97
+ properties and Play service account into an ephemeral runner checkout.
98
+ Tracked `creds/` files remain a backward-compatible fallback.
95
99
  - The Android action serves two build systems. `android_config.project_kind()`
96
100
  is the only place that decides which; every downstream step branches on the
97
101
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
package/README.md CHANGED
@@ -437,6 +437,13 @@ The iOS composite action runs on `macos-15` and:
437
437
 
438
438
  The iOS action requires only the p8. Everything else is derived.
439
439
 
440
+ Feature-branch runs installed from the standard workflow still archive and
441
+ upload to TestFlight, but pass `manage-app-store-version: 'false'`. This keeps
442
+ branch validation independent of the editable App Store release slot, so a
443
+ build can upload while the current release is locked in review. Default-branch
444
+ runs retain version creation and metadata automation. Direct action callers
445
+ keep the historical behavior because the new input defaults to `true`.
446
+
440
447
  ### App ID capabilities
441
448
 
442
449
  A provisioning profile carries only the capabilities enabled on its App ID.
@@ -569,6 +576,7 @@ common ones:
569
576
  | `uses-non-exempt-encryption` | Value for `ITSAppUsesNonExemptEncryption` |
570
577
  | `archive` | `false` to build-only (PR runs without secrets) |
571
578
  | `upload` | `false` to archive but not upload to TestFlight |
579
+ | `manage-app-store-version` | `false` to upload without creating/editing the App Store release slot |
572
580
  | `app-store-whats-new` | Inline "What's New" text (overrides files) |
573
581
  | `ai-metadata` | `false` to disable AI auto-fill of empty ASC metadata |
574
582
  | `ai-metadata-model` | GitHub Models model id (default `openai/gpt-4o`) |
@@ -1 +1 @@
1
- 1.0.29
1
+ 1.0.31
package/action/action.yml CHANGED
@@ -64,6 +64,13 @@ inputs:
64
64
  secrets. When 'false', `upload` is ignored.
65
65
  required: false
66
66
  default: "true"
67
+ manage-app-store-version:
68
+ description: >
69
+ Set to 'false' to archive and upload a TestFlight build without creating
70
+ or editing an App Store version. App Store metadata automation is skipped.
71
+ This is useful for feature-branch validation while a release is in review.
72
+ required: false
73
+ default: "true"
67
74
  run-tests:
68
75
  description: "Set to 'false' to skip the simulator test stage"
69
76
  required: false
@@ -282,7 +289,7 @@ runs:
282
289
  | tee -a "$GITHUB_ENV"
283
290
 
284
291
  - name: Resolve App Store version slot (REUSE or CREATE)
285
- if: ${{ inputs.archive == 'true' }}
292
+ if: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'true' }}
286
293
  shell: bash
287
294
  env:
288
295
  APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
@@ -328,6 +335,16 @@ runs:
328
335
  echo "APP_STORE_VERSION_ID=$APP_STORE_VERSION_ID" >> "$GITHUB_ENV"
329
336
  echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER (decision=$DECISION_JSON)"
330
337
 
338
+ - name: Resolve TestFlight build number
339
+ if: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'false' }}
340
+ shell: bash
341
+ run: |
342
+ set -o pipefail
343
+ BUILD_NUMBER=$(python3 "${{ github.action_path }}/scripts/next_build_number.py")
344
+ echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
345
+ echo "APP_STORE_VERSION_ID=" >> "$GITHUB_ENV"
346
+ echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER without an App Store version slot"
347
+
331
348
  - name: Notice on deprecated profile-name input
332
349
  if: ${{ inputs.archive == 'true' && inputs.profile-name != '' }}
333
350
  shell: bash
@@ -690,7 +707,9 @@ runs:
690
707
 
691
708
  - name: Detect empty ASC metadata fields
692
709
  id: detect_metadata
693
- if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.ai-metadata != 'false' }}
710
+ if: >-
711
+ ${{ inputs.archive == 'true' && inputs.upload == 'true'
712
+ && inputs.manage-app-store-version == 'true' && inputs.ai-metadata != 'false' }}
694
713
  shell: bash
695
714
  env:
696
715
  APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
@@ -945,6 +964,7 @@ runs:
945
964
  # with the default (CFG_WHATS_NEW), preserving any AI-generated notes.
946
965
  - name: Set App Store "What's New"
947
966
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
967
+ && inputs.manage-app-store-version == 'true'
948
968
  && (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
949
969
  shell: bash
950
970
  env:
@@ -0,0 +1,46 @@
1
+ import re
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+
6
+ ACTION_YAML = Path(__file__).resolve().parents[1] / "action.yml"
7
+
8
+
9
+ def step_block(source: str, name: str) -> str:
10
+ pattern = rf" - name: {re.escape(name)}\n(?P<body>.*?)(?=\n - name:|\Z)"
11
+ match = re.search(pattern, source, re.DOTALL)
12
+ if match is None:
13
+ raise AssertionError(f"missing action step: {name}")
14
+ return match.group("body")
15
+
16
+
17
+ class TestTestFlightVersionSlotWiring(unittest.TestCase):
18
+ @classmethod
19
+ def setUpClass(cls) -> None:
20
+ cls.source = ACTION_YAML.read_text(encoding="utf-8")
21
+
22
+ def test_opt_out_is_backward_compatible(self) -> None:
23
+ self.assertRegex(
24
+ self.source,
25
+ r"(?m)^ manage-app-store-version:\n(?: .*\n)*? default: \"true\"$",
26
+ )
27
+
28
+ def test_opt_out_skips_store_version_but_keeps_build_number(self) -> None:
29
+ store_slot = step_block(self.source, "Resolve App Store version slot (REUSE or CREATE)")
30
+ testflight = step_block(self.source, "Resolve TestFlight build number")
31
+
32
+ self.assertIn("inputs.manage-app-store-version == 'true'", store_slot)
33
+ self.assertIn("inputs.manage-app-store-version == 'false'", testflight)
34
+ self.assertIn("next_build_number.py", testflight)
35
+ self.assertNotIn("manage_marketing_version.py", testflight)
36
+
37
+ def test_metadata_requires_a_managed_store_version(self) -> None:
38
+ detector = step_block(self.source, "Detect empty ASC metadata fields")
39
+ whats_new = step_block(self.source, 'Set App Store "What\'s New"')
40
+
41
+ self.assertIn("inputs.manage-app-store-version == 'true'", detector)
42
+ self.assertIn("inputs.manage-app-store-version == 'true'", whats_new)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.29
1
+ 1.0.31
@@ -1 +1 @@
1
- 1.0.29
1
+ 1.0.31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
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
@@ -16,7 +16,7 @@ import {
16
16
  statSync,
17
17
  writeFileSync,
18
18
  } from 'node:fs';
19
- import { join, relative, resolve } from 'node:path';
19
+ import { join, resolve } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { dirname } from 'node:path';
22
22
 
@@ -182,29 +182,28 @@ function checkGitignore(repoRoot) {
182
182
  function printGitignoreWarning(result) {
183
183
  if (!result.exists || result.offenders.length === 0) return;
184
184
  console.log('');
185
- console.log('WARNING: .gitignore excludes required CI credentials.');
185
+ console.log('NOTICE: .gitignore excludes checkout credentials.');
186
186
  console.log(` File: ${result.path}`);
187
187
  for (const { lineNumber, text } of result.offenders) {
188
188
  console.log(` Line ${lineNumber}: ${text}`);
189
189
  }
190
190
  console.log('');
191
- console.log(' The actions read Apple and Android keys from creds/ in the checkout.');
192
- console.log(' Remove those lines from .gitignore manually, then commit the .p8 file.');
193
- console.log(' (Your repo MUST be private — never do this in a public repo.)');
191
+ console.log(' Keep the ignore when using encrypted Actions secrets (recommended).');
192
+ console.log(' Otherwise the actions still support credentials already tracked in creds/.');
194
193
  }
195
194
 
196
- function printSummary(repoRoot) {
195
+ function printSummary() {
197
196
  console.log('');
198
197
  console.log('Done.');
199
198
  console.log('');
200
199
  console.log('Next steps:');
201
- console.log(` 1. Place your ASC API key:`);
202
- console.log(` ${join(relative(process.cwd(), repoRoot) || '.', 'creds', 'AuthKey_<KEY_ID>_Issuer_<UUID>.p8')}`);
203
- console.log(` (filename must include the 10-char KEY_ID and the issuer UUID)`);
204
- console.log(` 2. Add creds/android-upload-key.jks and creds/android-signing.properties.`);
205
- console.log(` 3. Add a Google Play service-account JSON anywhere under creds/.`);
206
- console.log(` 4. Ensure the repo is PRIVATE; these long-lived credentials must remain private.`);
207
- console.log(` 5. Commit and push to main. GitHub Actions builds both platforms.`);
200
+ console.log(' Recommended: configure encrypted Actions secrets:');
201
+ console.log(' ASC_KEY_P8, ASC_KEY_ID, ASC_ISSUER_ID');
202
+ console.log(' ANDROID_UPLOAD_KEY_BASE64, ANDROID_SIGNING_PROPERTIES');
203
+ console.log(' GOOGLE_PLAY_SERVICE_ACCOUNT_JSON');
204
+ console.log(' The workflow materializes them with mode 0600 only on its ephemeral runner.');
205
+ console.log(' Existing checkout credentials remain a backward-compatible fallback.');
206
+ console.log(' Push to main after configuring the secrets; GitHub Actions builds both platforms.');
208
207
  console.log('');
209
208
  console.log('Re-run this installer anytime to update the vendored action:');
210
209
  console.log(' npx --yes gowalk-cicd');
@@ -242,5 +241,5 @@ export async function runInstall({ dryRun = false } = {}) {
242
241
  return;
243
242
  }
244
243
 
245
- printSummary(repoRoot);
244
+ printSummary();
246
245
  }
@@ -35,6 +35,19 @@ jobs:
35
35
  with:
36
36
  fetch-depth: 0
37
37
  persist-credentials: true
38
+ - name: Materialize encrypted iOS credentials
39
+ shell: bash
40
+ env:
41
+ CI_ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
42
+ CI_ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
43
+ CI_ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
44
+ run: |
45
+ [ -n "${CI_ASC_KEY_P8:-}" ] || exit 0
46
+ : "${CI_ASC_KEY_ID:?ASC_KEY_ID secret is required with ASC_KEY_P8}"
47
+ : "${CI_ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required with ASC_KEY_P8}"
48
+ umask 077
49
+ mkdir -p creds
50
+ printf '%s' "$CI_ASC_KEY_P8" > "creds/AuthKey_${CI_ASC_KEY_ID}_Issuer_${CI_ASC_ISSUER_ID}.p8"
38
51
  - name: Detect Flutter
39
52
  id: flutter
40
53
  shell: bash
@@ -74,7 +87,8 @@ jobs:
74
87
  exit 0
75
88
  fi
76
89
  echo "::add-mask::$GIT_PRIVATE_TOKEN"
77
- git config --global url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
90
+ git config --global \
91
+ url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
78
92
  echo "Configured authenticated github.com remotes for pub."
79
93
  - name: Prepare Flutter iOS project
80
94
  if: ${{ steps.flutter.outputs.enabled == 'true' }}
@@ -99,6 +113,11 @@ jobs:
99
113
  workspace: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcworkspace' || '' }}
100
114
  scheme: ${{ steps.flutter.outputs.enabled == 'true' && 'Runner' || '' }}
101
115
  run-tests: 'false' # tests belong to the CI gate, not the deploy path
116
+ # A feature-branch TestFlight build must not create or mutate a live
117
+ # App Store version. That also lets it upload while the current version
118
+ # is locked in review. Default-branch deploys retain metadata automation.
119
+ manage-app-store-version: >-
120
+ ${{ github.ref_name == github.event.repository.default_branch && 'true' || 'false' }}
102
121
 
103
122
  android:
104
123
  name: Android Google Play
@@ -107,6 +126,22 @@ jobs:
107
126
  timeout-minutes: 45
108
127
  steps:
109
128
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
129
+ - name: Materialize encrypted Android credentials
130
+ shell: bash
131
+ env:
132
+ CI_ANDROID_KEYSTORE: ${{ secrets.ANDROID_UPLOAD_KEY_BASE64 }}
133
+ CI_ANDROID_PROPERTIES: ${{ secrets.ANDROID_SIGNING_PROPERTIES }}
134
+ CI_PLAY_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
135
+ run: |
136
+ [ -n "${CI_ANDROID_KEYSTORE:-}${CI_ANDROID_PROPERTIES:-}${CI_PLAY_ACCOUNT:-}" ] || exit 0
137
+ : "${CI_ANDROID_KEYSTORE:?ANDROID_UPLOAD_KEY_BASE64 secret is required}"
138
+ : "${CI_ANDROID_PROPERTIES:?ANDROID_SIGNING_PROPERTIES secret is required}"
139
+ : "${CI_PLAY_ACCOUNT:?GOOGLE_PLAY_SERVICE_ACCOUNT_JSON secret is required}"
140
+ umask 077
141
+ mkdir -p creds
142
+ printf '%s' "$CI_ANDROID_KEYSTORE" | base64 --decode > creds/android-upload-key.jks
143
+ printf '%s' "$CI_ANDROID_PROPERTIES" > creds/android-signing.properties
144
+ printf '%s' "$CI_PLAY_ACCOUNT" > creds/play-service-account.json
110
145
  # Two Android delivery modes:
111
146
  # * bitrise — the upload key + Play service account live in Bitrise,
112
147
  # not the repo, so the GitHub runner cannot sign. Drop a
@@ -119,8 +154,9 @@ jobs:
119
154
  id: mode
120
155
  shell: bash
121
156
  run: |
122
- if [ -f creds/bitrise.json ] && \
123
- [ "$(python3 -c "import json,sys;print(json.load(open('creds/bitrise.json')).get('enabled',False))" 2>/dev/null)" = "True" ]; then
157
+ bitrise_enabled=$(python3 -c \
158
+ "import json;print(json.load(open('creds/bitrise.json')).get('enabled',False))" 2>/dev/null || true)
159
+ if [ -f creds/bitrise.json ] && [ "$bitrise_enabled" = "True" ]; then
124
160
  echo "mode=bitrise" >> "$GITHUB_OUTPUT"
125
161
  else
126
162
  echo "mode=local" >> "$GITHUB_OUTPUT"
@@ -225,7 +261,8 @@ jobs:
225
261
  exit 0
226
262
  fi
227
263
  echo "::add-mask::$GIT_PRIVATE_TOKEN"
228
- git config --global url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
264
+ git config --global \
265
+ url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
229
266
  echo "Configured authenticated github.com remotes for pub."
230
267
  # A Gradle daemon sized for a workstation does not fit a CI runner. Asking
231
268
  # for -Xmx8G with a 4G metaspace reserves 12 GB before the Kotlin compile
@@ -322,13 +359,8 @@ jobs:
322
359
  releaseFiles: ${{ steps.android.outputs.bundle-path }}
323
360
  tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
324
361
  status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}
325
- # Play rejects an 'inProgress' release that does not declare how much of
326
- # the audience it reaches. Only send the fraction in that mode: passing it
327
- # alongside status 'completed' is an error rather than a no-op.
328
- userFraction: ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
329
- # Localized release notes: commit distribution/whatsnew/whatsnew-<bcp47>
330
- # files (e.g. whatsnew-en-US, whatsnew-de-DE) and they ship with every
331
- # release. Empty when the directory is absent — the action skips it.
362
+ userFraction: >-
363
+ ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
332
364
  whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
333
365
 
334
366
  # The retry serves two different failures, and both need a fresh edit:
@@ -338,14 +370,12 @@ jobs:
338
370
  # this once an app has changes a human must submit — after a policy
339
371
  # rejection, for instance — and it fails the whole upload, so the built
340
372
  # bundle never lands at all.
341
- # Retrying with changesNotSentForReview gets the bundle onto Play either way.
342
- # In the ordinary case the first attempt already submitted it and this is
343
- # skipped; in the refused case the bundle is uploaded and waits for someone to
344
- # press "Send changes for review" in Play Console, which is the only thing
345
- # Google will not let a tool do.
346
373
  - name: Retry Google Play upload without auto-submit
347
374
  id: play-upload-retry
348
- if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' && steps.play-upload.outcome == 'failure' }}
375
+ if: >-
376
+ ${{ steps.mode.outputs.mode == 'local'
377
+ && steps.play.outputs.ready == 'true'
378
+ && steps.play-upload.outcome == 'failure' }}
349
379
  uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
350
380
  with:
351
381
  serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
@@ -353,8 +383,10 @@ jobs:
353
383
  releaseFiles: ${{ steps.android.outputs.bundle-path }}
354
384
  tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
355
385
  status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}
356
- userFraction: ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
357
- whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
386
+ userFraction: >-
387
+ ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
388
+ whatsNewDirectory: >-
389
+ ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
358
390
  changesNotSentForReview: true
359
391
 
360
392
  - name: Say what still needs a human