diff --git a/.eas/workflows/build-android-production.yml b/.eas/workflows/build-android-production.yml index e87dbf6..8a79247 100644 --- a/.eas/workflows/build-android-production.yml +++ b/.eas/workflows/build-android-production.yml @@ -16,11 +16,20 @@ jobs: params: platform: android profile: production - # To auto-upload to Google Play after the build, add a submit job once the - # Play service-account JSON is configured in eas.json (submit.production): - # submit_android: - # needs: [build_android] - # type: submit - # params: - # platform: android - # profile: production + + submit_android: + name: Submit to Google Play + needs: [build_android] + type: submit + environment: production + params: + build_id: ${{ needs.build_android.outputs.build_id }} + profile: production + hooks: + after_submit: + - name: Publish Play Store release notes + run: | + set -euo pipefail + printf '%s' "$GOOGLE_SERVICE_ACCOUNT_JSON" > "$GOOGLE_SERVICE_ACCOUNT_KEY_PATH" + chmod 600 "$GOOGLE_SERVICE_ACCOUNT_KEY_PATH" + node scripts/play-release-notes.mjs diff --git a/AGENTS.md b/AGENTS.md index 2eac0e6..d4e713e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,9 @@ - Web: `bun run web` - Preview cloud build: `bun run build:android:preview` - Production cloud build: `bun run build:android:production` -- Production submit: `bun run submit:android:production` (Google Play submission is currently performed manually) -- Build from GitHub: EAS Workflow `.eas/workflows/build-android-production.yml` (Android production), triggered on a version tag push (`v*`) or manual dispatch only +- Production submit: `bun run submit:android:production` (manual fallback; the tag workflow submits a draft automatically) +- Build from GitHub: EAS Workflow `.eas/workflows/build-android-production.yml` (Android production build, Google Play draft submission and release-notes promotion), triggered on a version tag push (`v*`) or manual dispatch only +- Play Store release notes: `store/play/release-notes//it-IT.txt` and `en-US.txt`, short and user-facing, at most 500 characters each. These are separate from the technical `CHANGELOG.md`; a release without valid notes stays a Play Console draft. - Android bundle export check: `bunx expo export --platform android --output-dir C:\tmp\keysoft-android-export` - Lint: `bun run lint` - Typecheck: `bun run typecheck` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8574d88..6588cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable project changes are documented here. ## [Unreleased] +### Release + +- Rebuilt Keysoft 3.3.2 from `main` HEAD `8c893ea` as versionCode 132 with `targetSdkVersion` + 36 and submitted it to the Google Play production track; Play review is pending while + 3.3.2/131 remains live. +- Automates Google Play release submission from the version-tag EAS workflow: the build is + uploaded as a draft, then `scripts/play-release-notes.mjs` adds the Play Store release notes + and promotes it to a completed production release. Play Store notes live under + `store/play/release-notes//` and are short, user-facing and separate from this + technical changelog. + ## [3.3.2] - 2026-10-23 ### Release diff --git a/README.md b/README.md index e629297..890ea3e 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ bun run deps:audit ``` `bun run verify` runs formatting, type checking, linting, tests with coverage and -`expo-doctor`. Release artifacts are produced through EAS; build, submission, tag and release +`expo-doctor`. Release artifacts are produced through EAS; a version tag builds, submits to +Google Play and publishes the release notes automatically. Build, submission, tag and release operations are documented in the [development guide](docs/development.md) and the [release guide](docs/release.md). Architecture notes live in [docs/architecture.md](docs/architecture.md). diff --git a/docs/development.md b/docs/development.md index 73d66f3..9354d4a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -180,13 +180,14 @@ bun run build:android:preview bun run build:android:production ``` -The repository is also linked to EAS Build, and the EAS Workflow `.eas/workflows/build-android-production.yml` can build the Android production app-bundle from GitHub. It is triggered only by a version tag push (`v*`) or manual dispatch to limit build-credit usage; it does not run on every push. iOS remains available only through the explicitly approved cloud-simulator workflow; App Store publication is outside the project workflow. +The repository is also linked to EAS Build, and the EAS Workflow `.eas/workflows/build-android-production.yml` builds the Android production app bundle from GitHub. It is triggered only by a version tag push (`v*`) or manual dispatch to limit build-credit usage; it does not run on every push. The same workflow submits the build to Google Play as a draft and then runs `scripts/play-release-notes.mjs` to add the Play Store release notes and promote it. iOS remains available only through the explicitly approved cloud-simulator workflow; App Store publication is outside the project workflow. When publishing an approved release, push and verify `main` first, wait for the GitHub -`Validate` and CodeQL checks, then create the version tag on that exact commit. The tag push -already starts the EAS production workflow; do not also run `bun run -build:android:production` unless the tag-triggered workflow failed to start and a deliberate -replacement build is approved. +`Validate` and CodeQL checks, add the short Play Store notes under +`store/play/release-notes//`, then create the version tag on that exact commit. The tag +push starts the EAS production workflow, build, draft submission and notes promotion; do not +also run `bun run build:android:production` unless the tag-triggered workflow failed to start +and a deliberate replacement build is approved. Expo Go cannot load custom native modules. Keysoft therefore uses the PBKDF2 KDF fallback in Expo Go and keeps Argon2 for EAS/native builds where `react-native-argon2` is available. diff --git a/docs/release.md b/docs/release.md index 4778f2f..d969282 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,11 +2,12 @@ ## Release Readiness Checklist -Current source release and Android build target: Keysoft 3.3.2, versionCode 131. Google Play -currently distributes Keysoft 3.3.1, versionCode 130, until the new app bundle completes -review and is submitted manually. EAS remote versioning remains authoritative for Android -builds. iOS remains a cloud-simulator compatibility target and is not an App Store release -target. +Current source release: Keysoft 3.3.2. The local manifest mirrors versionCode 131; the Android +16 compliance rebuild produced EAS remote versionCode 132 from `main` HEAD and is in Play +review. Google Play serves 3.3.2/131 until 3.3.2/132 is approved. EAS remote versioning remains +authoritative for Android builds, and the same workflow now submits the next release to Google +Play automatically. iOS remains a cloud-simulator compatibility target and is not an App Store +release target. Before preparing a release: @@ -42,11 +43,12 @@ Application version data is maintained in: - Android native configuration where applicable For the 3.3.2 source release, `app.config.js` uses `version: "3.3.2"`, -`android.versionCode: 131`, and an iOS simulator baseline of `ios.buildNumber: "1"`. Before -the build, the EAS remote Android value is 130; the production profile auto-increments it to -131. The local value mirrors the target for manifest visibility but does not control the EAS -counter. Because EAS Update uses the `appVersion` runtime policy, this release uses native -runtime 3.3.2. Google Play remains on 3.3.1/130 until manual submission and publication. +`android.versionCode: 131`, and an iOS simulator baseline of `ios.buildNumber: "1"`. The local +value mirrors the target for manifest visibility but does not control the EAS counter. Because +EAS Update uses the `appVersion` runtime policy, this release uses native runtime 3.3.2. The +Android 16 compliance rebuild produced remote versionCode 132 from `main` HEAD; Google Play +serves 3.3.2/131 until 3.3.2/132 is approved. The production workflow increments the remote +Android value on every build and submits the matching app bundle to Google Play. Read the current remote value with `bunx eas-cli build:version:get --platform android --profile production --non-interactive` @@ -102,12 +104,36 @@ The artifact can then be tested in Appetize. App Store signing, submission metad ### Google Play Submission -Google Play submission is currently performed manually (upload the app-bundle in the Play Console). To automate it later, configure a Google service-account key in `eas.json` (`submit.production`) and run: +The production EAS workflow is the normal release path. A matching version tag builds the +Android app bundle and submits it to Google Play as a **draft** with EAS Submit. The +`after_submit` hook then runs `scripts/play-release-notes.mjs`, which adds the Play Store +release notes and promotes the draft to a completed production release. A release without +valid notes stays in draft and the workflow fails. + +The workflow needs these EAS project environment variables in `production`: + +- `GOOGLE_SERVICE_ACCOUNT_JSON`: the Google service account key, stored as a secret. +- `GOOGLE_SERVICE_ACCOUNT_KEY_PATH`: a temporary path where the workflow writes the key. + +EAS Submit also needs the same service account assigned under the Android application +identifier in the project credentials. + +Play Store release notes are written for a non-technical audience and live in +`store/play/release-notes//` as `it-IT.txt` and `en-US.txt`, one short paragraph per +language, at most 500 characters each. Keep `CHANGELOG.md` technical for GitHub readers; the +Play Store text is separate. See +[`store/play/release-notes/README.md`](../store/play/release-notes/README.md) for the format +and examples. + +The manual fallback is: ```bash bun run submit:android:production ``` +It uses the same draft submit profile and still requires the matching release notes before the +release can be promoted. Do not run it while the tag-triggered workflow is in progress. + The production EAS workflow normally runs for every matching version tag. When backfilling a GitHub tag for an artifact that is already built and published, put `[eas skip]` in the tagged commit message to prevent a duplicate cloud diff --git a/docs/releases/3.3.2.md b/docs/releases/3.3.2.md index 1faba60..7dfe884 100644 --- a/docs/releases/3.3.2.md +++ b/docs/releases/3.3.2.md @@ -65,3 +65,11 @@ The `v3.3.2` tag identifies the exact source used to start the EAS Android produ The EAS production profile produces an Android App Bundle and auto-increments the remote version from 130 to 131. It does not submit to Google Play; store review and publication remain manual. + +## Rebuild 132 + +On 2026-11-01 the release was rebuilt from `main` HEAD `8c893ea` as versionCode 132 with +`targetSdkVersion` 36, to satisfy Google Play's Android 16 target-API requirement. The build +was submitted to the production track with EAS Submit and the Play Store release notes were +applied; review is pending. Google Play continues to serve versionCode 131 until 132 is +approved. diff --git a/eas.json b/eas.json index efb25bf..74db4c9 100644 --- a/eas.json +++ b/eas.json @@ -42,6 +42,12 @@ } }, "submit": { - "production": {} + "production": { + "android": { + "track": "production", + "releaseStatus": "draft", + "changesNotSentForReview": false + } + } } } diff --git a/scripts/play-release-notes.mjs b/scripts/play-release-notes.mjs new file mode 100644 index 0000000..701e329 --- /dev/null +++ b/scripts/play-release-notes.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * Publish the short, user-facing Play Store release notes for the version in + * package.json. EAS Submit uploads the bundle as a draft; this script adds the + * notes and promotes the draft to a completed production release in one edit. + * + * The service account needs release permissions on the app. The key is read + * from GOOGLE_SERVICE_ACCOUNT_KEY_PATH, GOOGLE_SERVICE_ACCOUNT_JSON, or + * --key-file. Release notes live in store/play/release-notes// and + * are split by Play locale, for example it-IT.txt and en-US.txt. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Buffer } from "node:buffer"; + +const MAX_RELEASE_NOTES_LENGTH = 500; +const DEFAULT_PACKAGE_NAME = "it.mikesoft.keysoft"; +const SCOPE = "https://www.googleapis.com/auth/androidpublisher"; + +function parseArgs(argv) { + const result = { dryRun: false, keyFile: null, packageName: DEFAULT_PACKAGE_NAME }; + for (let i = 0; i < argv.length; i += 1) { + const value = argv[i]; + if (value === "--dry-run") { + result.dryRun = true; + } else if (value === "--key-file") { + result.keyFile = argv[++i]; + } else if (value === "--package") { + result.packageName = argv[++i]; + } else { + throw new Error(`Unknown argument: ${value}`); + } + } + return result; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function loadServiceAccount(args) { + const keyFile = + args.keyFile ?? + process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH?.trim() ?? + null; + + if (keyFile) { + return readJson(keyFile); + } + + const inline = process.env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim(); + if (!inline) { + throw new Error( + "No Google service account key. Set GOOGLE_SERVICE_ACCOUNT_KEY_PATH, GOOGLE_SERVICE_ACCOUNT_JSON, or pass --key-file.", + ); + } + return JSON.parse(inline); +} + +function loadReleaseNotes(version, rootDir) { + const notesDir = path.join(rootDir, "store", "play", "release-notes", version); + if (!fs.existsSync(notesDir)) { + throw new Error( + `Missing Play Store release notes for ${version}: expected ${path.relative(rootDir, notesDir)}.`, + ); + } + + const files = fs + .readdirSync(notesDir) + .filter((file) => file.endsWith(".txt")) + .sort(); + + if (files.length === 0) { + throw new Error(`No .txt release note files in ${path.relative(rootDir, notesDir)}.`); + } + + const notes = files.map((file) => { + const locale = path.basename(file, ".txt"); + const text = fs.readFileSync(path.join(notesDir, file), "utf8").replace(/\r\n/g, "\n").trim(); + const length = [...text].length; + if (length === 0) { + throw new Error(`Release note ${file} is empty.`); + } + if (length > MAX_RELEASE_NOTES_LENGTH) { + throw new Error( + `Release note ${file} is ${length} characters; Google Play accepts at most ${MAX_RELEASE_NOTES_LENGTH}.`, + ); + } + return { language: locale, text }; + }); + + const languages = new Set(notes.map((note) => note.language)); + if (!languages.has("it-IT") || !languages.has("en-US")) { + throw new Error("Play Store release notes must include it-IT.txt and en-US.txt."); + } + + return notes; +} + +function base64Url(value) { + return Buffer.from(value).toString("base64url"); +} + +async function getAccessToken(account) { + if (!account.client_email || !account.private_key) { + throw new Error("The service account key is missing client_email or private_key."); + } + + const now = Math.floor(Date.now() / 1000); + const header = base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const claims = base64Url( + JSON.stringify({ + iss: account.client_email, + scope: SCOPE, + aud: account.token_uri ?? "https://oauth2.googleapis.com/token", + iat: now, + exp: now + 3600, + }), + ); + const signature = crypto + .createSign("RSA-SHA256") + .update(`${header}.${claims}`) + .sign(account.private_key, "base64url"); + + const response = await fetch(account.token_uri ?? "https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: `${header}.${claims}.${signature}`, + }), + }); + + if (!response.ok) { + throw new Error(`Google token exchange failed with HTTP ${response.status}.`); + } + + const payload = await response.json(); + if (!payload.access_token) { + throw new Error("Google token exchange returned no access token."); + } + return payload.access_token; +} + +function apiUrl(packageName, suffix) { + return `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(packageName)}${suffix}`; +} + +async function apiRequest(token, method, url, body) { + const response = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`Google Play API ${method} ${url} failed with HTTP ${response.status}: ${detail.slice(0, 500)}`); + } + + if (response.status === 204) { + return null; + } + return response.json().catch(() => null); +} + +function versionCodeOf(release) { + return Math.max(0, ...(release.versionCodes ?? []).map((value) => Number(value))); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const rootDir = process.cwd(); + const version = readJson(path.join(rootDir, "package.json")).version; + + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`package.json version must be stable SemVer, got ${version}.`); + } + + const notes = loadReleaseNotes(version, rootDir); + const account = loadServiceAccount(args); + const token = await getAccessToken(account); + + const edit = await apiRequest(token, "POST", apiUrl(args.packageName, "/edits"), {}); + const editId = edit?.id; + if (!editId) { + throw new Error("Google Play did not return an edit id."); + } + + try { + const track = await apiRequest( + token, + "GET", + apiUrl(args.packageName, `/edits/${encodeURIComponent(editId)}/tracks/production`), + ); + + const releases = Array.isArray(track?.releases) ? track.releases : []; + const latest = releases.reduce((best, release) => { + if (!best) return release; + return versionCodeOf(release) > versionCodeOf(best) ? release : best; + }, null); + + if (!latest) { + throw new Error("No production releases found on the production track."); + } + + const versionCode = versionCodeOf(latest); + if (!versionCode) { + throw new Error("The latest production release has no version code."); + } + + const wasDraft = latest.status === "draft"; + const plan = { + version, + versionCode, + statusBefore: latest.status, + statusAfter: wasDraft ? "completed" : latest.status, + languages: notes.map((note) => `${note.language}:${[...note.text].length}`), + dryRun: args.dryRun, + }; + + if (args.dryRun) { + console.log(JSON.stringify(plan, null, 2)); + await apiRequest(token, "DELETE", apiUrl(args.packageName, `/edits/${encodeURIComponent(editId)}`)).catch( + () => undefined, + ); + return; + } + + latest.releaseNotes = notes; + if (wasDraft) { + latest.status = "completed"; + } + + // Google Play permits only one completed release per track. Replacing the + // release list with the target release removes the previous completed + // release when a draft is promoted. + track.releases = [latest]; + await apiRequest( + token, + "PUT", + apiUrl(args.packageName, `/edits/${encodeURIComponent(editId)}/tracks/production`), + track, + ); + await apiRequest(token, "POST", apiUrl(args.packageName, `/edits/${encodeURIComponent(editId)}:commit`)); + + console.log(JSON.stringify(plan, null, 2)); + } catch (error) { + await apiRequest(token, "DELETE", apiUrl(args.packageName, `/edits/${encodeURIComponent(editId)}`)).catch( + () => undefined, + ); + throw error; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/store/play/release-notes/3.3.2/en-US.txt b/store/play/release-notes/3.3.2/en-US.txt new file mode 100644 index 0000000..79a720b --- /dev/null +++ b/store/play/release-notes/3.3.2/en-US.txt @@ -0,0 +1,2 @@ +This release unlocks the app faster with native protection and makes backup and restore +clearer. We also refreshed the app's look and improved accessibility. diff --git a/store/play/release-notes/3.3.2/it-IT.txt b/store/play/release-notes/3.3.2/it-IT.txt new file mode 100644 index 0000000..380ff00 --- /dev/null +++ b/store/play/release-notes/3.3.2/it-IT.txt @@ -0,0 +1,2 @@ +Questa versione sblocca l'app più rapidamente con la protezione nativa e rende più chiari +backup e ripristino. Abbiamo anche rinnovato l'aspetto dell'app e migliorato l'accessibilità. diff --git a/store/play/release-notes/README.md b/store/play/release-notes/README.md new file mode 100644 index 0000000..11222fe --- /dev/null +++ b/store/play/release-notes/README.md @@ -0,0 +1,50 @@ +# Play Store release notes + +These notes appear on Google Play and are written for people who use the app, not for +developers. They are separate from the technical `CHANGELOG.md` on GitHub. + +## Format + +Create one folder per release, named exactly like the version in `package.json`: + +``` +store/play/release-notes/3.3.3/ + it-IT.txt + en-US.txt +``` + +Both files are required. Each file must be plain text, between 1 and 500 characters, +including spaces. Google Play rejects longer notes. + +## Writing rules + +- Explain what changed for the user, in one or two short sentences. +- Use everyday language: no class names, APIs, build tools, version codes or commit jargon. +- Prefer concrete benefits: easier, faster, safer, clearer. +- Mention fixes only in general terms unless the fix is the main point of the release. +- Do not include links, support addresses, emoji or marketing claims. +- Keep Italian and English aligned in meaning; translate rather than rewriting separately. +- A release without valid notes fails the publishing workflow before the release leaves draft. + +## Example + +`it-IT.txt` + +``` +Questa versione rende più semplice trovare e organizzare le password. Abbiamo anche +migliorato la stabilità e corretto alcuni problemi segnalati. +``` + +`en-US.txt` + +``` +This release makes it easier to find and organize your passwords. We also improved +stability and fixed several reported issues. +``` + +## Where the workflow uses them + +EAS Submit uploads the production bundle as a draft. The `Release` workflow then runs +`scripts/play-release-notes.mjs`, which adds these notes and promotes the draft to a +completed production release. If the notes are missing or invalid, the workflow stops +and the release remains a draft. diff --git a/store/play/release-notes/_template/en-US.txt b/store/play/release-notes/_template/en-US.txt new file mode 100644 index 0000000..dce3a37 --- /dev/null +++ b/store/play/release-notes/_template/en-US.txt @@ -0,0 +1,2 @@ +This release makes it easier to find and organize your passwords. We also improved +stability and fixed several reported issues. diff --git a/store/play/release-notes/_template/it-IT.txt b/store/play/release-notes/_template/it-IT.txt new file mode 100644 index 0000000..2d781b5 --- /dev/null +++ b/store/play/release-notes/_template/it-IT.txt @@ -0,0 +1,2 @@ +Questa versione rende più semplice trovare e organizzare le password. Abbiamo anche +migliorato la stabilità e corretto alcuni problemi segnalati.