From d044218c6aa2241fc91ebabbc4a89f3c8b3e4fcb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 11 May 2026 13:13:33 -0700 Subject: [PATCH] Use a release-summary paragraph for App Store, Play Store, and the in-app toast Each CHANGELOG.md release section now begins with a single plaintext paragraph (max ~500 chars) before any `### Category` heading. That paragraph drives the release blurb in three storefronts and the in-app version-update toast, so we no longer ship a marketing-grade description in one place and a raw bullet list in another. scripts/extract-release-notes.mjs is the single source of truth for extraction. It emits the full section (summary + lists) by default and only the summary paragraph with --summary, with a `Ditto vX.Y.Z` fallback for legacy entries that have no summary. CI changes: - New `release-notes` job (build stage, default node:22 image) produces `artifacts/release-notes.md` and `artifacts/release-notes-summary.txt` once per pipeline. - `release` job pulls release-notes.md as the GitLab Release description (replaces the old inline awk extraction). It now uses `needs:` with `artifacts: false` for build-apk/build-ipa to avoid re-downloading the .apk/.aab/.ipa it doesn't open. - `publish-app-store` copies release-notes-summary.txt to `ios/fastlane/metadata/en-US/release_notes.txt` (replaces its own awk extraction). - `publish-google-play` drops `--skip_upload_changelogs`, writes the summary to `android/fastlane/metadata/android/en-US/changelogs/.txt` and points fastlane supply at `--metadata_path`. This is the first time we upload a What's New text to the Play Store from CI. App-side changes: - `src/lib/changelog.ts` parser captures the leading non-blank paragraph (before any bullet or category heading) into `entry.summary`. - `VersionCheck.tsx` toast uses `entry.summary` when present, falling back to the legacy 60-char first-bullet excerpt for backward compatibility. - `ChangelogPage` renders the summary as a lede paragraph above the bullet list in both LatestRelease and ChangelogEntryCard. Changelog content: - Added summary paragraphs to v2.14.3, v2.14.2, v2.14.1. Skill + AGENTS.md updates: - `release` skill documents the summary paragraph format, the 500-char convention, and the seven-job pipeline. - `ci-cd-publishing` skill gains a 'Release notes pipeline' section mapping each storefront to its source artifact. - AGENTS.md pipeline summary mentions release-notes and the summary flow into both store "What's new" fields. --- .agents/skills/ci-cd-publishing/SKILL.md | 38 ++++- .agents/skills/release/SKILL.md | 40 +++-- .gitlab-ci.yml | 96 ++++++++---- AGENTS.md | 6 +- CHANGELOG.md | 6 + ios/fastlane/metadata/en-US/release_notes.txt | 2 +- public/CHANGELOG.md | 6 + scripts/extract-release-notes.mjs | 141 ++++++++++++++++++ src/components/VersionCheck.tsx | 7 +- src/lib/changelog.ts | 33 +++- src/pages/ChangelogPage.tsx | 10 ++ 11 files changed, 333 insertions(+), 52 deletions(-) create mode 100755 scripts/extract-release-notes.mjs diff --git a/.agents/skills/ci-cd-publishing/SKILL.md b/.agents/skills/ci-cd-publishing/SKILL.md index a35855e4..5eae3041 100644 --- a/.agents/skills/ci-cd-publishing/SKILL.md +++ b/.agents/skills/ci-cd-publishing/SKILL.md @@ -153,7 +153,8 @@ The `publish-google-play` CI job uploads Android AABs to [Google Play](https://p - The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles. - Uploads go directly to the **production** track. Google's review process still applies before the update reaches users. -- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.). +- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`). +- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below. - The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`). ## App Store Publishing @@ -161,7 +162,7 @@ The `publish-google-play` CI job uploads Android AABs to [Google Play](https://p Ditto's iOS pipeline is split across two jobs: - **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs. -- **`publish-app-store`** (stage `publish`, `image: ruby:3.3` on a shared Linux runner) consumes the IPA artifact via `needs: [build-ipa]`. Installs fastlane via `gem install`, decodes the API key, extracts the changelog section for the tag into `release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + select the prebuilt build + auto-submit for App Store review. No Xcode required, no signing in this job — it's just an Apple API call. +- **`publish-app-store`** (stage `publish`, `image: ruby:3.3` on a shared Linux runner) consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Installs fastlane via `gem install`, decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + select the prebuilt build + auto-submit for App Store review. No Xcode required, no signing in this job — it's just an Apple API call. The Mac runner is therefore only used for `build-ipa`. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill. @@ -174,7 +175,7 @@ The Mac runner is therefore only used for `build-ipa`. For runner administration - `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill. - `ios/fastlane/Appfile` — bundle identifier and team ID - `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo -- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it from `CHANGELOG.md` per release +- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release - `.gitlab-ci.yml` — `build-ipa` (Mac runner, `tags: [macos]`) + `publish-app-store` (Linux runner) **Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`. @@ -183,7 +184,7 @@ The Mac runner is therefore only used for `build-ipa`. For runner administration **Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`. -**Release notes**: extracted from `CHANGELOG.md` per tag using the same `awk` extraction as the GitLab `release` job, written to `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. +**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below. **IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile). @@ -317,6 +318,33 @@ App Store Connect API keys can be revoked anytime. To rotate: - `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (Linux) submits it to Apple via `deliver`. - Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI. - Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`. -- Release notes ("What's New in This Version") are auto-extracted from `CHANGELOG.md` and uploaded by `deliver`. +- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below). - `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in. - `publish-app-store` doesn't sign anything, so it doesn't need macOS or a keychain — pure Apple API call. + +## Release notes pipeline + +Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`. + +**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts: + +- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description. +- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph. + +**Downstream consumers** all pull from the `release-notes` job via `needs:`: + +| Consumer | Job | Artifact used | +|---|---|---| +| GitLab Release description | `release` | `release-notes.md` | +| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` | +| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/.txt` → uploaded by `supply` | +| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) | + +**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build. + +**To preview locally** what each storefront will receive: + +```bash +node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body +node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb +``` diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index bc560aea..97ce18c2 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading ```markdown ## [X.Y.Z] - YYYY-MM-DD +A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast. + ### Added - Description of new features @@ -100,6 +102,24 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading - Description of removed features ``` +#### The Summary Paragraph + +Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences: + +- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text. +- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer. +- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below. +- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below). +- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones. + +The same paragraph is used in three places automatically: +- **App Store** -- "What's New in This Version" via fastlane `deliver` +- **Google Play** -- "What's new in this version" via fastlane `supply` `metadata/android//changelogs/.txt` +- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`) +- The full section (summary + lists) goes into the GitLab Release description. + +Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy. + #### Changelog Quality Checklist Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes. @@ -266,10 +286,11 @@ git push origin main vX.Y.Z This triggers the GitLab CI pipeline which will: 1. Build a signed Android APK and AAB 2. Build a signed iOS IPA on the self-hosted Mac runner -3. Create a GitLab Release with APK / AAB / IPA download links -4. Publish the APK to Zapstore -5. Publish the AAB to Google Play (production track) -6. Submit the iOS IPA to App Store Connect for review +3. Extract release notes (full body + summary paragraph) from `CHANGELOG.md` +4. Create a GitLab Release with APK / AAB / IPA download links +5. Publish the APK to Zapstore +6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text +7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text ### Step 12: Confirm @@ -290,14 +311,15 @@ After pushing, inform the user: ## CI Pipeline -The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs six jobs: +The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs: 1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build 2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs -3. **release**: Creates a GitLab Release with the changelog content and APK / AAB / IPA download links -4. **publish-zapstore**: Publishes the APK to Zapstore -5. **publish-google-play**: Uploads the AAB to Google Play production track -6. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review (runs on a shared Linux runner; no Xcode needed since the IPA is already built). The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill. +3. **release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play` +4. **release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links +5. **publish-zapstore**: Publishes the APK to Zapstore +6. **publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/.txt` +7. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text (runs on a shared Linux runner; no Xcode needed since the IPA is already built). The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill. ## Troubleshooting diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 16b30301..8be353a7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -73,6 +73,39 @@ build-web: paths: - dist/ +release-notes: + stage: build + timeout: 2 minutes + needs: [] + rules: + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ + script: + # Extract release notes from CHANGELOG.md for this tag. + # release-notes.md is the full section (summary + bulleted lists), used as + # the GitLab Release description. release-notes-summary.txt is the leading + # plaintext paragraph only, used as the App Store / Play Store release + # blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary. + - mkdir -p artifacts + - node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md + - node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt + - echo "--- release-notes.md ---" + - cat artifacts/release-notes.md + - echo "--- release-notes-summary.txt (length $(wc -c < artifacts/release-notes-summary.txt)) ---" + - cat artifacts/release-notes-summary.txt + - echo "------------------------" + # Warn (don't fail) when the summary exceeds the documented 500-character + # limit so the user spots it before App Store / Play Store reject the upload. + - | + SUMMARY_LEN=$(wc -c < artifacts/release-notes-summary.txt) + if [ "$SUMMARY_LEN" -gt 501 ]; then + echo "WARNING: release-notes-summary.txt is $SUMMARY_LEN bytes; convention is <=500." + fi + artifacts: + paths: + - artifacts/release-notes.md + - artifacts/release-notes-summary.txt + expire_in: 90 days + build-apk: stage: build image: eclipse-temurin:21-jdk @@ -254,25 +287,24 @@ release: stage: release image: registry.gitlab.com/gitlab-org/release-cli:latest needs: - - build-apk - - build-ipa + - job: build-apk + artifacts: false + - job: build-ipa + artifacts: false + - job: release-notes + artifacts: true rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ script: - echo "Creating release for $CI_COMMIT_TAG" - # Extract the latest changelog section for the release description. - # Reads from "## [version]" to the next "## [" or end of file. - - | - VERSION="${CI_COMMIT_TAG#v}" - RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md) - if [ -z "$RELEASE_NOTES" ]; then - RELEASE_NOTES="Ditto ${CI_COMMIT_TAG}" - fi - - echo "$RELEASE_NOTES" > release-notes.md + - test -f artifacts/release-notes.md + - echo "--- release-notes.md ---" + - cat artifacts/release-notes.md + - echo "------------------------" release: tag_name: $CI_COMMIT_TAG name: $CI_COMMIT_TAG - description: './release-notes.md' + description: './artifacts/release-notes.md' assets: links: - name: Ditto-${CI_COMMIT_TAG}.apk @@ -315,7 +347,10 @@ publish-google-play: stage: publish image: ruby:3.3 needs: - - build-apk + - job: build-apk + artifacts: true + - job: release-notes + artifacts: true rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ script: @@ -324,15 +359,27 @@ publish-google-play: # Decode base64-encoded service account JSON to a temp file - echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json - # Upload the AAB to Google Play production track + # Build the fastlane supply metadata layout for the changelog. + # supply maps changelogs/.txt to the Play Console "What's + # new in this version" field. versionCode matches what build-apk stamped + # into build.gradle (= CI_PIPELINE_IID). + - VERSION_CODE="${CI_PIPELINE_IID}" + - CHANGELOG_DIR="android/fastlane/metadata/android/en-US/changelogs" + - mkdir -p "$CHANGELOG_DIR" + - cp artifacts/release-notes-summary.txt "${CHANGELOG_DIR}/${VERSION_CODE}.txt" + - echo "--- ${CHANGELOG_DIR}/${VERSION_CODE}.txt ---" + - cat "${CHANGELOG_DIR}/${VERSION_CODE}.txt" + - echo "-------------------------------------------" + + # Upload the AAB to Google Play production track with the changelog. - >- fastlane supply --aab artifacts/Ditto.aab --package_name pub.ditto.app --track production --json_key /tmp/play-service-account.json + --metadata_path android/fastlane/metadata/android --skip_upload_metadata - --skip_upload_changelogs --skip_upload_images --skip_upload_screenshots --skip_upload_apk @@ -344,7 +391,10 @@ publish-app-store: stage: publish image: ruby:3.3 needs: - - build-ipa + - job: build-ipa + artifacts: true + - job: release-notes + artifacts: true rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/ variables: @@ -364,19 +414,13 @@ publish-app-store: - echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH" - chmod 600 "$ASC_KEY_PATH" script: - - VERSION="${CI_COMMIT_TAG#v}" - test -f artifacts/Ditto.ipa + - test -f artifacts/release-notes-summary.txt - # Extract the changelog section for this version into release_notes.txt. - # Mirrors the awk extraction used by the GitLab `release` job for Android. + # Use the release summary paragraph as the App Store "What's New" text. + # Generated by the release-notes job from CHANGELOG.md. - mkdir -p ios/fastlane/metadata/en-US - - >- - awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md - | awk 'NF {p=1} p' > ios/fastlane/metadata/en-US/release_notes.txt - - | - if [ ! -s ios/fastlane/metadata/en-US/release_notes.txt ]; then - echo "Ditto ${CI_COMMIT_TAG}" > ios/fastlane/metadata/en-US/release_notes.txt - fi + - cp artifacts/release-notes-summary.txt ios/fastlane/metadata/en-US/release_notes.txt - echo "--- release_notes.txt ---" - cat ios/fastlane/metadata/en-US/release_notes.txt - echo "-------------------------" diff --git a/AGENTS.md b/AGENTS.md index a522cb3d..ec1d961e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,9 +351,9 @@ Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages: 1. **test** — `npm run test` on every commit (skipped for tags). 2. **deploy** — `deploy-nsite` builds and uploads `dist/` to nsite via nsyte (default branch only). -3. **build** — `build-apk` produces a signed APK and AAB (Linux); `build-ipa` produces a signed IPA on the self-hosted Mac runner. Both run on tags only. -4. **release** — creates a GitLab Release with the APK, AAB, and IPA artifacts (tags only). -5. **publish** — `publish-zapstore` (APK → Zapstore), `publish-google-play` (AAB → Google Play), and `publish-app-store` (IPA → App Store Connect, runs on a shared Linux runner since the IPA is already signed in `build-ipa`), tags only. +3. **build** — `build-apk` produces a signed APK and AAB (Linux); `build-ipa` produces a signed IPA on the self-hosted Mac runner; `release-notes` extracts the changelog section + summary paragraph from `CHANGELOG.md`. All three run on tags only. +4. **release** — creates a GitLab Release with the changelog body and APK / AAB / IPA artifacts (tags only). +5. **publish** — `publish-zapstore` (APK → Zapstore), `publish-google-play` (AAB → Google Play with the release summary as "What's new"), and `publish-app-store` (IPA → App Store Connect with the release summary as "What's New", runs on a shared Linux runner since the IPA is already signed in `build-ipa`), tags only. To cut a release, load the **`release`** skill — it walks through version bumping (`X.Y.Z`), changelog generation, native build-file updates, and tagging/pushing (`vX.Y.Z`) to trigger the CI pipeline. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8da5b0..d49067f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,15 @@ ## [2.14.3] - 2026-05-11 +A behind-the-scenes maintenance release with no user-facing changes. + ### Changed - Behind-the-scenes maintenance release. No user-facing changes. ## [2.14.2] - 2026-05-11 +Your Blobbi gets some attention this release. Switch between Blobbis without leaving the home widget, find new energy items and a "Needs Now" summary in the shop, and visit friends knowing your help only lands where it's actually needed. Also fixes a missed reaction animation when you care for a friend's Blobbi. + ### Added - Switch Blobbis straight from the home widget -- a new arrow button below the companion icon opens a popover of all your Blobbis with horizontal scroll, a close button, and accessible labels, so you can flip between them without leaving your feed - Two new energy items in the Blobbi shop -- an Energy Drink and a Power Nap Pillow -- plus a "Needs Now" summary in the activity tab that surfaces what your Blobbi is asking for right now, with priority badges @@ -19,6 +23,8 @@ ## [2.14.1] - 2026-05-10 +On-chain zaps now appear in your notifications next to Lightning zaps, stacked avatars across follow lists are tappable so you can jump straight to a profile, follow lists always show their latest version, and the "X reposted" header shows up properly when reactions, zaps, reposts, or poll votes are reposted. + ### Added - On-chain zaps now show up in your notifications alongside Lightning zaps, with the same header, sats label, and bolt icon -- and the Zaps notification toggle controls both at once diff --git a/ios/fastlane/metadata/en-US/release_notes.txt b/ios/fastlane/metadata/en-US/release_notes.txt index dc0cae9b..ae4622eb 100644 --- a/ios/fastlane/metadata/en-US/release_notes.txt +++ b/ios/fastlane/metadata/en-US/release_notes.txt @@ -1 +1 @@ -Placeholder. CI overwrites this file with the latest CHANGELOG.md section per release. +Placeholder. CI overwrites this file with the release summary paragraph from CHANGELOG.md (the leading plaintext paragraph in the section for the current version). diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index ea8da5b0..d49067f1 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -2,11 +2,15 @@ ## [2.14.3] - 2026-05-11 +A behind-the-scenes maintenance release with no user-facing changes. + ### Changed - Behind-the-scenes maintenance release. No user-facing changes. ## [2.14.2] - 2026-05-11 +Your Blobbi gets some attention this release. Switch between Blobbis without leaving the home widget, find new energy items and a "Needs Now" summary in the shop, and visit friends knowing your help only lands where it's actually needed. Also fixes a missed reaction animation when you care for a friend's Blobbi. + ### Added - Switch Blobbis straight from the home widget -- a new arrow button below the companion icon opens a popover of all your Blobbis with horizontal scroll, a close button, and accessible labels, so you can flip between them without leaving your feed - Two new energy items in the Blobbi shop -- an Energy Drink and a Power Nap Pillow -- plus a "Needs Now" summary in the activity tab that surfaces what your Blobbi is asking for right now, with priority badges @@ -19,6 +23,8 @@ ## [2.14.1] - 2026-05-10 +On-chain zaps now appear in your notifications next to Lightning zaps, stacked avatars across follow lists are tappable so you can jump straight to a profile, follow lists always show their latest version, and the "X reposted" header shows up properly when reactions, zaps, reposts, or poll votes are reposted. + ### Added - On-chain zaps now show up in your notifications alongside Lightning zaps, with the same header, sats label, and bolt icon -- and the Zaps notification toggle controls both at once diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs new file mode 100755 index 00000000..4c034478 --- /dev/null +++ b/scripts/extract-release-notes.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +/** + * Extract release notes from CHANGELOG.md for a given version. + * + * The CHANGELOG follows Keep a Changelog format with one extension: each release + * section MAY begin with a single plaintext paragraph (the "summary") before any + * `### Added` / `### Changed` / etc. heading. The summary is used as the release + * blurb on the App Store, Play Store, and the in-app version-update toast. The + * full section body is used as the GitLab Release description. + * + * Format: + * + * ## [X.Y.Z] - YYYY-MM-DD + * + * A short single-paragraph summary (max 500 characters by convention). + * + * ### Added + * - bullet + * - bullet + * + * ### Changed + * - bullet + * + * Usage: + * node scripts/extract-release-notes.mjs [--summary] [--changelog ] + * + * --summary Print only the summary paragraph (no headings, no bullets). + * Falls back to "Ditto vX.Y.Z" if the section has no summary. + * --changelog Path to the changelog file. Defaults to CHANGELOG.md. + * + * Exits 0 with the extracted text on stdout. Exits non-zero if the version is + * not found in the changelog. + */ + +import { readFileSync } from 'node:fs'; +import { argv, exit, stderr, stdout } from 'node:process'; + +function parseArgs(args) { + let version; + let summary = false; + let changelog = 'CHANGELOG.md'; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--summary') summary = true; + else if (arg === '--changelog') changelog = args[++i]; + else if (!arg.startsWith('--') && !version) version = arg; + else { + stderr.write(`Unknown argument: ${arg}\n`); + exit(2); + } + } + if (!version) { + stderr.write('Usage: extract-release-notes.mjs [--summary] [--changelog ]\n'); + exit(2); + } + // Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3". + if (version.startsWith('v')) version = version.slice(1); + return { version, summary, changelog }; +} + +/** + * Extract the lines belonging to a single version section from changelog text, + * not including the version heading itself. + */ +function extractSection(markdown, version) { + const lines = markdown.split('\n'); + const headingPattern = new RegExp( + `^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`, + ); + const nextHeadingPattern = /^## \[/; + let inSection = false; + const out = []; + for (const line of lines) { + if (!inSection) { + if (headingPattern.test(line)) { + inSection = true; + continue; + } + } else { + if (nextHeadingPattern.test(line)) break; + out.push(line); + } + } + return inSection ? out : null; +} + +/** + * Pull the leading non-blank paragraph from a section, stopping at the first + * `###` category heading or `-` bullet. Returns null if no summary paragraph. + */ +function extractSummary(sectionLines) { + const paragraph = []; + let started = false; + for (const line of sectionLines) { + const trimmed = line.trim(); + if (!started) { + if (!trimmed) continue; + // If the very first non-blank line is a heading or bullet, there's no summary. + if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null; + started = true; + paragraph.push(trimmed); + continue; + } + // We're inside the paragraph. A blank line, a heading, or a bullet ends it. + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break; + paragraph.push(trimmed); + } + return paragraph.length ? paragraph.join(' ') : null; +} + +/** Trim leading and trailing blank lines from a list of lines. */ +function trimBlankEdges(lines) { + let start = 0; + let end = lines.length; + while (start < end && !lines[start].trim()) start++; + while (end > start && !lines[end - 1].trim()) end--; + return lines.slice(start, end); +} + +const { version, summary, changelog } = parseArgs(argv.slice(2)); +const markdown = readFileSync(changelog, 'utf8'); +const section = extractSection(markdown, version); + +if (!section) { + stderr.write(`Version ${version} not found in ${changelog}\n`); + exit(1); +} + +if (summary) { + const text = extractSummary(section); + stdout.write(text ?? `Ditto v${version}`); + stdout.write('\n'); +} else { + const body = trimBlankEdges(section).join('\n'); + if (body) { + stdout.write(body); + stdout.write('\n'); + } else { + stdout.write(`Ditto v${version}\n`); + } +} diff --git a/src/components/VersionCheck.tsx b/src/components/VersionCheck.tsx index 113dcc86..e12edf2c 100644 --- a/src/components/VersionCheck.tsx +++ b/src/components/VersionCheck.tsx @@ -7,7 +7,7 @@ import { useAppContext } from '@/hooks/useAppContext'; import { parseChangelog } from '@/lib/changelog'; import { getStorageKey } from '@/lib/storageKey'; -/** Fetch the first changelog item for the given version (or the latest entry). */ +/** Fetch the release blurb for the given version: prefer the section summary, fall back to the first bullet. */ async function fetchChangelogExcerpt(version: string): Promise { try { const res = await fetch('/CHANGELOG.md'); @@ -19,7 +19,10 @@ async function fetchChangelogExcerpt(version: string): Promise e.version === version) ?? entries[0]; if (!entry) return undefined; - // Return a truncated first item from the first section. + // Prefer the explicit summary paragraph if the changelog entry has one. + if (entry.summary) return entry.summary; + + // Legacy fallback: a truncated first item from the first section. const item = entry.sections[0]?.items[0]; if (!item) return undefined; if (item.length <= 60) return item; diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index b540b523..2d7704bc 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -5,6 +5,13 @@ type ChangelogCategory = 'Added' | 'Changed' | 'Deprecated' | 'Removed' | 'Fixed interface ChangelogEntry { version: string; date: string; + /** + * Optional plaintext summary paragraph that appears before any `### Category` + * heading. Used as the release blurb on the App Store, Play Store, and the + * in-app version-update toast. Convention is a single paragraph of at most + * 500 characters. + */ + summary?: string; sections: { category: ChangelogCategory; items: string[]; @@ -27,11 +34,21 @@ function parseChangelog(markdown: string): ChangelogEntry[] { const entries: ChangelogEntry[] = []; let current: ChangelogEntry | null = null; let currentCategory: ChangelogCategory | null = null; + /** Buffer for lines that are part of the summary paragraph (pre-section text). */ + let summaryLines: string[] = []; + + const flushSummary = () => { + if (current && summaryLines.length) { + current.summary = summaryLines.join(' '); + } + summaryLines = []; + }; for (const line of markdown.split('\n')) { // Match version heading: ## [X.Y.Z] - YYYY-MM-DD const versionMatch = line.match(/^## \[([^\]]+)\]\s*-\s*(.+)$/); if (versionMatch) { + flushSummary(); current = { version: versionMatch[1], date: versionMatch[2].trim(), sections: [] }; entries.push(current); currentCategory = null; @@ -41,6 +58,7 @@ function parseChangelog(markdown: string): ChangelogEntry[] { // Match category heading: ### Added, ### Changed, etc. const categoryMatch = line.match(/^### (.+)$/); if (categoryMatch && current) { + flushSummary(); currentCategory = categoryMatch[1].trim() as ChangelogCategory; current.sections.push({ category: currentCategory, items: [] }); continue; @@ -53,27 +71,30 @@ function parseChangelog(markdown: string): ChangelogEntry[] { if (section) { section.items.push(prettify(itemMatch[1])); } else { - // Item without a category heading — treat as "Changed" + // Bullet appearing before any category heading — flush any summary + // buffer and treat the bullet as a "Changed" entry. (Backward compat + // for legacy entries that opened straight into bullets.) + flushSummary(); current.sections.push({ category: 'Changed', items: [prettify(itemMatch[1])] }); } continue; } - // Lines that don't start with "- " but aren't blank may be a continuation or - // freeform text after the version heading (e.g. "Initial release of Ditto 2.0"). + // Non-blank, non-bullet, non-heading lines. const trimmed = line.trim(); if (trimmed && current && !trimmed.startsWith('#')) { const section = current.sections[current.sections.length - 1]; if (section) { - // Append to last item or add new item + // Continuation of the current bullet section. section.items.push(prettify(trimmed)); } else { - // Freeform text under version with no category — store in a generic section - current.sections.push({ category: 'Changed', items: [prettify(trimmed)] }); + // Pre-section freeform text — accumulate as the summary paragraph. + summaryLines.push(trimmed); } } } + flushSummary(); return entries; } diff --git a/src/pages/ChangelogPage.tsx b/src/pages/ChangelogPage.tsx index a7efeece..9aff9ba2 100644 --- a/src/pages/ChangelogPage.tsx +++ b/src/pages/ChangelogPage.tsx @@ -140,6 +140,11 @@ function LatestRelease({ entry }: { entry: ChangelogEntry }) { style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined} className="space-y-2.5" > + {entry.summary && ( +
  • + {entry.summary} +
  • + )} {entry.sections.flatMap((section) => { const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed; const Icon = style.icon; @@ -223,6 +228,11 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) { style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined} className="px-4 py-3 space-y-2.5" > + {entry.summary && ( +
  • + {entry.summary} +
  • + )} {entry.sections.flatMap((section) => { const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed; const Icon = style.icon;