gowalk-cicd 1.0.171 → 1.0.172

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/README.md CHANGED
@@ -813,11 +813,11 @@ it: the retain step runs even when that action failed (a metadata error after
813
813
  the upload must not discard the symbols of a build that is already live), and
814
814
  an action that succeeded without writing symbols fails the job.
815
815
 
816
- Artifacts follow the repository's retention setting — 90 days unless you
817
- change it under *Settings → Actions → Artifact and log retention* (private
818
- repositories allow up to 400). A production release usually outlives that, so
819
- either raise the setting or, better, upload the symbols to your crash reporter,
820
- which is where crashes arrive anyway.
816
+ Artifacts follow the artifact retention setting, which an organization can cap far
817
+ below GitHub's 90-day default (*Settings → Actions → Artifact and log retention*).
818
+ A production release outlives that, so the durable copy belongs in your crash
819
+ reporter: with `FIREBASE_APP_ID` the Android job uploads its Dart symbols to
820
+ Crashlytics. iOS Dart symbols exist only as this artifact.
821
821
 
822
822
  To read an obfuscated crash, download the artifact for that exact build and
823
823
  run `flutter symbolize` with the file matching the device's architecture:
@@ -1092,6 +1092,9 @@ Signing credentials are refused from maintenance commits; use encrypted reposito
1092
1092
  **Source-of-truth resolution.** The auto-bump writes the new value
1093
1093
  into the file your project actually reads from, in this order:
1094
1094
 
1095
+ 0. **Flutter root `pubspec.yaml`** (project under the root `ios/`, M.m.p values only): the
1096
+ `version:` name, keeping `+build`. The Runner's `$(FLUTTER_BUILD_NAME)` and Android's
1097
+ versionName both read it; literal pbxproj values are synced, `$(…)` references are left alone.
1095
1098
  1. **xcodegen `project.yml`** (preferred when present): regex-rewrite
1096
1099
  of the `MARKETING_VERSION:` key, preserving formatting. The
1097
1100
  generated `*.xcodeproj` is regenerated on every build, so editing
@@ -1164,7 +1167,7 @@ A failed upload emits the typed `apple_build_upload_failed` error annotation wit
1164
1167
  the receipt's own identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`,
1165
1168
  `provider_status`, `provider_code`, `provider_pointer`). Exception text is never
1166
1169
  published. The job also retains the exact exported IPA as `apple-ipa-<run>-<attempt>`
1167
- (14 days) so a continuation can complete that same upload identity instead of rebuilding
1170
+ (7 days) so a continuation can complete that same upload identity instead of rebuilding
1168
1171
  different bytes under the same number. Build numbers come from `/builds`,
