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 +9 -6
- package/action/action.yml +1 -1
- package/action/scripts/commit_bot_changes.sh +2 -1
- package/action/scripts/mobile_artifact_inventory.cjs +113 -2
- package/action/scripts/reconcile_apple_upload.cjs +6 -6
- package/action/scripts/source_maintenance.py +1 -1
- package/action/scripts/source_preflight.py +54 -4
- package/action/scripts/version_utils.py +53 -5
- package/android-action/action.yml +3 -3
- package/android-action/play-upload/run.cjs +2 -32
- package/docs/CANDIDATE_ARTIFACTS.md +9 -0
- package/package.json +1 -1
- package/templates/deploy-web.yml +1 -1
- package/templates/deploy.yml +18 -8
- package/templates/history.json.gz +0 -0
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
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
(
|
|
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:
|
|
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:
|
|
56
|
-
const payload = encoded({iss:
|
|
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:
|
|
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(
|
|
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
|
|
2
|
+
"""Refuse app source the stores or the iOS build reject, before the build runs.
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
268
|
-
"""Rewrite
|
|
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 =
|
|
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:
|
|
363
|
-
project.pbxproj (only without a spec) ->
|
|
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
|
|
182
|
-
#
|
|
183
|
-
#
|
|
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
|
|
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
package/templates/deploy-web.yml
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -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
|
|
81
|
-
# checkout alone, so they fail here in under a second
|
|
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
|
|
281
|
-
#
|
|
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:
|
|
569
|
+
retention-days: 7
|
|
560
570
|
- name: Package candidate shipping binary
|
|
561
571
|
id: candidate_binary
|
|
562
572
|
if: ${{ inputs.candidate-build }}
|
|
Binary file
|