vydanne 0.6.0 → 0.8.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 +92 -16
- package/SKILL.md +148 -25
- package/bin/vydanne.mjs +24 -1
- package/package.json +4 -2
- package/src/client.mjs +42 -6
- package/src/commands/accessibility.mjs +13 -0
- package/src/commands/ageRating.mjs +94 -21
- package/src/commands/appInfo.mjs +124 -0
- package/src/commands/bridge.mjs +328 -0
- package/src/commands/compliance.mjs +78 -9
- package/src/commands/diff.mjs +69 -20
- package/src/commands/fill.mjs +82 -15
- package/src/commands/inspect.mjs +4 -2
- package/src/commands/preflight.mjs +59 -6
- package/src/commands/prepare.mjs +266 -0
- package/src/commands/prerelease.mjs +17 -5
- package/src/commands/previews.mjs +33 -2
- package/src/commands/privacy.mjs +75 -5
- package/src/commands/push.mjs +118 -0
- package/src/commands/reviewContact.mjs +63 -13
- package/src/config.mjs +59 -4
- package/src/index.mjs +60 -3
- package/src/locales.mjs +27 -5
- package/src/play/aab.mjs +133 -0
- package/src/play/commands/diff.mjs +40 -0
- package/src/play/commands/fill.mjs +20 -19
- package/src/play/commands/prerelease.mjs +94 -20
- package/src/play/images.mjs +67 -0
- package/src/registry.mjs +12 -0
- package/src/screenshots.mjs +112 -0
- package/src/upload.mjs +3 -1
- package/types/index.d.ts +254 -10
- package/vydanne.config.example.mjs +54 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../util.mjs";
|
|
4
|
+
import { pointVersionAtBuild, LOCKED_STATES } from "./prerelease.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create — or reuse — the App Store version you are preparing, and point it at a build.
|
|
8
|
+
*
|
|
9
|
+
* THE GAP THIS CLOSES. Every other Apple command edits *the version being prepared*, which it finds
|
|
10
|
+
* through `client.editVersion()`. On an app whose only version is already on sale there is nothing
|
|
11
|
+
* editable to return, so that call falls back to the live record — and then `fill` aims its
|
|
12
|
+
* description/whatsNew PATCHes at the version customers are looking at, while `prerelease` declines
|
|
13
|
+
* to attach the build it just uploaded ("no editable App Store version — build uploaded, nothing to
|
|
14
|
+
* attach it to"). Neither command can recover on its own, because the thing that is missing is a
|
|
15
|
+
* *version*, and nothing in vydanne ever created one. So preparing a second release always started
|
|
16
|
+
* with a trip to the App Store Connect web UI — exactly the manual step this tool exists to delete.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT IS NOT. Creating a version is not submitting one. The record this POSTs lands in
|
|
19
|
+
* PREPARE_FOR_SUBMISSION: an editable draft, and the state `fill`, `previews` and `review-contact`
|
|
20
|
+
* all need before they can write anything. `releaseType: MANUAL` then keeps the release itself a
|
|
21
|
+
* human act even after Apple approves it. Add to Review and Submit stay exactly where they were —
|
|
22
|
+
* with you. There is no argument to this command that reaches a reviewer or the public.
|
|
23
|
+
*
|
|
24
|
+
* WHERE THE NUMBER COMES FROM. The newest build Apple holds, read off its preReleaseVersion — which
|
|
25
|
+
* is the archive's own CFBundleShortVersionString. The marketing version therefore travels with the
|
|
26
|
+
* binary, the same way `prerelease` already lets the build number travel with it: Apple assigns
|
|
27
|
+
* nothing and neither do we, so there is no second place to keep the number in step. Preparing a
|
|
28
|
+
* version *before* its build exists is the one case that needs telling, hence VYDANNE_VERSION.
|
|
29
|
+
*
|
|
30
|
+
* RE-RUNNING IS THE POINT. Find-or-create means the command is idempotent: run it before the build
|
|
31
|
+
* exists to get an empty draft, run it again after `prerelease` to attach the binary, run it a third
|
|
32
|
+
* time and it reports that everything already matches. And once the draft exists, `prerelease`
|
|
33
|
+
* attaches to it unaided — `editVersion()` starts finding a real editable version, so the "nothing
|
|
34
|
+
* to attach it to" dead end disappears for every future release too.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** How the CLI is meant to be driven, printed wherever the command can't finish the job itself. */
|
|
38
|
+
const NEXT_STEPS = [
|
|
39
|
+
" next: `vydanne fill --apply` for metadata, then Add to Review + Submit in App Store Connect.",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Every version Apple holds for this platform. Deliberately NOT `editVersion()`, which collapses the
|
|
43
|
+
* list to a single guess — finding a specific versionString is the whole job here. */
|
|
44
|
+
async function versionsFor(client, platform) {
|
|
45
|
+
const { json } = await client.get(
|
|
46
|
+
`/v1/apps/${client.appId}/appStoreVersions?filter[platform]=${platform}&limit=200` +
|
|
47
|
+
// `copyright` is requested because syncCopyright compares against it. A sparse fieldset that
|
|
48
|
+
// omits it does not read as absent — it reads as EMPTY, so the comparison can never match and
|
|
49
|
+
// the field is re-PATCHed on every run, spending a request to change nothing and printing
|
|
50
|
+
// "(was empty)" over a value that was already correct. A guard that cannot see the thing it
|
|
51
|
+
// guards is not a guard.
|
|
52
|
+
`&fields[appStoreVersions]=versionString,appStoreState,copyright`,
|
|
53
|
+
);
|
|
54
|
+
return json.data || [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The newest build, together with the marketing version its archive declares. */
|
|
58
|
+
async function newestBuildWithVersion(client) {
|
|
59
|
+
const { json } = await client.get(
|
|
60
|
+
`/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
|
|
61
|
+
`&fields[builds]=version,processingState&include=preReleaseVersion` +
|
|
62
|
+
`&fields[preReleaseVersions]=version`,
|
|
63
|
+
);
|
|
64
|
+
const build = json.data?.[0] ?? null;
|
|
65
|
+
const marketing =
|
|
66
|
+
json.included?.find((r) => r.type === "preReleaseVersions")?.attributes?.version ?? null;
|
|
67
|
+
return { build, marketing };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `<metadataDir>/copyright.txt`, the same supply convention `fill` reads listing text from. */
|
|
71
|
+
function readCopyright(config) {
|
|
72
|
+
if (!config.metadataDir) return null;
|
|
73
|
+
const p = path.join(config.metadataDir, "copyright.txt");
|
|
74
|
+
if (!fs.existsSync(p)) return null;
|
|
75
|
+
return fs.readFileSync(p, "utf8").trim() || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Copyright on a version that ALREADY EXISTED.
|
|
80
|
+
*
|
|
81
|
+
* It was only ever set at creation, on the reasoning below that nothing else in vydanne writes this
|
|
82
|
+
* version-level field — which was true and yet left the gap wide open, because `prepare` is
|
|
83
|
+
* find-or-CREATE. Every app whose draft was made before `copyright.txt` existed, or made by hand in
|
|
84
|
+
* App Store Connect, or created by an older vydanne, reused that version forever and never got a
|
|
85
|
+
* copyright. The tooling then reported "prepare done — the version is editable" and the operator
|
|
86
|
+
* discovered the truth from Apple, at the end, as `Copyright - This field is required` on the Add
|
|
87
|
+
* for Review screen. The comment three lines down even called it "a metadata rejection waiting to
|
|
88
|
+
* happen".
|
|
89
|
+
*
|
|
90
|
+
* So the create path and the reuse path now converge: after this, a version has the copyright the
|
|
91
|
+
* repo declares, however that version came to exist.
|
|
92
|
+
*
|
|
93
|
+
* PATCHes only on a real difference. Apple's rate limits are per-app and a `prepare` that rewrites
|
|
94
|
+
* an already-correct field on every `push` spends a request to change nothing — and, more to the
|
|
95
|
+
* point, prints a line claiming it did something.
|
|
96
|
+
*/
|
|
97
|
+
async function syncCopyright(client, config, version) {
|
|
98
|
+
const want = readCopyright(config);
|
|
99
|
+
if (!want) return;
|
|
100
|
+
const have = version.attributes?.copyright ?? "";
|
|
101
|
+
if (have === want) return;
|
|
102
|
+
|
|
103
|
+
const was = have ? ` (was "${have}")` : " (was empty)";
|
|
104
|
+
const r = await client.patch(`/v1/appStoreVersions/${version.id}`, {
|
|
105
|
+
data: { type: "appStoreVersions", id: version.id, attributes: { copyright: want } },
|
|
106
|
+
});
|
|
107
|
+
if (r.status >= 300) {
|
|
108
|
+
console.error(red(` could not set copyright (${r.status})`));
|
|
109
|
+
for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
version.attributes.copyright = want;
|
|
113
|
+
console.log(client.dryRun
|
|
114
|
+
? yellow(` WOULD set copyright: "${want}"${was}`)
|
|
115
|
+
: green(` copyright set: "${want}"${was}`));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Is `a` a higher marketing version than `b`?
|
|
120
|
+
*
|
|
121
|
+
* Compared component-wise as NUMBERS, because Apple refuses a versionString that does not exceed the
|
|
122
|
+
* one on sale and a string compare gets the interesting case backwards: "1.10" < "1.9" as text, and
|
|
123
|
+
* 1.10 > 1.9 as a version. Worth a pre-check rather than a 409 the operator has to decode.
|
|
124
|
+
*/
|
|
125
|
+
export function isHigherVersion(a, b) {
|
|
126
|
+
const pa = String(a).split(".").map((n) => Number(n) || 0);
|
|
127
|
+
const pb = String(b).split(".").map((n) => Number(n) || 0);
|
|
128
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
129
|
+
const x = pa[i] || 0;
|
|
130
|
+
const y = pb[i] || 0;
|
|
131
|
+
if (x !== y) return x > y;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find the version called `versionString`, or create it. Returns the version record, or null when it
|
|
138
|
+
* exists but Apple has locked it — a state only the operator can leave, by withdrawing in the UI.
|
|
139
|
+
*/
|
|
140
|
+
export async function ensureVersion(client, config, platform, versionString) {
|
|
141
|
+
const existing = (await versionsFor(client, platform)).find(
|
|
142
|
+
(v) => v.attributes.versionString === versionString,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (existing) {
|
|
146
|
+
const state = existing.attributes.appStoreState;
|
|
147
|
+
// Reusing prerelease's LOCKED_STATES rather than enumerating the editable states here: one list
|
|
148
|
+
// to keep right, and it is already the list that decides whether a build may be re-pointed.
|
|
149
|
+
if (LOCKED_STATES.has(state)) {
|
|
150
|
+
console.error(red(` version ${versionString} is ${state} — Apple will not let it be edited.`));
|
|
151
|
+
// Two locked cases, two different fixes — telling someone to "withdraw" a version that is ON
|
|
152
|
+
// SALE is advice they cannot take. Shipped once already means the next release needs a HIGHER
|
|
153
|
+
// number, which is the everyday case: the newest build still declares the shipped version
|
|
154
|
+
// because MARKETING_VERSION was never bumped after release.
|
|
155
|
+
if (state === "READY_FOR_SALE" || state === "REPLACED_WITH_NEW_VERSION") {
|
|
156
|
+
console.error(` That version has shipped; the next release needs a higher number. Bump`);
|
|
157
|
+
console.error(` MARKETING_VERSION and re-archive — or name it now: VYDANNE_VERSION=<next> vydanne prepare --apply`);
|
|
158
|
+
} else {
|
|
159
|
+
console.error(" Withdrawing that submission has reviewer-facing consequences, so it stays your");
|
|
160
|
+
console.error(" call: withdraw it in App Store Connect, then re-run this.");
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
console.log(green(` version ${versionString} already exists (${state}) — reusing it`));
|
|
165
|
+
await syncCopyright(client, config, existing);
|
|
166
|
+
return existing;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const attributes = { platform, versionString, releaseType: "MANUAL" };
|
|
170
|
+
// Set at creation because it is a version-level field: nothing else in vydanne writes `copyright`,
|
|
171
|
+
// and a version submitted without one is a metadata rejection waiting to happen.
|
|
172
|
+
const copyright = readCopyright(config);
|
|
173
|
+
if (copyright) attributes.copyright = copyright;
|
|
174
|
+
|
|
175
|
+
const r = await client.post(`/v1/appStoreVersions`, {
|
|
176
|
+
data: {
|
|
177
|
+
type: "appStoreVersions",
|
|
178
|
+
attributes,
|
|
179
|
+
relationships: { app: { data: { type: "apps", id: client.appId } } },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (r.status >= 300) {
|
|
184
|
+
console.error(red(` could not create version ${versionString} (${r.status})`));
|
|
185
|
+
for (const e of r.json?.errors || []) console.error(` ${e.title}: ${e.detail}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const how = copyright ? ", copyright set" : "";
|
|
190
|
+
// Said in the tense that happened. A dry run reported "created version 1.2" in green immediately
|
|
191
|
+
// after saying it would POST one — two lines that contradict each other, and the green one is the
|
|
192
|
+
// one people remember.
|
|
193
|
+
console.log(client.dryRun
|
|
194
|
+
? yellow(` WOULD create version ${versionString} — PREPARE_FOR_SUBMISSION, releaseType MANUAL${how}`)
|
|
195
|
+
: green(` created version ${versionString} — PREPARE_FOR_SUBMISSION, releaseType MANUAL${how}`));
|
|
196
|
+
return r.json.data;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function run(config, client) {
|
|
200
|
+
await client.findApp(config.bundleId);
|
|
201
|
+
// EVERY declared platform. iOS and macOS are separate App Store versions with separate records, and
|
|
202
|
+
// preparing only `platforms[0]` left the second one with nothing editable — so `fill MAC_OS` refused
|
|
203
|
+
// ("no editable version") on an app whose iOS draft had just been created, and `push` stopped there.
|
|
204
|
+
// `fill`, `previews` and `preflight` have always looped; this is the command that creates what they
|
|
205
|
+
// loop over.
|
|
206
|
+
let ok = true;
|
|
207
|
+
for (const platform of config.platforms) {
|
|
208
|
+
if (!(await prepareOne(config, client, platform))) ok = false;
|
|
209
|
+
}
|
|
210
|
+
if (!ok) return false;
|
|
211
|
+
|
|
212
|
+
console.log("prepare done — the version is editable.");
|
|
213
|
+
for (const line of NEXT_STEPS) console.log(line);
|
|
214
|
+
console.log(yellow(" Submitting for App Store review stays manual, by design."));
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function prepareOne(config, client, platform) {
|
|
219
|
+
console.log(green(`prepare → App Store version (${platform})`));
|
|
220
|
+
|
|
221
|
+
const { build, marketing } = await newestBuildWithVersion(client);
|
|
222
|
+
const target = process.env.VYDANNE_VERSION || marketing;
|
|
223
|
+
|
|
224
|
+
if (!target) {
|
|
225
|
+
console.error(red("prepare: nothing tells me which version to prepare."));
|
|
226
|
+
console.error(" Apple holds no build to read a marketing version from. Either upload one —");
|
|
227
|
+
console.error(" `vydanne prerelease --apply` — or name the version yourself:");
|
|
228
|
+
console.error(" VYDANNE_VERSION=1.2 vydanne prepare --apply");
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const source = process.env.VYDANNE_VERSION ? "VYDANNE_VERSION" : `build ${build?.attributes?.version}`;
|
|
233
|
+
console.log(` preparing ${target} (from ${source})`);
|
|
234
|
+
|
|
235
|
+
// The version on sale is the floor Apple enforces. Checking it here turns an opaque 409 into a
|
|
236
|
+
// sentence, and catches the likelier mistake: forgetting to bump MARKETING_VERSION before archiving.
|
|
237
|
+
const versions = await versionsFor(client, platform);
|
|
238
|
+
const live = versions.find((v) => v.attributes.appStoreState === "READY_FOR_SALE");
|
|
239
|
+
if (live && live.attributes.versionString !== target && !isHigherVersion(target, live.attributes.versionString)) {
|
|
240
|
+
console.error(red(` ${target} does not exceed ${live.attributes.versionString}, which is on sale.`));
|
|
241
|
+
console.error(" Apple only accepts a higher version. Bump MARKETING_VERSION, re-archive, re-upload.");
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (live) console.log(` on sale: ${live.attributes.versionString}`);
|
|
245
|
+
|
|
246
|
+
const version = await ensureVersion(client, config, platform, target);
|
|
247
|
+
if (!version) return false;
|
|
248
|
+
|
|
249
|
+
if (!build) {
|
|
250
|
+
console.log(yellow(" no build uploaded yet — the version is ready for one."));
|
|
251
|
+
console.log(" `vydanne prerelease --apply` will upload it and attach it to this version.");
|
|
252
|
+
} else if (marketing && marketing !== target) {
|
|
253
|
+
// The newest build belongs to a DIFFERENT marketing version — almost always because the archive for
|
|
254
|
+
// this one has not been uploaded yet, and VYDANNE_VERSION named the version ahead of its binary.
|
|
255
|
+
// Attaching it would put the previous release's binary behind the new version's number: Apple rejects
|
|
256
|
+
// that at submission, and it is a confusing thing to debug from the reviewer's message.
|
|
257
|
+
console.log(yellow(` build ${build.attributes.version} declares ${marketing}, not ${target} — not attaching it.`));
|
|
258
|
+
console.log(` Archive ${target} and upload it (\`vydanne prerelease --apply\`); this version is waiting.`);
|
|
259
|
+
} else if (build.attributes.processingState === "PROCESSING") {
|
|
260
|
+
console.log(yellow(` build ${build.attributes.version} is still PROCESSING — not attaching it yet.`));
|
|
261
|
+
console.log(" Re-run once Apple finishes, or let `vydanne prerelease --apply` attach it.");
|
|
262
|
+
} else {
|
|
263
|
+
await pointVersionAtBuild(client, config, build, version);
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
@@ -127,7 +127,7 @@ export async function run(config, client, credentials) {
|
|
|
127
127
|
// right answer for an accident and a dead end for a re-run, so say which it is.
|
|
128
128
|
if (/already exists|redundant binary|previously uploaded/i.test(text)) {
|
|
129
129
|
console.error(red(" Apple already holds this build number."));
|
|
130
|
-
console.error(" Bump CFBundleVersion (
|
|
130
|
+
console.error(" Bump CFBundleVersion (the build number, not the marketing version) and re-archive.");
|
|
131
131
|
} else {
|
|
132
132
|
console.error(red(" upload failed:"));
|
|
133
133
|
console.error(indent(text));
|
|
@@ -207,14 +207,21 @@ async function assignToInternalGroup(client, build, groupName) {
|
|
|
207
207
|
else console.error(yellow(` could not add to "${groupName}" (${r.status})`));
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/** Versions Apple will not let us re-point without the operator withdrawing them first.
|
|
211
|
-
|
|
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([
|
|
212
213
|
"WAITING_FOR_REVIEW",
|
|
213
214
|
"IN_REVIEW",
|
|
214
215
|
"PENDING_DEVELOPER_RELEASE",
|
|
215
216
|
"PENDING_APPLE_RELEASE",
|
|
216
217
|
"READY_FOR_SALE",
|
|
217
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",
|
|
218
225
|
]);
|
|
219
226
|
|
|
220
227
|
/**
|
|
@@ -222,12 +229,17 @@ const LOCKED_STATES = new Set([
|
|
|
222
229
|
*
|
|
223
230
|
* This is what makes "upload a fix and try again" one command rather than a trip to the web UI:
|
|
224
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.
|
|
225
236
|
*/
|
|
226
|
-
export async function pointVersionAtBuild(client, config, build) {
|
|
237
|
+
export async function pointVersionAtBuild(client, config, build, known = null) {
|
|
227
238
|
const platform = (config.platforms && config.platforms[0]) || "IOS";
|
|
228
|
-
const version = await client.editVersion(platform);
|
|
239
|
+
const version = known || (await client.editVersion(platform));
|
|
229
240
|
if (!version) {
|
|
230
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.");
|
|
231
243
|
return;
|
|
232
244
|
}
|
|
233
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);
|
|
@@ -36,6 +60,7 @@ export async function run(config, client) {
|
|
|
36
60
|
// works. Name it and move on, so one missing video doesn't hide the rest of the report.
|
|
37
61
|
if (!fs.existsSync(file)) {
|
|
38
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`);
|
|
39
64
|
continue;
|
|
40
65
|
}
|
|
41
66
|
console.log(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would upload" : "uploading"} ${path.basename(file)}...`);
|
|
@@ -44,9 +69,15 @@ export async function run(config, client) {
|
|
|
44
69
|
if (!client.dryRun) console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
45
70
|
} catch (e) {
|
|
46
71
|
console.error(red(` error ${s.platform}/${code}/${s.type}: ${e.message}`));
|
|
72
|
+
failures.push(`${s.platform}/${code}/${s.type}: ${e.message}`);
|
|
47
73
|
}
|
|
48
74
|
}
|
|
49
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
|
+
}
|
|
50
81
|
return true;
|
|
51
82
|
}
|
|
52
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
|
+
}
|