Everything the App Store Connect forms ask for, staged here so submission is
transcription rather than composition. The Developer ID release path
(.github/workflows/release.yml) is unchanged; the Mac App Store is a second
channel, not a replacement.
Apple ID 6796493980. Which version is on sale, and which is being prepared
behind it, is not written down here: it moves every release and a transcribed
version number is stale the day after it is typed. GET /v1/apps/6796493980/appStoreVersions answers it, and the release tooling prints
it before every publication.
Updating an existing app is a shorter loop than the first submission: the app record, privacy label, pricing, age rating, categories and review contact are all already set and carry across versions. Per version you need a new version number, What's New, a build, and the submission.
scripts/appstore-archive.sh --uploadThen either the web UI — + beside "macOS App" → the new version number → paste What's New → attach the build once Apple finishes processing it → Add for Review — or the API, which does the same thing without a browser.
The header above says Xcode must be signed in to the team account. That is one
way and it is not the only one, which matters because a machine where Xcode has
never been signed in fails after a successful archive, on the export, with
No signing certificate "Mac App Distribution" found and No Accounts. That
reads like a build problem and is an account problem.
Mac App Store signing needs two certificates and a profile that the Developer ID release path does not:
MAC_APP_DISTRIBUTION |
3rd Party Mac Developer Application — signs the app |
MAC_INSTALLER_DISTRIBUTION |
3rd Party Mac Developer Installer — signs the pkg |
MAC_APP_STORE profile |
ties the app certificate to the bundle id |
All three can be made from the App Store Connect API with nothing but openssl,
no Xcode session anywhere in it:
openssl req -new -newkey rsa:2048 -nodes -keyout k.key -out k.csr -subj "/CN=…"POST /v1/certificateswithcertificateTypeandcsrContent; base64-decodecertificateContentinto a.cer. Once per type.openssl x509 -inform DER→ PEM,openssl pkcs12 -export -legacywith the key, thensecurity import … -T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/productsign.POST /v1/profileswithprofileType: MAC_APP_STORE, the bundle id and the app certificate; writeprofileContentto~/Library/MobileDevice/Provisioning Profiles/<uuid>.provisionprofile.
scripts/appstore-archive.sh picks the identities up from the keychain and
switches the export to manual signing when both are present, so after step 4
it just works. Automatic signing is still the fallback when they are not.
Uploading takes an API key too. destination: upload authenticates as
whichever account Xcode is signed in to — nothing, here. The script prefers
xcrun altool --upload-app --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID",
which reads the same .p8 the rest of the release tooling uses.
The whole update loop is App Store Connect API calls with an ES256 JWT signed by an API key. No Xcode GUI step, no web portal step, no password. The order:
POST /v1/appStoreVersions—versionString,platform: MAC_OS, related to the app.PATCH /v1/appStoreVersions/{id}/relationships/build— attach the build once it reportsVALID.PATCH /v1/appStoreVersionLocalizations/{id}—whatsNew, once per locale. Writing to a locale the app does not have is a 404 rather than a create, so read the list back rather than assuming it.PATCH /v1/builds/{id}—usesNonExemptEncryption: false, if the build predates the Info.plist key below.POST /v1/reviewSubmissions, thenPOST /v1/reviewSubmissionItemsto put the version in it, thenPATCHthe submission withsubmitted: true.
Three things that each cost a failed attempt:
usesNonExemptEncryptionlives on the build, not the version. Sending it onappStoreVersionsreturns "unknown attribute". ShippingITSAppUsesNonExemptEncryptionin Info.plist — which this app now does — answers it at upload time and skips the call entirely.- A review submission can reach
READY_FOR_REVIEWwith zero items. Creating one is not submitting anything; the item attach is a separate call that fails independently. Check that/itemsis non-empty before you believe a submission exists. - The Issuer ID is on a web page and in no file and no API. It is printed under Users and Access ▸ Integrations ▸ App Store Connect API. Save it with the key.
-allowProvisioningUpdates on the export creates the distribution certificate
and profile itself. security find-identity listing only Development and
Developer ID does not mean the distribution identity is missing, and the
archive step falling back to "Sign to Run Locally" does not mean the export
will. The build reaching VALID after upload is the only proof that counts.
scripts/appstore-archive.sh --allow-provisioningArchives Release as universal, asserts the things App Store validation would
otherwise fail remotely (sandbox, no get-task-allow, privacy manifest, icon,
category, export-compliance key), exports dist/appstore/export/ItsPaint.pkg
signed for the App Store, and prints the upload instructions.
--validate-only runs the archive and the asserts without needing the team's
certificates.
Screenshots regenerate with scripts/appstore-screenshots.sh into
docs/appstore/ — four 2880×1800 PNGs, which is Apple's 16:10 Retina size.
The fields that do not change per release:
| Field | Value |
|---|---|
| Name | ItsPaint |
| Primary category | Graphics & Design |
| Secondary category | Productivity |
| Price | Free |
| Bundle ID | com.joshlin.itspaint |
| SKU | itspaint |
| Copyright | Josh Lin |
| Locales | en-GB (primary), en-US, zh-Hans, ja, de-DE, fr-FR, ru |
The repository's voice is British English ("colours", "licence"); keep it in
the en-GB listing rather than half-translating it. en-US is the same copy
with American spelling, and exists because Apple indexes name, subtitle and
keywords per locale: a second storefront is a second hundred characters, not
a translation exercise.
A localisation can only be created while a version is in
PREPARE_FOR_SUBMISSION. Apple answers 409 Cannot create localization after the app version has been submitted for review, so a language missed in one
release waits for the next. Screenshots and the App Preview hang off the
localisation as well, not the version, and a language with copy and no media
fails at submission — which arrives at release time looking like a build
problem. Writing to a locale that does not exist is a 404, not a create.
The listing was en-GB alone until 2026-08-25, when Apple's own analytics said
China mainland was the second-largest storefront and Russia the third, both
reading an English page.
The listing copy itself is not transcribed here any more. The subtitle, the
promotional text, the keywords, the description and the support and marketing
URLs are held as data in the release tooling, applied field by field, and
verified by reading the listing back from Apple rather than by trusting the
200.
That is a deliberate deletion, and this is what it cost to learn. This section
held a second copy of all six on 2026-08-17, and every one of them disagreed
with the listing it described: the subtitle was the one retired the day before,
the support URL was the pre-domain host, the marketing URL was this repository
rather than the site, the keyword string was two terms short of the one Apple
was serving, and the description said "Twelve tools" while ToolKind had
thirteen. What it had lost was Clone and Spotlight — and the 0.11.0 release
note further down this same file says "Thirteen tools" correctly, so the file
contradicted itself as well as the app.
None of that was catchable. The repository's number-checker reads markdown and these were quotations inside a document; the listing lives at Apple, which it cannot see at all.
The count is now measured out of ToolKind.swift before the description can be
written anywhere, so a tool added or removed fails the publication rather than
quietly making the store page wrong.
/v1/accessibilityDeclarations — undocumented, and it exists. Apple returns
[] until something is declared, and the product page then says the developer
"hasn't indicated" anything, which is one of the seven things an editor scores.
The schema, recovered by posting wrong values and reading the 409s:
deviceFamilyis required at creation and is the only attribute that can be. Everything else is aPATCH.- The nine features are
supportsVoiceover,supportsVoiceControl,supportsCaptions,supportsAudioDescriptions,supportsDarkInterface,supportsDifferentiateWithoutColorAlone,supportsLargerText,supportsReducedMotion,supportsSufficientContrast. supportsLargerTextcannot be set forMAC— it answers 409 saying so. There is no Dynamic Type on the Mac, and the field knows it.stateis read-only (DRAFT), and cannot be included in an update. A declaration publishes with the next review submission, in the same window everything else does.
Apple's bar is that a user can complete all common tasks with the feature
on. The common task here is drawing, so VoiceOver is declared false on the
strength of the app being drawable, not on the strength of having labels.
supportsDarkInterface is true and grounded in DesignTokens.swift, which
resolves every value to a system semantic colour and carries explicit dark
branches. supportsReducedMotion was false until every animation started
routing through one guard.
Rewritten per release from CHANGELOG.md, in user-facing terms — what changed for someone using the app, not how it was done. 4,000 characters.
0.12.0
Remove Background Image ▸ Remove Background keys the page out from behind your subject in one command — no model, no network, nothing to sign in to. If the image is too flat to key safely it says so and changes nothing, rather than erasing your picture.
Signature capture Tools ▸ Signature… (⌃⌘S). Sign with a trackpad, mouse or tablet, or import a photo of a signature on paper. Either way the ink is keyed to transparent and trimmed to itself, so it lands on the artwork instead of dropping a white patch over it, and it arrives as floating content you position like a paste. Signatures are saved for reuse.
Fixed • Editing an imported image and quitting no longer throws the edit away in silence. Every document now autosaves as the type it was opened as. • Saving a large screenshot no longer needs most of a gigabyte of memory, or holds the window while it works. • Undo history now scales with the canvas instead of reserving 512 MB per document, so a screenshot cannot quietly retain a gigabyte of pixels. • The canvas is no longer copied whole on every repaint, save and export. • Trimming borders is faster, and the canvas shadow no longer re-renders the artwork offscreen to find its own outline. • The Place / Crop / Discard bar no longer lingers over content that undo has already taken away.
Still no account, no telemetry, and no network code at all. The README now carries the three commands that check that against an installed build.
0.11.0 (first release)
First Mac App Store release. Thirteen tools, fifteen shapes, automatic step badges, pixelation, Instant Alpha, and export to nine formats.
scripts/appstore-screenshots.sh writes docs/appstore/01-hero.png through
04-export.png, in that order — 2880×1800, Apple's 16:10 Retina size, fully
opaque. If App Store Connect ever objects to the PNG alpha channel, flatten
with sips -s format jpeg <in> --out <out> and upload the JPEGs.
They are generated, not committed: they show the interface at whatever
version produced them, so a stale set in the repository is worse than none —
it looks current and is not. Regenerate before a submission that changed
anything visible. docs/appstore/ is gitignored.
- "Do you or your third-party partners collect data from this app?" — No, we do not collect data from this app. The label publishes as Data Not Collected.
- Tracking: none.
This is verifiable, not aspirational: the app has no network entitlement, no
URLSession/NWConnection anywhere in the source, and no third-party code.
App/Resources/PrivacyInfo.xcprivacy declares the two Required-Reason APIs
(file timestamps, UserDefaults) with their standard reason codes.
Age rating questionnaire: every category None → rates 4+. Content rights: the app contains no third-party content.
| Check | State |
|---|---|
| App Sandbox | On; only files.user-selected.read-write + app-scoped bookmarks |
get-task-allow |
Stripped from Release (CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO) |
| Privacy manifest | PrivacyInfo.xcprivacy bundled in Resources |
| Private API | None — verified in docs/MAC_ESSENTIALS.md |
| Self-updater | None (no Sparkle; MAS forbids self-update) |
| Third-party dependencies | None |
| Export compliance | ITSAppUsesNonExemptEncryption = false in Info.plist |
| Category | public.app-category.graphics-design in Info.plist |
| Version / build | MARKETING_VERSION and CURRENT_PROJECT_VERSION in project.yml, then xcodegen generate — the committed .xcodeproj is what xcodebuild reads, and a re-upload of the same version needs a new build number |
| Upload toolchain | Xcode 26+ required by Apple since 2026-04-28; 26.6 installed |
| Universal | arm64 + x86_64, asserted by the archive script |
Certificates (Apple Distribution and Mac Installer Distribution, created by
--allow-provisioning), the App ID, the app record, the privacy label,
pricing, availability, age rating, content rights, categories, and the App
Review contact details. None of it needs redoing per version.
Submission does not. Archive, upload, version record, release notes and review submission all run from the command line — that is how 0.13.0 went out. What is left is the part that is a legal declaration rather than an API call:
- EU trader status, if EU distribution matters — App Information ▸ Digital Services Act ▸ Set Up. Identity verification attached to a real person; without it the app cannot be distributed in the EU.
- Trademark clearance. docs/PLAN.md records that the ItsPaint name passed a practical USPTO knock-out search, and that counsel-led clearance was named the gate for an App Store launch. That gate has not been closed.
Release is AFTER_APPROVAL, so an approval puts the version on the store by
itself. This document said "manual" until 0.17.0, when the API was actually
read: asc.py create_version has always sent releaseType: AFTER_APPROVAL, and
every shipped 0.16.x version carries it. Pass MANUAL there, or PATCH the
version, if a release ever needs to be held behind an approval — and check the
field rather than this sentence.
Nothing in this app touches the usual Mac rejection reasons — no updater, no broad file access, no private API, no undeclared data collection.