gowalk-cicd 1.0.28 → 1.0.30

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
@@ -1 +1 @@
1
- 1.0.28
1
+ 1.0.30
@@ -139,7 +139,21 @@ def _stale_editable_id(versions: list[dict], target: str) -> str | None:
139
139
  vid = v.get("id")
140
140
  if not vid:
141
141
  continue
142
- if semver_tuple(version_string) >= target_t:
142
+ # STRICTLY greater, not >=. The skip above is a STRING compare while
143
+ # this is a TUPLE compare, so a version that is the SAME but written
144
+ # differently — "1.0" against a target of "1.0.0" — slipped past the
145
+ # skip and then tripped this guard, refusing the whole deploy.
146
+ #
147
+ # That is precisely what App Store Connect hands a brand-new app: it
148
+ # auto-creates version "1.0" when the record is made, while a Flutter
149
+ # project ships MARKETING_VERSION "1.0.0". Every first release hit it.
150
+ #
151
+ # Renaming an editable to an EQUAL version downgrades nothing — it
152
+ # normalises the string — so it falls through to the PATCH-rename
153
+ # candidates below. The guard's real target is unaffected: an editable
154
+ # genuinely AHEAD of the project (draft 1.2 vs target 1.1) still
155
+ # refuses, because renaming that one WOULD lose an in-progress draft.
156
+ if semver_tuple(version_string) > target_t:
143
157
  _refuse_downgrade(target, version_string, vid, state)
144
158
  # In-review rows (WAITING_FOR_REVIEW / IN_REVIEW) are editable
145
159
  # but not safe to rename — leave them alone and POST a fresh row.
@@ -117,3 +117,36 @@ class StaleEditableSelectionTests(unittest.TestCase):
117
117
 
118
118
  if __name__ == "__main__":
119
119
  unittest.main()
120
+
121
+
122
+ class EqualVersionWrittenDifferently(unittest.TestCase):
123
+ """ASC auto-creates "1.0" with a new app record; Flutter ships "1.0.0".
124
+
125
+ The skip above the guard is a STRING compare while the guard itself is a
126
+ TUPLE compare, so this pair slipped past the skip and then tripped the
127
+ guard — refusing the very FIRST deploy of every newly created app.
128
+ "1.0" and "1.0.0" are the same version: renaming the editable normalises
129
+ the string and downgrades nothing.
130
+ """
131
+
132
+ def test_equal_version_is_patch_renamed_not_refused(self):
133
+ editable = _v("1.0", "PREPARE_FOR_SUBMISSION", vid="ed-10")
134
+ result, stderr, calls = _run_decide_for_version(
135
+ "1.0.0", versions=[editable], ground_truth=None,
136
+ )
137
+ self.assertEqual(result["decision"], "CREATE")
138
+ self.assertEqual(result["versionString"], "1.0.0")
139
+ self.assertEqual(calls[0].kwargs.get("stale_editable_id"), "ed-10")
140
+
141
+ def test_a_genuinely_higher_draft_still_refuses(self):
142
+ """The guard's real purpose must survive: renaming a draft AHEAD of
143
+ the project would lose in-progress work."""
144
+ editable = _v("1.2.0", "PREPARE_FOR_SUBMISSION", vid="ed-120")
145
+ stderr = _assert_decide_exits(
146
+ self, "1.1.0", versions=[editable], ground_truth=None,
147
+ )
148
+ self.assertIn("downgrade", stderr.lower())
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.28
1
+ 1.0.30
@@ -1 +1 @@
1
- 1.0.28
1
+ 1.0.30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
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' }}
@@ -107,6 +121,22 @@ jobs:
107
121
  timeout-minutes: 45
108
122
  steps:
