npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vydanne

v1.2.0

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

Readme

vydanne

npm version npm downloads node license companion: zdymak

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 version

Works 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 | | ASC_KEY_CONTENT (the .p8, raw or base64) or ASC_KEY_PATH | CI — signs without writing the key to disk | | 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.*.local

Values 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, creative } — which zdymak output folder feeds which store slot, when dir: overrides make them differ. apple.iphoneduo: { dirs, order } picks the Duo targets and their order; creative.header / creative.search name the file to carry when zdymak renders two for one slot. | | creative | { dir, searchFromHeader, poster } — the iOS 27 product page header and search results asset. See iOS 27. | | 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.txt

4. 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 it

Run 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, creative, 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/ DELETE leaves the process at all. prerelease also refuses the altool upload, after validating the archive.

Upgrading from ≤ 0.5? The Apple half used to write immediately — vydanne fill now needs --apply. VYDANNE_COMMIT=1 still works as an alias so existing Play scripts keep running, but prefer the flag.

fill takes four more overrides, all off by default: VYDANNE_REPLACE=1 replaces a store screenshot set instead of leaving the live one alone (what you want after re-rendering them; previews and creative read the same flag), and VYDANNE_SKIP_METADATA=1 / VYDANNE_SKIP_SCREENSHOTS=1 do one half of the job when the other is already right. VYDANNE_FLATTEN=1 reads a flat screenshot folder rather than per-locale ones.

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. It also asserts the fields no local file drives and that no diff can therefore see: copyright, the App Review contact, the age rating, the primary category and the content-rights declaration. Those are each set by their own command, so a listing that never ran one had them EMPTY while this printed green. Green means submittable. Also refuses a blank screenshot — a file with almost no distinct colours in the middle of the frame, i.e. one where the app drew nothing — a screenshot of a size its set does not take, and a set with more files than Apple allows (ten), both judged against Apple's own catalog. Notes, without blocking, an iPhone listing with no iPhone Duo set (optional until April 2027) and empty header / search slots; a local header or search asset the store does not show is a blocker. | | 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 → creative → 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 — text, screenshots and previews by checksum, and the header / search assets by the checksum creative records. Nothing is changed — a safe preview. | | fill | Uploads your listing text and screenshots. Handles iPhone (including the iPhone Duo set), iPad and Mac. Refuses to upload text that names the other mobile platform, and a screenshot set with more files than Apple allows. | | previews | Uploads App Preview videos, every locale at once, and waits for all of them together (up to 30 minutes): a delivery Apple marks FAILED fails the run. A poster (HH:MM:SS:FF) is set only after the video is processed, and only counts once Apple has made a new, non-empty poster image for that frame; without one the run fails, because App Store Connect then shows a tile that never loads (Niva, 2026-10-14: four locales with a 0x0 poster). Leave poster out to keep Apple's own frame. preflight refuses a configured locale with no preview, or one whose poster image is empty. | | creative | Places the iOS 27 product page header and search results asset on the version being prepared, per localization — through Apple's App Asset Library, the only part of the API those slots exist in. Optional; nothing to do without the files. See iOS 27. | | inspect | Shows the app's current state in the store, including the iPhone Duo set and what the header and search slots show. Read-only. With --store google it also lists every track carrying a release — track, versionCode, version name, status, and the rollout percentage of a staged release. | | releases | Every version this app has ever had, with the build each one shipped, the commit that build number resolves to, and whether that commit is tagged. Read-only. inspect cannot answer this — it reports the version in preparation or the live one and nothing behind them. With --store google it reports what each track is serving; Play keeps no history of superseded releases, so tag as you release. | | withdraw | Takes a version back out of App Store review so it can be edited — the step prepare and prerelease both tell you to do by hand. Refuses a live version, exactly as prerelease --store google refuses the production track: it retrieves what is waiting and never touches what customers have. Undoes a submission; never makes one. Dry run without --apply. | | 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, creating the declaration for each device family the app ships on (devices; default iPhone + iPad for iOS, Mac for macOS) when Apple has none yet. 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, and its header and search graphics and reels into the folder creative 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, creative, 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 eight 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 → creative → 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 prepare finds 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 prepare is the step that matters: it creates the next version (numbered from the newest build's own CFBundleShortVersionString, 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 --apply

Prefer 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.

iOS 27: iPhone Duo and the product page header

Apple added three things to the App Store listing with iOS 27. vydanne handles all three; here is what each is, where its files go, and what the evidence for each decision is (checked 2026-10-12).

iPhone Duo screenshots

One set, both screens. Files named iphoneduo_* go to APP_IPHONE_DUO, a single set of up to ten that holds the outer screen (1398×2034 or 2034×1398) and the inner one (2007×2853 or 2853×2007) together. The evidence: the live API creates a screenshot set with APP_IPHONE_DUO (HTTP 201) and a 1398×2034 and 2034×1398 capture uploaded into it processed and showed in App Store Connect's Duo gallery (App-Store-Connect-CLI#2926, 2026-10-06); and Apple's own asset catalog (GET /v1/appAssetLibraryRefData) lists all four sizes under one placement group, IPHONE_DUO_PROFILE, with one limit of ten. The display type is missing from Apple's published OpenAPI (4.5.1), so it rests on the live API rather than on the document.

The order is yours. Apple has not said how the App Store sequences a mixed Duo set, so bridge makes it a setting. By default it carries whichever of zdymak's four Duo targets were rendered — appstore-iphone-duo-outer, -outer-landscape, -inner, -inner-landscape — outer first:

fastlane/screenshots/en-GB/iphoneduo_1-outer_01-home.png      ← "display" order (the default)
fastlane/screenshots/en-GB/iphoneduo_1-outer_02-table.png
fastlane/screenshots/en-GB/iphoneduo_3-inner_01-home.png

bridge: { apple: { iphoneduo: { dirs: [...], order: "scene" } } } picks the directories and interleaves them instead (iphoneduo_01-home_1-outer.png, iphoneduo_01-home_2-inner.png, …). More than ten files in all is refused, naming the fix — six outer and four inner, say, comes either from naming the two directories in dirs, or from a zdymak device for the inner screen whose own scenes stop at four. Managing the files by hand works the same way: anything named iphoneduo_… is the Duo set, uploaded in filename order.

Optional until April 2027. Apple requires Duo screenshots from April 2027 for apps built with the iOS 27.1 SDK or later. Until then preflight notes a missing Duo set on an iPhone listing; from then on it is a blocker, unless the build is on an older SDK (VYDANNE_DUO_OPTIONAL=1, because vydanne cannot see which SDK a build used).

Product page header and search results asset

Two optional slots per localization, shown on iOS and iPadOS 27 and later. One file each:

fastlane/creative/en-GB/header.png    ← or header.mp4 / .mov / .m4v
fastlane/creative/en-GB/search.png    ← or search.jpg / .mp4 / …

| Slot | Image | Video | |---|---|---| | Header | 5244×2950 PNG (universal: fits search too) or 3840×1646 PNG | 3840×1646, 30 or 60 fps, 5–30 s | | Search results | 5244×2950 PNG (universal) or 3:2 from 1920×1280 to 3840×2560, PNG or JPEG | 3:2, same range, 30 or 60 fps, 5–30 s |

No alpha, anywhere. Those are the sizes in Apple's catalog on 2026-10-12; vydanne checks against the live catalog whenever it has a client, so a change on Apple's side reaches it without a release. One disagreement to know about: Apple's help page lists JPEG for the 3840×1646 header, and the catalog takes PNG only for it. The catalog is what the upload is processed against, so vydanne follows it.

From zdymak, bridge carries them: appstore-header.png (or appstore-universal.png, which becomes the header) and appstore-search.png, per locale, and a reel whose destination is appstore-header-video or appstore-search-video for the primary listing — found through zdymak.config.mjs whatever the reel is called. Each file is checked against the same catalog creative uses before anything is copied. Two outputs for one slot (a header image AND a header reel) are refused until bridge.creative.header names the one to carry; a folder without that file keeps the default, so naming the primary's reel does not cost the translated listings their own images. Like the screenshot folders, fastlane/creative is rebuilt on each run that produces creative assets, or whose zdymak config declares any.

vydanne creative validates every file first and refuses the whole run on one bad file, then uploads each distinct file once into the app's App Asset Library and places it on each localization. The library keeps no checksum, so vydanne writes the file's md5 into the asset's reference name; that is how diff and preflight compare the store with your folder, and how a re-run places the asset it already uploaded instead of uploading it again. creative: { searchFromHeader: true } puts a universal header in search results too, for a locale with no search.*.

What stays in App Store Connect. Placements can only be added to a version being prepared, so the assets go to review with that version. Two routes have no API yet, and creative prints them when it cannot proceed: submitting library assets for review on their own, and publishing an approved asset onto the version already on sale (Product Page Information → Header and Search Results → Browse Assets → Publish).

The App Asset Library, and what is deprecated

App Store Connect API 4.5.1 introduced the App Asset Library and deprecated the screenshot-set and app-preview-set resources fill and previews use. They still work, Apple migrated every existing set into the library (each screenshot is now also a placement), and fill keeps using them because they carry the checksum diff compares by. creative uses the library because the header and search slots exist nowhere else. Moving screenshots and previews to the library is a later change.

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 it

prerelease — 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 processing
ios: {
  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 67

The 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 track
google: {
  packageName: "com.x.app",
  aab: "./dist",        // a file, or a directory whose NEWEST .aab is taken
  track: "internal",    // 'internal' (default) | 'alpha' | 'beta' | ["alpha", "internal"]
}

Several tracks, one edit. track: ["alpha", "internal"] (or VYDANNE_TRACK=alpha,internal) puts the same bundle and the same notes on every track named, inside one edit that is committed once: either every track has the build or none does. Two separate runs cannot promise that. The first archives next.txt as <versionCode>.txt, so the second has no notes of its own, and a build between them sends each track a different versionCode.

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). | | <versionName>.txt | This version's notes, e.g. 1.3.txt — the one to write. The marketing version is known before the build exists, every test build of that version shares the file, nothing is renamed after an upload, and the file's git history is exactly how this version's notes changed. The name is read from the bundle's own manifest, so it can never pick another version's file. | | 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. |

The App Store's "What's New" works the same way, keyed by the App Store version being prepared: <locale>/release_notes/<version>.txt (e.g. release_notes/1.6.txt) is used first by fill and diff, then the classic release_notes.txt. Each store keys its notes by its own marketing version, since an app's iOS and Android versions are independent.

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, VYDANNE_STATUS; and VYDANNE_IPA for the App Store half.

An app that has never been published needs releaseStatus: "draft". Play calls it a draft app and refuses a completed release on every track except internal, answering "Only releases with status draft may be created on draft app." — which names neither the track nor the fix, and is why the same bundle uploads to internal and fails on alpha. Set it in google.releaseStatus (or VYDANNE_STATUS=draft for one run); the build then waits in Play Console for a person to start the rollout, which is where an unpublished app's first one belongs. Remove it once the app is live. vydanne explains this rejection rather than printing Play's version of it.

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 | | A screenshot one size off, or an eleventh in a set of ten — refused after the first ten are already up | bridge, preflight and fill check both against Apple's own catalog first | | iPhone Duo's two screens look like two slots, but Apple holds them in one set of ten | One iphoneduo_ set; bridge merges zdymak's outer and inner targets in an order you choose |

Middleware — your own checks around any command

A release has rules that belong to your app, not to vydanne: "never upload a bundle that has not opened on the lowest Android we support", "tell the channel when a build reaches testers", "refuse a Friday". middleware in the config is where that logic goes, so you do not wrap the CLI in a script that someone can bypass by calling vydanne directly.

// vydanne.config.mjs
export default {
  // …
  middleware: [
    // an object limits an entry to the commands and stores it names
    {
      name: "release-gate",
      commands: ["prerelease"],
      stores: ["google"],
      async run(ctx, next) {
        if (ctx.apply && !hasReceipt(ctx.config.google.aab)) ctx.fail("this bundle has not passed the legacy-device check");
        const result = await next();          // the command itself
        if (ctx.apply && result?.ok) await tell("build is with testers");
        return result;                         // returning nothing keeps the command's own result
      },
    },
    // a bare function runs for every store command
    async (ctx, next) => { ctx.log(`${ctx.command} on ${ctx.store}`); return next(); },
  ],
};

The first entry is outermost. Before await next() you may refuse: ctx.fail(reason) stops the command, prints refused by middleware release-gate: … and exits 1, before any client exists, so a refusal costs no Play or App Store Connect request. After it you see the result ({ ok, planned }). An entry that never calls next() skips the command and says so. ctx carries command, store, apply, dryRun, writes, argv, cwd, env, version, and the live config: a change made before next() (a different google.track, say) is what the command sees.

It wraps every store command, from the CLI and from runCommand. It does not wrap auth, locales, version or help, which touch no store. A malformed entry is an error when the config loads. Middleware runs with the full power of the file it lives in, so it is not a sandbox. The same middleware key, with the same semantics, is in zdymak, so a check written for one reads the same in the other.

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 / .aab you 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.

When the build number is not the commit count

releases maps a store's build number back to a commit because the build number IS git rev-list --count. A repo can be forced off that and be unable to get back: ship from a long branch, squash it onto the release branch, and the count lands below build numbers already uploaded. Google Play reserves every versionCode it has ever been given, so those numbers cannot be freed and the only way past them is a constant added to the count.

buildNumberOffset: 100,   // build number = git rev-list --count <commit> + 100

releases then resolves commits again and prints the offset it applied, on both stores. Leave it at 0 — the default — for every app that never had the accident.

It is the one input here vydanne cannot verify. The count check validates an index against its own commit, which a wrong offset passes just as cleanly as a right one, so a bad value names a wrong commit with full confidence. It is refused unless it is an integer, and reported wherever it is used, but keeping it true is yours.

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.