1169
1172
  `/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
1170
1173
  reservation is never reused; delete such a reservation only after reading it back.
package/action/action.yml CHANGED
@@ -808,7 +808,7 @@ runs:
808
808
  name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
809
809
  path: ${{ runner.temp }}/export/*.ipa
810
810
  if-no-files-found: ignore
811
- retention-days: 14
811
+ retention-days: 7
812
812
 
813
813
  # The whatsNew backstop. An empty "What's New" makes a version
814
814
  # un-submittable (ASC: "This field is required" on every locale). With an
@@ -11,6 +11,7 @@ fi
11
11
  staged="$(git diff --cached --name-only)"
12
12
  # marketing-version-auto-bump (mmv_floor_check) writes the bumped value
13
13
  # into the project's source-of-truth (resolved by version_utils):
14
+ # - a Flutter app's root pubspec.yaml `version:`
14
15
  # - xcodegen project.yml / project.yaml / Project.yml / Project.yaml
15
16
  # - *.xcconfig referenced by the project
16
17
  # - project.pbxproj (only when no xcodegen spec is present)
@@ -19,7 +20,7 @@ staged="$(git diff --cached --name-only)"
19
20
  # commit; surface it in the subject so reviewers see the version
20
21
  # delta without diffing.
21
22
  subject="ci: bot maintenance [skip ci]"
22
- if grep -qE '(\.pbxproj|Info\.plist|(^|/)[Pp]roject\.ya?ml|\.xcconfig)$' <<<"$staged"; then
23
+ if grep -qE '(\.pbxproj|Info\.plist|(^|/)[Pp]roject\.ya?ml|\.xcconfig|^pubspec\.yaml)$' <<<"$staged"; then
23
24
  subject="ci: bump MARKETING_VERSION [skip ci]"
24
25
  fi
25
26
 
@@ -1,11 +1,13 @@
1
1
  /* Reuse completed candidate artifacts immediately; a miss never reserves or waits for a runner. */
2
2
  'use strict';
3
3
  const {execFileSync} = require('node:child_process');
4
- const {mkdirSync, mkdtempSync, readFileSync, appendFileSync} = require('node:fs');
4
+ const {mkdirSync, mkdtempSync, readdirSync, readFileSync, appendFileSync} = require('node:fs');
5
5
  const {join} = require('node:path');
6
6
  const {createHash} = require('node:crypto');
7
+ const {inflateRawSync} = require('node:zlib');
7
8
  const inputs = require('./mobile_inputs.cjs');
8
9
  const api = require('./mobile_release_api.cjs');
10
+ const {appleGet} = require('./reconcile_apple_upload.cjs');
9
11
 
10
12
  function identity(platform) {
11
13
  const manifest = inputs.read(JSON.parse(process.env.MOBILE_RELEASE_VARIABLES || '{}'));
@@ -54,6 +56,104 @@ async function find(platform, deadline) {
54
56
  return null;
55
57
  }
56
58
 
59
+ // Admission reads the candidate's receipt without downloading its binary: the zip's central
60
+ // directory, then that one entry, by HTTP range from the artifact's storage link.
61
+ async function remoteReceipt(artifact, deadline) {
62
+ const signal = () => AbortSignal.timeout(Math.max(1, deadline - Date.now()));
63
+ const zip = `https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/artifacts/${artifact.id}/zip`;
64
+ const link = await fetch(zip, {redirect: 'manual', signal: signal(), headers: {'User-Agent': 'gowalk-cicd',
65
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN || ''}`, Accept: 'application/vnd.github+json'}});
66
+ const url = link.headers.get('location') || '', size = artifact.size_in_bytes;
67
+ if (link.status !== 302 || !url.startsWith('https://') || !Number.isSafeInteger(size)) {
68
+ throw Error('mobile_artifact_receipt_unavailable');
69
+ }
70
+ const bytes = async (start, end) => {
71
+ const response = await fetch(url, {headers: {Range: `bytes=${start}-${end - 1}`}, signal: signal()});
72
+ const body = response.status === 206 ? Buffer.from(await response.arrayBuffer()) : null;
73
+ if (!body) await response.body?.cancel();
74
+ if (body?.length !== end - start) throw Error('mobile_artifact_receipt_unavailable');
75
+ return body;
76
+ };
77
+ const from = Math.max(0, size - 65557), tail = await bytes(from, size);
78
+ const end = tail.lastIndexOf(Buffer.from('504b0506', 'hex'));
79
+ if (end < 0) throw Error('mobile_artifact_receipt_unavailable');
80
+ const count = tail.readUInt16LE(end + 10), length = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
81
+ const directory = offset >= from ? tail.subarray(offset - from, offset - from + length)
82
+ : await bytes(offset, offset + length);
83
+ for (let entry = 0, at = 0; entry < count; entry++) {
84
+ if (directory.readUInt32LE(at) !== 0x02014b50) break;
85
+ const named = directory.readUInt16LE(at + 28);
86
+ if (directory.toString('utf8', at + 46, at + 46 + named) === 'mobile-binary.json') {
87
+ const method = directory.readUInt16LE(at + 10), packed = directory.readUInt32LE(at + 20);
88
+ const local = directory.readUInt32LE(at + 42);
89
+ if (![0, 8].includes(method) || packed > 2 * 1024 * 1024) break;
90
+ const header = await bytes(local, local + 30);
91
+ const start = local + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
92
+ const data = await bytes(start, start + packed);
93
+ return JSON.parse((method ? inflateRawSync(data) : data).toString('utf8'));
94
+ }
95
+ at += 46 + named + directory.readUInt16LE(at + 30) + directory.readUInt16LE(at + 32);
96
+ }
97
+ throw Error('mobile_artifact_receipt_unavailable');
98
+ }
99
+
100
+ // The iOS job's credential step writes creds/AuthKey_<KEY_ID>_Issuer_<ISSUER>.p8 (or the app tracks
101
+ // it); admission has no credential files and receives the same encrypted secrets instead.
102
+ const KEY_FILE = /^AuthKey_([A-Z0-9]{8,10})_Issuer_([0-9a-fA-F-]{36})\.p8$/;
103
+ function appleKey() {
104
+ const {CI_ASC_KEY_P8: pem, CI_ASC_KEY_ID: id, CI_ASC_ISSUER_ID: issuer} = process.env;
105
+ if (pem) return {pem, id, issuer};
106
+ const folder = join(process.env.GITHUB_WORKSPACE, 'creds');
107
+ let names = [];
108
+ try { names = readdirSync(folder).filter(name => KEY_FILE.test(name)); } catch { /* no creds folder */ }
109
+ if (names.length !== 1) throw Error('mobile_artifact_apple_key_absent');
110
+ const [, keyId, issuerId] = KEY_FILE.exec(names[0]);
111
+ return {pem: readFileSync(join(folder, names[0]), 'utf8'), id: keyId, issuer: issuerId};
112
+ }
113
+
114
+ // App Store Connect consumed the candidate's build number once any source next_build_number.py
115
+ // reads holds it or a higher one, unless the (version, build) upload is this very IPA, which the
116
+ // uploader resumes. Such a candidate is rebuilt under a fresh number instead of failing its upload.
117
+ async function consumed(receipt, key, deadline, get = appleGet) {
118
+ const environment = receipt.environment || {}, app = environment.CFG_APP_STORE_APPLE_ID || '';
119
+ const build = environment.BUILD_NUMBER || '', version = environment.MARKETING_VERSION || '';
120
+ const file = `${receipt.files?.['export/app.ipa']}.ipa`;
121
+ if (!/^\d+$/.test(app) || !/^[1-9]\d*$/.test(build) || !/^\d+(\.\d+)*$/.test(version) ||
122
+ !/^[a-f0-9]{64}\.ipa$/.test(file)) {
123
+ throw Error('mobile_artifact_receipt_invalid');
124
+ }
125
+ const read = async (path, query) =>
126
+ get(query ? `${path}?${new URLSearchParams(query)}` : path, key, Math.max(1, deadline - Date.now()));
127
+ const [builds, trains, uploads] = await Promise.all([
128
+ read('/builds', {'filter[app]': app, sort: '-version', limit: 200}),
129
+ read('/preReleaseVersions', {'filter[app]': app, include: 'builds', limit: 200}),
130
+ read(`/apps/${app}/buildUploads`, {'filter[platform]': 'IOS', limit: 200})]);
131
+ const versions = [...builds.data || [], ...(trains.included || []).filter(row => row.type === 'builds')]
132
+ .map(row => row.attributes?.version).concat((uploads.data || []).map(row => row.attributes?.cfBundleVersion));
133
+ if (!versions.map(Number).some(number => Number.isSafeInteger(number) && number >= Number(build))) return false;
134
+ const same = ((await read(`/apps/${app}/buildUploads`, {'filter[cfBundleVersion]': build,
135
+ 'filter[cfBundleShortVersionString]': version, 'filter[platform]': 'IOS', limit: 200}))
136
+ .data || []).filter(row => row.attributes?.state?.state !== 'FAILED');
137
+ if (same.length !== 1) return true;
138
+ const assets = ((await read(`/buildUploads/${same[0].id}/buildUploadFiles`)).data || [])
139
+ .filter(row => row.attributes?.assetType === 'ASSET');
140
+ return assets.length !== 1 || assets[0].attributes.fileName !== file;
141
+ }
142
+
143
+ // Any doubt keeps today's reuse: the probe takes at most 30 s and adds no failure mode. The log
144
+ // names only our own codes, never a URL, key or token from an error message.
145
+ async function stale(read) {
146
+ const deadline = Date.now() + 30000;
147
+ try {
148
+ const key = appleKey();
149
+ return await consumed(await read(deadline), key, deadline);
150
+ } catch (error) {
151
+ const code = /^[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'mobile_artifact_probe_unavailable';
152
+ console.log(`::notice::Candidate build-number probe unavailable (${code}); reusing the candidate`);
153
+ return false;
154
+ }
155
+ }
156
+
57
157
  async function restore(platform) {
58
158
  const deadline = Date.now() + 120000;
59
159
  const found = await find(platform, deadline);
@@ -69,6 +169,11 @@ async function restore(platform) {
69
169
  const operation = platform === 'ios' ? 'verify' : 'restore';
70
170
  execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), operation, platform, directory],
71
171
  {stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: fingerprint}});