109
123
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
124
+ - name: Materialize encrypted Android credentials
125
+ shell: bash
126
+ env:
127
+ CI_ANDROID_KEYSTORE: ${{ secrets.ANDROID_UPLOAD_KEY_BASE64 }}
128
+ CI_ANDROID_PROPERTIES: ${{ secrets.ANDROID_SIGNING_PROPERTIES }}
129
+ CI_PLAY_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
130
+ run: |
131
+ [ -n "${CI_ANDROID_KEYSTORE:-}${CI_ANDROID_PROPERTIES:-}${CI_PLAY_ACCOUNT:-}" ] || exit 0
132
+ : "${CI_ANDROID_KEYSTORE:?ANDROID_UPLOAD_KEY_BASE64 secret is required}"
133
+ : "${CI_ANDROID_PROPERTIES:?ANDROID_SIGNING_PROPERTIES secret is required}"
134
+ : "${CI_PLAY_ACCOUNT:?GOOGLE_PLAY_SERVICE_ACCOUNT_JSON secret is required}"
135
+ umask 077
136
+ mkdir -p creds
137
+ printf '%s' "$CI_ANDROID_KEYSTORE" | base64 --decode > creds/android-upload-key.jks
138
+ printf '%s' "$CI_ANDROID_PROPERTIES" > creds/android-signing.properties
139
+ printf '%s' "$CI_PLAY_ACCOUNT" > creds/play-service-account.json
110
140
  # Two Android delivery modes:
111
141
  # * bitrise — the upload key + Play service account live in Bitrise,
112
142
  # not the repo, so the GitHub runner cannot sign. Drop a
@@ -119,8 +149,9 @@ jobs:
119
149
  id: mode
120
150
  shell: bash
121
151
  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
152
+ bitrise_enabled=$(python3 -c \
153
+ "import json;print(json.load(open('creds/bitrise.json')).get('enabled',False))" 2>/dev/null || true)
154
+ if [ -f creds/bitrise.json ] && [ "$bitrise_enabled" = "True" ]; then
124
155
  echo "mode=bitrise" >> "$GITHUB_OUTPUT"
125
156
  else
126
157
  echo "mode=local" >> "$GITHUB_OUTPUT"
@@ -225,7 +256,8 @@ jobs:
225
256
  exit 0
226
257
  fi
227
258
  echo "::add-mask::$GIT_PRIVATE_TOKEN"
228
- git config --global url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
259
+ git config --global \
260
+ url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
229
261
  echo "Configured authenticated github.com remotes for pub."
230
262
  # A Gradle daemon sized for a workstation does not fit a CI runner. Asking
231
263
  # for -Xmx8G with a 4G metaspace reserves 12 GB before the Kotlin compile
@@ -322,13 +354,8 @@ jobs:
322
354
  releaseFiles: ${{ steps.android.outputs.bundle-path }}
323
355
  tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
324
356
  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.
357
+ userFraction: >-
358
+ ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
332
359
  whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
333
360
 
334
361
  # The retry serves two different failures, and both need a fresh edit:
@@ -338,14 +365,12 @@ jobs:
338
365
  # this once an app has changes a human must submit — after a policy
339
366
  # rejection, for instance — and it fails the whole upload, so the built
340
367
  # 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
368
  - name: Retry Google Play upload without auto-submit
347
369
  id: play-upload-retry
348
- if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' && steps.play-upload.outcome == 'failure' }}
370
+ if: >-
371
+ ${{ steps.mode.outputs.mode == 'local'
372
+ && steps.play.outputs.ready == 'true'
373
+ && steps.play-upload.outcome == 'failure' }}
349
374
  uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
350
375
  with:
351
376
  serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
@@ -353,8 +378,10 @@ jobs:
353
378
  releaseFiles: ${{ steps.android.outputs.bundle-path }}
354
379
  tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
355
380
  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' || '' }}
381
+ userFraction: >-
382
+ ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
383
+ whatsNewDirectory: >-
384
+ ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
358
385
  changesNotSentForReview: true
359
386
 
360
387
  - name: Say what still needs a human