vydanne 0.2.0 → 0.4.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 +86 -3
- package/SKILL.md +38 -9
- package/bin/vydanne.mjs +34 -2
- package/package.json +2 -1
- package/src/commands/fill.mjs +47 -4
- package/src/config.mjs +12 -4
- package/src/credentials.mjs +164 -0
- package/src/play/client.mjs +25 -0
- package/src/play/commands/prerelease.mjs +125 -0
- package/src/registry.mjs +3 -2
- package/types/index.d.ts +13 -2
package/README.md
CHANGED
|
@@ -67,15 +67,67 @@ Download the `.p8` (once only!), then:
|
|
|
67
67
|
```sh
|
|
68
68
|
mkdir -p ~/.appstoreconnect/private_keys
|
|
69
69
|
mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/private_keys/
|
|
70
|
-
|
|
70
|
+
|
|
71
|
+
# Write the ids ONCE — every app you ship reads them from here. Nothing to export per shell,
|
|
72
|
+
# nothing secret in any repo. (The .p8 stays next to it, in private_keys/.)
|
|
73
|
+
cat > ~/.appstoreconnect/config.json <<'JSON'
|
|
74
|
+
{ "keyId": "ABCD123456", "issuerId": "69a6de70-…" }
|
|
75
|
+
JSON
|
|
76
|
+
chmod 600 ~/.appstoreconnect/config.json
|
|
77
|
+
|
|
78
|
+
npx vydanne auth # confirms what resolved, and from where
|
|
71
79
|
```
|
|
72
80
|
```powershell
|
|
73
81
|
# Windows PowerShell
|
|
74
82
|
New-Item -ItemType Directory -Force "$env:USERPROFILE\.appstoreconnect\private_keys"
|
|
75
83
|
Move-Item "$env:USERPROFILE\Downloads\AuthKey_*.p8" "$env:USERPROFILE\.appstoreconnect\private_keys\"
|
|
76
|
-
|
|
84
|
+
'{ "keyId": "ABCD123456", "issuerId": "69a6de70-…" }' |
|
|
85
|
+
Set-Content "$env:USERPROFILE\.appstoreconnect\config.json"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Where credentials come from
|
|
89
|
+
|
|
90
|
+
Resolved automatically, highest priority first — and **never** from `vydanne.config.mjs`, which is
|
|
91
|
+
committed. A keyId or issuerId found there is refused at load, with a warning telling you to move it:
|
|
92
|
+
|
|
93
|
+
| Source | Use it for |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE` in the environment | CI secrets, one-off overrides |
|
|
96
|
+
| the `.env` cascade in the repo | one app that needs a different account from the rest |
|
|
97
|
+
| **the user config file** | **the default for every app you ship** — one account, many repos |
|
|
98
|
+
|
|
99
|
+
**The `.env` cascade** is the standard one (same shape Vite and Next.js use), parsed with `dotenv`, later
|
|
100
|
+
file wins: `.env` → `.env.<mode>` → `.env.local` → `.env.<mode>.local`, where `mode` is `VYDANNE_ENV` (or
|
|
101
|
+
`NODE_ENV`) and is optional. So `.env` and `.env.<mode>` stay **committable** for shared non-secret
|
|
102
|
+
defaults, and only the `*.local` files hold secrets — those are the ones to gitignore:
|
|
103
|
+
|
|
104
|
+
```gitignore
|
|
105
|
+
.env.local
|
|
106
|
+
.env.*.local
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Values are *parsed*, never loaded into `process.env`, so a real environment variable always wins.
|
|
110
|
+
|
|
111
|
+
**The user config file** is looked up in this order, first hit wins — there is no single cross-platform
|
|
112
|
+
home for it, so all three are honoured instead of forcing one on everyone:
|
|
113
|
+
|
|
114
|
+
| Path | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `$VYDANNE_CONFIG_HOME/config.json` | explicit escape hatch (a secrets mount, a shared drive, tests) |
|
|
117
|
+
| `%APPDATA%\vydanne\config.json` (Windows)<br>`$XDG_CONFIG_HOME/vydanne/config.json` → `~/.config/vydanne/config.json` | what a Windows or Linux user expects |
|
|
118
|
+
| `~/.appstoreconnect/config.json` | beside the keys — Apple's tooling and fastlane already keep the `.p8` in `~/.appstoreconnect/private_keys` on every platform |
|
|
119
|
+
|
|
120
|
+
Shipping under more than one account? Use named profiles, selected with `VYDANNE_PROFILE=client-x` or
|
|
121
|
+
pinned per app via `asc: { profile: "client-x" }` in its config — a label, not a secret:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{ "default": "kupalinka",
|
|
125
|
+
"profiles": { "kupalinka": { "keyId": "…", "issuerId": "…", "playJsonKeyFile": "~/.config/play/sa.json" },
|
|
126
|
+
"client-x": { "keyId": "…", "issuerId": "…" } } }
|
|
77
127
|
```
|
|
78
128
|
|
|
129
|
+
`vydanne auth` prints what resolved and which source won, masked — run it first when Apple returns a 401.
|
|
130
|
+
|
|
79
131
|
**2. Describe your app** in a `vydanne.config.mjs` file next to your project:
|
|
80
132
|
|
|
81
133
|
```js
|
|
@@ -128,7 +180,7 @@ Run vydanne **from your project folder** — it finds everything relative to whe
|
|
|
128
180
|
| `compliance` | Generates the US encryption self-classification PDF that Apple asks for. |
|
|
129
181
|
| `version` | Prints the version of vydanne. |
|
|
130
182
|
|
|
131
|
-
For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, or `
|
|
183
|
+
For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, `fill`, or `prerelease`.
|
|
132
184
|
|
|
133
185
|
<br>
|
|
134
186
|
|
|
@@ -144,6 +196,37 @@ VYDANNE_COMMIT=1 npx vydanne fill --store google # actually do it
|
|
|
144
196
|
# Windows PowerShell: $env:VYDANNE_COMMIT = "1"; npx vydanne fill --store google
|
|
145
197
|
```
|
|
146
198
|
|
|
199
|
+
### `prerelease` — the build, to a testing track
|
|
200
|
+
|
|
201
|
+
`fill` writes the *listing*; `prerelease` uploads the **binary** to a **closed testing track** with
|
|
202
|
+
release notes, all inside one edit transaction:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
npx vydanne prerelease --store google # dry run
|
|
206
|
+
VYDANNE_COMMIT=1 npx vydanne prerelease --store google # publish to the track
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
google: {
|
|
211
|
+
packageName: "com.x.app",
|
|
212
|
+
aab: "./dist", // a file, or a directory whose NEWEST .aab is taken
|
|
213
|
+
track: "internal", // 'internal' (default) | 'alpha' | 'beta'
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**It refuses `production`** — that isn't a flag you can pass, it's a refusal. A staged rollout can be
|
|
218
|
+
halted but never un-shipped, so promoting a tested build stays a human decision in Play Console. This is
|
|
219
|
+
the same line the Apple side draws by never submitting.
|
|
220
|
+
|
|
221
|
+
**Paid app? Use `internal`.** It is the only track where testers install without buying; closed and open
|
|
222
|
+
testers pay like everyone else.
|
|
223
|
+
|
|
224
|
+
Release notes follow supply's layout, so an existing repo needs no migration —
|
|
225
|
+
`<metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`, truncated to
|
|
226
|
+
Play's 500-char cap with a warning. The versionCode comes from the bundle's own manifest, so build
|
|
227
|
+
numbering stays with the build and re-uploading a used code fails loudly instead of silently replacing a
|
|
228
|
+
binary. Overrides: `VYDANNE_AAB`, `VYDANNE_TRACK`, `VYDANNE_RELEASE_NAME`.
|
|
229
|
+
|
|
147
230
|
**Play is dry by default on purpose.** Nothing goes live until you add `VYDANNE_COMMIT=1`, so a
|
|
148
231
|
half-finished folder can never overwrite a good listing. Play also uses its **own** language codes
|
|
149
232
|
(`zh-CN`, `iw-IL`) which are *not* Apple's — `vydanne locales` and the
|
package/SKILL.md
CHANGED
|
@@ -22,14 +22,33 @@ Play images (below), so the two line up with no glue.
|
|
|
22
22
|
path in it resolve against the working directory. Running from a subfolder silently reads the wrong paths.
|
|
23
23
|
|
|
24
24
|
One `vydanne.config.mjs` per app (schema: `vydanne.config.example.mjs`; type
|
|
25
|
-
`import('vydanne').VydanneConfig`).
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
`import('vydanne').VydanneConfig`). Node ≥20.9. Cross-platform. On Windows PowerShell the `VAR=1 cmd`
|
|
26
|
+
form does **not** exist; set `$env:VAR = "1"` first.
|
|
27
|
+
|
|
28
|
+
**Auth resolves automatically — NEVER put credentials in the config.** That file is committed; vydanne
|
|
29
|
+
refuses a keyId/issuerId found there and warns. The signing key stays at
|
|
30
|
+
`~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8`. The ids resolve highest-priority-first from: the
|
|
31
|
+
environment (`ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE`) → the **`.env` cascade** → the **user
|
|
32
|
+
config file**.
|
|
33
|
+
|
|
34
|
+
The cascade is the standard one (dotenv-parsed, later file wins): `.env` → `.env.<mode>` → `.env.local` →
|
|
35
|
+
`.env.<mode>.local`, `mode` from `VYDANNE_ENV`/`NODE_ENV`. So `.env` and `.env.<mode>` stay COMMITTABLE
|
|
36
|
+
for shared non-secret defaults and only `*.local` holds secrets — never tell a user to gitignore `.env`
|
|
37
|
+
itself. Files are parsed, never merged into `process.env`, so a real env var always wins.
|
|
38
|
+
|
|
39
|
+
The user config file is the answer for a portfolio: write `{"keyId","issuerId","playJsonKeyFile"}` once
|
|
40
|
+
and every app picks it up with no per-repo setup. Looked up first-hit-wins —
|
|
41
|
+
`$VYDANNE_CONFIG_HOME/config.json` → `%APPDATA%\vydanne\config.json` (Windows) or
|
|
42
|
+
`$XDG_CONFIG_HOME/vydanne/config.json` (default `~/.config/vydanne/`) → `~/.appstoreconnect/config.json`
|
|
43
|
+
(beside the keys, where Apple's tooling and fastlane already keep the `.p8`). Several accounts → named
|
|
44
|
+
`profiles` + `VYDANNE_PROFILE`, or pin one per app with `asc: {profile}` (a label, not a secret).
|
|
45
|
+
**Run `vydanne auth` before debugging any 401** — it prints what resolved, from which source, masked,
|
|
46
|
+
which user file was used, and whether the `.p8` is on disk.
|
|
29
47
|
|
|
30
48
|
**Config fields:** `bundleId` · `primaryLocale` (the fallback — must be populated) · `asc` (optional
|
|
31
|
-
`{
|
|
32
|
-
`metadataDir` · `rating` · `privacy` · `iaps` · `previews` · `export` ·
|
|
49
|
+
`{profile}` — selection only, never secrets) · `platforms` (iOS and macOS are SEPARATE) · `uiLocales`
|
|
50
|
+
(auto-mapped to ASC codes) · `metadataDir` · `rating` · `privacy` · `iaps` · `previews` · `export` ·
|
|
51
|
+
`google` (Google Play).
|
|
33
52
|
|
|
34
53
|
For a non-technical user asking how to set this up from scratch, walk them through
|
|
35
54
|
**`GETTING_STARTED.md`** (accounts → API key → config → folders → push) rather than improvising.
|
|
@@ -143,9 +162,19 @@ who it's for → honest close. Keep it scannable; lead each bullet with the payo
|
|
|
143
162
|
`previews` (App Preview videos) · `age-rating` · `review-contact` · `accessibility` (draft; publish once
|
|
144
163
|
live) · `privacy` (prints answers for the UI — the API can't reach Apple's iris host) · `iap` (validate +
|
|
145
164
|
RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
|
|
146
|
-
(completeness gate) · `inspect` · `locales` · `version`.
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
(completeness gate) · `inspect` · `auth` (what credentials resolved, and from where) · `locales` · `version`.
|
|
166
|
+
|
|
167
|
+
`prerelease` (**Play only**) uploads an `.aab` to a **closed testing track** with release notes, inside one
|
|
168
|
+
edit transaction. `production` is REFUSED — not flag-gated — so no argument combination ships to the
|
|
169
|
+
public; promoting the tested build stays a human's job, mirroring the Apple side never submitting. Track
|
|
170
|
+
comes from `google.track` / `VYDANNE_TRACK`, default `internal`; the bundle from `google.aab` /
|
|
171
|
+
`VYDANNE_AAB` (a directory takes its newest `.aab`). **For a PAID app use `internal`** — it's the only
|
|
172
|
+
track where testers install without buying. Notes follow supply's layout:
|
|
173
|
+
`<google.metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`, capped
|
|
174
|
+
at Play's 500 chars. DRY by default like `fill --store google`; `VYDANNE_COMMIT=1` publishes. The
|
|
175
|
+
versionCode comes from the bundle itself, so re-uploading one fails loudly instead of silently replacing.
|
|
176
|
+
|
|
177
|
+
`--store google` routes `inspect` · `diff` · `preflight` · `fill` · `prerelease` to the Play Developer **Edits** API
|
|
149
178
|
(OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
|
|
150
179
|
`fill --store google` is **DRY by default**; `VYDANNE_COMMIT=1` commits. The AAB binary and the
|
|
151
180
|
(YouTube-URL) promo video stay outside vydanne.
|
package/bin/vydanne.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { loadConfig } from "../src/config.mjs";
|
|
4
6
|
import { Client } from "../src/client.mjs";
|
|
5
7
|
import { COMMANDS, PLAY_COMMANDS } from "../src/registry.mjs";
|
|
@@ -16,6 +18,30 @@ const store = si >= 0 ? argv[si + 1] : "apple";
|
|
|
16
18
|
try {
|
|
17
19
|
if (["version", "-v", "--version"].includes(cmd)) {
|
|
18
20
|
console.log(`vydanne ${VERSION}`);
|
|
21
|
+
} else if (cmd === "auth") {
|
|
22
|
+
// "Why isn't it picking up my key?" — answered, without ever printing a secret.
|
|
23
|
+
const cr = (await loadConfig(cfgPath)).credentials;
|
|
24
|
+
const mask = (v) => (v ? `${String(v).slice(0, 4)}…${String(v).slice(-4)}` : null);
|
|
25
|
+
const row = (label, value, key, shown) =>
|
|
26
|
+
console.log(value
|
|
27
|
+
? ` \x1b[32m✓\x1b[0m ${label.padEnd(20)} ${String(shown ?? mask(value)).padEnd(26)} ← ${cr.sources[key]}`
|
|
28
|
+
: ` \x1b[31m✗\x1b[0m ${label.padEnd(20)} \x1b[31mnot found\x1b[0m`);
|
|
29
|
+
console.log("Credentials — environment > .env cascade > user config (never the committed config):");
|
|
30
|
+
row("ASC_KEY_ID", cr.keyId, "ASC_KEY_ID", cr.keyId);
|
|
31
|
+
row("ASC_ISSUER_ID", cr.issuerId, "ASC_ISSUER_ID");
|
|
32
|
+
row("PLAY_JSON_KEY_FILE", cr.playJsonKeyFile, "PLAY_JSON_KEY_FILE", cr.playJsonKeyFile);
|
|
33
|
+
if (cr.keyId) {
|
|
34
|
+
const p = path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${cr.keyId}.p8`);
|
|
35
|
+
console.log(existsSync(p)
|
|
36
|
+
? ` \x1b[32m✓\x1b[0m ${"signing key".padEnd(20)} ${p}`
|
|
37
|
+
: ` \x1b[31m✗\x1b[0m ${"signing key".padEnd(20)} \x1b[31mmissing\x1b[0m — ${p}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(cr.userFile ? `\nuser config: ${cr.userFile}` : `\nuser config: none found. Looked in:\n${cr.candidates.map((c) => ` ${c}`).join("\n")}`);
|
|
40
|
+
if (!cr.keyId || !cr.issuerId) {
|
|
41
|
+
const target = cr.userFile || cr.candidates[cr.candidates.length - 1];
|
|
42
|
+
console.log(`\nSet once for EVERY app, no secrets in any repo:\n ${target}\n { "keyId": "ABCD123456", "issuerId": "69a6de70-…" }\n chmod 600 ${target}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
19
45
|
} else if (cmd === "locales") {
|
|
20
46
|
const r = (await loadConfig(cfgPath)).resolvedLocales;
|
|
21
47
|
console.log(`supported (${Object.keys(r.supported).length}):`);
|
|
@@ -61,6 +87,12 @@ usage: vydanne <command> [--config vydanne.config.mjs]
|
|
|
61
87
|
inspect read-only ASC state
|
|
62
88
|
diff show what differs between local (metadata/screenshots/previews) and ASC
|
|
63
89
|
preflight verify submission-completeness (the gotcha checker)
|
|
90
|
+
prerelease --store google: upload the .aab to a closed testing track (refuses production)
|
|
64
91
|
locales UI -> ASC locale mapping + unsupported
|
|
65
|
-
|
|
92
|
+
auth which credentials resolved, and from where (masked) — run this on a 401
|
|
93
|
+
credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
|
|
94
|
+
(\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
|
|
95
|
+
NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
|
|
96
|
+
toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_A11Y_PUBLISH (accessibility),
|
|
97
|
+
VYDANNE_PROFILE (named profile), VYDANNE_ENV (.env mode)`;
|
|
66
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vydanne",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app-store-connect",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"release:major": "node scripts/release.mjs major"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
+
"dotenv": "^17.4.2",
|
|
60
61
|
"pdfkit": "^0.15.0",
|
|
61
62
|
"sharp": "^0.35.3"
|
|
62
63
|
},
|
package/src/commands/fill.mjs
CHANGED
|
@@ -10,11 +10,44 @@ const INFO_TXT = { name: "name", subtitle: "subtitle" };
|
|
|
10
10
|
const IOS_DEVICE = { iphone69: "APP_IPHONE_67", iphone65: "APP_IPHONE_65", ipad13: "APP_IPAD_PRO_3GEN_129", watch: "APP_WATCH_ULTRA" };
|
|
11
11
|
const MAC_DEVICE = { macos: "APP_DESKTOP" };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* PATCH a localization, surviving attributes Apple refuses to edit RIGHT NOW.
|
|
15
|
+
*
|
|
16
|
+
* A version-localization PATCH is all-or-nothing: one un-editable attribute rejects the WHOLE payload
|
|
17
|
+
* with 409 STATE_ERROR and nothing is written. The classic case is `whatsNew` on a first version — there
|
|
18
|
+
* is no previous release, so "What's New" cannot exist, and sending it silently costs you the
|
|
19
|
+
* description and keywords for every locale.
|
|
20
|
+
*
|
|
21
|
+
* So: send everything, and if Apple names offending attributes, drop exactly those and retry once. Any
|
|
22
|
+
* other failure is reported rather than swallowed — a fill that writes nothing must never look like a
|
|
23
|
+
* fill that worked.
|
|
24
|
+
*/
|
|
25
|
+
async function patchAttrs(client, url, type, id, attrs, label) {
|
|
26
|
+
const send = (a) => client.patch(url, { data: { type, id, attributes: a } });
|
|
27
|
+
let res = await send(attrs);
|
|
28
|
+
if (res.status < 300) return { ok: true, dropped: [] };
|
|
29
|
+
|
|
30
|
+
// "Attribute 'whatsNew' cannot be edited at this time" → drop the named attributes and retry.
|
|
31
|
+
const blocked = (res.json?.errors || [])
|
|
32
|
+
.flatMap((e) => [...String(e.detail || "").matchAll(/Attribute '([^']+)' cannot be edited/g)].map((m) => m[1]))
|
|
33
|
+
.filter((k) => k in attrs);
|
|
34
|
+
if (blocked.length) {
|
|
35
|
+
const rest = Object.fromEntries(Object.entries(attrs).filter(([k]) => !blocked.includes(k)));
|
|
36
|
+
if (!Object.keys(rest).length) return { ok: true, dropped: blocked };
|
|
37
|
+
res = await send(rest);
|
|
38
|
+
if (res.status < 300) return { ok: true, dropped: blocked };
|
|
39
|
+
}
|
|
40
|
+
const why = (res.json?.errors || []).map((e) => `${e.code || res.status}: ${e.detail || e.title}`).join("; ") || `HTTP ${res.status}`;
|
|
41
|
+
console.error(red(` ✗ ${label}: ${why}`));
|
|
42
|
+
return { ok: false, dropped: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
13
45
|
// Push metadata (native PATCH — works at any editable state, incl. READY_FOR_REVIEW, unlike deliver) +
|
|
14
46
|
// screenshots (native chunked upload; skips sets that already have shots so it never duplicates).
|
|
15
47
|
// iOS and macOS are separate platforms. Toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS.
|
|
16
48
|
export async function run(config, client) {
|
|
17
49
|
await client.findApp(config.bundleId);
|
|
50
|
+
let ok = true; // a locale Apple refused must fail the command, not just print
|
|
18
51
|
const skipMeta = process.env.VYDANNE_SKIP_METADATA === "1";
|
|
19
52
|
const skipShots = process.env.VYDANNE_SKIP_SCREENSHOTS === "1";
|
|
20
53
|
const info = await client.appInfo();
|
|
@@ -27,6 +60,8 @@ export async function run(config, client) {
|
|
|
27
60
|
const verLocs = await client.versionLocalizations(v.id);
|
|
28
61
|
|
|
29
62
|
if (!skipMeta) {
|
|
63
|
+
let failed = 0;
|
|
64
|
+
const dropped = new Set();
|
|
30
65
|
const dirs = fs.readdirSync(config.metadataDir, { withFileTypes: true }).filter((d) => d.isDirectory() && VALID.has(d.name)).map((d) => d.name);
|
|
31
66
|
for (const code of dirs) {
|
|
32
67
|
const folder = path.join(config.metadataDir, code);
|
|
@@ -36,7 +71,11 @@ export async function run(config, client) {
|
|
|
36
71
|
if (!vl) { const c = await client.post(`/v1/appStoreVersionLocalizations`, { data: { type: "appStoreVersionLocalizations", attributes: { locale: code }, relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: v.id } } } } }); vl = c.json.data; verLocs.push(vl); }
|
|
37
72
|
const vattrs = {};
|
|
38
73
|
for (const [k, f] of Object.entries(VERSION_TXT)) { const t = read(f); if (t != null) vattrs[k] = t; }
|
|
39
|
-
if (Object.keys(vattrs).length)
|
|
74
|
+
if (Object.keys(vattrs).length) {
|
|
75
|
+
const r = await patchAttrs(client, `/v1/appStoreVersionLocalizations/${vl.id}`, "appStoreVersionLocalizations", vl.id, vattrs, `${code} version`);
|
|
76
|
+
if (!r.ok) failed++;
|
|
77
|
+
for (const d of r.dropped) dropped.add(d);
|
|
78
|
+
}
|
|
40
79
|
// app-info localization (name/subtitle — shared across platforms). Apple REQUIRES `name` when
|
|
41
80
|
// CREATING a localization (409 ATTRIBUTE.REQUIRED otherwise), so build the attrs first and send
|
|
42
81
|
// them in the POST; only PATCH when the localization already exists.
|
|
@@ -50,16 +89,20 @@ export async function run(config, client) {
|
|
|
50
89
|
if (il) infoLocs.push(il);
|
|
51
90
|
else console.error(` appInfo ${code}: create failed — ${JSON.stringify(c.json?.errors?.[0]?.detail || c.json)}`);
|
|
52
91
|
} else if (Object.keys(iattrs).length) {
|
|
53
|
-
await client
|
|
92
|
+
const r = await patchAttrs(client, `/v1/appInfoLocalizations/${il.id}`, "appInfoLocalizations", il.id, iattrs, `${code} app-info`);
|
|
93
|
+
if (!r.ok) failed++;
|
|
94
|
+
for (const d of r.dropped) dropped.add(d);
|
|
54
95
|
}
|
|
55
96
|
}
|
|
56
97
|
}
|
|
57
|
-
console.log(
|
|
98
|
+
if (dropped.size) console.log(yellow(` skipped un-editable attribute(s) in this version state: ${[...dropped].join(", ")}`));
|
|
99
|
+
console.log(failed ? red(` metadata: ${dirs.length - failed}/${dirs.length} locales written, ${failed} FAILED`) : green(` metadata: ${dirs.length} locales`));
|
|
100
|
+
if (failed) ok = false;
|
|
58
101
|
}
|
|
59
102
|
|
|
60
103
|
if (!skipShots) await uploadScreenshots(config, client, platform, verLocs);
|
|
61
104
|
}
|
|
62
|
-
return
|
|
105
|
+
return ok;
|
|
63
106
|
}
|
|
64
107
|
|
|
65
108
|
async function uploadScreenshots(config, client, platform, verLocs) {
|
package/src/config.mjs
CHANGED
|
@@ -2,13 +2,15 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { resolveLocales } from "./locales.mjs";
|
|
5
|
+
import { resolveCredentials } from "./credentials.mjs";
|
|
5
6
|
|
|
6
7
|
// The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
|
|
7
8
|
// (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
|
|
8
9
|
export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "metadataDir", "rating", "privacy", "iaps", "previews", "export", "google"];
|
|
9
10
|
|
|
10
11
|
// One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
|
|
11
|
-
//
|
|
12
|
+
// credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
|
|
13
|
+
// credentials.mjs) and are REFUSED if found in this committed file; review-contact PII stays gitignored.
|
|
12
14
|
export async function loadConfig(p) {
|
|
13
15
|
const file = path.resolve(p || process.env.VYDANNE_CONFIG || "vydanne.config.mjs");
|
|
14
16
|
if (!fs.existsSync(file)) throw new Error(`vydanne: config not found at ${file}`);
|
|
@@ -18,12 +20,15 @@ export async function loadConfig(p) {
|
|
|
18
20
|
if (raw[k] == null) throw new Error(`vydanne: config missing '${k}'`);
|
|
19
21
|
return raw[k];
|
|
20
22
|
};
|
|
23
|
+
const creds = resolveCredentials(raw, path.dirname(file));
|
|
24
|
+
for (const w of creds.warnings) console.warn(`\x1b[33mvydanne: ${w}\x1b[0m`);
|
|
21
25
|
const c = {
|
|
22
26
|
raw,
|
|
27
|
+
credentials: creds,
|
|
23
28
|
bundleId: need("bundleId"),
|
|
24
29
|
primaryLocale: need("primaryLocale"),
|
|
25
|
-
keyId:
|
|
26
|
-
issuerId:
|
|
30
|
+
keyId: creds.keyId,
|
|
31
|
+
issuerId: creds.issuerId,
|
|
27
32
|
uiLocales: raw.uiLocales || [],
|
|
28
33
|
platforms: raw.platforms || ["IOS"],
|
|
29
34
|
rating: raw.rating || "4+",
|
|
@@ -37,9 +42,12 @@ export async function loadConfig(p) {
|
|
|
37
42
|
google: raw.google
|
|
38
43
|
? {
|
|
39
44
|
packageName: raw.google.packageName || raw.bundleId,
|
|
40
|
-
serviceAccountKey:
|
|
45
|
+
serviceAccountKey: creds.playJsonKeyFile,
|
|
41
46
|
metadataDir: raw.google.metadataDir || "fastlane/metadata/android",
|
|
42
47
|
defaultLocale: raw.google.defaultLocale || raw.primaryLocale,
|
|
48
|
+
aab: raw.google.aab || null,
|
|
49
|
+
track: raw.google.track || "internal", // testing only — `prerelease` refuses production
|
|
50
|
+
|
|
43
51
|
}
|
|
44
52
|
: null,
|
|
45
53
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Credential resolution, so nobody has to paste `ASC_KEY_ID=… ASC_ISSUER_ID=…` in front of every command.
|
|
8
|
+
*
|
|
9
|
+
* CREDENTIALS NEVER COME FROM `vydanne.config.mjs`. That file is committed, and a repo may be public —
|
|
10
|
+
* so the config carries no keyId, no issuerId, no key paths. It may only name an `asc.profile`, which is
|
|
11
|
+
* a label, not a secret. If credentials ARE found there we refuse them and say so.
|
|
12
|
+
*
|
|
13
|
+
* Sources, highest priority first:
|
|
14
|
+
* 1. process.env — CI secrets and one-off overrides. Always wins.
|
|
15
|
+
* 2. the .env cascade — per-repo, the usual local-dev idiom (see ENV_FILES).
|
|
16
|
+
* 3. the user file — the ACCOUNT-level default, outside every repo (see CONFIG_CANDIDATES).
|
|
17
|
+
*
|
|
18
|
+
* (3) is the answer for a portfolio: one App Store Connect account, many repos. Write it once and every
|
|
19
|
+
* consumer picks it up with zero per-repo setup and nothing secret in any repo:
|
|
20
|
+
*
|
|
21
|
+
* { "keyId": "ABCD123456", "issuerId": "69a6de70-…", "playJsonKeyFile": "~/.config/play/sa.json" }
|
|
22
|
+
*
|
|
23
|
+
* Shipping under more than one account? Use named profiles, selected with `VYDANNE_PROFILE`, or pinned
|
|
24
|
+
* per app via `asc: { profile: "client-x" }` — a name, still not a secret:
|
|
25
|
+
*
|
|
26
|
+
* { "default": "kupalinka",
|
|
27
|
+
* "profiles": { "kupalinka": { "keyId": "…", "issuerId": "…" },
|
|
28
|
+
* "client-x": { "keyId": "…", "issuerId": "…" } } }
|
|
29
|
+
*
|
|
30
|
+
* The .p8 is never read here — it stays at ~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8 (jwt.mjs),
|
|
31
|
+
* so the private key is never pasted into a repo, a CI variable, or a shell history.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where the account-level file may live, best-match first. There is no single cross-platform home for
|
|
36
|
+
* this, so all three are honoured rather than forcing one on everybody:
|
|
37
|
+
*
|
|
38
|
+
* - `$VYDANNE_CONFIG_HOME/config.json` — explicit escape hatch (shared drives, a secrets mount, tests).
|
|
39
|
+
* - The OS config dir — `%APPDATA%\vydanne\` on Windows, `$XDG_CONFIG_HOME` (default `~/.config`)
|
|
40
|
+
* elsewhere. This is what a Windows or Linux user expects; `~/.appstoreconnect` is neither.
|
|
41
|
+
* - `~/.appstoreconnect/config.json` — beside the keys. Apple's own tooling (and fastlane) already put
|
|
42
|
+
* the .p8 in `~/.appstoreconnect/private_keys` on every platform, so credentials and key sit together.
|
|
43
|
+
*/
|
|
44
|
+
export function configCandidates(env = process.env, home = os.homedir()) {
|
|
45
|
+
const out = [];
|
|
46
|
+
if (env.VYDANNE_CONFIG_HOME) out.push(path.join(env.VYDANNE_CONFIG_HOME, "config.json"));
|
|
47
|
+
const osConfigDir = process.platform === "win32"
|
|
48
|
+
? env.APPDATA || path.join(home, "AppData", "Roaming")
|
|
49
|
+
: env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
50
|
+
out.push(path.join(osConfigDir, "vydanne", "config.json"));
|
|
51
|
+
out.push(path.join(home, ".appstoreconnect", "config.json"));
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The first candidate that exists, or null. */
|
|
56
|
+
export function findConfigFile(env = process.env, home = os.homedir()) {
|
|
57
|
+
return configCandidates(env, home).find((p) => fs.existsSync(p)) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The standard `.env` cascade, lowest priority first — the same shape Vite/Next users already know, so
|
|
62
|
+
* `.env` stays COMMITTABLE (shared, non-secret defaults) and only `*.local` holds secrets. That is why
|
|
63
|
+
* only `*.local` belongs in .gitignore; ignoring `.env` outright fights the convention.
|
|
64
|
+
*
|
|
65
|
+
* `mode` comes from VYDANNE_ENV (or NODE_ENV) and is optional — with none set this is just
|
|
66
|
+
* `.env` then `.env.local`.
|
|
67
|
+
*/
|
|
68
|
+
export function envFiles(cwd, mode) {
|
|
69
|
+
const files = [".env"];
|
|
70
|
+
if (mode) files.push(`.env.${mode}`);
|
|
71
|
+
files.push(".env.local");
|
|
72
|
+
if (mode) files.push(`.env.${mode}.local`);
|
|
73
|
+
return files.map((f) => path.join(cwd, f));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Expand a leading `~` so the user file can hold portable paths. */
|
|
77
|
+
export function expandHome(p, home = os.homedir()) {
|
|
78
|
+
if (!p || typeof p !== "string") return p;
|
|
79
|
+
return p.startsWith("~/") || p === "~" ? path.join(home, p.slice(1)) : p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the account-level file, resolving the profile if it uses the named-profile form. */
|
|
83
|
+
function readUserConfig(file, profile, env, warn) {
|
|
84
|
+
if (!file) return {};
|
|
85
|
+
let json;
|
|
86
|
+
try {
|
|
87
|
+
json = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
warn(`${file} is not valid JSON (${e.message}) — ignored`);
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
let values = json;
|
|
93
|
+
if (json.profiles) {
|
|
94
|
+
const name = profile || env.VYDANNE_PROFILE || json.default;
|
|
95
|
+
if (!name) {
|
|
96
|
+
warn(`${file} has profiles but no "default" — set one, or pass VYDANNE_PROFILE`);
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
values = json.profiles[name];
|
|
100
|
+
if (!values) {
|
|
101
|
+
warn(`${file} has no profile "${name}" (have: ${Object.keys(json.profiles).join(", ")})`);
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// A credentials file readable by anyone on the machine is worth one line of noise. POSIX only —
|
|
106
|
+
// Windows ACLs don't map onto the mode bits, so checking there would just produce false alarms.
|
|
107
|
+
try {
|
|
108
|
+
if (process.platform !== "win32" && fs.statSync(file).mode & 0o004) {
|
|
109
|
+
warn(`${file} is world-readable — run: chmod 600 ${file}`);
|
|
110
|
+
}
|
|
111
|
+
} catch { /* a stat failure is not worth failing the run over */ }
|
|
112
|
+
return values || {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve every credential, recording WHERE each value came from so `vydanne auth` can explain itself —
|
|
117
|
+
* "it isn't picking up my key" is otherwise pure guesswork.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveCredentials(raw = {}, cwd = process.cwd(), env = process.env, home = os.homedir()) {
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const warn = (m) => warnings.push(m);
|
|
122
|
+
|
|
123
|
+
// Parsed, NOT loaded into process.env: dotenv.config() would mutate the environment and quietly
|
|
124
|
+
// destroy the precedence order below. Later files win, per the cascade.
|
|
125
|
+
const mode = env.VYDANNE_ENV || env.NODE_ENV || "";
|
|
126
|
+
const dotenvValues = {};
|
|
127
|
+
const dotenvFrom = {};
|
|
128
|
+
for (const file of envFiles(cwd, mode)) {
|
|
129
|
+
if (!fs.existsSync(file)) continue;
|
|
130
|
+
const parsed = dotenv.parse(fs.readFileSync(file));
|
|
131
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
132
|
+
dotenvValues[k] = v;
|
|
133
|
+
dotenvFrom[k] = path.relative(cwd, file) || path.basename(file);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const userFile = findConfigFile(env, home);
|
|
138
|
+
const user = readUserConfig(userFile, raw.asc?.profile, env, warn);
|
|
139
|
+
|
|
140
|
+
// Secrets in the committed config are refused, not silently honoured — otherwise the one machine that
|
|
141
|
+
// "works" is the one leaking them, and nobody notices until the repo goes public.
|
|
142
|
+
if (raw.asc?.keyId || raw.asc?.issuerId || raw.google?.serviceAccountKey) {
|
|
143
|
+
warn(`credentials found in vydanne.config.mjs (that file is committed) — IGNORED. Move them to ${userFile || configCandidates(env, home)[0]} or .env.local`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sources = {};
|
|
147
|
+
const pick = (envKey, userKey) => {
|
|
148
|
+
if (env[envKey]) return (sources[envKey] = "environment"), env[envKey];
|
|
149
|
+
if (dotenvValues[envKey]) return (sources[envKey] = dotenvFrom[envKey]), dotenvValues[envKey];
|
|
150
|
+
if (user[userKey]) return (sources[envKey] = userFile), user[userKey];
|
|
151
|
+
sources[envKey] = null;
|
|
152
|
+
return undefined;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
keyId: pick("ASC_KEY_ID", "keyId"),
|
|
157
|
+
issuerId: pick("ASC_ISSUER_ID", "issuerId"),
|
|
158
|
+
playJsonKeyFile: expandHome(pick("PLAY_JSON_KEY_FILE", "playJsonKeyFile"), home),
|
|
159
|
+
sources,
|
|
160
|
+
warnings,
|
|
161
|
+
userFile,
|
|
162
|
+
candidates: configCandidates(env, home),
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/play/client.mjs
CHANGED
|
@@ -65,4 +65,29 @@ export class PlayClient {
|
|
|
65
65
|
if (res.status >= 300) throw new Error(`image upload ${res.status}: ${JSON.stringify(j).slice(0, 200)}`);
|
|
66
66
|
return j;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// ── Binaries & tracks ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/** Upload an .aab. The returned versionCode comes from the BUNDLE's manifest — Play assigns it, not us. */
|
|
72
|
+
async uploadBundle(editId, filePath) {
|
|
73
|
+
const bytes = fs.readFileSync(filePath);
|
|
74
|
+
const res = await fetch(`${UPLOAD}/applications/${this.pkg}/edits/${editId}/bundles?uploadType=media`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/octet-stream" },
|
|
77
|
+
body: bytes,
|
|
78
|
+
});
|
|
79
|
+
const j = await res.json().catch(() => ({}));
|
|
80
|
+
if (res.status >= 300) throw new Error(`bundle upload ${res.status}: ${JSON.stringify(j).slice(0, 300)}`);
|
|
81
|
+
return j;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getTrack(editId, track) { return this.req("GET", `/edits/${editId}/tracks/${track}`); }
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Point a track at version codes. `releases` is the FULL desired state of that track — Play replaces
|
|
88
|
+
* it wholesale, so send one complete release object rather than appending to what is already there.
|
|
89
|
+
*/
|
|
90
|
+
putTrack(editId, track, releases) {
|
|
91
|
+
return this.req("PUT", `/edits/${editId}/tracks/${track}`, { body: { track, releases } });
|
|
92
|
+
}
|
|
68
93
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { green, yellow, red } from "../../util.mjs";
|
|
4
|
+
|
|
5
|
+
/** Tracks this command will write. `production` is deliberately absent — see below. */
|
|
6
|
+
const TESTING_TRACKS = new Set(["internal", "alpha", "beta"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Upload an .aab to a CLOSED TESTING track, with release notes.
|
|
10
|
+
*
|
|
11
|
+
* Why testing-only: shipping to everyone is a judgement call with no undo — a staged rollout can be
|
|
12
|
+
* halted but not un-shipped, and the reviewer-facing consequences are the operator's to own. So this
|
|
13
|
+
* mirrors the Apple side, which never submits: `production` is REFUSED, not gated behind a flag, so
|
|
14
|
+
* there is no arrangement of arguments that ships to the public by accident.
|
|
15
|
+
*
|
|
16
|
+
* For a PAID app, `internal` is usually the only track you want: it is the one where testers install
|
|
17
|
+
* without buying. Closed/open testers must purchase it like anyone else.
|
|
18
|
+
*
|
|
19
|
+
* Play assigns the versionCode from the bundle's own manifest, so build numbering stays with the build,
|
|
20
|
+
* and re-uploading the same code fails loudly rather than silently replacing a binary.
|
|
21
|
+
*
|
|
22
|
+
* Everything happens inside one edit transaction: nothing is live until commit, and any throw leaves the
|
|
23
|
+
* edit uncommitted — i.e. the track untouched.
|
|
24
|
+
*/
|
|
25
|
+
export async function run(config, client) {
|
|
26
|
+
const g = config.google;
|
|
27
|
+
const track = process.env.VYDANNE_TRACK || g.track || "internal";
|
|
28
|
+
|
|
29
|
+
if (track === "production") {
|
|
30
|
+
console.error(red("prerelease: refusing to write the production track — that release is a human's to make."));
|
|
31
|
+
console.error(" Promote the tested build in Play Console when you're ready.");
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (!TESTING_TRACKS.has(track)) {
|
|
35
|
+
console.error(red(`prerelease: unknown track "${track}" (expected: ${[...TESTING_TRACKS].join(", ")})`));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const aab = resolveAab(g.aab);
|
|
40
|
+
if (!aab) {
|
|
41
|
+
console.error(red("prerelease: no .aab found — set `google.aab` in the config, or pass VYDANNE_AAB=<path>."));
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
console.log(green(`prerelease → track "${track}"`));
|
|
45
|
+
console.log(` bundle: ${path.relative(process.cwd(), aab) || aab} (${(fs.statSync(aab).size / 1e6).toFixed(1)} MB)`);
|
|
46
|
+
|
|
47
|
+
const editId = await client.newEdit();
|
|
48
|
+
try {
|
|
49
|
+
const bundle = await client.uploadBundle(editId, aab);
|
|
50
|
+
const versionCode = bundle.versionCode;
|
|
51
|
+
if (!versionCode) throw new Error(`upload returned no versionCode: ${JSON.stringify(bundle).slice(0, 200)}`);
|
|
52
|
+
console.log(green(` uploaded versionCode ${versionCode}`));
|
|
53
|
+
|
|
54
|
+
const releaseNotes = readNotes(g.metadataDir, versionCode, g.defaultLocale);
|
|
55
|
+
if (releaseNotes.length) {
|
|
56
|
+
console.log(` release notes: ${releaseNotes.length} locale(s) — ${releaseNotes.map((n) => n.language).join(", ")}`);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(yellow(` no release notes found under ${g.metadataDir}/<locale>/changelogs/{${versionCode},default}.txt`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// One complete release object: Play replaces the track's releases wholesale.
|
|
62
|
+
const release = { status: "completed", versionCodes: [String(versionCode)] };
|
|
63
|
+
if (releaseNotes.length) release.releaseNotes = releaseNotes;
|
|
64
|
+
const name = process.env.VYDANNE_RELEASE_NAME;
|
|
65
|
+
if (name) release.name = name;
|
|
66
|
+
|
|
67
|
+
const put = await client.putTrack(editId, track, [release]);
|
|
68
|
+
if (put.status >= 300) throw new Error(`tracks.update ${put.status}: ${JSON.stringify(put.json).slice(0, 300)}`);
|
|
69
|
+
|
|
70
|
+
if (process.env.VYDANNE_COMMIT !== "1") {
|
|
71
|
+
await client.deleteEdit(editId);
|
|
72
|
+
console.log(yellow(`\n DRY RUN — edit discarded, nothing changed. Re-run with VYDANNE_COMMIT=1 to publish to "${track}".`));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const res = await client.commit(editId);
|
|
76
|
+
if (res.status >= 300) throw new Error(`edits.commit ${res.status}: ${JSON.stringify(res.json).slice(0, 300)}`);
|
|
77
|
+
console.log(green(`\n committed — versionCode ${versionCode} is live on "${track}".`));
|
|
78
|
+
console.log(" Production stays manual: promote it in Play Console when you're ready.");
|
|
79
|
+
return true;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Abandon the edit so a failed run leaves the track exactly as it was.
|
|
82
|
+
await client.deleteEdit(editId).catch(() => {});
|
|
83
|
+
console.error(red(`prerelease: ${e.message}`));
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `google.aab` may be a file or a directory; a directory takes its newest .aab. */
|
|
89
|
+
function resolveAab(configured) {
|
|
90
|
+
const p = process.env.VYDANNE_AAB || configured;
|
|
91
|
+
if (!p) return null;
|
|
92
|
+
const abs = path.resolve(p);
|
|
93
|
+
if (!fs.existsSync(abs)) return null;
|
|
94
|
+
if (!fs.statSync(abs).isDirectory()) return abs;
|
|
95
|
+
const files = fs.readdirSync(abs).filter((f) => f.endsWith(".aab"))
|
|
96
|
+
.map((f) => path.join(abs, f))
|
|
97
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
98
|
+
return files[0] || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Release notes per locale, following fastlane supply's layout so an existing repo needs no migration:
|
|
103
|
+
* `<metadataDir>/<play-locale>/changelogs/<versionCode>.txt`, falling back to `default.txt`.
|
|
104
|
+
*/
|
|
105
|
+
function readNotes(metadataDir, versionCode, defaultLocale) {
|
|
106
|
+
const out = [];
|
|
107
|
+
if (!metadataDir || !fs.existsSync(metadataDir)) return out;
|
|
108
|
+
for (const language of fs.readdirSync(metadataDir)) {
|
|
109
|
+
const dir = path.join(metadataDir, language, "changelogs");
|
|
110
|
+
if (!fs.existsSync(dir)) continue;
|
|
111
|
+
const file = [path.join(dir, `${versionCode}.txt`), path.join(dir, "default.txt")].find((f) => fs.existsSync(f));
|
|
112
|
+
if (!file) continue;
|
|
113
|
+
const text = fs.readFileSync(file, "utf8").trim();
|
|
114
|
+
if (!text) continue;
|
|
115
|
+
// Play caps release notes at 500 chars and rejects the whole edit if any locale is over.
|
|
116
|
+
if (text.length > 500) {
|
|
117
|
+
console.log(yellow(` ${language}: release notes ${text.length}/500 chars — truncated`));
|
|
118
|
+
out.push({ language, text: text.slice(0, 500) });
|
|
119
|
+
} else {
|
|
120
|
+
out.push({ language, text });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Keep the default locale first purely so the log reads sensibly.
|
|
124
|
+
return out.sort((a, b) => (a.language === defaultLocale ? -1 : b.language === defaultLocale ? 1 : 0));
|
|
125
|
+
}
|
package/src/registry.mjs
CHANGED
|
@@ -23,7 +23,8 @@ export const PLAY_COMMANDS = {
|
|
|
23
23
|
preflight: { mod: "preflight" },
|
|
24
24
|
diff: { mod: "diff" },
|
|
25
25
|
fill: { mod: "fill" },
|
|
26
|
+
prerelease: { mod: "prerelease" },
|
|
26
27
|
};
|
|
27
28
|
|
|
28
|
-
// Full public command surface (the module-dispatched ones above + the
|
|
29
|
-
export const COMMAND_NAMES = [...Object.keys(COMMANDS), "locales", "version"];
|
|
29
|
+
// Full public command surface (the module-dispatched ones above + the three handled inline in bin/).
|
|
30
|
+
export const COMMAND_NAMES = [...Object.keys(COMMANDS), "prerelease", "auth", "locales", "version"];
|
package/types/index.d.ts
CHANGED
|
@@ -16,6 +16,9 @@ export type CommandName =
|
|
|
16
16
|
| 'inspect'
|
|
17
17
|
| 'diff'
|
|
18
18
|
| 'preflight'
|
|
19
|
+
/** Google Play only: upload an .aab to a closed testing track. Refuses `production`. */
|
|
20
|
+
| 'prerelease'
|
|
21
|
+
| 'auth'
|
|
19
22
|
| 'locales'
|
|
20
23
|
| 'version';
|
|
21
24
|
|
|
@@ -57,6 +60,10 @@ export interface GoogleConfig {
|
|
|
57
60
|
/** Listing-text folders, supply convention. Default 'fastlane/metadata/android'. */
|
|
58
61
|
metadataDir?: string;
|
|
59
62
|
defaultLocale?: string;
|
|
63
|
+
/** `.aab` for `prerelease` — a file, or a directory whose NEWEST .aab is taken. Override: VYDANNE_AAB. */
|
|
64
|
+
aab?: string;
|
|
65
|
+
/** Testing track for `prerelease`: 'internal' (default) | 'alpha' | 'beta'. 'production' is refused. */
|
|
66
|
+
track?: "internal" | "alpha" | "beta";
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export interface ExportConfig {
|
|
@@ -74,8 +81,12 @@ export interface VydanneConfig {
|
|
|
74
81
|
bundleId: string;
|
|
75
82
|
/** Fallback for every locale without its own listing — must be populated. */
|
|
76
83
|
primaryLocale: string;
|
|
77
|
-
/**
|
|
78
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Credential SELECTION only — never credentials. This file is committed, so a keyId/issuerId here is
|
|
86
|
+
* refused at load with a warning. Values resolve from the environment, a gitignored `.env`, or
|
|
87
|
+
* `~/.appstoreconnect/config.json`. `profile` picks one entry when that file uses named profiles.
|
|
88
|
+
*/
|
|
89
|
+
asc?: { profile?: string };
|
|
79
90
|
/** iOS and macOS are separate ASC platforms. */
|
|
80
91
|
platforms?: Platform[];
|
|
81
92
|
/** App UI locales; mapped to ASC codes (unsupported ones fall back to primary). */
|