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/<versionCode>.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.
This commit is contained in:
Alex Gleason
2026-05-11 13:13:33 -07:00
parent b8773c47d7
commit d044218c6a
11 changed files with 333 additions and 52 deletions
+33 -5
View File
@@ -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/<versionCode>.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/<versionCode>.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
```
+31 -9
View File
@@ -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/<lang>/changelogs/<versionCode>.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/<versionCode>.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
+70 -26
View File
@@ -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/<versionCode>.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 "-------------------------"
+3 -3
View File
@@ -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.
+6
View File
@@ -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
@@ -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).
+6
View File
@@ -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
+141
View File
@@ -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 <version> [--summary] [--changelog <path>]
*
* --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 <version> [--summary] [--changelog <path>]\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`);
}
}
+5 -2
View File
@@ -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<string | undefined> {
try {
const res = await fetch('/CHANGELOG.md');
@@ -19,7 +19,10 @@ async function fetchChangelogExcerpt(version: string): Promise<string | undefine
const entry = entries.find((e) => 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;
+27 -6
View File
@@ -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;
}
+10
View File
@@ -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 && (
<li className="text-base text-foreground/90 leading-relaxed">
{entry.summary}
</li>
)}
{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 && (
<li className="text-sm text-foreground/90 leading-relaxed">
{entry.summary}
</li>
)}
{entry.sections.flatMap((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;