172
+ if (platform === 'ios' && await stale(async () => receipt)) {
173
+ output('restored', 'false');
174
+ return console.log(`::notice::App Store Connect already holds candidate build ${receipt.environment.BUILD_NUMBER}` +
175
+ ' or a higher one; building a fresh binary');
176
+ }
72
177
  output('directory', directory);
73
178
  output('restored', 'true');
74
179
  console.log(`::notice::Reusing ${platform} binary from candidate run ${run.id}`);
@@ -96,6 +201,12 @@ async function main([operation, platform, directory]) {
96
201
  // Admission picks the iOS runner from this answer; any doubt keeps the macOS build path.
97
202
  let found = null;
98
203
  try { found = await find(platform, Date.now() + 30000); } catch { /* optional optimization */ }
204
+ if (found && platform === 'ios' && await stale(deadline => remoteReceipt(found.artifact, deadline))) {
205
+ // 'consumed' routes to macOS and skips the restore, so a flaky second probe there cannot
206
+ // reuse what this one proved stale.
207
+ console.log(`Candidate ${platform} binary in run ${found.run.id} holds a consumed build number`);
208
+ return output('found', 'consumed');
209
+ }
99
210
  console.log(found ? `Candidate ${platform} binary in run ${found.run.id}` : `No candidate ${platform} binary`);
100
211
  return output('found', found ? 'true' : 'false');
101
212
  }
@@ -108,7 +219,7 @@ async function main([operation, platform, directory]) {
108
219
  }
109
220
  }
110
221
 
