vydanne 0.12.0 → 0.14.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
@@ -105,6 +105,7 @@ committed. A keyId or issuerId found there is refused at load, with a warning te
105
105
  | Source | Use it for |
106
106
  |---|---|
107
107
  | `ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE` in the environment | CI secrets, one-off overrides |
108
+ | `ASC_KEY_CONTENT` (the .p8, raw or base64) or `ASC_KEY_PATH` | CI — signs without writing the key to disk |
108
109
  | the `.env` cascade in the repo | one app that needs a different account from the rest |
109
110
  | **the user config file** | **the default for every app you ship** — one account, many repos |
110
111
 
@@ -491,6 +492,26 @@ allowCrossStoreTerms: ["Apple"],
491
492
 
492
493
  `VYDANNE_ALLOW_CROSS_STORE=1` overrides the whole check for one run.
493
494
 
495
+ ### When the build number is not the commit count
496
+
497
+ `releases` maps a store's build number back to a commit because the build number IS
498
+ `git rev-list --count`. A repo can be forced off that and be unable to get back: ship from a long
499
+ branch, squash it onto the release branch, and the count lands *below* build numbers already
500
+ uploaded. Google Play reserves every versionCode it has ever been given, so those numbers cannot
501
+ be freed and the only way past them is a constant added to the count.
502
+
503
+ ```js
504
+ buildNumberOffset: 100, // build number = git rev-list --count <commit> + 100
505
+ ```
506
+
507
+ `releases` then resolves commits again and prints the offset it applied, on both stores. Leave it
508
+ at 0 — the default — for every app that never had the accident.
509
+
510
+ It is the one input here vydanne **cannot** verify. The count check validates an index against its
511
+ own commit, which a wrong offset passes just as cleanly as a right one, so a bad value names a
512
+ wrong commit with full confidence. It is refused unless it is an integer, and reported wherever it
513
+ is used, but keeping it true is yours.
514
+
494
515
  ### Accessibility Nutrition Labels
495
516
 
496
517
  Every other thing vydanne writes is a *fact* about your app. This one is a **claim about its
package/SKILL.md CHANGED
@@ -36,7 +36,7 @@ form does **not** exist; set `$env:VAR = "1"` first.
36
36
  **Auth resolves automatically — NEVER put credentials in the config.** That file is committed; vydanne
37
37
  refuses a keyId/issuerId found there and warns. The signing key stays at
38
38
  `~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8`. The ids resolve highest-priority-first from: the
39
- environment (`ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE`) → the **`.env` cascade** → the **user
39
+ environment (`ASC_KEY_ID` / `ASC_ISSUER_ID` / `PLAY_JSON_KEY_FILE`, plus `ASC_KEY_CONTENT` or `ASC_KEY_PATH` for the signing key in CI) → the **`.env` cascade** → the **user
40
40
  config file**.
41
41
 
42
42
  The cascade is the standard one (dotenv-parsed, later file wins): `.env` → `.env.<mode>` → `.env.local` →
@@ -57,7 +57,7 @@ which user file was used, and whether the `.p8` is on disk.
57
57
  `{profile}` — selection only, never secrets) · `platforms` (iOS and macOS are SEPARATE) · `uiLocales`
58
58
  (auto-mapped to ASC codes) · `localeMap` · `metadataDir` · `screenshots` · `rating` · `ageRating` ·
59
59
  `privacy` · `iaps` · `previews` · `export` · `accessibility` · `ios` · `google` (Google Play) ·
60
- `bridge` · `push` · `reviewContact` · `allowCrossStoreTerms`.
60
+ `bridge` · `push` · `reviewContact` · `allowCrossStoreTerms` · `buildNumberOffset`.
61
61
 
62
62
  **Paths are defaults, not laws.** `metadataDir` (default `fastlane/metadata`), `screenshots`
