vydanne 0.4.2 → 0.6.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/README.md +152 -16
- package/SKILL.md +50 -13
- package/bin/vydanne.mjs +46 -13
- package/package.json +2 -2
- package/src/client.mjs +36 -1
- package/src/commands/accessibility.mjs +129 -19
- package/src/commands/ageRating.mjs +1 -1
- package/src/commands/fill.mjs +11 -0
- package/src/commands/preflight.mjs +6 -0
- package/src/commands/prerelease.mjs +267 -0
- package/src/commands/previews.mjs +10 -3
- package/src/commands/reviewContact.mjs +3 -2
- package/src/config.mjs +3 -1
- package/src/crossStore.mjs +151 -0
- package/src/play/client.mjs +6 -3
- package/src/play/commands/fill.mjs +12 -5
- package/src/play/commands/preflight.mjs +5 -0
- package/src/play/commands/prerelease.mjs +2 -2
- package/src/registry.mjs +19 -9
- package/src/upload.mjs +3 -0
- package/types/index.d.ts +57 -3
|
@@ -1,36 +1,146 @@
|
|
|
1
1
|
import { green, yellow, red } from "../util.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Accessibility Nutrition Labels.
|
|
5
|
+
*
|
|
6
|
+
* These used to come from a hardcoded matrix that said the same thing for every app the tool was
|
|
7
|
+
* pointed at. That is the wrong shape for this one command. Everything else vydanne writes is a
|
|
8
|
+
* FACT about an app — its name, its price, the data it collects — but this is a CLAIM about
|
|
9
|
+
* behaviour, made to Apple, and the tool had no way of knowing whether it was true. It asserted
|
|
10
|
+
* VoiceOver, Voice Control and Larger Text support for apps nobody had audited, and at least one
|
|
11
|
+
* of them did not honour Larger Text at all: its board glyphs scaled twice and grew off the
|
|
12
|
+
* high-contrast disc drawn behind them.
|
|
13
|
+
*
|
|
14
|
+
* So the app declares it, feature by feature, in `vydanne.config.mjs`. Silence is not consent — an
|
|
15
|
+
* app with no `accessibility` block gets an error, not a default, because "nobody wrote this down"
|
|
16
|
+
* must never become "supports everything".
|
|
17
|
+
*
|
|
18
|
+
* Apple's platform caveats are still applied here, because they are facts about the platforms
|
|
19
|
+
* rather than about any app: Larger Text does not exist on macOS and Voice Control does not exist
|
|
20
|
+
* on watchOS, so those are forced false regardless of what an app claims.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Config key -> the ASC attribute it sets. */
|
|
24
|
+
const FEATURES = {
|
|
25
|
+
voiceover: "supportsVoiceover",
|
|
26
|
+
voiceControl: "supportsVoiceControl",
|
|
27
|
+
largerText: "supportsLargerText",
|
|
28
|
+
sufficientContrast: "supportsSufficientContrast",
|
|
29
|
+
darkInterface: "supportsDarkInterface",
|
|
30
|
+
differentiateWithoutColorAlone: "supportsDifferentiateWithoutColorAlone",
|
|
31
|
+
reducedMotion: "supportsReducedMotion",
|
|
32
|
+
captions: "supportsCaptions",
|
|
33
|
+
audioDescriptions: "supportsAudioDescriptions",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Features Apple does not offer on a device family — never sent, whatever the app declares. */
|
|
37
|
+
const UNAVAILABLE = {
|
|
38
|
+
IPHONE: [],
|
|
39
|
+
IPAD: [],
|
|
40
|
+
MAC: ["largerText"],
|
|
41
|
+
APPLE_WATCH: ["voiceControl"],
|
|
11
42
|
};
|
|
12
43
|
|
|
44
|
+
const EXAMPLE = ` accessibility: {
|
|
45
|
+
voiceover: true,
|
|
46
|
+
voiceControl: true,
|
|
47
|
+
largerText: true,
|
|
48
|
+
sufficientContrast: true,
|
|
49
|
+
darkInterface: true,
|
|
50
|
+
differentiateWithoutColorAlone: true,
|
|
51
|
+
reducedMotion: true,
|
|
52
|
+
captions: false,
|
|
53
|
+
audioDescriptions: false,
|
|
54
|
+
},`;
|
|
55
|
+
|
|
56
|
+
/** Turn the app's declaration into the attributes for one device family. */
|
|
57
|
+
function attributesFor(declared, family) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [key, attribute] of Object.entries(FEATURES)) {
|
|
60
|
+
out[attribute] = UNAVAILABLE[family].includes(key) ? false : declared[key] === true;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Returns a human-readable problem, or null when the declaration is usable. */
|
|
66
|
+
export function validate(config) {
|
|
67
|
+
const declared = config.accessibility;
|
|
68
|
+
if (!declared || typeof declared !== "object") {
|
|
69
|
+
return [
|
|
70
|
+
"accessibility: missing.",
|
|
71
|
+
"This command publishes CLAIMS about your app's behaviour to Apple, so it will not guess.",
|
|
72
|
+
"Declare what you have actually verified:",
|
|
73
|
+
"",
|
|
74
|
+
EXAMPLE,
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
const unknown = Object.keys(declared).filter((k) => !(k in FEATURES));
|
|
78
|
+
if (unknown.length) {
|
|
79
|
+
return `accessibility: unknown feature(s) ${unknown.join(", ")}. Known: ${Object.keys(FEATURES).join(", ")}`;
|
|
80
|
+
}
|
|
81
|
+
const missing = Object.keys(FEATURES).filter((k) => typeof declared[k] !== "boolean");
|
|
82
|
+
if (missing.length) {
|
|
83
|
+
return [
|
|
84
|
+
`accessibility: ${missing.join(", ")} must be declared true or false.`,
|
|
85
|
+
"Every feature is stated explicitly — an omission would read as a quiet 'no', which is just",
|
|
86
|
+
"as unverified as a quiet 'yes'.",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
13
92
|
export async function run(config, client) {
|
|
93
|
+
const problem = validate(config);
|
|
94
|
+
if (problem) {
|
|
95
|
+
console.error(red(problem));
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const declared = config.accessibility;
|
|
99
|
+
|
|
14
100
|
await client.findApp(config.bundleId);
|
|
15
101
|
const publish = process.env.VYDANNE_A11Y_PUBLISH === "1";
|
|
16
102
|
const { json } = await client.get(`/v1/apps/${client.appId}/accessibilityDeclarations?limit=50`);
|
|
17
103
|
const decls = {};
|
|
18
104
|
for (const d of json.data || []) decls[d.attributes.deviceFamily] = d.id;
|
|
105
|
+
|
|
106
|
+
const claimed = Object.keys(FEATURES).filter((k) => declared[k]);
|
|
107
|
+
console.log(` declaring: ${claimed.length ? claimed.join(", ") : "(nothing)"}`);
|
|
108
|
+
|
|
19
109
|
let gated = false;
|
|
20
|
-
for (const
|
|
21
|
-
const id = decls[
|
|
22
|
-
if (!id) {
|
|
23
|
-
|
|
24
|
-
|
|
110
|
+
for (const family of Object.keys(UNAVAILABLE)) {
|
|
111
|
+
const id = decls[family];
|
|
112
|
+
if (!id) {
|
|
113
|
+
console.error(yellow(` no ${family} declaration`));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const attributes = attributesFor(declared, family);
|
|
117
|
+
const r = await client.patch(`/v1/accessibilityDeclarations/${id}`, {
|
|
118
|
+
data: { type: "accessibilityDeclarations", id, attributes },
|
|
119
|
+
});
|
|
120
|
+
if (r.status >= 300) {
|
|
121
|
+
console.error(red(` ${family} draft ${r.status}`));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
25
124
|
if (publish) {
|
|
26
|
-
const p = await client.patch(`/v1/accessibilityDeclarations/${id}`, {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
125
|
+
const p = await client.patch(`/v1/accessibilityDeclarations/${id}`, {
|
|
126
|
+
data: { type: "accessibilityDeclarations", id, attributes: { publish: true } },
|
|
127
|
+
});
|
|
128
|
+
if (p.status < 300) {
|
|
129
|
+
console.log(green(` ${family}: PUBLISHED`));
|
|
130
|
+
} else if (JSON.stringify(p.json).includes("CANNOT_PUBLISH_APP_MUST_BE_AVAILABLE")) {
|
|
131
|
+
gated = true;
|
|
132
|
+
console.log(yellow(` ${family}: draft saved — publish deferred (app not live yet)`));
|
|
133
|
+
} else {
|
|
134
|
+
console.error(red(` ${family} publish ${p.status}`));
|
|
135
|
+
}
|
|
30
136
|
} else {
|
|
31
|
-
console.log(green(` ${
|
|
137
|
+
console.log(green(` ${family}: draft saved`));
|
|
32
138
|
}
|
|
33
139
|
}
|
|
34
|
-
console.log(
|
|
140
|
+
console.log(
|
|
141
|
+
gated
|
|
142
|
+
? yellow("accessibility staged (DRAFT); re-run with VYDANNE_A11Y_PUBLISH=1 once the app is live")
|
|
143
|
+
: "accessibility done",
|
|
144
|
+
);
|
|
35
145
|
return true;
|
|
36
146
|
}
|
|
@@ -28,6 +28,6 @@ export async function run(config, client) {
|
|
|
28
28
|
};
|
|
29
29
|
const r = await client.patch(`/v1/ageRatingDeclarations/${id}`, { data: { type: "ageRatingDeclarations", id, attributes } });
|
|
30
30
|
if (r.status >= 300) { console.error(red(`age-rating: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
|
|
31
|
-
console.log(green("age rating set -> 4+"));
|
|
31
|
+
console.log(client.dryRun ? yellow("age rating WOULD be set -> 4+") : green("age rating set -> 4+"));
|
|
32
32
|
return true;
|
|
33
33
|
}
|
package/src/commands/fill.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { green, yellow, red } from "../util.mjs";
|
|
4
4
|
import { VALID } from "../locales.mjs";
|
|
5
5
|
import { uploadAsset } from "../upload.mjs";
|
|
6
|
+
import { reportCrossStore } from "../crossStore.mjs";
|
|
6
7
|
|
|
7
8
|
// Version-localization fields (attr -> metadata filename) and AppInfo fields (name/subtitle, shared).
|
|
8
9
|
const VERSION_TXT = { description: "description", keywords: "keywords", promotionalText: "promotional_text", whatsNew: "release_notes", marketingUrl: "marketing_url", supportUrl: "support_url" };
|
|
@@ -50,6 +51,16 @@ export async function run(config, client) {
|
|
|
50
51
|
let ok = true; // a locale Apple refused must fail the command, not just print
|
|
51
52
|
const skipMeta = process.env.VYDANNE_SKIP_METADATA === "1";
|
|
52
53
|
const skipShots = process.env.VYDANNE_SKIP_SCREENSHOTS === "1";
|
|
54
|
+
|
|
55
|
+
// Checked here and not only in preflight, because preflight is something you REMEMBER to run and
|
|
56
|
+
// this is the thing that actually uploads. A cross-store reference costs a review cycle, and it
|
|
57
|
+
// is free to catch one function call earlier. VYDANNE_ALLOW_CROSS_STORE=1 is the deliberate
|
|
58
|
+
// override for the rare listing that genuinely needs the word.
|
|
59
|
+
if (!skipMeta && process.env.VYDANNE_ALLOW_CROSS_STORE !== "1"
|
|
60
|
+
&& !reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
|
|
61
|
+
console.error(red("fill: refusing to upload — fix the listing text, or set VYDANNE_ALLOW_CROSS_STORE=1."));
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
53
64
|
const info = await client.appInfo();
|
|
54
65
|
const infoLocs = info ? (await client.get(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)).json.data || [] : [];
|
|
55
66
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { green, red, yellow, LIMITS, VERSION_FIELDS } from "../util.mjs";
|
|
2
|
+
import { reportCrossStore } from "../crossStore.mjs";
|
|
2
3
|
|
|
3
4
|
// Verify a listing is submission-complete the CORRECT way — each localization read by id (not the sparse
|
|
4
5
|
// list), char limits, primary-locale coverage, per-platform — and warn on the gotchas before ASC does.
|
|
@@ -34,6 +35,11 @@ export async function run(config, client) {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// Local copy that is about to be uploaded, checked before it can earn a rejection.
|
|
39
|
+
if (!reportCrossStore("apple", config.metadataDir, config.allowCrossStoreTerms)) {
|
|
40
|
+
problems.push("listing text references another mobile platform (see above)");
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
console.log();
|
|
38
44
|
if (!problems.length) console.log(green("preflight: no blockers"));
|
|
39
45
|
else { console.log(red(`preflight: ${problems.length} blocker(s)`)); problems.forEach((p) => console.log(` ${red("x")} ${p}`)); }
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { green, yellow, red } from "../util.mjs";
|
|
6
|
+
|
|
7
|
+
const run_ = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upload a build to App Store Connect, for TestFlight.
|
|
11
|
+
*
|
|
12
|
+
* The Play side has had this since the beginning; Apple did not, so the one step that actually
|
|
13
|
+
* moves a binary had to be done by hand for iOS while Android was a command. This closes that,
|
|
14
|
+
* with the same refusals.
|
|
15
|
+
*
|
|
16
|
+
* **The REST API cannot carry a binary.** Everything else vydanne does is ASC REST over a JWT it
|
|
17
|
+
* mints itself, but Apple has never exposed binary upload there — `altool` and Transporter are the
|
|
18
|
+
* only supported paths. So this shells out to `xcrun altool`, which is not a departure from "no
|
|
19
|
+
* fastlane/Ruby": altool ships with Xcode, and you already needed Xcode to produce the .ipa this
|
|
20
|
+
* command uploads. It does mean the command is macOS-only, which is checked up front rather than
|
|
21
|
+
* discovered as a confusing spawn error.
|
|
22
|
+
*
|
|
23
|
+
* WHAT IT REFUSES, mirroring `--store google`:
|
|
24
|
+
* - it does not submit for App Store review. That release is a human's to make, and there is no
|
|
25
|
+
* arrangement of arguments here that reaches the public.
|
|
26
|
+
* - it does not touch EXTERNAL TestFlight groups. External testing needs Beta App Review, which
|
|
27
|
+
* is a submission by another name. Internal groups only — the exact parallel of Play's
|
|
28
|
+
* `internal` track, and for the same practical reason: on a PAID app, internal testers are the
|
|
29
|
+
* ones who install without buying it.
|
|
30
|
+
*
|
|
31
|
+
* Apple assigns nothing: the build number comes from the archive's own CFBundleVersion, so build
|
|
32
|
+
* numbering stays with the build, and re-uploading one Apple already holds fails loudly instead of
|
|
33
|
+
* silently replacing a binary.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IT ALSO DOES: points the version you are preparing AT the build it just uploaded. That is
|
|
36
|
+
* the loop this command exists to serve — ship a build to testers, find something, fix it, upload
|
|
37
|
+
* again — and without it the version keeps whatever build was attached first while TestFlight
|
|
38
|
+
* quietly moves on. Re-running simply re-points: the relationship is a single build, so the newest
|
|
39
|
+
* upload replaces the old one and there is nothing to clean up.
|
|
40
|
+
*
|
|
41
|
+
* It will not re-point a version that is no longer editable (in review, or already released),
|
|
42
|
+
* because Apple would need that version withdrawn first — a decision with reviewer-facing
|
|
43
|
+
* consequences, and therefore the operator's.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** How long to watch processing before handing back. Apple is usually minutes, occasionally not. */
|
|
47
|
+
const PROCESS_TIMEOUT_MS = 15 * 60 * 1000;
|
|
48
|
+
const POLL_MS = 20 * 1000;
|
|
49
|
+
|
|
50
|
+
/** Newest `.ipa` in a directory, or the file itself. Mirrors how `google.aab` is resolved. */
|
|
51
|
+
export function resolveIpa(spec) {
|
|
52
|
+
const target = process.env.VYDANNE_IPA || spec;
|
|
53
|
+
if (!target) return null;
|
|
54
|
+
if (!fs.existsSync(target)) return null;
|
|
55
|
+
if (fs.statSync(target).isFile()) return target.endsWith(".ipa") ? path.resolve(target) : null;
|
|
56
|
+
const ipas = fs
|
|
57
|
+
.readdirSync(target)
|
|
58
|
+
.filter((f) => f.endsWith(".ipa"))
|
|
59
|
+
.map((f) => path.join(target, f))
|
|
60
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
61
|
+
return ipas.length ? path.resolve(ipas[0]) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function altool(args, credentials) {
|
|
65
|
+
// altool finds the key itself, in the same ~/.appstoreconnect/private_keys location vydanne
|
|
66
|
+
// already resolves for its JWT — so a working `vydanne auth` is a working upload.
|
|
67
|
+
const full = ["altool", ...args, "--apiKey", credentials.keyId, "--apiIssuer", credentials.issuerId];
|
|
68
|
+
return run_("xcrun", full, { maxBuffer: 32 * 1024 * 1024 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The newest build Apple has for this app, if any. */
|
|
72
|
+
async function newestBuild(client) {
|
|
73
|
+
const { json } = await client.get(
|
|
74
|
+
`/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
|
|
75
|
+
`&fields[builds]=version,processingState,uploadedDate`,
|
|
76
|
+
);
|
|
77
|
+
return json.data?.[0] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function run(config, client, credentials) {
|
|
81
|
+
const ios = config.ios || {};
|
|
82
|
+
|
|
83
|
+
if (process.platform !== "darwin") {
|
|
84
|
+
console.error(red("prerelease: uploading to App Store Connect needs `xcrun altool`, which is macOS-only."));
|
|
85
|
+
console.error(" Apple has never exposed binary upload over the REST API — altool or Transporter only.");
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ipa = resolveIpa(ios.ipa);
|
|
90
|
+
if (!ipa) {
|
|
91
|
+
console.error(red("prerelease: no .ipa found — set `ios.ipa` in the config, or pass VYDANNE_IPA=<path>."));
|
|
92
|
+
console.error(" It may be a file or a directory; a directory takes its newest .ipa.");
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await client.findApp(config.bundleId);
|
|
97
|
+
const before = await newestBuild(client);
|
|
98
|
+
const size = (fs.statSync(ipa).size / 1e6).toFixed(1);
|
|
99
|
+
console.log(green("prerelease → App Store Connect (TestFlight)"));
|
|
100
|
+
console.log(` archive: ${path.relative(process.cwd(), ipa) || ipa} (${size} MB)`);
|
|
101
|
+
|
|
102
|
+
// Validate before uploading. A rejected upload has already cost the transfer; altool's validation
|
|
103
|
+
// catches the common refusals (entitlements, missing icons, bad version) in seconds.
|
|
104
|
+
try {
|
|
105
|
+
await altool(["--validate-app", "-f", ipa, "-t", "ios"], credentials);
|
|
106
|
+
console.log(green(" validated"));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error(red(" validation failed — Apple would reject this archive:"));
|
|
109
|
+
console.error(indent(e.stdout || e.stderr || e.message));
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
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
|
+
|
|
121
|
+
try {
|
|
122
|
+
await altool(["--upload-app", "-f", ipa, "-t", "ios"], credentials);
|
|
123
|
+
console.log(green(" uploaded"));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const text = `${e.stdout || ""}${e.stderr || ""}` || e.message;
|
|
126
|
+
// The one refusal worth naming: re-uploading a build number Apple already holds. It is the
|
|
127
|
+
// right answer for an accident and a dead end for a re-run, so say which it is.
|
|
128
|
+
if (/already exists|redundant binary|previously uploaded/i.test(text)) {
|
|
129
|
+
console.error(red(" Apple already holds this build number."));
|
|
130
|
+
console.error(" Bump CFBundleVersion (Scripts/build-number.sh in the games) and re-archive.");
|
|
131
|
+
} else {
|
|
132
|
+
console.error(red(" upload failed:"));
|
|
133
|
+
console.error(indent(text));
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const build = await waitForProcessing(client, before);
|
|
139
|
+
if (!build) {
|
|
140
|
+
console.log(yellow(" build not visible yet — Apple is still ingesting it."));
|
|
141
|
+
console.log(" It will appear in TestFlight shortly; nothing further is needed here.");
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
console.log(green(` build ${build.attributes.version}: ${build.attributes.processingState}`));
|
|
145
|
+
|
|
146
|
+
if (build.attributes.processingState === "FAILED") {
|
|
147
|
+
console.error(red(" Apple failed to process this build — check the email it sent for the reason."));
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (ios.testFlightGroup) {
|
|
152
|
+
await assignToInternalGroup(client, build, ios.testFlightGroup);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await pointVersionAtBuild(client, config, build);
|
|
156
|
+
|
|
157
|
+
console.log("prerelease done — the build is in TestFlight.");
|
|
158
|
+
console.log(yellow(" Submitting for App Store review stays manual, by design."));
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function waitForProcessing(client, before) {
|
|
163
|
+
const deadline = Date.now() + PROCESS_TIMEOUT_MS;
|
|
164
|
+
let announced = false;
|
|
165
|
+
while (Date.now() < deadline) {
|
|
166
|
+
const build = await newestBuild(client);
|
|
167
|
+
// "New" means a different build than the one that was newest before the upload. Comparing ids
|
|
168
|
+
// rather than timestamps avoids any assumption about clock skew between here and Apple.
|
|
169
|
+
if (build && build.id !== before?.id) {
|
|
170
|
+
if (build.attributes.processingState !== "PROCESSING") return build;
|
|
171
|
+
if (!announced) {
|
|
172
|
+
console.log(" processing…");
|
|
173
|
+
announced = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Add the build to an INTERNAL TestFlight group.
|
|
183
|
+
*
|
|
184
|
+
* External groups are refused: distributing to them requires Beta App Review, which is a
|
|
185
|
+
* submission, and this tool does not submit.
|
|
186
|
+
*/
|
|
187
|
+
async function assignToInternalGroup(client, build, groupName) {
|
|
188
|
+
const { json } = await client.get(
|
|
189
|
+
`/v1/apps/${client.appId}/betaGroups?limit=200&fields[betaGroups]=name,isInternalGroup`,
|
|
190
|
+
);
|
|
191
|
+
const groups = json.data || [];
|
|
192
|
+
const group = groups.find((g) => g.attributes.name === groupName);
|
|
193
|
+
if (!group) {
|
|
194
|
+
const names = groups.map((g) => `${g.attributes.name}${g.attributes.isInternalGroup ? "" : " (external)"}`);
|
|
195
|
+
console.error(yellow(` no TestFlight group named "${groupName}" — have: ${names.join(", ") || "none"}`));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!group.attributes.isInternalGroup) {
|
|
199
|
+
console.error(red(` "${groupName}" is an EXTERNAL group — refusing.`));
|
|
200
|
+
console.error(" External testing needs Beta App Review, which is a submission by another name.");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const r = await client.post(`/v1/betaGroups/${group.id}/relationships/builds`, {
|
|
204
|
+
data: [{ type: "builds", id: build.id }],
|
|
205
|
+
});
|
|
206
|
+
if (r.status < 300) console.log(green(` added to internal group "${groupName}"`));
|
|
207
|
+
else console.error(yellow(` could not add to "${groupName}" (${r.status})`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Versions Apple will not let us re-point without the operator withdrawing them first. */
|
|
211
|
+
const LOCKED_STATES = new Set([
|
|
212
|
+
"WAITING_FOR_REVIEW",
|
|
213
|
+
"IN_REVIEW",
|
|
214
|
+
"PENDING_DEVELOPER_RELEASE",
|
|
215
|
+
"PENDING_APPLE_RELEASE",
|
|
216
|
+
"READY_FOR_SALE",
|
|
217
|
+
"REPLACED_WITH_NEW_VERSION",
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Attach [build] to the version being prepared, replacing whatever was there.
|
|
222
|
+
*
|
|
223
|
+
* This is what makes "upload a fix and try again" one command rather than a trip to the web UI:
|
|
224
|
+
* the version → build relationship holds exactly one build, so a PATCH re-points it.
|
|
225
|
+
*/
|
|
226
|
+
export async function pointVersionAtBuild(client, config, build) {
|
|
227
|
+
const platform = (config.platforms && config.platforms[0]) || "IOS";
|
|
228
|
+
const version = await client.editVersion(platform);
|
|
229
|
+
if (!version) {
|
|
230
|
+
console.log(yellow(" no editable App Store version — build uploaded, nothing to attach it to."));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const state = version.attributes.appStoreState;
|
|
234
|
+
const versionString = version.attributes.versionString;
|
|
235
|
+
if (LOCKED_STATES.has(state)) {
|
|
236
|
+
console.log(yellow(` version ${versionString} is ${state} — leaving its build alone.`));
|
|
237
|
+
console.log(" Re-pointing it would mean withdrawing that submission, which is your call.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// What it currently points at, so a no-op re-run says so rather than looking like a change.
|
|
242
|
+
const { json: current } = await client.get(
|
|
243
|
+
`/v1/appStoreVersions/${version.id}/build?fields[builds]=version`,
|
|
244
|
+
);
|
|
245
|
+
const was = current.data?.attributes?.version ?? current.data?.id ?? null;
|
|
246
|
+
if (current.data?.id === build.id) {
|
|
247
|
+
console.log(green(` version ${versionString} already points at build ${build.attributes.version}`));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const r = await client.patch(`/v1/appStoreVersions/${version.id}/relationships/build`, {
|
|
252
|
+
data: { type: "builds", id: build.id },
|
|
253
|
+
});
|
|
254
|
+
if (r.status < 300) {
|
|
255
|
+
const from = was ? `build ${was} -> ` : "";
|
|
256
|
+
console.log(green(` version ${versionString}: ${from}build ${build.attributes.version}`));
|
|
257
|
+
} else {
|
|
258
|
+
console.error(yellow(` could not attach the build to version ${versionString} (${r.status})`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const indent = (text) =>
|
|
263
|
+
String(text)
|
|
264
|
+
.trim()
|
|
265
|
+
.split("\n")
|
|
266
|
+
.map((l) => ` ${l}`)
|
|
267
|
+
.join("\n");
|
|
@@ -24,17 +24,24 @@ export async function run(config, client) {
|
|
|
24
24
|
}
|
|
25
25
|
for (const p of existing) { // VYDANNE_REPLACE: drop the old preview so the new upload takes its place
|
|
26
26
|
await client.del(`/v1/appPreviews/${p.id}`);
|
|
27
|
-
console.log(yellow(` ${s.platform}/${code}/${s.type}: removed old preview ${p.id}`));
|
|
27
|
+
console.log(yellow(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would remove" : "removed"} old preview ${p.id}`));
|
|
28
28
|
}
|
|
29
29
|
if (!set) {
|
|
30
30
|
const c = await client.post(`/v1/appPreviewSets`, { data: { type: "appPreviewSets", attributes: { previewType: s.type }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: loc.id } } } } });
|
|
31
31
|
set = c.json.data;
|
|
32
32
|
}
|
|
33
33
|
const file = path.resolve(s.file);
|
|
34
|
-
|
|
34
|
+
// A configured preview whose file is missing is the whole reason this is checked here: the
|
|
35
|
+
// upload would throw ENOENT mid-run, and in a DRY run it would otherwise look like a plan that
|
|
36
|
+
// works. Name it and move on, so one missing video doesn't hide the rest of the report.
|
|
37
|
+
if (!fs.existsSync(file)) {
|
|
38
|
+
console.error(red(` ${s.platform}/${code}/${s.type}: ${path.relative(process.cwd(), file)} does not exist — nothing to upload`));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
console.log(` ${s.platform}/${code}/${s.type}: ${client.dryRun ? "would upload" : "uploading"} ${path.basename(file)}...`);
|
|
35
42
|
const id = await uploadAsset(client, { type: "appPreviews", setType: "appPreviewSet", setId: set.id, filePath: file });
|
|
36
43
|
await setPreviewPoster(client, id, s.poster);
|
|
37
|
-
console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
44
|
+
if (!client.dryRun) console.log(green(` done ${s.platform}/${code}/${s.type}`));
|
|
38
45
|
} catch (e) {
|
|
39
46
|
console.error(red(` error ${s.platform}/${code}/${s.type}: ${e.message}`));
|
|
40
47
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
// App Review contact from the GITIGNORED metadata/review_information/*.txt. PATCH (or POST) the review
|
|
6
6
|
// detail directly — deliver can't do this cleanly pre-first-submission.
|
|
@@ -22,6 +22,7 @@ export async function run(config, client) {
|
|
|
22
22
|
? await client.patch(`/v1/appStoreReviewDetails/${existing.id}`, { data: { type: "appStoreReviewDetails", id: existing.id, attributes } })
|
|
23
23
|
: await client.post(`/v1/appStoreReviewDetails`, { data: { type: "appStoreReviewDetails", attributes, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } });
|
|
24
24
|
if (r.status >= 300) { console.error(red(`review-contact: ${r.status}: ${JSON.stringify(r.json).slice(0, 200)}`)); return false; }
|
|
25
|
-
|
|
25
|
+
const who = `${attributes.contactFirstName} ${attributes.contactLastName} · ${attributes.contactPhone}`;
|
|
26
|
+
console.log(client.dryRun ? yellow(`review contact WOULD be set -> ${who}`) : green(`review contact set -> ${who}`));
|
|
26
27
|
return true;
|
|
27
28
|
}
|
package/src/config.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { resolveCredentials } from "./credentials.mjs";
|
|
|
6
6
|
|
|
7
7
|
// The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
|
|
8
8
|
// (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", "google"];
|
|
9
|
+
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "allowCrossStoreTerms"];
|
|
10
10
|
|
|
11
11
|
// One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
|
|
12
12
|
// credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
|
|
@@ -35,6 +35,8 @@ export async function loadConfig(p) {
|
|
|
35
35
|
privacy: raw.privacy || { collected: ["CRASH_DATA", "PERFORMANCE_DATA"], tracking: false },
|
|
36
36
|
iaps: raw.iaps || [],
|
|
37
37
|
metadataDir: raw.metadataDir || "fastlane/metadata",
|
|
38
|
+
// Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
|
|
39
|
+
allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
|
|
38
40
|
previews: raw.previews || null,
|
|
39
41
|
export: raw.export || { encryption: "standard" },
|
|
40
42
|
// Google Play. serviceAccountKey resolves from PLAY_JSON_KEY_FILE env first (keep the secret path out
|