vydanne 0.5.0 → 0.7.0
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/GETTING_STARTED.md +69 -40
- package/README.md +161 -23
- package/SKILL.md +158 -26
- package/bin/vydanne.mjs +67 -13
- package/package.json +4 -3
- package/src/client.mjs +78 -7
- package/src/commands/accessibility.mjs +13 -0
- package/src/commands/ageRating.mjs +94 -21
- package/src/commands/bridge.mjs +328 -0
- package/src/commands/compliance.mjs +78 -9
- package/src/commands/diff.mjs +63 -19
- package/src/commands/fill.mjs +85 -13
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +65 -6
- package/src/commands/prepare.mjs +220 -0
- package/src/commands/prerelease.mjs +25 -5
- package/src/commands/previews.mjs +43 -5
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +65 -14
- package/src/config.mjs +55 -4
- package/src/crossStore.mjs +151 -0
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +32 -24
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +96 -22
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +27 -9
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +6 -1
- package/types/index.d.ts +240 -10
- package/vydanne.config.example.mjs +54 -1
|
@@ -110,6 +110,14 @@ export async function run(config, client, credentials) {
|
|
|
110
110
|
return false;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// The binary upload is the one mutation that does NOT go through the ASC client, so the client-level
|
|
114
|
+
// dry-run gate cannot see it — it has to be refused here, or a dry run would ship a build to TestFlight.
|
|
115
|
+
// Validation above has already run, which is the useful half: exactly Play's "validate, then discard".
|
|
116
|
+
if (client.dryRun) {
|
|
117
|
+
console.log(yellow(" DRY RUN — archive validated, NOT uploaded. Re-run with --apply to send it to TestFlight."));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
try {
|
|
114
122
|
await altool(["--upload-app", "-f", ipa, "-t", "ios"], credentials);
|
|
115
123
|
console.log(green(" uploaded"));
|
|
@@ -119,7 +127,7 @@ export async function run(config, client, credentials) {
|
|
|
119
127
|
// right answer for an accident and a dead end for a re-run, so say which it is.
|
|
120
128
|
if (/already exists|redundant binary|previously uploaded/i.test(text)) {
|
|
121
129
|
console.error(red(" Apple already holds this build number."));
|
|
122
|
-
console.error(" Bump CFBundleVersion (
|
|
130
|
+
console.error(" Bump CFBundleVersion (the build number, not the marketing version) and re-archive.");
|
|
123
131
|
} else {
|
|
124
132
|
console.error(red(" upload failed:"));
|
|
125
133
|
console.error(indent(text));
|
|
@@ -199,14 +207,21 @@ async function assignToInternalGroup(client, build, groupName) {
|
|
|
199
207
|
else console.error(yellow(` could not add to "${groupName}" (${r.status})`));
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
/** Versions Apple will not let us re-point without the operator withdrawing them first.
|
|
203
|
-
|
|
210
|
+
/** Versions Apple will not let us re-point without the operator withdrawing them first. Exported
|
|
211
|
+
* because `prepare` needs the same answer about the same states — two lists would drift. */
|
|
212
|
+
export const LOCKED_STATES = new Set([
|
|
204
213
|
"WAITING_FOR_REVIEW",
|
|
205
214
|
"IN_REVIEW",
|
|
206
215
|
"PENDING_DEVELOPER_RELEASE",
|
|
207
216
|
"PENDING_APPLE_RELEASE",
|
|
208
217
|
"READY_FOR_SALE",
|
|
209
218
|
"REPLACED_WITH_NEW_VERSION",
|
|
219
|
+
// Both removed-from-sale states are shipped versions that Apple will not reopen for editing.
|
|
220
|
+
// client.mjs has always treated them as dead for `editVersion()`; leaving them out here meant
|
|
221
|
+
// `prepare` would "reuse" one and hand every later step a version whose first write Apple refuses —
|
|
222
|
+
// the refusal arriving as an opaque INVALID_STATE instead of the sentence below it.
|
|
223
|
+
"REMOVED_FROM_SALE",
|
|
224
|
+
"DEVELOPER_REMOVED_FROM_SALE",
|
|
210
225
|
]);
|
|
211
226
|
|
|
212
227
|
/**
|
|
@@ -214,12 +229,17 @@ const LOCKED_STATES = new Set([
|
|
|
214
229
|
*
|
|
215
230
|
* This is what makes "upload a fix and try again" one command rather than a trip to the web UI:
|
|
216
231
|
* the version → build relationship holds exactly one build, so a PATCH re-points it.
|
|
232
|
+
*
|
|
233
|
+
* [known] is the version to attach to, when the caller has already resolved it. `prepare` passes the
|
|
234
|
+
* draft it just found or created, because `editVersion()` cannot tell a fresh draft from the live
|
|
235
|
+
* record on an app that already has a version on sale — it returns whichever the list yields first.
|
|
217
236
|
*/
|
|
218
|
-
export async function pointVersionAtBuild(client, config, build) {
|
|
237
|
+
export async function pointVersionAtBuild(client, config, build, known = null) {
|
|
219
238
|
const platform = (config.platforms && config.platforms[0]) || "IOS";
|
|
220
|
-
const version = await client.editVersion(platform);
|
|
239
|
+
const version = known || (await client.editVersion(platform));
|
|
221
240
|
if (!version) {
|
|
222
241
|
console.log(yellow(" no editable App Store version — build uploaded, nothing to attach it to."));
|
|
242
|
+
console.log(" `vydanne prepare --apply` creates the version to attach it to, then re-run this.");
|
|
223
243
|
return;
|
|
224
244
|
}
|
|
225
245
|
const state = version.attributes.appStoreState;
|
|
@@ -5,16 +5,40 @@ import { uploadAsset, setPreviewPoster } from "../upload.mjs";
|
|
|
5
5
|
|
|
6
6
|
// Upload App Preview videos natively (reserve → chunked PUT → commit → poll processing → poster frame).
|
|
7
7
|
// config.previews: [{ platform, type, file, poster, locales }]. Skips locales that already have a preview.
|
|
8
|
+
//
|
|
9
|
+
// EVERY failure below is recorded, not just printed. This command used to `return true` unconditionally:
|
|
10
|
+
// each error path printed red and `continue`d, so a rejected upload was indistinguishable from a clean
|
|
11
|
+
// run to the one caller that asks — `push`, which promises to stop at the first failing step. It didn't:
|
|
12
|
+
// previews could fail loudly, push would carry on through age-rating/review-contact/accessibility, and
|
|
13
|
+
// preflight (which never inspects previews) would sign the whole thing off as green. The `continue`s are
|
|
14
|
+
// still right — one missing video must not hide the state of the rest — but the verdict has to survive
|
|
15
|
+
// them, so the loop keeps going and the RETURN carries the failure out.
|
|
8
16
|
export async function run(config, client) {
|
|
9
17
|
await client.findApp(config.bundleId);
|
|
10
18
|
const specs = config.previews || defaultSpecs(config);
|
|
19
|
+
const failures = [];
|
|
20
|
+
// defaultSpecs filters to files that exist, so with no `previews` config and no video on disk this
|
|
21
|
+
// command used to print NOTHING and exit 0 — indistinguishable from a successful upload in a CI log.
|
|
22
|
+
// Not a failure: an app with no previews is a normal app, and `push` must not stall on one.
|
|
23
|
+
if (!specs.length) console.log(yellow(" no previews configured and none found at the default paths — nothing to upload."));
|
|
11
24
|
for (const s of specs) {
|
|
12
25
|
const v = await client.editVersion(s.platform);
|
|
13
|
-
if (!v) {
|
|
26
|
+
if (!v) {
|
|
27
|
+
console.error(red(` ${s.platform}: no editable version — nothing to upload previews to.`));
|
|
28
|
+
console.error(" `vydanne prepare --apply` creates the version being submitted, then re-run this.");
|
|
29
|
+
failures.push(`${s.platform}: no editable version`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
14
32
|
const locs = await client.versionLocalizations(v.id);
|
|
15
33
|
for (const code of s.locales || [config.primaryLocale]) {
|
|
16
34
|
const loc = locs.find((l) => l.attributes.locale === code);
|
|
17
|
-
|
|
35
|
+
// A configured locale the version doesn't have must be said, not skipped — the spec NAMES this
|
|
36
|
+
// locale, so silence here reports success for a preview that never left the disk.
|
|
37
|
+
if (!loc) {
|
|
38
|
+
console.error(red(` ${s.platform}/${code}: no App Store localization — preview skipped (run \`fill\` to create it)`));
|
|
39
|
+
failures.push(`${s.platform}/${code}: no App Store localization`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
18
42
|
try {
|
|
19
43
|
const { json: sets } = await client.get(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets?include=appPreviews&limit=50`);
|
|
20
44
|
let set = (sets.data || []).find((x) => x.attributes.previewType === s.type);
|
|
@@ -24,22 +48,36 @@ export async function run(config, client) {
|
|
|
24
48
|
}
|
|
25
49
|
for (const p of existing) { // VYDANNE_REPLACE: drop the old preview so the new upload takes its place
|
|
26
50
|
await client.del(`/v1/appPreviews/${p.id}`);
|
|
27
|
-
console.log(yellow(` ${s.platform}/${code}/${s.type}: removed old preview ${p.id}`));
|
|
51
|
+
console.log(yellow(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would remove" : "removed"} old preview ${p.id}`));
|
|
28
52
|
}
|
|
29
53
|
if (!set) {
|
|
30
54
|
const c = await client.post(`/v1/appPreviewSets`, { data: { type: "appPreviewSets", attributes: { previewType: s.type }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } });
|
|
31
55
|
set = c.json.data;
|
|
32
56
|
}
|
|
33
57
|
const file = path.resolve(s.file);
|
|
34
|
-
|
|
58
|
+
// A configured preview whose file is missing is the whole reason this is checked here: the
|
|
59
|
+
// upload would throw ENOENT mid-run, and in a DRY run it would otherwise look like a plan that
|
|
60
|
+
// works. Name it and move on, so one missing video doesn't hide the rest of the report.
|
|
61
|
+
if (!fs.existsSync(file)) {
|
|
62
|
+
console.error(red(` ${s.platform}/${code}/${s.type}: ${path.relative(process.cwd(), file)} does not exist — nothing to upload`));
|
|
63
|
+
failures.push(`${s.platform}/${code}/${s.type}: ${path.relative(process.cwd(), file)} missing`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
console.log(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would upload" : "uploading"} ${path.basename(file)}...`);
|
|
35
67
|
const id = await uploadAsset(client, { type: "appPreviews", setType: "appPreviewSet", setId: set.id, filePath: file });
|
|
36
68
|
await setPreviewPoster(client, id, s.poster);
|
|
37
|
-
console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
69
|
+
if (!client.dryRun) console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
38
70
|
} catch (e) {
|
|
39
71
|
console.error(red(` error ${s.platform}/${code}/${s.type}: ${e.message}`));
|
|
72
|
+
failures.push(`${s.platform}/${code}/${s.type}: ${e.message}`);
|
|
40
73
|
}
|
|
41
74
|
}
|
|
42
75
|
}
|
|
76
|
+
if (failures.length) {
|
|
77
|
+
console.error(red(`previews: ${failures.length} failed — nothing above was silently skipped:`));
|
|
78
|
+
for (const f of failures) console.error(` ${red("x")} ${f}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
43
81
|
return true;
|
|
44
82
|
}
|
|
45
83
|
|
package/src/commands/privacy.mjs
CHANGED
|
@@ -2,26 +2,96 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { green, yellow } from "../util.mjs";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* App Privacy is on Apple's iris host, which 401s the ASC API key — it CANNOT be set through the API.
|
|
7
|
+
* So this writes the honest record and prints the exact answers to type into the ASC UI (where a
|
|
8
|
+
* passkey login works).
|
|
9
|
+
*
|
|
10
|
+
* PURPOSES ARE DECLARED, NOT ASSUMED. Every collected category used to be reported as
|
|
11
|
+
* "APP_FUNCTIONALITY", which is right for crash data in a game and wrong for almost anything else — an
|
|
12
|
+
* email address collected for marketing, or location used for analytics, would have been printed as
|
|
13
|
+
* App Functionality and typed into Apple's form that way. And the UI group was known only for the three
|
|
14
|
+
* diagnostics categories, so anything else printed a literal "?" as its section heading, which is not
|
|
15
|
+
* an answer anyone can enter. Both are now data tables covering Apple's full vocabulary, with
|
|
16
|
+
* per-category overrides in the config for the app that needs them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Apple's data categories -> the section they live under in the App Privacy form. */
|
|
20
|
+
const GROUP = {
|
|
21
|
+
// Contact info
|
|
22
|
+
NAME: "Contact Info", EMAIL_ADDRESS: "Contact Info", PHONE_NUMBER: "Contact Info",
|
|
23
|
+
PHYSICAL_ADDRESS: "Contact Info", OTHER_USER_CONTACT_INFO: "Contact Info",
|
|
24
|
+
// Health & fitness
|
|
25
|
+
HEALTH: "Health & Fitness", FITNESS: "Health & Fitness",
|
|
26
|
+
// Financial info
|
|
27
|
+
PAYMENT_INFO: "Financial Info", CREDIT_INFO: "Financial Info", OTHER_FINANCIAL_INFO: "Financial Info",
|
|
28
|
+
// Location
|
|
29
|
+
PRECISE_LOCATION: "Location", COARSE_LOCATION: "Location",
|
|
30
|
+
// Sensitive info
|
|
31
|
+
SENSITIVE_INFO: "Sensitive Info",
|
|
32
|
+
// Contacts
|
|
33
|
+
CONTACTS: "Contacts",
|
|
34
|
+
// User content
|
|
35
|
+
EMAILS_OR_TEXT_MESSAGES: "User Content", PHOTOS_OR_VIDEOS: "User Content", AUDIO_DATA: "User Content",
|
|
36
|
+
GAMEPLAY_CONTENT: "User Content", CUSTOMER_SUPPORT: "User Content", OTHER_USER_CONTENT: "User Content",
|
|
37
|
+
// Browsing / search history
|
|
38
|
+
BROWSING_HISTORY: "Browsing History", SEARCH_HISTORY: "Search History",
|
|
39
|
+
// Identifiers
|
|
40
|
+
USER_ID: "Identifiers", DEVICE_ID: "Identifiers",
|
|
41
|
+
// Purchases
|
|
42
|
+
PURCHASE_HISTORY: "Purchases",
|
|
43
|
+
// Usage data
|
|
44
|
+
PRODUCT_INTERACTION: "Usage Data", ADVERTISING_DATA: "Usage Data", OTHER_USAGE_DATA: "Usage Data",
|
|
45
|
+
// Diagnostics
|
|
46
|
+
CRASH_DATA: "Diagnostics", PERFORMANCE_DATA: "Diagnostics", OTHER_DIAGNOSTIC_DATA: "Diagnostics",
|
|
47
|
+
// Other
|
|
48
|
+
OTHER_DATA_TYPES: "Other Data",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Apple's purpose codes -> the label the form uses. */
|
|
52
|
+
const PURPOSE_LABEL = {
|
|
53
|
+
THIRD_PARTY_ADVERTISING: "Third-Party Advertising",
|
|
54
|
+
DEVELOPERS_ADVERTISING: "Developer's Advertising or Marketing",
|
|
55
|
+
ANALYTICS: "Analytics",
|
|
56
|
+
PRODUCT_PERSONALIZATION: "Product Personalization",
|
|
57
|
+
APP_FUNCTIONALITY: "App Functionality",
|
|
58
|
+
OTHER_PURPOSES: "Other Purposes",
|
|
59
|
+
};
|
|
8
60
|
|
|
9
61
|
export async function run(config) {
|
|
10
62
|
const collected = config.privacy?.collected || ["CRASH_DATA", "PERFORMANCE_DATA"];
|
|
11
63
|
const tracking = !!config.privacy?.tracking;
|
|
64
|
+
// `purposes` may be one list for everything, or a per-category map. App Functionality stays the
|
|
65
|
+
// default because it is the honest answer for the diagnostics an app collects to fix itself — but it
|
|
66
|
+
// is now a default that an app can disagree with, rather than the only thing this command can say.
|
|
67
|
+
const declared = config.privacy?.purposes;
|
|
68
|
+
const purposesFor = (cat) => {
|
|
69
|
+
const p = Array.isArray(declared) ? declared : declared?.[cat];
|
|
70
|
+
return (p && p.length ? p : ["APP_FUNCTIONALITY"]);
|
|
71
|
+
};
|
|
72
|
+
|
|
12
73
|
const record = collected.map((cat) => ({
|
|
13
|
-
category: cat,
|
|
74
|
+
category: cat,
|
|
75
|
+
purposes: purposesFor(cat),
|
|
14
76
|
data_protections: [tracking ? "DATA_LINKED_TO_YOU" : "DATA_NOT_LINKED_TO_YOU", tracking ? "DATA_USED_TO_TRACK_YOU" : null].filter(Boolean),
|
|
15
77
|
}));
|
|
16
78
|
const out = path.join(path.dirname(config.metadataDir), "app_privacy_details.json");
|
|
17
79
|
fs.writeFileSync(out, JSON.stringify(record, null, 2));
|
|
18
80
|
console.log(green(`wrote ${out} (declaration record)`));
|
|
19
81
|
console.log();
|
|
82
|
+
|
|
83
|
+
const unknown = collected.filter((c) => !GROUP[c]);
|
|
84
|
+
if (unknown.length) {
|
|
85
|
+
console.log(yellow(` category not in Apple's published list, section unknown: ${unknown.join(", ")}`));
|
|
86
|
+
console.log(" Check the spelling against App Privacy in App Store Connect before entering it.");
|
|
87
|
+
}
|
|
88
|
+
|
|
20
89
|
console.log(yellow("App Privacy is UI-only (passkey) — the API key 401s on Apple's iris host. Enter:"));
|
|
21
90
|
if (collected.length) console.log(" Do you or your partners collect data? -> Yes");
|
|
22
91
|
for (const c of collected) {
|
|
23
92
|
const name = c.split("_").map((w) => w[0] + w.slice(1).toLowerCase()).join(" ");
|
|
24
|
-
|
|
93
|
+
const purposes = purposesFor(c).map((p) => PURPOSE_LABEL[p] || p).join(" + ");
|
|
94
|
+
console.log(` ${GROUP[c] || "?"} -> ${name}: ${purposes} · ${tracking ? "Linked" : "Not Linked"} · ${tracking ? "Tracking" : "No Tracking"}`);
|
|
25
95
|
}
|
|
26
96
|
console.log(" Everything else -> Not Collected.");
|
|
27
97
|
console.log(yellow(" 'accesses' is not 'collects' — E2EE content you can't read is not collected."));
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { green, yellow, red } from "../util.mjs";
|
|
2
|
+
import { run as prepare } from "./prepare.mjs";
|
|
3
|
+
import { run as fill } from "./fill.mjs";
|
|
4
|
+
import { run as previews } from "./previews.mjs";
|
|
5
|
+
import { run as ageRating } from "./ageRating.mjs";
|
|
6
|
+
import { run as reviewContact } from "./reviewContact.mjs";
|
|
7
|
+
import { run as accessibility } from "./accessibility.mjs";
|
|
8
|
+
import { run as preflight } from "./preflight.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The whole Apple release pipeline, in the one order that works.
|
|
12
|
+
*
|
|
13
|
+
* WHY A COMMAND AND NOT A PARAGRAPH. Shipping an update takes seven commands in a specific order —
|
|
14
|
+
* `prepare` first (or nothing has a version to write into), `preflight` last (or green is measured
|
|
15
|
+
* before the writes it is meant to bless) — and that ordering lived in nobody's head. The near-miss
|
|
16
|
+
* that proved it: with the order forgotten, `fill` was pointed at an app whose only version was ON
|
|
17
|
+
* SALE, and only the editVersion() fix stood between the dry run and a rewritten live listing. A
|
|
18
|
+
* README section can teach the order; only a command can make it impossible to run out of order.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT IS. A sequencer, nothing more: each step is the same `run` its standalone command uses, on
|
|
21
|
+
* the same client, so `push` can do nothing a step couldn't. The first failure stops the run — a step
|
|
22
|
+
* that cannot proceed says why and names its fix (that is each command's own contract), and continuing
|
|
23
|
+
* past it would let a green `preflight` at the end bless a release with a known hole in it.
|
|
24
|
+
*
|
|
25
|
+
* SKIPPING IS EXPLICIT AND LOUD. `--skip <step>[,<step>]` (or `push: { skip: [...] }`) drops a step
|
|
26
|
+
* from the run. This exists because a pipeline with no way out is a pipeline that stops working the
|
|
27
|
+
* moment one step legitimately does not apply to an app: `accessibility` refuses when no block is
|
|
28
|
+
* declared — correctly, it publishes claims — so an app that has not audited its accessibility could
|
|
29
|
+
* not complete `push` at all, and neither could one with no App Review PII on disk. The skip is named
|
|
30
|
+
* on every line of the report and again at the end, because the whole value of the last step is that
|
|
31
|
+
* green means green: a run that skipped something must never read like a run that did everything.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IT IS NOT. It never submits — the pipeline ends at `preflight`, and Add to Review + Submit
|
|
34
|
+
* stay in App Store Connect, human-only, exactly as every step's own refusals already guarantee.
|
|
35
|
+
* `prerelease` (the build upload) is deliberately not a step: it is macOS-only, shells out to altool,
|
|
36
|
+
* and belongs wherever the archive is produced. Run it whenever the build is ready — before or after
|
|
37
|
+
* `push`; `prepare` attaches the newest build either way.
|
|
38
|
+
*
|
|
39
|
+
* Dry-run composes: without --apply the shared client refuses every mutation, each step reports its
|
|
40
|
+
* plan, and bin/ prints the withheld-write count at the end. The one case a dry run cannot preview
|
|
41
|
+
* past is an app with no editable version — the draft the later steps target does not exist until
|
|
42
|
+
* `prepare` is APPLIED — so that is said up front rather than discovered as a refusal mid-run.
|
|
43
|
+
*/
|
|
44
|
+
const STEPS = [
|
|
45
|
+
["prepare", prepare],
|
|
46
|
+
["fill", fill],
|
|
47
|
+
["previews", previews],
|
|
48
|
+
["age-rating", ageRating],
|
|
49
|
+
["review-contact", reviewContact],
|
|
50
|
+
["accessibility", accessibility],
|
|
51
|
+
["preflight", preflight],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Steps named by `--skip a,b` and/or `push.skip` in the config, validated against the real step list. */
|
|
55
|
+
function resolveSkips(config) {
|
|
56
|
+
const flag = process.argv.find((a) => a.startsWith("--skip="))?.slice("--skip=".length)
|
|
57
|
+
?? (process.argv.includes("--skip") ? process.argv[process.argv.indexOf("--skip") + 1] : null);
|
|
58
|
+
const named = [
|
|
59
|
+
...(flag ? String(flag).split(",") : []),
|
|
60
|
+
...(config.push?.skip || []),
|
|
61
|
+
].map((s) => s.trim()).filter(Boolean);
|
|
62
|
+
const names = STEPS.map(([n]) => n);
|
|
63
|
+
const unknown = named.filter((s) => !names.includes(s));
|
|
64
|
+
return { skip: new Set(named.filter((s) => names.includes(s))), unknown };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function run(config, client) {
|
|
68
|
+
await client.findApp(config.bundleId);
|
|
69
|
+
const { skip, unknown } = resolveSkips(config);
|
|
70
|
+
if (unknown.length) {
|
|
71
|
+
console.error(red(`push: --skip named step(s) that do not exist: ${unknown.join(", ")}`));
|
|
72
|
+
console.error(` Steps: ${STEPS.map(([n]) => n).join(", ")}`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// Skipping the last step would make the command's own promise unverifiable, and skipping the first
|
|
76
|
+
// leaves every later step writing into a version that may not exist. Both are refusals rather than
|
|
77
|
+
// warnings: there is no reading of "push, but don't check the result" that is worth supporting.
|
|
78
|
+
for (const required of ["prepare", "preflight"]) {
|
|
79
|
+
if (skip.has(required)) {
|
|
80
|
+
console.error(red(`push: \`${required}\` cannot be skipped — run the other steps individually if that is what you want.`));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const platform = (config.platforms && config.platforms[0]) || "IOS";
|
|
86
|
+
if (client.dryRun && !(await client.editVersion(platform))) {
|
|
87
|
+
console.log(yellow("push: no editable App Store version exists, so this dry run will stop where the draft is needed."));
|
|
88
|
+
console.log(" `vydanne prepare --apply` creates it — a draft, not a submission — then re-run `vydanne push`");
|
|
89
|
+
console.log(" to preview the rest of the pipeline.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const skipped = [];
|
|
93
|
+
for (const [name, step] of STEPS) {
|
|
94
|
+
if (skip.has(name)) {
|
|
95
|
+
console.log(yellow(`\n▸ push: ${name} — SKIPPED (--skip)`));
|
|
96
|
+
skipped.push(name);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
console.log(green(`\n▸ push: ${name}`));
|
|
100
|
+
const ok = await step(config, client);
|
|
101
|
+
if (ok === false) {
|
|
102
|
+
console.error(red(`\npush: stopped at \`${name}\` — fix what it reported above, then re-run \`vydanne push\`.`));
|
|
103
|
+
console.error(" Every completed step is idempotent, so re-running repeats nothing destructive.");
|
|
104
|
+
if (name === "age-rating" && !config.ageRating && config.rating !== "4+") {
|
|
105
|
+
console.error(` For a '${config.rating}' rating, declare the content descriptors in \`ageRating\` — see the message above.`);
|
|
106
|
+
}
|
|
107
|
+
console.error(` Or drop this step for now: \`vydanne push --apply --skip ${name}\``);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(green("\npush done — the release is staged and preflight is green."));
|
|
113
|
+
// Repeated at the end on purpose. The one thing this command sells is that its last line means the
|
|
114
|
+
// release is ready; a skipped step is precisely the case where that would otherwise overstate it.
|
|
115
|
+
if (skipped.length) console.log(yellow(` NOT green for: ${skipped.join(", ")} — skipped, never run. Run them before you submit.`));
|
|
116
|
+
console.log(yellow(" Submitting stays yours: App Store Connect → the prepared version → Add to Review → Submit."));
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
@@ -1,27 +1,78 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { green, red } from "../util.mjs";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* App Review contact, from the GITIGNORED `<metadataDir>/review_information/*.txt`. PATCH (or POST) the
|
|
7
|
+
* review detail directly — deliver can't do this cleanly pre-first-submission.
|
|
8
|
+
*
|
|
9
|
+
* DEMO ACCOUNT. `demoAccountRequired` used to be hardcoded false. That is true of an account-less game
|
|
10
|
+
* and false of anything with a login, and getting it wrong is not a small thing: telling Apple no demo
|
|
11
|
+
* account is needed for an app whose first screen is a sign-in wall is a guaranteed rejection, with a
|
|
12
|
+
* review cycle attached. fastlane's own convention already puts `demo_user.txt` / `demo_password.txt`
|
|
13
|
+
* in the very directory this reads from, so the fix is to read the two files that were sitting there.
|
|
14
|
+
*
|
|
15
|
+
* Every platform gets the same contact, because the contact is a property of the submitter rather than
|
|
16
|
+
* of a platform — an app shipping iOS and macOS used to set it on `platforms[0]` and leave the other
|
|
17
|
+
* version's review detail empty.
|
|
18
|
+
*/
|
|
7
19
|
export async function run(config, client) {
|
|
8
20
|
const dir = path.join(config.metadataDir, "review_information");
|
|
9
21
|
const read = (n) => { const p = path.join(dir, `${n}.txt`); return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : ""; };
|
|
22
|
+
|
|
23
|
+
const demoUser = read("demo_user");
|
|
24
|
+
const demoPassword = read("demo_password");
|
|
10
25
|
const attributes = {
|
|
11
26
|
contactFirstName: read("first_name"), contactLastName: read("last_name"),
|
|
12
27
|
contactPhone: read("phone_number"), contactEmail: read("email_address"),
|
|
13
|
-
|
|
28
|
+
// Declared by what is on disk, with an explicit config override for the app that needs one and
|
|
29
|
+
// supplies it some other way (a reviewer-specific build, a magic link in the notes).
|
|
30
|
+
demoAccountRequired: config.reviewContact?.demoAccountRequired ?? !!demoUser,
|
|
31
|
+
notes: read("notes"),
|
|
14
32
|
};
|
|
33
|
+
if (demoUser) attributes.demoAccountName = demoUser;
|
|
34
|
+
if (demoPassword) attributes.demoAccountPassword = demoPassword;
|
|
35
|
+
|
|
15
36
|
if (!attributes.contactEmail) { console.error(red(`review-contact: ${dir}/*.txt missing`)); return false; }
|
|
37
|
+
// A demo account that is required but has no credentials is the rejection this command exists to
|
|
38
|
+
// avoid, arriving by a different door — say it here rather than letting Apple say it in a week.
|
|
39
|
+
if (attributes.demoAccountRequired && !demoUser) {
|
|
40
|
+
console.error(red("review-contact: demoAccountRequired is true but no demo_user.txt was found."));
|
|
41
|
+
console.error(` Add ${dir}/demo_user.txt and demo_password.txt (both gitignored), or set`);
|
|
42
|
+
console.error(" reviewContact: { demoAccountRequired: false } if the reviewer genuinely needs no account.");
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
16
46
|
await client.findApp(config.bundleId);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
|
|
48
|
+
let ok = true;
|
|
49
|
+
let touched = 0;
|
|
50
|
+
for (const platform of config.platforms) {
|
|
51
|
+
const v = await client.editVersion(platform);
|
|
52
|
+
if (!v) {
|
|
53
|
+
console.error(red(`review-contact ${platform}: no editable version — nothing to attach the contact to.`));
|
|
54
|
+
console.error(" `vydanne prepare --apply` creates the version being submitted, then re-run this.");
|
|
55
|
+
ok = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const { json } = await client.get(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`);
|
|
59
|
+
const existing = json.data;
|
|
60
|
+
const r = existing?.id
|
|
61
|
+
? await client.patch(`/v1/appStoreReviewDetails/${existing.id}`, { data: { type: "appStoreReviewDetails", id: existing.id, attributes } })
|
|
62
|
+
: await client.post(`/v1/appStoreReviewDetails`, { data: { type: "appStoreReviewDetails", attributes, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
|
|
63
|
+
if (r.status >= 300) {
|
|
64
|
+
console.error(red(`review-contact ${platform}: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`));
|
|
65
|
+
ok = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
touched++;
|
|
69
|
+
}
|
|
70
|
+
if (!touched) return false;
|
|
71
|
+
|
|
72
|
+
const who = `${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`;
|
|
73
|
+
const demo = attributes.demoAccountRequired ? ` · demo account ${demoUser}` : "";
|
|
74
|
+
console.log(client.dryRun
|
|
75
|
+
? yellow(`review contact WOULD be set on ${touched} platform(s) -> ${who}${demo}`)
|
|
76
|
+
: green(`review contact set on ${touched} platform(s) -> ${who}${demo}`));
|
|
77
|
+
return ok;
|
|
27
78
|
}
|
package/src/config.mjs
CHANGED
|
@@ -3,10 +3,12 @@ import fs from "node:fs";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { resolveLocales } from "./locales.mjs";
|
|
5
5
|
import { resolveCredentials } from "./credentials.mjs";
|
|
6
|
+
import { DEFAULT_SCREENSHOT_BASE } from "./screenshots.mjs";
|
|
7
|
+
import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
|
|
6
8
|
|
|
7
9
|
// The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
|
|
8
10
|
// (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
|
|
9
|
-
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility"];
|
|
11
|
+
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms"];
|
|
10
12
|
|
|
11
13
|
// One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
|
|
12
14
|
// credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
|
|
@@ -22,21 +24,64 @@ export async function loadConfig(p) {
|
|
|
22
24
|
};
|
|
23
25
|
const creds = resolveCredentials(raw, path.dirname(file));
|
|
24
26
|
for (const w of creds.warnings) console.warn(`\x1b[33mvydanne: ${w}\x1b[0m`);
|
|
27
|
+
// No `raw` passthrough, deliberately. It was assigned here and read by nothing — an attractive
|
|
28
|
+
// nuisance rather than dead weight: while `config.ios` sat unassigned (the bug check-config.mjs now
|
|
29
|
+
// guards), `config.raw.ios` WAS populated, so "fixing" a caller by reading through `raw` would have
|
|
30
|
+
// entrenched the dropped key instead of exposing it. Everything a command may read is an explicit
|
|
31
|
+
// key below, where the guard can see it.
|
|
25
32
|
const c = {
|
|
26
|
-
raw,
|
|
27
33
|
credentials: creds,
|
|
28
34
|
bundleId: need("bundleId"),
|
|
29
35
|
primaryLocale: need("primaryLocale"),
|
|
30
36
|
keyId: creds.keyId,
|
|
31
37
|
issuerId: creds.issuerId,
|
|
32
38
|
uiLocales: raw.uiLocales || [],
|
|
39
|
+
// App code -> App Store locale, for codes Apple spells differently or does not know yet. Merged over
|
|
40
|
+
// the built-in table rather than replacing it, so an app declares only its exceptions.
|
|
41
|
+
localeMap: raw.localeMap || null,
|
|
33
42
|
platforms: raw.platforms || ["IOS"],
|
|
34
43
|
rating: raw.rating || "4+",
|
|
44
|
+
// Content descriptors for a rating above 4+. Null means "the 4+ shorthand", which `age-rating`
|
|
45
|
+
// expands to an all-NONE declaration; anything else must be declared, never guessed.
|
|
46
|
+
ageRating: raw.ageRating || null,
|
|
35
47
|
privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
|
|
36
48
|
iaps: raw.iaps || [],
|
|
37
49
|
metadataDir: raw.metadataDir || "fastlane/metadata",
|
|
50
|
+
// Where each platform's screenshots live. fastlane's supply convention by default — the same
|
|
51
|
+
// relationship `metadataDir` has always had to it, and for the same reason: a convention worth
|
|
52
|
+
// defaulting to is not a reason to be unable to point the tool at your own repo.
|
|
53
|
+
screenshots: raw.screenshots
|
|
54
|
+
? { IOS: raw.screenshots.IOS || DEFAULT_SCREENSHOT_BASE.IOS, MAC_OS: raw.screenshots.MAC_OS || DEFAULT_SCREENSHOT_BASE.MAC_OS }
|
|
55
|
+
: null,
|
|
56
|
+
// App Review contact overrides. The PII itself still comes from the gitignored
|
|
57
|
+
// `<metadataDir>/review_information/*.txt` — this is only for what isn't a secret.
|
|
58
|
+
reviewContact: raw.reviewContact || null,
|
|
59
|
+
// `push: { skip: [...] }` — steps this app never runs. `--skip` adds to it per invocation; both are
|
|
60
|
+
// reported on every run, because a skipped step must never read as a completed one.
|
|
61
|
+
push: raw.push ? { skip: raw.push.skip || [] } : null,
|
|
62
|
+
// `bridge`: where zdymak wrote, and which of its output directories feed which store slot. Both
|
|
63
|
+
// default to zdymak's own conventions; both must be overridable, because `dir:` overrides in
|
|
64
|
+
// zdymak.config.mjs mean the directory name and the target name are not the same thing.
|
|
65
|
+
bridge: raw.bridge
|
|
66
|
+
? { out: raw.bridge.out || null, apple: raw.bridge.apple || null, play: raw.bridge.play || null }
|
|
67
|
+
: null,
|
|
68
|
+
// Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
|
|
69
|
+
allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
|
|
38
70
|
previews: raw.previews || null,
|
|
39
71
|
export: raw.export || { encryption: "standard" },
|
|
72
|
+
// What the app claims to support, for the Accessibility Nutrition Labels. Passed through RAW and
|
|
73
|
+
// deliberately NOT defaulted: `accessibility` validates its own block and must be able to tell
|
|
74
|
+
// "declared nothing" from "declared everything false" — a default here would turn a missing block
|
|
75
|
+
// into a silent set of claims, which is the one thing that command exists to refuse.
|
|
76
|
+
accessibility: raw.accessibility || null,
|
|
77
|
+
// iOS build upload (`prerelease`). Normalised like `google` below so the shape is stable whether or
|
|
78
|
+
// not the block is present.
|
|
79
|
+
ios: raw.ios
|
|
80
|
+
? {
|
|
81
|
+
ipa: raw.ios.ipa || null, // VYDANNE_IPA overrides, resolved in prerelease.resolveIpa
|
|
82
|
+
testFlightGroup: raw.ios.testFlightGroup || null, // internal groups only; external is refused
|
|
83
|
+
}
|
|
84
|
+
: null,
|
|
40
85
|
// Google Play. serviceAccountKey resolves from PLAY_JSON_KEY_FILE env first (keep the secret path out
|
|
41
86
|
// of the committed config). metadataDir follows fastlane supply's convention (fastlane/metadata/android).
|
|
42
87
|
google: raw.google
|
|
@@ -47,10 +92,16 @@ export async function loadConfig(p) {
|
|
|
47
92
|
defaultLocale: raw.google.defaultLocale || raw.primaryLocale,
|
|
48
93
|
aab: raw.google.aab || null,
|
|
49
94
|
track: raw.google.track || "internal", // testing only — `prerelease` refuses production
|
|
50
|
-
|
|
95
|
+
// Play image type -> local source path. Merged over the defaults so an app overrides only the
|
|
96
|
+
// slots whose layout differs; the default for `icon` points at znachok's output, which is a
|
|
97
|
+
// sensible default for this portfolio and a mystery to anyone else, so it is overridable.
|
|
98
|
+
images: { ...DEFAULT_PLAY_IMAGES, ...(raw.google.images || {}) },
|
|
99
|
+
// Play supports per-language graphics. Uploading only to `defaultLocale` is a choice, not a
|
|
100
|
+
// platform limit — list locales here (or "*" for every local listing folder) to localize them.
|
|
101
|
+
imageLocales: raw.google.imageLocales || null,
|
|
51
102
|
}
|
|
52
103
|
: null,
|
|
53
104
|
};
|
|
54
|
-
c.resolvedLocales = resolveLocales(c.uiLocales);
|
|
105
|
+
c.resolvedLocales = resolveLocales(c.uiLocales, c.localeMap);
|
|
55
106
|
return c;
|
|
56
107
|
}
|