63
63
  (`{IOS, MAC_OS}`, default `fastlane/screenshots` + `-macos`) and `google.images` (Play image type →
@@ -316,7 +316,16 @@ names and whether it carries a **tag**. Both stores in this portfolio derive the
316
316
  `git rev-list --count HEAD` (Android's `versionCode`, iOS's `CURRENT_PROJECT_VERSION` via
317
317
  `Scripts/build-number.sh`), which makes it reversible.
318
318
 
319
- **Every mapping is verified, never assumed.** `rev-list --reverse` is ordered, not counted, so on a
319
+ **`buildNumberOffset` when the count no longer matches.** A repo can be forced off the plain
320
+ convention and be unable to get back: ship from a long branch, squash it onto the release branch,
321
+ and the count lands BELOW versionCodes already uploaded — which Play reserves permanently, so a
322
+ constant is the only way over them. `buildNumberOffset: 100` tells `releases` that
323
+ `build = commits + 100`, and both commands print the offset they applied. It is the one number
324
+ here vydanne cannot verify — the count check validates an index against its own commit and cannot
325
+ tell a right offset from one wrong by five — so it is refused unless it is an integer, and stated
326
+ wherever it is used rather than assumed.
327
+
328
+ **Every other mapping is verified, never assumed.** `rev-list --reverse` is ordered, not counted, so on a
320
329
  merged history the Nth line need not be the commit with N ancestors — the candidate's own count has
321
330
  to match. An app that does not build this way, or a build number that is not a commit count, gets a
322
331
  stated reason instead of a confident wrong answer. That check has already caught a real one: a build
package/bin/vydanne.mjs CHANGED
@@ -48,11 +48,16 @@ try {
48
48
  row("ASC_KEY_ID", cr.keyId, "ASC_KEY_ID", cr.keyId);
49
49
  row("ASC_ISSUER_ID", cr.issuerId, "ASC_ISSUER_ID");
50
50
  row("PLAY_JSON_KEY_FILE", cr.playJsonKeyFile, "PLAY_JSON_KEY_FILE", cr.playJsonKeyFile);
51
- if (cr.keyId) {
52
- const p = path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${cr.keyId}.p8`);
51
+ // The key can now arrive three ways; say WHICH, because "missing" pointing at a path that CI was
52
+ // never going to have is the least useful thing this command could print on a 401.
53
+ if (cr.keyContent) {
54
+ console.log(` \x1b[32m✓\x1b[0m ${"signing key".padEnd(20)} <inline> ${"".padEnd(17)}← ${cr.sources.ASC_KEY_CONTENT}`);
55
+ } else if (cr.keyId) {
56
+ const p = cr.keyPath || path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${cr.keyId}.p8`);
53
57
  console.log(existsSync(p)
54
58
  ? ` \x1b[32m✓\x1b[0m ${"signing key".padEnd(20)} ${p}`
55
- : ` \x1b[31m✗\x1b[0m ${"signing key".padEnd(20)} \x1b[31mmissing\x1b[0m — ${p}`);
59
+ : ` \x1b[31m✗\x1b[0m ${"signing key".padEnd(20)} \x1b[31mmissing\x1b[0m — ${p}\n` +
60
+ ` in CI set ASC_KEY_CONTENT (the .p8, raw or base64) instead of writing this file`);
56
61
  }
57
62
  console.log(cr.userFile ? `\nuser config: ${cr.userFile}` : `\nuser config: none found. Looked in:\n${cr.candidates.map((c) => ` ${c}`).join("\n")}`);
58
63
  if (!cr.keyId || !cr.issuerId) {
@@ -88,7 +93,7 @@ try {
88
93
  const spec = COMMANDS[cmd];
89
94
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
90
95
  const dryRun = Boolean(spec.writes) && !apply;
91
- const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, dryRun }) : null;
96
+ const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, keyPath: cfg.keyPath, keyContent: cfg.keyContent, dryRun }) : null;
92
97
  if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
93
98
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
94
99
  const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
@@ -153,6 +158,8 @@ usage: vydanne <command> [--apply] [--config vydanne.config.mjs]
153
158
  locales UI -> ASC locale mapping + unsupported