111
- module.exports = {identity, candidate, find, restore, pack, main};
222
+ module.exports = {identity, candidate, find, remoteReceipt, consumed, stale, restore, pack, main};
112
223
  if (require.main === module) main(process.argv.slice(2)).catch(() => {
113
224
  console.error('::error::mobile_artifact_pack_failed');
114
225
  process.exitCode = 1;
@@ -48,21 +48,21 @@ async function verifyOwner(deploymentId, runId, attempt, api = github) {
48
48
  return 'pending';
49
49
  }
50
50
 
51
- async function appleGet(path) {
51
+ async function appleGet(path, key = {pem: process.env.ASC_KEY_P8, id: process.env.ASC_KEY_ID,
52
+ issuer: process.env.ASC_ISSUER_ID}, timeout = 30000) {
52
53
  if (!path.startsWith('/') || path.includes('..')) refuse('path_invalid');
53
54
  const now = Math.floor(Date.now() / 1000);
54
55
  const encoded = value => Buffer.from(JSON.stringify(value)).toString('base64url');
55
- const header = encoded({alg: 'ES256', kid: process.env.ASC_KEY_ID, typ: 'JWT'});
56
- const payload = encoded({iss: process.env.ASC_ISSUER_ID, iat: now, exp: now + 1200,
57
- aud: 'appstoreconnect-v1'});
56
+ const header = encoded({alg: 'ES256', kid: key.id, typ: 'JWT'});
57
+ const payload = encoded({iss: key.issuer, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1'});
58
58
  let signature;
59
59
  try {
60
60
  signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`),
61
- {key: process.env.ASC_KEY_P8, dsaEncoding: 'ieee-p1363'}).toString('base64url');
61
+ {key: key.pem, dsaEncoding: 'ieee-p1363'}).toString('base64url');
62
62
  } catch { refuse('apple_key_invalid'); }
63
63
  const response = await fetch(`https://api.appstoreconnect.apple.com/v1${path}`, {
64
64
  headers: {Authorization: `Bearer ${header}.${payload}.${signature}`, Accept: 'application/json'},
65
- signal: AbortSignal.timeout(30000),
65
+ signal: AbortSignal.timeout(timeout),
66
66
  });
67
67
  if (!response.ok) refuse(`apple_http_${response.status}`);
68
68
  try { return await response.json(); } catch { refuse('apple_response_invalid'); }
@@ -23,7 +23,7 @@ def source_paths() -> list[str]:
23
23
  or re.search(r"\.(?:p8|p12|pfx|pem|key|keystore|jks|mobileprovision)$", name, re.I)):
24
24
  raise ValueError("source_maintenance_secret_path_refused")
25
25
  managed = name.startswith((".github/actions/swift-app/", ".github/actions/android-app/"))
26
- version = re.search(r"(?:\.pbxproj|\.plist|(?:^|/)[Pp]roject\.ya?ml|\.xcconfig)$", name)
26
+ version = re.search(r"(?:\.pbxproj|\.plist|(?:^|/)[Pp]roject\.ya?ml|\.xcconfig|^pubspec\.yaml)$", name)
27
27
  if not (managed or version):
28
28
  raise ValueError("source_maintenance_unexpected_path")
29
29
  return names
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """Refuse app artwork and icon coverage the stores reject, before the build runs.
2
+ """Refuse app source the stores or the iOS build reject, before the build runs.
3
3
 
4
- Two failures cost a whole release each and are both visible in the checked-out
5
- source in under a second:
4
+ Three failures cost a whole release (or a macOS job) each and are all visible in
5
+ the checked-out source in under a second:
6
6
 
7
7
  * the app still ships its framework's PLACEHOLDER icon. Apple refuses that under
8
8
  guideline 2.3.8 ("the app icons appear to be placeholder icons") after a full
@@ -12,8 +12,11 @@ source in under a second:
12
12
  * the asset catalog does not cover a device family the target declares. Apple
13
13
  refuses that at DELIVERY (ITMS-90023), after the archive, the signing and the
14
14
  upload have all been paid for.
15
+ * the Podfile.lock was resolved by another CocoaPods than the app's Gemfile.lock
16
+ installs. `pod install --deployment` refuses the lock's `COCOAPODS:` trailer
17
+ alone, 3.4 macOS minutes in (`ios-airpods-finder2`, 2026-09-28).
15
18
 
16
- Both checks read only what the app actually SHIPS. An appiconset routinely keeps
19
+ The icon checks read only what the app actually SHIPS. An appiconset routinely keeps
17
20
  files nothing references, and `flutter create` leaves its stock icons behind
18
21
  after real artwork is installed under another name; hashing a directory instead
19
22
  of the declared set convicts apps that ship correctly.
@@ -325,10 +328,57 @@ def check_icon_coverage(root: Path) -> list[dict]:
325
328
  return failures
326
329
 
327
330
 
331
+ #: The CocoaPods that RESOLVED a Podfile.lock (its trailer) and the one a
332
+ #: Gemfile.lock installs (its 4-space `specs:` entry, never a dependency line).
333
+ LOCK_COCOAPODS = re.compile(r"^COCOAPODS:\s*([0-9][0-9A-Za-z.\-]*)\s*$", re.MULTILINE)
334
+ GEM_COCOAPODS = re.compile(r"^ {4}cocoapods \(([0-9][0-9A-Za-z.\-]*)\)\s*$", re.MULTILINE)
335
+
336
+
337
+ def first_version(path: Path, pattern: re.Pattern) -> str:
338
+ try:
339
+ found = pattern.search(path.read_text(encoding="utf-8", errors="replace"))
340
+ except OSError:
341
+ return ""
342
+ return found.group(1) if found else ""
343
+
344
+
345
+ def check_cocoapods_version(root: Path) -> list[dict]:
346
+ """The Podfile.lock CI installs against the CocoaPods its bundle resolves.
347
+
348
+ CI installs Pods only where a Podfile sits in `ios/` when `pubspec.yaml`
349
+ exists, else in the root, with that folder's Gemfile else the root one
350
+ (`native_pods.prepare`, `native_bundle.prepare`). With no Gemfile CI runs the
351
+ runner's own `pod`, which nothing here can read, so that and any unreadable
352
+ half is silent, never a refusal.
353
+ """
354
+ folder = root / "ios" if (root / "pubspec.yaml").is_file() else root
355
+ lock = folder / "Podfile.lock"
356
+ gemfile = next((path for path in (folder / "Gemfile", root / "Gemfile") if path.is_file()),
357
+ None)
358
+ if gemfile is None or not (folder / "Podfile").is_file():
359
+ return []
360
+ gemlock = gemfile.with_name("Gemfile.lock")
361
+ locked, resolved = first_version(lock, LOCK_COCOAPODS), first_version(gemlock, GEM_COCOAPODS)
362
+ if not locked or not resolved or locked == resolved:
363
+ return []
364
+ lock_name, gem_name = str(lock.relative_to(root)), str(gemlock.relative_to(root))
365
+ return [{
366
+ "guideline": "pod_lock_drift",
367
+ "message": (f"`{lock_name}` was resolved by CocoaPods {locked}, but `{gem_name}` installs "
368
+ f"{resolved}. `pod install --deployment` refuses a lock whose `COCOAPODS:` "
369
+ "trailer differs from the CocoaPods it runs, minutes into the macOS build."),
370
+ "next": (f"Set the Gemfile's cocoapods to {locked} and commit its Gemfile.lock, or "
371
+ f"re-resolve {lock_name} under {resolved} (`bundle exec pod install`); the two "
372
+ "are one decision and move together."),
373
+ "files": [lock_name, gem_name],
374
+ }]
375
+
376
+
328
377
  CHECKS = (
329
378
  {"id": "placeholder_icons",
330
379
  "run": lambda ctx: check_placeholder_icons(ctx["root"], ctx["flutter"])},
331
380
  {"id": "icon_coverage", "run": lambda ctx: check_icon_coverage(ctx["root"])},
381
+ {"id": "cocoapods_version", "run": lambda ctx: check_cocoapods_version(ctx["root"])},
332
382
  )
333
383
 
334
384
 
@@ -24,6 +24,11 @@ the project's 10-functions-per-file cap. Two concerns live here:
24
24
  2. ``write_marketing_version(new_version)`` -- I/O. Writes
25
25
  ``new_version`` into the project's source-of-truth. Resolution order:
26
26
 
27
+ 0. A Flutter app's root pubspec.yaml ``version:`` (the project is
28
+ ``ios/``), for a full M.m.p value only. Its name is the Runner's
29
+ FLUTTER_BUILD_NAME and Android's versionName; literal pbxproj
30
+ values are synced beside it, ``$(FLUTTER_BUILD_NAME)`` references
31
+ are left to follow it.
27
32
  a. xcodegen project.yml / project.yaml / Project.yml /
28
33
  Project.yaml at the workspace root (the source of truth for
29
34
  xcodegen consumers; the generated .xcodeproj is regenerated
@@ -69,6 +74,14 @@ _SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
69
74
  _MARKETING_LINE = re.compile(
70
75
  r"^(\s*)MARKETING_VERSION\s*=\s*[^;]+;\s*$", re.MULTILINE,
71
76
  )
77
+ # The same line when it holds a literal, not a $(VAR) reference.
78
+ _LITERAL_MARKETING_LINE = re.compile(
79
+ r"^(\s*)MARKETING_VERSION\s*=(?!\s*[\"']?\$)\s*[^;]+;\s*$", re.MULTILINE,
80
+ )
81
+ # pubspec.yaml's top-level ``version: <M.m.p>[+<build>]``; the build suffix stays.
82
+ _PUBSPEC_VERSION_LINE = re.compile(
83
+ r"^(version:[ \t]*['\"]?)\d+\.\d+\.\d+(?=[+'\"\s#]|$)", re.MULTILINE,
84
+ )
72
85
  # xcodegen project.yml line shape (YAML key colon, optional quotes).
73
86
  # Matches "MARKETING_VERSION: 1.0" / "MARKETING_VERSION: '1.0'" /
74
87
  # "MARKETING_VERSION: \"1.0\"". Indent preserved via group 1.
@@ -264,8 +277,32 @@ def _write_xcconfig_marketing_version(new_version: str) -> bool:
264
277
  return False
265
278
 
266
279
 
267
- def _write_pbxproj_marketing_version(pbx: Path, new_version: str) -> bool:
268
- """Rewrite every ``MARKETING_VERSION = ...;`` line in pbxproj to
280
+ def _write_pubspec_version(path: Path, new_version: str) -> bool:
281
+ """Rewrite the version name in pubspec.yaml's ``version:`` line, keeping
282
+ any ``+build`` suffix. Returns False when the file declares no version or
283
+ ``new_version`` is not the full M.m.p pub requires."""
284
+ if not re.fullmatch(r"\d+\.\d+\.\d+", new_version):
285
+ return False
286
+ text = path.read_text(encoding="utf-8")
287
+ new_text, count = _PUBSPEC_VERSION_LINE.subn(
288
+ rf"\g<1>{new_version}", text, count=1,
289
+ )
290
+ if count == 0:
291
+ return False
292
+ path.write_text(new_text, encoding="utf-8")
293
+ print(
294
+ f"[auto-bump] wrote version {new_version} to {path}",
295
+ file=sys.stderr,
296
+ )
297
+ _record_bumped_path(path)
298
+ return True
299
+
300
+
301
+ def _write_pbxproj_marketing_version(
302
+ pbx: Path, new_version: str, line: re.Pattern = _MARKETING_LINE,
303
+ ) -> bool:
304
+ """Rewrite every ``MARKETING_VERSION = ...;`` line in pbxproj (every
305
+ literal one with ``_LITERAL_MARKETING_LINE``) to
269
306
  ``MARKETING_VERSION = <new_version>;``. Returns True when at least
270
307
  one substitution happened, False otherwise.
271
308
 
@@ -275,7 +312,7 @@ def _write_pbxproj_marketing_version(pbx: Path, new_version: str) -> bool:
275
312
  it is only ever a *sync* of a value already persisted in the real
276
313
  source of truth, never a substitute for writing that source."""
277
314
  text = pbx.read_text(encoding="utf-8")
278
- new_text, count = _MARKETING_LINE.subn(
315
+ new_text, count = line.subn(
279
316
  rf"\g<1>MARKETING_VERSION = {new_version};", text,
280
317
  )
281
318
  if count == 0:
@@ -359,13 +396,24 @@ _XCODEGEN_REFUSE_MSG = (
359
396
  def write_marketing_version(new_version: str) -> bool:
360
397
  """Persist ``new_version`` into the project source-of-truth.
361
398
 
362
- Resolution order is xcodegen-aware: project.yml -> xcconfig ->
363
- project.pbxproj (only without a spec) -> Info.plist. Records every
399
+ Resolution order is Flutter- and xcodegen-aware: pubspec.yaml ->
400
+ project.yml -> xcconfig -> project.pbxproj (only without a spec) ->
401
+ Info.plist. Records every
364
402
  successful write to ``$RUNNER_TEMP/<marker>`` so the action.yml
365
403
  staging step picks up exactly what was touched (never
366
404
  prepare_signing's pbxproj diff). Returns True on the first
367
405
  successful write, False when no source of truth resolved."""
368
406
  _proj_args, base = _xcodebuild_args()
407
+ # A Flutter Runner reads $(FLUTTER_BUILD_NAME) from pubspec, which
408
+ # Android ships too; the template's only literal belongs to RunnerTests.
409
+ # Root only, like source maintenance's allowlist.
410
+ pubspec = Path("pubspec.yaml")
411
+ if (base.resolve() == Path("ios").resolve() and pubspec.is_file()
412
+ and _write_pubspec_version(pubspec, new_version)):
413
+ pbx = _resolve_pbxproj_path()
414
+ if pbx is not None:
415
+ _write_pbxproj_marketing_version(pbx, new_version, _LITERAL_MARKETING_LINE)
416
+ return True
369
417
  spec_bases = {Path(".").resolve()}
370
418
  if base.exists():
371
419
  spec_bases.add(base.resolve())
@@ -178,9 +178,9 @@ runs:
178
178
  # here while the safety net lived in the workflow, that pairing would ship
179
179
  # obfuscated bundles and destroy the only copy of their symbols with the
180
180
  # runner. Whatever obfuscation needs to be safe must travel in the same
181
- # file as the flag. No retention-days: the repository's
182
- # own artifact retention setting governs, and unlike a fixed number it can
183
- # be raised to cover the life of a release.
181
+ # file as the flag. No retention-days: the artifact retention setting
182
+ # governs (an organization can cap it at days), so the durable copy is the
183
+ # Crashlytics upload below.
184
184
  - name: Retain Dart symbol files
185
185
  if: ${{ steps.config.outputs.project_kind == 'flutter' }}
186
186
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
@@ -32,21 +32,6 @@ async function upload(program, env, directory, execute = processRunner.execute,
32
32
  }
33
33
  }
34
34
 
35
- //: A code reaches the annotation only as a bare token: it is interpolated into
36
- //: workflow-command syntax, so anything outside this shape is reported as such
37
- //: rather than trusted.
38
- function state(code) {
39
- return typeof code === 'string' && /^[a-z0-9_]{1,64}$/.test(code) ? code : 'unknown';
40
- }
41
-
42
- function stateAnnotation(receipt) {
43
- return `::${receipt.ok === true ? 'notice' : 'error'} title=play_upload_state::`
44
- + `gowalk-cicd/play-upload-state.v1 ok=${receipt.ok === true}`
45
- + ` code=${state(receipt.code)} review_pending=${receipt.review_pending === true}`
46
- + ` attempts=${Number.isSafeInteger(receipt.attempts) ? receipt.attempts : 'unknown'}`;
47
- }
48
-
49
-
50
35
  async function main() {
51
36
  let directory, receipt;
52
37
  try {
@@ -80,27 +65,12 @@ async function main() {
80
65
  if (process.env.GITHUB_OUTPUT) {
81
66
  appendFileSync(process.env.GITHUB_OUTPUT, `review-pending=${receipt.review_pending}\nreceipt=${encoded}\n`);
82
67
  }
68
+ // The panel parses a success receipt even when a multi-line secret masks its outer braces.
83
69
  console.log(`::${receipt.ok ? 'notice' : 'error'} title=play_upload_result::${encoded}`);
84
- // The JSON receipt above does not survive as a READABLE annotation on this job.
85
- // `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` is a multi-line JSON secret, so Actions
86
- // registers each of its lines as maskable — and its first and last lines are a
87
- // bare `{` and `}`. Every brace in this job's output is therefore replaced by
88
- // `***`, leaving the payload legible to a person but not parseable by the
89
- // panel, which reads typed check-run annotations and is forbidden the log text.
90
- // The iOS job emits the same JSON shape unmasked, which is what isolates the
91
- // cause to this job's secret rather than to redaction policy in general.
92
- //
93
- // So the decision-relevant fields also go out brace-free, in the same
94
- // key=value shape as `play_upload_diagnostic` — which is emitted from this
95
- // very action and arrives unmasked. `review_pending` is the field that
96
- // distinguishes an upload from a submission, and losing it is how a green
97
- // Android job reads as "Play is done" when the AAB sits unreviewed on a
98
- // test track.
99
- console.log(stateAnnotation(receipt));
100
70
  if (!receipt.ok) process.exitCode = 1;
101
71
  }
102
72
 
103
- module.exports = {upload, main, state, stateAnnotation};
73
+ module.exports = {upload, main};
104
74
  if (require.main === module) void main().catch(() => {
105
75
  process.stderr.write('play_upload_receipt_failed\n'); process.exitCode = 1;
106
76
  });
@@ -36,6 +36,15 @@ choice inside that expression, and runs its reviewed build-only steps (Xcode sel
36
36
  macOS only. The candidate receipt carries the checkout-relative source Info.plist, so a promotion
37
37
  can persist a planned version without `xcodebuild`.
38
38
 
39
+ A candidate IPA whose build number App Store Connect has since consumed is rebuilt, not reused:
40
+ once any source `next_build_number.py` reads holds that number or a higher one, and the
41
+ (version, build) upload is not this very IPA, admission answers `consumed`: the macOS job skips the
42
+ restore on every attempt and builds under a fresh number (a "Re-run all jobs" asks admission again).
43
+ An interrupted upload of the same bytes still resumes. Admission reads only the receipt from the
44
+ artifact (its zip directory and one entry, by range) and asks with the `ASC_KEY_*` secrets; every
45
+ other restore repeats the probe with the key in `creds/`. A probe that cannot answer within 30
46
+ seconds keeps the reuse and logs its failure code.
47
+
39
48
  The iOS action restores the frozen build number, marketing version, metadata and symbols, discovers
40
49
  the current ASC key, and runs its existing version-slot, upload and metadata operations. A changed
41
50
  store version cannot silently relabel an existing IPA. Candidate version planning can select the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.171",
3
+ "version": "1.0.172",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,4 +58,4 @@ jobs:
58
58
  name: web-${{ github.event.repository.name }}-${{ github.run_number }}
59
59
  path: build/web
60
60
  if-no-files-found: error
61
- retention-days: 30
61
+ retention-days: 7
@@ -43,13 +43,19 @@ jobs:
43
43
  MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
44
44
  run: node .github/actions/swift-app/scripts/mobile_release.cjs admit "$DEPLOY_PLATFORMS"
45
45
  # A verified candidate IPA only needs REST calls to ship, so the iOS job
46
- # takes a Linux runner for it (see the job's runs-on). Reads only.
46
+ # takes a Linux runner for it (see the job's runs-on). Reads only: the
47
+ # ASC key asks App Store Connect whether the candidate's build number is
48
+ # still free; a consumed one answers 'consumed' and is rebuilt on macOS
49
+ # under a fresh number.
47
50
  - name: Look up the candidate iOS binary
48
51
  id: ios_candidate
49
52
  if: ${{ !inputs.candidate-build && steps.intent.outputs.ios != '' }}
50
53
  env:
51
54
  GITHUB_TOKEN: ${{ github.token }}
52
55
  MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
56
+ CI_ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
57
+ CI_ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
58
+ CI_ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
53
59
  run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs lookup ios
54
60
  # Repository variable DEPLOY_PLATFORMS selects which stores this repo ships to:
55
61
  # unset / 'both' — iOS and Android (the default)
@@ -77,9 +83,9 @@ jobs:
77
83
  with:
78
84
  fetch-depth: 0
79
85
  persist-credentials: true
80
- # Placeholder artwork and missing icon coverage are refused from the
81
- # checkout alone, so they fail here in under a second instead of after
82
- # the Flutter setup and the Pods pass.
86
+ # Placeholder artwork, missing icon coverage and CocoaPods lock drift are
87
+ # refused from the checkout alone, so they fail here in under a second
88
+ # instead of after the Flutter setup and the Pods pass.
83
89
  - name: Source preflight
84
90
  shell: bash
85
91
  run: python3 .github/actions/swift-app/scripts/source_preflight.py "$GITHUB_WORKSPACE"
@@ -113,9 +119,11 @@ jobs:
113
119
  printf '%s' "$CI_DISTRIBUTION_META" | base64 --decode > creds/cert.meta.json
114
120
  printf '%s' "$CI_DISTRIBUTION_REGISTRY" | base64 --decode > creds/cert.registry.json
115
121
  echo "source=encrypted" >> "$GITHUB_OUTPUT"
122
+ # A build number App Store Connect consumed never frees up, so no attempt
123
+ # restores a candidate admission answered 'consumed' for.
116
124
  - name: Reuse matching candidate binary
117
125
  id: reuse
118
- if: ${{ !inputs.candidate-build }}
126
+ if: ${{ !inputs.candidate-build && needs.release.outputs.ios-candidate != 'consumed' }}
119
127
  shell: bash
120
128
  run: node .github/actions/swift-app/scripts/mobile_artifact_inventory.cjs restore ios
121
129
  # Linux cannot build. Nothing has reached a provider yet, so a re-run of the
@@ -277,8 +285,8 @@ jobs:
277
285
  fi
278
286
  # BUILD_NUMBER comes from the action through GITHUB_ENV, so the artifact
279
287
  # is named after the TestFlight build it belongs to. No retention-days:
280
- # the repository's artifact retention setting governs (90 days unless
281
- # raised), and a crash reporter needs its own copy — see the README.
288
+ # the artifact retention setting governs (an organization can cap it at
289
+ # days), so a crash reporter needs its own copy — see the README.
282
290
  - name: Retain Dart symbol files
283
291
  if: ${{ !cancelled() && steps.ios_symbols.outputs.present == 'true' }}
284
292
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
@@ -547,6 +555,8 @@ jobs:
547
555
  # same define the iOS job passes. Empty leaves the app's default.
548
556
  dart-defines: >-
549
557
  ${{ vars.ONBOARDING_PUBLIC_ORIGIN != '' && format('ONBOARDING_PUBLIC_ORIGIN={0}', vars.ONBOARDING_PUBLIC_ORIGIN) || '' }}
558
+ # Keep: a package not yet bound to Play skips the API upload, and the panel's
559
+ # initial Play setup uploads this bundle through the console.
550
560
  - name: Retain signed Android App Bundle
551
561
  if: ${{ !inputs.candidate-build }}
552
562
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
@@ -556,7 +566,7 @@ jobs:
556
566
  name: android-${{ steps.android.outputs.package-name || steps.reuse.outputs.package-name }}-${{ steps.android.outputs.build-number || steps.reuse.outputs.build-number }}
557
567
  path: ${{ steps.android.outputs.bundle-path || steps.reuse.outputs.bundle-path }}
558
568
  if-no-files-found: error
559
- retention-days: 30
569
+ retention-days: 7
560
570
  - name: Package candidate shipping binary
561
571
  id: candidate_binary
562
572
  if: ${{ inputs.candidate-build }}
Binary file