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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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 "-------------------------"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+141
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user