154
159
  auth which credentials resolved, and from where (masked) — run this on a 401
155
160
  credentials: env > .env cascade (.env, .env.<mode>, .env.local, .env.<mode>.local) > user config
161
+ CI: ASC_KEY_CONTENT carries the .p8 itself (raw or base64) so no key is written to the runner disk;
162
+ ASC_KEY_PATH points at one. Without either, the key is read from ~/.appstoreconnect/private_keys.
156
163
  (\$VYDANNE_CONFIG_HOME, %APPDATA%\\vydanne or \$XDG_CONFIG_HOME/vydanne, ~/.appstoreconnect).
157
164
  NEVER the committed vydanne.config.mjs — run \`vydanne auth\` to see what resolved.
158
165
  toggles: VYDANNE_SKIP_METADATA / VYDANNE_SKIP_SCREENSHOTS (fill), VYDANNE_REPLACE=1 (fill/previews:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.12.0",
3
+ "version": "0.14.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, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -10,7 +10,22 @@ import { execFileSync } from "node:child_process";
10
10
  * or reproducing a store binary needs the tree the archive was cut from, and a version string does
11
11
  * not identify one.
12
12
  *
13
- * ### Nothing here assumes the convention holds — it VERIFIES it, per build
13
+ * ### The offset, and what it costs
14
+ *
15
+ * `buildNumberOffset` shifts the relationship to `count + offset`, because a repo can be forced
16
+ * off the plain count and not be able to get back. Squash a long branch onto `master` after
17
+ * shipping from it and master's count lands BELOW build numbers already uploaded — and Google Play
18
+ * reserves every versionCode it has ever been given, so the only way over them is a constant.
19
+ *
20
+ * **The offset is a declaration this module cannot check**, and that is a real weakening worth
21
+ * stating rather than burying. The count check below verifies an INDEX against its own commit; it
22
+ * cannot tell a correct offset from one that is wrong by five, because the commit five places away
23
+ * verifies just as cleanly. So a wrong offset buys exactly the confident wrong answer everything
24
+ * else here exists to refuse. It is declared once per app, in a committed file, next to the script
25
+ * that stamps the number — and the commands say which offset they applied, so a reader can see the
26
+ * assumption instead of inheriting it.
27
+ *
28
+ * ### Nothing else here assumes the convention holds — it VERIFIES it, per build
14
29
  *
15
30
  * vydanne is pointed at apps that do not build this way, so a mapping that trusted the convention
16
31
  * would confidently name the wrong commit for them. Every answer below is checked
@@ -59,12 +74,20 @@ export function tagsAt(sha) {
59
74
  * Returns `{ sha }` when the count checks out, or `{ why }` naming what stopped it — which is the
60
75
  * useful half. "This build has no commit" is a finding about the release, not a gap in the tool.
61
76
  */
62
- export function commitForBuild(order, build) {
63
- const n = Number(build);
77
+ export function commitForBuild(order, build, offset = 0) {
78
+ const raw = Number(build);
64
79
  if (!order) return { why: "not a git checkout" };
65
- if (!Number.isInteger(n) || n < 1) return { why: `build "${build}" is not a number` };
80
+ if (!Number.isInteger(raw)) return { why: `build "${build}" is not a number` };
81
+ // Every message below quotes the arithmetic when an offset is in play. A misconfigured offset
82
+ // surfaces here first, as a build that is impossibly far past HEAD, and "490 - 100 = 390, past
83
+ // HEAD (200 commits)" says which of the two numbers to go and look at.
84
+ const n = raw - offset;
85
+ const shown = offset ? `build ${raw} - offset ${offset} = ${n}` : `build ${n}`;
86
+ if (n < 1) {
87
+ return { why: `${shown} is below the first commit — the offset is larger than the build number` };
88
+ }
66
89
  if (n > order.length) {
67
- return { why: `build ${n} is past HEAD (${order.length} commits) — built elsewhere, or on an unmerged branch` };
90
+ return { why: `${shown} is past HEAD (${order.length} commits) — built elsewhere, or on an unmerged branch` };
68
91
  }
69
92
  const sha = order[n - 1];
70
93
  let count;
package/src/client.mjs CHANGED
@@ -21,8 +21,8 @@ const MUTATING = new Set(["POST", "PATCH", "PUT", "DELETE"]);
21
21
  // fetched from the FULL list (get_edit filters out READY_FOR_REVIEW), and individual localization reads
22
22
  // (list endpoints return sparse/empty text).
23
23
  export class Client {
24
- constructor({ keyId, issuerId, dryRun = false }) {
25
- this.token = makeToken({ keyId, issuerId });
24
+ constructor({ keyId, issuerId, keyPath, keyContent, dryRun = false }) {
25
+ this.token = makeToken({ keyId, issuerId, keyPath, keyContent });
26
26
  /** No mutating request leaves this process. Set by bin/ for a write command without `--apply`. */
27
27
  this.dryRun = dryRun;
28
28
  /** What a real run WOULD have sent, in order — the dry-run report, and the count bin/ prints. */
@@ -42,6 +42,10 @@ export async function run(config, client) {
42
42
 
43
43
  const order = commitOrder();
44
44
  if (!order) console.log(yellow(" not a git checkout — commit and tag columns unavailable"));
45
+ // Said once, not per row: the commit column is only as true as this number, and a reader who
46
+ // does not know it was applied has no way to tell a mapping from an assumption.
47
+ const offset = config.buildNumberOffset || 0;
48
+ if (order && offset) console.log(` commit column assumes buildNumberOffset=${offset} (build = commits + ${offset})`);
45
49
 
46
50
  // Newest first: the question is almost always about the last one or two.
47
51
  versions.sort((a, b) => String(b.attributes.createdDate).localeCompare(String(a.attributes.createdDate)));
@@ -71,7 +75,7 @@ export async function run(config, client) {
71
75
  } else if (backwards) {
72
76
  note = yellow(backwards);
73
77
  } else {
74
- const got = commitForBuild(order, build);
78
+ const got = commitForBuild(order, build, offset);
75
79
  if (got.sha) {
76
80
  commit = got.sha.slice(0, 9);
77
81
  const tags = tagsAt(got.sha);
package/src/config.mjs CHANGED
@@ -8,11 +8,26 @@ import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
8
8
 
9
9
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
10
10
  // (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
11
- export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms"];
11
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms", "buildNumberOffset"];
12
12
 
13
13
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
14
14
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
15
15
  // credentials.mjs) and are REFUSED if found in this committed file; review-contact PII stays gitignored.
16
+ /**
17
+ * An integer config value, or the default when absent — and a refusal when it is neither.
18
+ *
19
+ * `buildNumberOffset: "100"` would otherwise coerce through the arithmetic and shift every commit
20
+ * lookup by a string, which resolves to a real commit and reports it with no hint anything is
21
+ * wrong. A value this load-bearing is worth one type check.
22
+ */
23
+ function integerOr(name, value, fallback) {
24
+ if (value == null) return fallback;
25
+ if (!Number.isInteger(value)) {
26
+ throw new Error(`vydanne: config '${name}' must be an integer, got ${JSON.stringify(value)}`);
27
+ }
28
+ return value;
29
+ }
30
+
16
31
  export async function loadConfig(p) {
17
32
  const file = path.resolve(p || process.env.VYDANNE_CONFIG || "vydanne.config.mjs");
18
33
  if (!fs.existsSync(file)) throw new Error(`vydanne: config not found at ${file}`);
@@ -35,6 +50,7 @@ export async function loadConfig(p) {
35
50
  primaryLocale: need("primaryLocale"),
36
51
  keyId: creds.keyId,
37
52
  issuerId: creds.issuerId,
53
+ keyPath: creds.keyPath,
38
54
  uiLocales: raw.uiLocales || [],
39
55
  // App code -> App Store locale, for codes Apple spells differently or does not know yet. Merged over
40
56
  // the built-in table rather than replacing it, so an app declares only its exceptions.
@@ -71,6 +87,13 @@ export async function loadConfig(p) {
71
87
  bridge: raw.bridge
72
88
  ? { out: raw.bridge.out || null, apple: raw.bridge.apple || null, play: raw.bridge.play || null }
73
89
  : null,
90
+ // `build number = git rev-list --count <commit> + buildNumberOffset`, for a repo that was
91
+ // forced off the plain count and cannot get back — squash a long branch onto the release
92
+ // branch after shipping from it and the count lands below versionCodes already spent, which
93
+ // Play reserves forever. Unlike everything else in buildCommit.mjs this is a DECLARATION the
94
+ // tool cannot verify, so it is refused unless it is an integer and reported wherever it is
95
+ // applied. 0 means the plain convention, which is every app that never had the accident.
96
+ buildNumberOffset: integerOr("buildNumberOffset", raw.buildNumberOffset, 0),
74
97
  // Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
75
98
  allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
76
99
  previews: raw.previews || null,
@@ -115,5 +138,8 @@ export async function loadConfig(p) {
115
138
  : null,
116
139
  };
117
140
  c.resolvedLocales = resolveLocales(c.uiLocales, c.localeMap);
141
+ // The signing key rides along NON-ENUMERABLE, like it does on the credentials object: a config is
142
+ // exactly the kind of thing someone dumps while debugging, and a private key must not be in that dump.
143
+ Object.defineProperty(c, "keyContent", { value: creds.keyContent, enumerable: false, writable: false });
118
144
  return c;
119
145
  }
@@ -152,13 +152,25 @@ export function resolveCredentials(raw = {}, cwd = process.cwd(), env = process.
152
152
  return undefined;
153
153
  };
154
154
 
155
- return {
155
+ // The signing key itself, for CI. A developer machine keeps the .p8 in ~/.appstoreconnect and needs
156
+ // neither of these; a runner has only environment variables, and without them every workflow had to
157
+ // write the key to disk itself just to satisfy vydanne's path convention.
158
+ //
159
+ // NON-ENUMERABLE, deliberately: a private key must not be one careless `console.log(creds)` or
160
+ // `JSON.stringify(config)` away from a CI log that the whole team can read. Explicit property access
161
+ // still works, which is the only way it is ever read; a spread or a dump cannot see it.
162
+ const keyContent = pick("ASC_KEY_CONTENT", "keyContent");
163
+
164
+ const out = {
156
165
  keyId: pick("ASC_KEY_ID", "keyId"),
157
166
  issuerId: pick("ASC_ISSUER_ID", "issuerId"),
167
+ keyPath: expandHome(pick("ASC_KEY_PATH", "keyPath"), home),
158
168
  playJsonKeyFile: expandHome(pick("PLAY_JSON_KEY_FILE", "playJsonKeyFile"), home),
159
169
  sources,
160
170
  warnings,
161
171
  userFile,
162
172
  candidates: configCandidates(env, home),
163
173
  };
174
+ Object.defineProperty(out, "keyContent", { value: keyContent, enumerable: false, writable: false });
175
+ return out;
164
176
  }
package/src/index.mjs CHANGED
@@ -13,7 +13,7 @@ export { PlayClient } from "./play/client.mjs";
13
13
  export { loadConfig, CONFIG_KEYS } from "./config.mjs";
14
14
  export { COMMANDS, PLAY_COMMANDS, COMMAND_NAMES } from "./registry.mjs";
15
15
  export { resolveLocales, toAsc, VALID, UI_TO_ASC } from "./locales.mjs";
16
- export { makeToken } from "./jwt.mjs";
16
+ export { makeToken, resolveKey } from "./jwt.mjs";
17
17
  export { DEFAULT_SCREENSHOT_BASE, IOS_DEVICE, MAC_DEVICE, screenshotBase } from "./screenshots.mjs";
18
18
  export { DEFAULT_PLAY_IMAGES, PLAY_IMAGE_KIND, playImages } from "./play/images.mjs";
19
19
 
@@ -55,7 +55,7 @@ export async function runCommand(name, opts = {}) {
55
55
  return { ok: (await run(config, client)) !== false, planned: [] };
56
56
  }
57
57
 
58
- const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, dryRun }) : null;
58
+ const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, keyPath: config.keyPath, keyContent: config.keyContent, dryRun }) : null;
59
59
  const { run } = await import(`./commands/${spec.mod}.mjs`);
60
60
  // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
61
61
  const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
package/src/jwt.mjs CHANGED
@@ -3,12 +3,51 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ const PEM_MARKER = "-----BEGIN";
7
+
8
+ /**
9
+ * The .p8 itself, from whichever place this machine keeps it.
10
+ *
11
+ * A FILE is the right default on a developer's Mac, where the key sits beside the credentials in
12
+ * `~/.appstoreconnect`. It is the wrong and only option in CI, where a secret arrives as an
13
+ * environment variable and there is no home directory worth writing to — every caller was left
14
+ * materialising the key itself:
15
+ *
16
+ * mkdir -p "$HOME/.appstoreconnect/private_keys"
17
+ * printf '%s' "$ASC_KEY_CONTENT" | base64 --decode > ".../AuthKey_${ASC_KEY_ID}.p8"
18
+ *
19
+ * That is four lines of shell, per workflow, that must agree with vydanne's private path convention —
20
+ * and it silently writes a signing key to disk on a shared runner. `keyContent` removes the need.
21
+ *
22
+ * Accepts the PEM as-is or base64-encoded, because CI secrets are usually stored base64 (GitHub's own
23
+ * docs recommend it for multi-line values, and `ASC_KEY_CONTENT` is already base64 wherever fastlane
24
+ * reads it with `is_key_content_base64`).
25
+ */
26
+ export function resolveKey({ keyId, keyPath, keyContent }) {
27
+ if (keyContent) {
28
+ const raw = keyContent.trim();
29
+ if (raw.includes(PEM_MARKER)) return raw;
30
+ const decoded = Buffer.from(raw, "base64").toString("utf8");
31
+ if (decoded.includes(PEM_MARKER)) return decoded;
32
+ throw new Error(
33
+ "vydanne: ASC_KEY_CONTENT is neither a PEM nor base64 of one — expected it to contain " +
34
+ `"${PEM_MARKER} PRIVATE KEY". Check the secret was stored whole, newlines included.`,
35
+ );
36
+ }
37
+ const file = keyPath || path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${keyId}.p8`);
38
+ if (!fs.existsSync(file)) {
39
+ throw new Error(
40
+ `vydanne: ASC key not found at ${file}. On a developer machine put the .p8 there; in CI set ` +
41
+ "ASC_KEY_CONTENT (the .p8, raw or base64) or ASC_KEY_PATH instead — no file needed.",
42
+ );
43
+ }
44
+ return fs.readFileSync(file, "utf8");
45
+ }
46
+
6
47
  // App Store Connect JWT (ES256). Node's built-in crypto signs it — `dsaEncoding: "ieee-p1363"` returns the
