decePubClient fetches itself into a folder of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
11 KiB
Markdown
216 lines
11 KiB
Markdown
# app-stores
|
|
|
|
Three Gitea Actions for any project's release workflow: `play` publishes to Google Play, `apple` to the App Store,
|
|
and `changelog` writes what changed since the previous release from the commit messages, in every format a release
|
|
needs. Each is one Python file; the store ones talk to the store's own web API (Google's Play Developer API, Apple's
|
|
App Store Connect API). Nothing else is needed on the runner beyond Python 3.9 or newer and `openssl`, which every
|
|
Linux and Mac runner has. No fastlane, no Ruby, no service in between.
|
|
|
|
```yaml
|
|
- uses: https://git.thepra.dev/Arasaka/app-stores/play@v1
|
|
- uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1
|
|
- uses: https://git.thepra.dev/Arasaka/app-stores/changelog@v1
|
|
```
|
|
|
|
> Status: checked against fakes of both stores built from their published API definitions (Google's discovery
|
|
> document of 22 October 2026, Apple's OpenAPI 4.5.1), with every request body, query and signature verified, and
|
|
> called by URL from jobs on both of this Gitea's runners, where both stores refused only the made-up keys. Not yet
|
|
> run against a real store account; the first project to use one is the first real run.
|
|
|
|
## Google Play: `play`
|
|
|
|
Two things it does, one per run:
|
|
|
|
- **Upload**: given `aab`, it uploads the bundle and puts it on `track`.
|
|
- **Move**: given `from-track`, it takes the newest release already on that track and puts it on `track`, with
|
|
its notes unless `notes` is given. This promotes internal testing to production, raises a staged rollout
|
|
(`from-track: production`, `track: production`, a bigger `fraction`), completes it (`status: completed`) or halts
|
|
it (`status: halted`).
|
|
|
|
| Input | | |
|
|
|---|---|---|
|
|
| `service-account` | required | the JSON key of the service account, from a secret |
|
|
| `package` | required | the application id, `software.arasaka.meweb` |
|
|
| `aab` | upload | the signed `.aab` |
|
|
| `from-track` | move | the track to take the release from |
|
|
| `track` | `internal` | `internal`, `alpha` (closed testing), `beta` (open testing), `production` or a custom closed track |
|
|
| `status` | `completed` | `completed`, `draft`, `inProgress` (staged, needs `fraction`) or `halted` |
|
|
| `fraction` | | the share of users of a staged rollout, between 0 and 1 |
|
|
| `notes` | | a folder of `<language>.txt` files, `en-US.txt`, `it-IT.txt`, 500 characters each |
|
|
| `name` | | the release name in Play Console; Google uses the version name when empty |
|
|
|
|
Every tag on the internal testing track:
|
|
|
|
```yaml
|
|
- name: Google Play, internal testing
|
|
if: ${{ github.ref_type == 'tag' }}
|
|
uses: https://git.thepra.dev/Arasaka/app-stores/play@v1
|
|
with:
|
|
service-account: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
|
|
package: software.arasaka.meweb
|
|
aab: dist/android/software.arasaka.meweb-Signed.aab
|
|
notes: store/notes
|
|
```
|
|
|
|
Production by hand, from the Actions tab:
|
|
|
|
```yaml
|
|
name: Google Play production
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
fraction:
|
|
description: "Share of users, 0.1 for 10%; empty for everyone"
|
|
default: "0.1"
|
|
|
|
jobs:
|
|
promote:
|
|
runs-on: nuvola
|
|
steps:
|
|
- uses: https://git.thepra.dev/Arasaka/app-stores/play@v1
|
|
with:
|
|
service-account: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
|
|
package: software.arasaka.meweb
|
|
from-track: internal
|
|
track: production
|
|
status: ${{ inputs.fraction && 'inProgress' || 'completed' }}
|
|
fraction: ${{ inputs.fraction }}
|
|
```
|
|
|
|
## App Store: `apple`
|
|
|
|
- **Upload**: given `ipa`, it reads the version and build number from the app's `Info.plist`, uploads it, waits
|
|
until Apple has processed it (up to an hour and a half), writes what to test from `notes`, and gives it to the
|
|
TestFlight `groups`. A group outside the team (external testers) also sends the build to TestFlight review.
|
|
- **Submit**: with `submit: true`, it sends the build for App Store review: the uploaded one, or the newest
|
|
processed build of `version` (or exactly `build`) when no `ipa` is given. It reuses the version waiting to be
|
|
submitted, or creates it, links the build, writes what is new from `notes`, and submits. With `release:
|
|
after-approval` the version goes on sale when Apple approves it; with `manual` it waits for you in App Store
|
|
Connect. `phased: true` spreads the update over seven days.
|
|
|
|
| Input | | |
|
|
|---|---|---|
|
|
| `issuer-id`, `key-id`, `private-key` | required | the team API key: two ids and the text of the `.p8` file, from secrets |
|
|
| `bundle-id` | required | the app's bundle identifier |
|
|
| `ipa` | upload | the signed `.ipa` |
|
|
| `notes` | | a folder of `<locale>.txt` files, `en-US.txt`, `it.txt`, 4,000 characters each |
|
|
| `groups` | | TestFlight groups by name, separated by commas or lines |
|
|
| `submit` | `false` | `true` sends the build for App Store review |
|
|
| `version`, `build` | submit | which build to submit when no `ipa` is given |
|
|
| `release` | `after-approval` | or `manual` |
|
|
| `phased` | `false` | `true` for a seven-day phased release |
|
|
|
|
The build itself needs a Mac with Xcode; the upload does not. A Mac job can build, sign and run this step itself,
|
|
or hand the `.ipa` to a Linux job that runs it:
|
|
|
|
```yaml
|
|
- name: TestFlight
|
|
uses: https://git.thepra.dev/Arasaka/app-stores/apple@v1
|
|
with:
|
|
issuer-id: ${{ secrets.ASC_ISSUER_ID }}
|
|
key-id: ${{ secrets.ASC_KEY_ID }}
|
|
private-key: ${{ secrets.ASC_PRIVATE_KEY }}
|
|
bundle-id: software.arasaka.meweb
|
|
ipa: dist/ios/MeWeb.ipa
|
|
notes: store/notes
|
|
groups: Team
|
|
```
|
|
|
|
## Once per app, by hand
|
|
|
|
Neither store lets an API create an app, so the first steps happen in their consoles.
|
|
|
|
**Google Play**
|
|
|
|
1. A Play Console developer account ($25 once). A personal account opened after 13 November 2023 must run a closed
|
|
test with at least 12 testers for 14 days before it can publish to production; an organisation account needs a
|
|
D-U-N-S number.
|
|
2. Create the app and fill in what Play Console asks before a first release: the store listing, the content rating
|
|
questionnaire, the target audience, data safety and a privacy policy address.
|
|
3. Upload the first `.aab` by hand, on internal testing. Play asks then which key signs the app for users (Play
|
|
App Signing). To keep a copy installed from a website and the Play copy updating each other, give Play the key
|
|
the website APK is already signed with, rather than letting Google generate a new one. The choice is permanent.
|
|
4. In Google Cloud, create a project, enable the **Google Play Android Developer API**, create a service account,
|
|
and make a JSON key for it. Store the whole file as a secret (`PLAY_SERVICE_ACCOUNT`).
|
|
5. In Play Console, Users and permissions, invite the service account's email address with release rights for
|
|
this app (testing tracks, and production if a workflow promotes to it).
|
|
6. Until the app's first release is published by hand in Play Console, Google accepts only `status: draft`.
|
|
|
|
**App Store**
|
|
|
|
1. The Apple Developer Program ($99 a year).
|
|
2. Register the bundle identifier (Certificates, Identifiers and Profiles) and create the app in App Store Connect.
|
|
3. Fill in the App Privacy answers, the age rating, the screenshots, the description and the support address for
|
|
the first version.
|
|
4. Users and Access, Integrations, App Store Connect API: generate a **team key** with the App Manager role. Store
|
|
the issuer id, the key id and the text of the `.p8` file (downloadable once) as three secrets.
|
|
5. Put `ITSAppUsesNonExemptEncryption` in the app's `Info.plist`. Without it every build waits for an export
|
|
compliance answer in App Store Connect, so the action refuses the upload.
|
|
6. Create the TestFlight groups. An external group needs the Beta App Review information (contact and notes)
|
|
filled in once.
|
|
|
|
## Release notes: `changelog`
|
|
|
|
**The commit messages are the changelog** (owner, 1 November 2026: every commit between two releases, the whole
|
|
message, in every place a release is shown). The action takes the commits since the previous tag of the same kind
|
|
(`prefix`: `v`, `app-v`, `server-v`; the previous one is the highest lower version among those tags), leaves out merges
|
|
and the `Co-Authored-By`, `Signed-off-by` and Claude attribution lines, and writes, into `output`:
|
|
|
|
| File | For |
|
|
|---|---|
|
|
| `notes.md` | "What's new in <name> <version>": each commit's subject as a heading, its body below; the Releases tab, a page |
|
|
| `notes.txt` | the same as plain text: Velopack's `--releaseNotes`, an update dialog, F-Droid |
|
|
| `CHANGELOG.md` | the last `history` releases, newest first |
|
|
| `changelog.yml` | the same releases for nfpm's `changelog:` (chglog), so the deb carries `changelog.Debian.gz` and the rpm its `%changelog`; bodies become one line per paragraph |
|
|
| `notes.json` | everything above as data |
|
|
| `store/en-US.txt` | the subjects, cut to 500 characters, for the `notes` folder of `play` and `apple` |
|
|
|
|
With `release: 'true'` it also writes the notes onto this tag's release on the Releases tab: it creates the release
|
|
when there is none, and otherwise replaces only what sits between `<!-- changelog -->` and `<!-- /changelog -->`
|
|
(adding that block at the end the first time), so a release text written by the project itself stays. Give the job
|
|
`permissions: contents: write`.
|
|
|
|
```yaml
|
|
- uses: actions/checkout@v4
|
|
- name: Release notes
|
|
id: notes
|
|
uses: https://git.thepra.dev/Arasaka/app-stores/changelog@v1
|
|
with:
|
|
prefix: app-v
|
|
name: SimpleSchool
|
|
packager: Scuola di lingua Cinese di Varese <info@scuolacinese.org>
|
|
output: release-notes
|
|
release: 'true'
|
|
- run: echo "the notes are in ${{ steps.notes.outputs.dir }}, the previous release was ${{ steps.notes.outputs.previous }}"
|
|
```
|
|
|
|
| Input | | |
|
|
|---|---|---|
|
|
| `path` | `.` | the git checkout to read, when it is not the workspace itself |
|
|
| `prefix` | `v` | the tags of this kind of release |
|
|
| `tag` | the tag that started the run | the release being made; empty on a branch, which writes everything since the newest tag as `unreleased` |
|
|
| `name` | the repository's name | the product's name in headings |
|
|
| `strip` | | a regular expression removed from the start of each subject (me-web? removes its `“me-web?” 0.15.4: `) |
|
|
| `packager` | `Arasaka di Hvozdikov Yevheniy <https://arasaka.software/contact>` | `Name <address>` for the deb and rpm changelogs: the package's own maintainer |
|
|
| `history` | `20` | how many releases `CHANGELOG.md` and the package changelog carry |
|
|
| `output` | `release-notes` | the folder |
|
|
| `release` | `false` | `true` writes the notes onto the Releases tab |
|
|
| `token` | the job token | only with `release` |
|
|
|
|
A shallow checkout is fetched in full, with its tags, before anything is read. A first release lists its newest 100
|
|
commits. The same file reads the history from Gitea's API instead of a checkout (`--gitea URL --repository
|
|
owner/name`, token in `READ_TOKEN`); that is how `thepra/packages.arasaka.software` writes the notes for every app it
|
|
publishes, so the two can never tell a release differently.
|
|
|
|
## Store notes
|
|
|
|
One file per language, the file name being the code: Google uses language and region (`en-US`, `it-IT`), Apple
|
|
its own list (`en-US`, `it`, `de-DE`). Empty files are skipped. Apple takes no release notes for an app's first
|
|
version; the action leaves them out and says so.
|
|
|
|
## Versions
|
|
|
|
`v1` moves forward with every change that keeps the inputs working; a change that breaks them becomes `v2`. Pin a
|
|
commit instead of `v1` to never move.
|