vydanne
v0.8.1
Published
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
Maintainers
Readme
vydanne
Fill in your app's App Store and Google Play listing — in every language — from files on your computer.
Publishing an app means typing the same things into a web form over and over: a name, a subtitle, a description, keywords, screenshots… once per language, twice per store, again for the Mac version. Miss a box and the store quietly shows a blank page to half the world. Get a folder name wrong and the whole upload fails.
vydanne does that typing for you, then checks your work before Apple or Google does — and puts your build in front of testers on both stores. It stops at the one step that should stay a human's: it never submits for review, and never ships to the public.
выданне (Belarusian) — "publishing".
Is this for me?
Yes, if you have an app and you're tired of the store's web forms — especially in more than one language. You do not need to be a programmer: you write text files, and run three commands.
Probably not, if you publish one app in one language and never change it. The web form is fine.
You'll still need an Apple Developer account (and/or a Play Console account) and someone to build the actual app. vydanne handles the listing, not the software.
The two halves of a release
vydanne has a sibling — zdymak. They split the job cleanly:
| | zdymak 📸 | vydanne 📝 | |---|---|---| | Makes | The pictures — screenshots, App Preview videos, the Play feature graphic | The words and the paperwork — listing text, ratings, contacts, privacy, compliance | | Ends with | Image and video files on disk | A listing filled in and verified, ready for a human to submit |
Use them together: zdymak produces the assets, npx vydanne bridge moves them to where vydanne
reads (zdymak writes one store-assets/ tree with short locale codes and numbered files; vydanne reads
fastlane/screenshots/<Apple-locale>/ with device-prefixed names, and fixed marketing/out/ paths for
Play), and fill uploads them alongside your text. Run bridge after every capture — skipping it means
fill quietly re-uploads whatever was bridged last time. Use vydanne alone if you already have your
screenshots in its layout.
Install
You need Node.js 20.9 or newer (nodejs.org — the LTS build). Then, in your app's folder:
npm i -D vydanne
npx vydanne versionWorks on macOS, Linux and Windows. The examples below use macOS/Linux shell syntax; Windows
PowerShell equivalents are in GETTING_STARTED.md ($env:NAME = "value" instead of
export, and set variables on their own line — PowerShell has no VAR=1 command form).
Quick start
→ First time? Follow GETTING_STARTED.md. It walks through every click: getting your Apple API key, setting up a Play service account, where each file goes, and what to do when something errors. This section is the short version for people who've done it before.
1. Get a key from Apple. App Store Connect → Users and Access → Integrations → App Store Connect API.
Download the .p8 (once only!), then:
mkdir -p ~/.appstoreconnect/private_keys
mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/private_keys/
# Write the ids ONCE — every app you ship reads them from here. Nothing to export per shell,
# nothing secret in any repo. (The .p8 stays next to it, in private_keys/.)
cat > ~/.appstoreconnect/config.json <<'JSON'
{ "keyId": "ABCD123456", "issuerId": "69a6de70-…" }
JSON
chmod 600 ~/.appstoreconnect/config.json
npx vydanne auth # confirms what resolved, and from where# Windows PowerShell
New-Item -ItemType Directory -Force "$env:USERPROFILE\.appstoreconnect\private_keys"
Move-Item "$env:USERPROFILE\Downloads\AuthKey_*.p8" "$env:USERPROFILE\.appstoreconnect\private_keys\"
'{ "keyId": "ABCD123456", "issuerId": "69a6de70-…" }' |
Set-Content "$env:USERPROFILE\.appstoreconnect\config.json"Where credentials come from
Resolved automatically, highest priority first — and never from vydanne.config.mjs, which is
committed. A keyId or issuerId found there is refused at load, with a warning telling you to move it:
| Source | Use it for |
|---|---|
| ASC_KEY_ID / ASC_ISSUER_ID / PLAY_JSON_KEY_FILE in the environment | CI secrets, one-off overrides |
| the .env cascade in the repo | one app that needs a different account from the rest |
| the user config file | the default for every app you ship — one account, many repos |
The .env cascade is the standard one (same shape Vite and Next.js use), parsed with dotenv, later
file wins: .env → .env.<mode> → .env.local → .env.<mode>.local, where mode is VYDANNE_ENV (or
NODE_ENV) and is optional. So .env and .env.<mode> stay committable for shared non-secret
defaults, and only the *.local files hold secrets — those are the ones to gitignore:
.env.local
.env.*.localValues are parsed, never loaded into process.env, so a real environment variable always wins.
The user config file is looked up in this order, first hit wins — there is no single cross-platform home for it, so all three are honoured instead of forcing one on everyone:
| Path | |
|---|---|
| $VYDANNE_CONFIG_HOME/config.json | explicit escape hatch (a secrets mount, a shared drive, tests) |
| %APPDATA%\vydanne\config.json (Windows)$XDG_CONFIG_HOME/vydanne/config.json → ~/.config/vydanne/config.json | what a Windows or Linux user expects |
| ~/.appstoreconnect/config.json | beside the keys — Apple's tooling and fastlane already keep the .p8 in ~/.appstoreconnect/private_keys on every platform |
Shipping under more than one account? Use named profiles, selected with VYDANNE_PROFILE=client-x or
pinned per app via asc: { profile: "client-x" } in its config — a label, not a secret:
{ "default": "kupalinka",
"profiles": { "kupalinka": { "keyId": "…", "issuerId": "…", "playJsonKeyFile": "~/.config/play/sa.json" },
"client-x": { "keyId": "…", "issuerId": "…" } } }vydanne auth prints what resolved and which source won, masked — run it first when Apple returns a 401.
2. Describe your app in a vydanne.config.mjs file next to your project:
export default {
bundleId: "com.example.myapp",
primaryLocale: "en-US",
platforms: ["IOS"],
uiLocales: ["en", "de", "fr"],
metadataDir: "fastlane/metadata",
rating: "4+",
};Every field is optional except bundleId and primaryLocale. The ones you reach for next:
| Field | What it's for |
|---|---|
| screenshots | Where your screenshots live, per platform. Defaults to fastlane/screenshots + fastlane/screenshots-macos. |
| localeMap | Your language codes → Apple's, for anything the built-in table misses ({ nb: "no" }). |
| ageRating | The content descriptors behind any rating above 4+. Apple computes the band from them. |
| reviewContact | { demoAccountRequired } — otherwise inferred from whether review_information/demo_user.txt exists. |
| export | Export-compliance details. algorithms + statement are required for encryption: "standard". |
| bridge | { out, apple, play } — which zdymak output folder feeds which store slot, when dir: overrides make them differ. |
| push | { skip: [...] } — pipeline steps this app never runs. |
| google | The Play block, including track, images and imageLocales. |
3. Write your listing as plain text files, one folder per language:
fastlane/metadata/en-US/name.txt ← "My App"
fastlane/metadata/en-US/subtitle.txt ← "The one-line pitch"
fastlane/metadata/en-US/description.txt
fastlane/metadata/en-US/keywords.txt4. Check, then push:
npx vydanne preflight # anything missing or too long?
npx vydanne diff # what exactly would change?
npx vydanne fill # DRY RUN — prints every write it would make
npx vydanne fill --apply # do itRun vydanne from your project folder — it finds everything relative to where you are.
--apply, or nothing happens
Every command that can change a store is a dry run unless you pass --apply. It reads the store,
prints each write it would make, and sends nothing:
DRY RUN — 'fill' will not change App Store Connect. Add --apply to write.
would PATCH /v1/appStoreVersionLocalizations/ad2f… — description, keywords, whatsNew
…
DRY RUN — 40 store write(s) withheld. Re-run with --apply to perform them.The commands this applies to are marked ✎ in the usage text (vydanne with no arguments): prepare, push, fill, previews,
appinfo, age-rating, review-contact, accessibility, prerelease. Everything else only reads, and ignores
the flag.
Two details worth knowing:
- A dry run walks the whole plan. It does not stop at the first locale — the count at the end is the
number to compare against
diff. "Nothing to write" and "nothing happened" are different sentences, and only the first one means your local files already match the store. - The two stores enforce it differently, deliberately. Play builds the Edit and validates it against
Google for real, then discards it — so a dry run catches everything a commit would have caught. Apple
has no transaction to roll back, so there the block is at the HTTP layer: no
POST/PATCH/PUT/DELETEleaves the process at all.prereleasealso refuses thealtoolupload, after validating the archive.
Upgrading from ≤ 0.5? The Apple half used to write immediately —
vydanne fillnow needs--apply.VYDANNE_COMMIT=1still works as an alias so existing Play scripts keep running, but prefer the flag.
What each command does
| Command | In plain English |
|---|---|
| preflight | Run this first. Checks the listing is complete, nothing is over a character limit, no locale mentions the other app store — and that the screenshots on the store are your current ones, not a stale set. Green means submittable. |
| prepare | Starts the next release: creates the App Store version you're preparing and attaches your newest build to it. Needed before fill on an app that already has a version on sale — until a draft exists there is nothing for the listing text to go into. Reuses the draft if it's already there, so it's safe to re-run. It does not submit. |
| push | The whole release, one command: runs prepare → fill → previews → age-rating → review-contact → accessibility → preflight, in that order, stopping at the first failure. Dry run without --apply, like everything else. Ends at a green preflight — it never submits. |
| diff | Shows exactly what's different between your files and what's live. Nothing is changed — a safe preview. |
| fill | Uploads your listing text and screenshots. Handles iPhone, iPad and Mac. Refuses to upload text that names the other mobile platform. |
| previews | Uploads App Preview videos. |
| inspect | Shows the app's current state in the store. Read-only. |
| locales | Lists your languages and Apple's code for each — and warns about any language the App Store doesn't offer. |
| appinfo | Sets the App Store category and the content-rights answer — the two app-level facts that block Add for Review and belong to no single release. Declared as categories (Apple's ids: GAMES, GAMES_PUZZLE — never display names) and contentRights: false for an app that shows no third-party content. Left undeclared, contentRights is not written at all, so an answer already given in App Store Connect is never overwritten by a default nobody chose. |
| age-rating | Sets the age rating. rating: "4+" needs nothing else; any higher rating is described feature-by-feature in ageRating and Apple computes the band from it. |
| review-contact | Fills in the App Review contact details (who Apple calls if there's a problem). |
| accessibility | Saves Accessibility Nutrition Labels from the accessibility block in your config. Stays a draft until your app is live. Refuses to run if you have not declared one — see below. |
| privacy | Prints the privacy answers to paste into Apple's website (Apple's privacy section has no API). |
| iap | Checks your in-app purchase text fits, and can strip transparency from an image. |
| compliance | Generates the US encryption self-classification PDF that Apple asks for. |
| bridge | Moves zdymak's captured screenshots into the folders fill reads — renaming locales to the stores' codes and files to the device-slot convention. Local files only; --dry-run previews. Run it after every capture. |
| version | Prints the version of vydanne. |
For Google Play, add --store google to inspect, diff, preflight, fill, or prerelease.
prepare, push, fill, previews, appinfo, age-rating, review-contact, accessibility and prerelease
change the store, so they need --apply; without it they report and exit.
The release pipeline — the order matters
A release is the same seven steps in the same order, every time. prepare must come first (until the
draft version exists, nothing has anywhere to write) and preflight must come last (green has to be
measured after the writes it blesses, or it blesses nothing). That ordering is exactly the kind of
thing that lives in someone's head until the day it doesn't — so push runs it for you, stopping at the
first failure, and each step's own refusals still apply:
npx vydanne prerelease --apply # whenever the build is ready — before or after push is fine
npx vydanne push # DRY RUN of the whole pipeline: read the plan
npx vydanne push --apply # do it: prepare → fill → previews → age-rating
# → review-contact → accessibility → preflight
# then, in App Store Connect: Add to Review -> Submit (always you, never vydanne)The two flows differ only at the first step, and push absorbs the difference:
- First release. Creating the app record in App Store Connect already gave you a version in Prepare
for Submission, so
preparefinds it and reuses it — a no-op that simply reports what's there. - An update to a live app. A version on sale is read-only and there is no draft until someone makes
one. Here
prepareis the step that matters: it creates the next version (numbered from the newest build's ownCFBundleShortVersionString, so it can't drift from the binary) and attaches that build.
Skip prepare on a live app and fill has nowhere valid to aim — it refuses, rather than falling back
to the version on sale and rewriting the listing your customers are reading.
One wrinkle worth knowing: on a live app, a dry run of push stops at fill, because the draft the
later steps write into doesn't exist until prepare is applied. push says so up front. Creating the
draft is safe — it isn't a submission — so vydanne prepare --apply first, then a dry push, previews
the whole plan. Preparing a version before its build exists is the one case that needs telling:
VYDANNE_VERSION=1.2 npx vydanne prepare --applyPrefer doing it step by step? Every step is its own command (the table above), and push is nothing
more than those commands in the right order.
Google Play
Add a google block to your config and point PLAY_JSON_KEY_FILE at a service-account key
(how to get one):
npx vydanne preflight --store google
npx vydanne fill --store google # dry run — shows what would change
npx vydanne fill --store google --apply # actually do itprerelease — the build, to testers
fill writes the listing; prerelease uploads the binary — to TestFlight on Apple, or to a
closed testing track on Play. Neither submits anything for review.
Apple — TestFlight
npx vydanne prerelease # validate, upload, wait for processingios: {
ipa: "./dist", // a file, or a directory whose NEWEST .ipa is taken
testFlightGroup: "Internal", // optional; INTERNAL groups only
}The App Store Connect REST API has never accepted a binary, so this is the one command that shells
out — to xcrun altool, which ships with Xcode and authenticates from the same
~/.appstoreconnect/private_keys key vydanne auth reports. That makes it macOS-only, which it
checks up front. The archive is validated before it is uploaded, so the common refusals (bad
entitlements, missing icons, a version Apple already holds) cost seconds rather than a full transfer.
Build numbers come from the archive's own CFBundleVersion — re-uploading one Apple already holds
fails loudly instead of quietly replacing a binary.
It also points the version you are preparing at the build it just uploaded, which is the loop this command exists for:
npx vydanne prerelease # version 1.1 -> build 66, off to testers
# …find something, fix it, re-archive with a new build number…
npx vydanne prerelease # version 1.1: build 66 -> build 67The version→build relationship holds exactly one build, so re-running re-points it and there is
nothing to clean up. A version that is no longer editable — IN_REVIEW, READY_FOR_SALE — is left
alone and said so, because re-pointing it would mean withdrawing that submission, and that is a
decision with reviewer-facing consequences.
External TestFlight groups are refused. Distributing to them requires Beta App Review, which is a
submission by another name; internal groups are the exact parallel of Play's internal track, and on
a paid app they are the testers who install without buying it.
Google Play — a closed track
npx vydanne prerelease --store google # dry run
npx vydanne prerelease --store google --apply # publish to the trackgoogle: {
packageName: "com.x.app",
aab: "./dist", // a file, or a directory whose NEWEST .aab is taken
track: "internal", // 'internal' (default) | 'alpha' | 'beta'
}It refuses production — that isn't a flag you can pass, it's a refusal. A staged rollout can be
halted but never un-shipped, so promoting a tested build stays a human decision in Play Console. This is
the same line the Apple side draws by never submitting.
Paid app? Use internal. It is the only track where testers install without buying; closed and open
testers pay like everyone else.
Release notes follow supply's layout, so an existing repo needs no migration — per locale, first match wins:
| File in <metadataDir>/<play-locale>/changelogs/ | When to use it |
|---|---|
| <versionCode>.txt | You know the exact code (supply's own convention). |
| next.txt | The notes for the release you're about to cut — for when the versionCode isn't knowable in advance (e.g. it's derived from the git commit count, so every commit moves it). After a real (--apply) publish, vydanne renames it to <versionCode>.txt, so the next release can't inherit this one's notes by accident. |
| default.txt | Evergreen fallback ("bug fixes and improvements"). Falling back to it is warned, because notes written for one release quietly serving every later one is how a listing shows last release's news. |
Notes are truncated to Play's 500-char cap with a warning. The versionCode comes from the bundle's own
manifest — vydanne reads it out of the .aab locally and reports which changelog file each locale
resolves to before the upload, so a wrong file costs a re-run, not a re-release. Re-uploading a used
code fails loudly instead of silently replacing a binary. Overrides: VYDANNE_AAB, VYDANNE_TRACK,
VYDANNE_RELEASE_NAME.
Play is dry by default on purpose. Nothing goes live until you add --apply, so a
half-finished folder can never overwrite a good listing. Play also uses its own language codes
(zh-CN, iw-IL) which are not Apple's — vydanne locales and the
layout guide keep them straight.
Your config, field by field
bundleId · primaryLocale (your main language — everything else falls back to it, so it must be
complete) · asc (Apple key IDs, if you'd rather not use environment variables) · platforms (iOS and
macOS are separate listings) · uiLocales (your languages, auto-translated to Apple's codes) ·
metadataDir (where your text lives) · rating · privacy (what data actually leaves the device) ·
iaps (in-app purchases) · previews (App Preview videos) · export (encryption compliance details) ·
google (the Play block).
A commented example ships with the package: vydanne.config.example.mjs.
The mistakes it saves you from
Every one of these has cost somebody a rejected build or a blank store page. vydanne handles them so you don't have to learn them the hard way.
| The trap | What vydanne does |
|---|---|
| A wrongly-named language folder aborts the entire upload | Maps your codes to Apple's and flags any language the store doesn't support |
| Your main language is left empty → most of the world sees a blank page | preflight refuses to pass |
| macOS is a separate listing; its text is not shared with iOS | Fills each platform independently |
| Apple's list endpoints return blank text, so tools "see" an empty listing | Reads each language individually |
| Screenshots with transparency get rejected | bridge refuses before copying and names the files; VYDANNE_FLATTEN converts one |
| Once a version is Ready for Review, most tools can no longer edit it | Uses a method that still works |
| Character limits (name/subtitle 30, keywords 100, promo 170; purchases 30 / 45) | All checked by preflight before upload, not after rejection |
| One translation says "also on Google Play" → rejected under guideline 2.3.10 | Every locale is scanned before upload; preflight and fill both refuse |
| Apple's privacy section can't be reached by any API key | Prints the exact answers to paste in |
| Accessibility labels can't publish before launch | Saved as a draft automatically |
| In-app purchases need two different images, easily confused | Labels both slots |
| Play's language codes differ from Apple's | Documented and validated separately |
| A missing local screenshot folder deleting your live ones | Only uploads what exists — never deletes by omission |
What vydanne will never do
- It never submits for review, and never ships to the public. It will put a build in front of your testers — TestFlight internal, or a Play closed track — and point the version you are preparing at it. Pressing Submit, promoting to Play production, and distributing to external TestFlight (which needs Beta App Review) all stay yours. Those are refusals, not flags: there is no argument combination that reaches the public.
- It doesn't build or sign your binary. It uploads the
.ipa/.aabyou already produced. - It doesn't create the app record — make that in App Store Connect / Play Console first.
For developers & AI agents
The package ships SKILL.md — a precise, agent-facing operating guide (exact file layout,
the store gotchas, the ASO rules) — plus TypeScript definitions
(types/index.d.ts, type import('vydanne').VydanneConfig).
Two guards keep the docs honest: npm run check:docs (every config field and command is documented) and
npm run check:types (every one is typed). Both run before publish, so the docs can't drift from the code.
Technically: native Node, no fastlane, Ruby, or Python. ES256 JWT via node:crypto, the App Store Connect
REST API over fetch, chunked uploads by hand, PDFs via pdfkit, image flattening via sharp. Android
goes through the Google Play Developer Edits API.
Releasing a new version: RELEASING.md.
MIT.
One store never mentions the other
Both stores reject a listing that advertises the competing platform — Apple under App Review guideline 2.3.10 ("no names, icons, or imagery of other mobile platforms"), Google under its Store Listing and Promotion policy. It is an easy mistake to make and an expensive one to find: the two listings come from the same source copy, so a single translator writing "auch für Android" costs a review cycle, in one locale out of twenty, days later.
preflight and fill both scan the local metadata before anything is uploaded, per locale, per
field — store names, store URLs, and the other platform's device names, in Latin script and in the
localized forms (安卓, Андроид, アンドロイド, …). A store's OWN platform is never flagged: "Android"
belongs in a Play listing. Neither does the bare word "Play", which every game listing uses.
Ambiguous words ("apple" in a game about fruit) are reported as warnings and never block. If one genuinely belongs in your copy:
allowCrossStoreTerms: ["Apple"],VYDANNE_ALLOW_CROSS_STORE=1 overrides the whole check for one run.
Accessibility Nutrition Labels
Every other thing vydanne writes is a fact about your app. This one is a claim about its behaviour, made to Apple — so the tool will not guess it for you.
accessibility: {
voiceover: true,
voiceControl: true,
largerText: true,
sufficientContrast: true,
darkInterface: true,
differentiateWithoutColorAlone: true,
reducedMotion: true,
captions: false,
audioDescriptions: false,
},Every feature is stated explicitly. An omission would read as a quiet "no", which is just as unverified as a quiet "yes", so a partial block is rejected along with a missing one.
Earlier versions applied one hardcoded matrix to every app, which meant an app inherited claims nobody had checked against it. At least one shipped app declared Larger Text support while its board glyphs scaled twice and grew off the high-contrast disc behind them. If you are upgrading, audit before you declare.
Apple's platform caveats are still applied automatically: Larger Text does not exist on macOS and Voice Control does not exist on watchOS, so those are sent as false whatever you declare.