7
48
  // raw r||s signature JWT/JOSE needs (the default DER encoding would be rejected). No fastlane/spaceship.
8
- export function makeToken({ keyId, issuerId, keyPath }) {
9
- keyPath ||= path.join(os.homedir(), ".appstoreconnect", "private_keys", `AuthKey_${keyId}.p8`);
10
- if (!fs.existsSync(keyPath)) throw new Error(`vydanne: ASC key not found at ${keyPath}`);
11
- const p8 = fs.readFileSync(keyPath, "utf8");
49
+ export function makeToken({ keyId, issuerId, keyPath, keyContent }) {
50
+ const p8 = resolveKey({ keyId, keyPath, keyContent });
12
51
  const b64u = (o) => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
13
52
  const now = Math.floor(Date.now() / 1000);
14
53
  const input = `${b64u({ alg: "ES256", kid: keyId, typ: "JWT" })}.${b64u({ iss: issuerId, iat: now, exp: now + 1200, aud: "appstoreconnect-v1" })}`;
@@ -41,6 +41,10 @@ export async function run(config, client) {
41
41
 
42
42
  const order = commitOrder();
43
43
  if (!order) console.log(yellow(" not a git checkout — commit and tag columns unavailable"));
44
+ // Said once, not per row: the commit column is only as true as this number, and a reader who
45
+ // does not know it was applied has no way to tell a mapping from an assumption.
46
+ const offset = config.buildNumberOffset || 0;
47
+ if (order && offset) console.log(` commit column assumes buildNumberOffset=${offset} (versionCode = commits + ${offset})`);
44
48
 
45
49
  const editId = await client.newEdit();
46
50
  let uploaded = [];
@@ -92,7 +96,7 @@ export async function run(config, client) {
92
96
  let commit = "-";
93
97
  let tag = "-";
94
98
  let note = "";
95
- const got = commitForBuild(order, row.code);
99
+ const got = commitForBuild(order, row.code, offset);
96
100
  if (got.sha) {
97
101
  commit = got.sha.slice(0, 9);
98
102
  const tags = tagsAt(got.sha);
package/types/index.d.ts CHANGED
@@ -343,11 +343,23 @@ export interface VydanneConfig {
343
343
  * "apple"); it is not a way to ship a store name.
344
344
  */
345
345
  allowCrossStoreTerms?: string[];
346
+
347
+ /**
348
+ * `build number = git rev-list --count <commit> + buildNumberOffset`. Defaults to 0, which is
349
+ * the plain convention.
350
+ *
351
+ * Set it only for a repo that was forced off the plain count and cannot get back: ship from a
352
+ * long branch, squash that branch onto the release branch, and the count lands below build
353
+ * numbers already uploaded — which Google Play reserves permanently, so a constant is the only
354
+ * way over them. It is a declaration vydanne cannot verify, so keep it true; a wrong one names
355
+ * a wrong commit as confidently as a right one.
356
+ */
357
+ buildNumberOffset?: number;
346
358
  }
347
359
 
348
360
  /** Thin ASC REST client (native fetch + ES256 JWT). */
349
361
  export declare class Client {
350
- constructor(opts: { keyId: string; issuerId: string; dryRun?: boolean });
362
+ constructor(opts: { keyId: string; issuerId: string; keyPath?: string; keyContent?: string; dryRun?: boolean });
351
363
  token: string;
352
364
  appId?: string;
353
365
  app?: unknown;
@@ -408,7 +420,22 @@ export declare function runCommand(
408
420
  ): Promise<{ ok: boolean; planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }> }>;
409
421
 
410
422
  export declare function loadConfig(path?: string): Promise<ResolvedConfig>;
411
- export declare function makeToken(opts: { keyId: string; issuerId: string; keyPath?: string }): string;
423
+ /**
424
+ * App Store Connect JWT (ES256).
425
+ *
426
+ * The signing key comes from ONE of three places, in order: `keyContent` (the .p8 itself, raw PEM or
427
+ * base64 — what CI hands over, so no key is written to a runner's disk), `keyPath`, or the default
428
+ * `~/.appstoreconnect/private_keys/AuthKey_<keyId>.p8`.
429
+ */
430
+ export declare function makeToken(opts: {
431
+ keyId: string;
432
+ issuerId: string;
433
+ keyPath?: string;
434
+ keyContent?: string;
435
+ }): string;
436
+
437
+ /** The .p8 text itself, from `keyContent` / `keyPath` / the default location. Throws if it is neither. */
438
+ export declare function resolveKey(opts: { keyId?: string; keyPath?: string; keyContent?: string }): string;
412
439
  export declare function resolveLocales(uiCodes: string[], extra?: Record<string, string>): ResolvedLocales;
413
440
  export declare function toAsc(code: string, extra?: Record<string, string>): string | null;
414
441
  export declare const VALID: Set<string>;