vydanne 0.3.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 CHANGED
@@ -180,7 +180,7 @@ Run vydanne **from your project folder** — it finds everything relative to whe
180
180
  | `compliance` | Generates the US encryption self-classification PDF that Apple asks for. |
181
181
  | `version` | Prints the version of vydanne. |
182
182
 
183
- For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, or `fill`.
183
+ For **Google Play**, add `--store google` to `inspect`, `diff`, `preflight`, `fill`, or `prerelease`.
184
184
 
185
185
  <br>
186
186
 
@@ -196,6 +196,37 @@ VYDANNE_COMMIT=1 npx vydanne fill --store google # actually do it
196
196
  # Windows PowerShell: $env:VYDANNE_COMMIT = "1"; npx vydanne fill --store google
197
197
  ```
198
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
+
199
230
  **Play is dry by default on purpose.** Nothing goes live until you add `VYDANNE_COMMIT=1`, so a
200
231
  half-finished folder can never overwrite a good listing. Play also uses its **own** language codes
201
232
  (`zh-CN`, `iw-IL`) which are *not* Apple's — `vydanne locales` and the
package/SKILL.md CHANGED
@@ -164,7 +164,17 @@ live) · `privacy` (prints answers for the UI — the API can't reach Apple's ir
164
164
  RGB flatten) · `compliance` (US self-classification PDF) · `diff` (what differs vs live) · `preflight`
165
165
  (completeness gate) · `inspect` · `auth` (what credentials resolved, and from where) · `locales` · `version`.
166
166
 
167
- `--store google` routes `inspect` · `diff` · `preflight` · `fill` to the Play Developer **Edits** API
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
168
178
  (OAuth2 service account; **scoped to the config's `packageName`** — a shared key can't touch another app).
169
179
  `fill --store google` is **DRY by default**; `VYDANNE_COMMIT=1` commits. The AAB binary and the
170
180
  (YouTube-URL) promo video stay outside vydanne.
package/bin/vydanne.mjs CHANGED
@@ -87,6 +87,7 @@ usage: vydanne <command> [--config vydanne.config.mjs]
87
87
  inspect read-only ASC state
88
88
  diff show what differs between local (metadata/screenshots/previews) and ASC
89
89
  preflight verify submission-completeness (the gotcha checker)
90
+ prerelease --store google: upload the .aab to a closed testing track (refuses production)
90
91
  locales UI -> ASC locale mapping + unsupported
91
92
  auth which credentials resolved, and from where (masked) — run this on a 401
92
93
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.3.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",
package/src/config.mjs CHANGED
@@ -45,6 +45,9 @@ export async function loadConfig(p) {
45
45
  serviceAccountKey: creds.playJsonKeyFile,
46
46
  metadataDir: raw.google.metadataDir || "fastlane/metadata/android",
47
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
+
48
51
  }
49
52
  : null,
50
53
  };
@@ -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
29
  // Full public command surface (the module-dispatched ones above + the three handled inline in bin/).
29
- export const COMMAND_NAMES = [...Object.keys(COMMANDS), "auth", "locales", "version"];
30
+ export const COMMAND_NAMES = [...Object.keys(COMMANDS), "prerelease", "auth", "locales", "version"];
package/types/index.d.ts CHANGED
@@ -16,6 +16,8 @@ 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'
19
21
  | 'auth'
20
22
  | 'locales'
21
23
  | 'version';
@@ -58,6 +60,10 @@ export interface GoogleConfig {
58
60
  /** Listing-text folders, supply convention. Default 'fastlane/metadata/android'. */
59
61
  metadataDir?: string;
60
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";
61
67
  }
62
68
 
63
69
  export interface ExportConfig {