docs(healthchat): add the upstream sync skill

This commit is contained in:
Sedat Qaja
2026-10-20 11:03:45 +02:00
parent 44451c733f
commit 9df5d95732
2 changed files with 202 additions and 8 deletions
+12 -8
View File
@@ -261,12 +261,12 @@ compliance metadata review.
- **Production rollout gone wrong** → Play Console → Production → **Halt - **Production rollout gone wrong** → Play Console → Production → **Halt
rollout** (works during staged rollout), then ship a hotfix as above. rollout** (works during staged rollout), then ship a hotfix as above.
## Known branch divergence when merging upstream ## Known branch divergence when syncing with upstream
When merging a newer upstream Element X release tag into `release/healthchat` Upstream is taken by **rebasing** the HealthChat commits onto the new release
(temporarily add `https://github.com/element-hq/element-x-android` as a tag, never by merging — the procedure, Git LFS handling and force-push live in
remote, fetch, merge, remove the remote — never keep a permanent upstream the `syncing-upstream-healthchat` skill. The HealthChat-only facts that must
remote): survive every sync:
- **`.github/workflows/healthchat-release-playstore.yml`**, - **`.github/workflows/healthchat-release-playstore.yml`**,
**`HEALTHCHAT_CHANGES.md`**, and **`HEALTHCHAT_CHANGES.md`**, and
@@ -279,9 +279,13 @@ remote):
Upstream uses `io.element.android.x` / `Element X` / nulls. Upstream uses `io.element.android.x` / `Element X` / nulls.
- **`appconfig/.../ApplicationConfig.kt`** — `PRODUCTION_APPLICATION_NAME` / - **`appconfig/.../ApplicationConfig.kt`** — `PRODUCTION_APPLICATION_NAME` /
`DESKTOP_APPLICATION_NAME = "HealthChat"` must survive. Upstream: `Element`. `DESKTOP_APPLICATION_NAME = "HealthChat"` must survive. Upstream: `Element`.
- **`features/enterprise/impl-foss/.../DefaultEnterpriseService.kt`** — - **`features/enterprise/impl-foss/.../DefaultEnterpriseService.kt`** — the
`defaultHomeserverList() = listOf("matrix.healthchat.ch")` must survive. homeserver lock: `accountProviderAllowList() = listOf(Generic("matrix.healthchat.ch"))`,
Upstream returns an empty list. `canConnectToAnyAccountProvider() = false`, `isAllowedToConnectToAccountProvider()`
= membership in that list (since upstream 26.10.0; before that
`defaultHomeserverList()`). Upstream returns an empty list / `true` / `true`.
`BuildTimeConfig.OAUTH_CLIENT_URL_PATH` must stay `null` (upstream:
`"apps/android"`), or MAS rejects the redirect scheme.
- **`app/build.gradle.kts`** — the `healthchat` signing config and the - **`app/build.gradle.kts`** — the `healthchat` signing config and the
`appicon/healthchat` dependency must survive; upstream signs release with `appicon/healthchat` dependency must survive; upstream signs release with
the debug keystore and depends on `appicon/element`. the debug keystore and depends on `appicon/element`.
@@ -0,0 +1,190 @@
---
name: syncing-upstream-healthchat
description: Bring the HealthChat fork (release/healthchat) onto a newer element-hq/element-x-android release tag. Use when the user asks to rebase, sync, merge or update to upstream, take a new Element X version, or asks why the fork is behind upstream. Covers the patch-stack model, the rebase procedure, Git LFS, conflicts, verification, the force-push and the rewind.
---
# Syncing the HealthChat fork with upstream
`release/healthchat` = upstream release tag + HealthChat commits on top. Patch stack, same as
healthchat-web and element-x-ios. A sync replaces the stack's base and force-pushes the branch.
That is the one sanctioned history rewrite in this repo; everything else in `AGENTS.md` still
holds. We rebase, we never merge upstream into the branch.
First done v26.07.0 → v26.10.0 (2026-10-16 to 10-19). Numbers below come from that run.
## Before touching anything
- [ ] **Toolchain.** Upstream pins the JDK (`java-version` in `.github/workflows/*.yml`, 21),
`COMPILE_SDK` / `BUILD_TOOLS_VERSION` (`plugins/src/main/kotlin/Versions.kt`), AGP, Kotlin
and Metro (`gradle/libs.versions.toml`). Gradle and AGP install themselves; the Rust SDK
comes from Maven. What does not: the self-hosted incus runners ship no Android SDK — the
`Set up Android SDK` step installs it (see the releasing skill, "Runner and build tuning").
If `COMPILE_SDK` moved, check that step still resolves the platform. 26.10.0 moved nothing.
- [ ] **Dev Mac Gradle.** A warm daemon can hang `assembleDebug` forever (0 % CPU, no log).
`./gradlew --stop` first, then compile-only tasks with `--max-workers=2`, e.g.
`:tests:uitests:compileDebugUnitTestKotlin` compiles every module. Gate scripts on the
exit code — a `rebase --continue` after an unchecked compile committed a broken stop once.
- [ ] **Upstream.** There is no upstream remote by default. Tags are `v<version>`.
`git fetch https://github.com/element-hq/element-x-android.git tag v<new> --no-tags` is
enough for the rebase. LFS needs a named remote:
`git remote add upstream https://github.com/element-hq/element-x-android.git`. After
adding it, `gh` may resolve to upstream — always pass `-R Novaloop-AG/element-x-android`.
- [ ] **Decisions, product owner:** which upstream feature-flag defaults flipped
(`git diff v<old> v<new> -- libraries/featureflag/api/.../FeatureFlags.kt`; 26.10.0:
message search default on, kept on), where fork UI lands when upstream restructures a
screen (26.10.0: settings split, HealthChat entries went to the account section), and
whether fork commits may be squashed (only within one HealthChat release, never across).
## The stack
```bash
git log --oneline --no-merges v<old>..release/healthchat # the fork's commits
git merge-base --is-ancestor v<old> release/healthchat # must be true
```
Upstream files the fork edits — conflicts live here every time:
- `features/enterprise/impl-foss/.../DefaultEnterpriseService.kt` + test — the homeserver
lock (since 26.10.0: `accountProviderAllowList()` = `Generic("matrix.healthchat.ch")`,
`canConnectToAnyAccountProvider() = false`, membership check).
- `plugins/src/main/kotlin/config/BuildTimeConfig.kt` — app id, name, OIDC URLs,
`OAUTH_CLIENT_URL_PATH = null` (MAS: redirect scheme must reverse-match the apex host).
- `plugins/src/main/kotlin/Versions.kt` — version; dropped from the stack, set again at release.
- `features/home/impl` (Members tab: `HomeView`, `HomePresenter`, `HomeState`,
`HomeStatePreviewParam`, `HomeNavigationBarItem`, `HomeTopBar`, `HomeFlowNode`, tests) and
`features/home/api/HomeEntryPoint.kt`, `appnav/.../LoggedInFlowNode.kt` (`navigateToUserProfile`).
- `features/preferences/impl` root node/view + `PreferencesFlowNode` (profile editor replaces
the display-name editor, account status banner).
- `features/userprofile`, `features/roomdetails` (profile fields section).
- `libraries/deeplink/impl`, `libraries/matrix/{api,impl}` permalink (HealthChat hosts),
`features/login/impl/DefaultLoginIntentResolver.kt`.
- `.github/workflows/{tests,quality,maestro-local,recordScreenshots}.yml` (self-hosted
runners, heap, fork guards), `app/src/gplay/AndroidManifest.xml` (permission strip),
`HEALTHCHAT_CHANGES.md`.
## Rebuilding the stack
Measure first: dry-run `git rebase --onto v<new> v<old> HEAD` in a throwaway worktree with a
script that records the conflicting files per commit and resolves blindly, then abort. The
conflict map decides the cleanup. 26.10.0: 11 of 31 commits stopped, 29 hunks, 62 goldens.
**1. Clean the series on the old base** (scratch branch off `release/healthchat`). Interactive
rebase is not available to agents; rebuild with `cherry-pick` (`-n` + `commit -C <orig>` for
edited commits; `cherry-pick` has no `-q`). Drop golden-only commits and the version bump;
strip every PNG, and edits to files upstream deleted, out of feature commits; squash the CI
run-of-commits within one release. `git diff --stat release/healthchat` must show only the
intended removals; compile once.
**2. Plain rebase** — `git rebase --onto v<new> v<old> <scratch>`, resolve per stop, keep each
commit's subject and intent. Upstream API removals cannot be "resolved", they need new code
*inside that commit* (26.10.0: `EnterpriseService` redesign). After a large stop compile its
modules before `--continue`. Cross-commit fallout (renames, moved fakes, signature changes)
goes into ONE trailing `Adapt HealthChat to upstream v<new>` commit, not amended into owners —
every amend replays the rest. A mistake in an already-committed stop: `git commit --fixup=<sha>`,
then `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <sha>^` (conflict-free when later
commits do not touch the file). Intermediates may not compile; the tip must.
**3. Review** — `git range-diff v<old>..release/healthchat v<new>..<scratch>`. `!` on a commit
that applied cleanly is context drift; check the `-`/`+` lines are not ours.
Recurring fallout to expect: Konsist naming (`*PreviewParam` for `PreviewParameterProvider`
subclasses, `*Event` for sealed interfaces), design-system signature changes (26.10.0:
`ListItem(headlineContent)` → `ListItem(content)`), test fakes moving modules, action pins in
our CI hunks regressing upstream's bumps, exhaustive `when` over `HomeNavigationBarItem`.
zsh: `for c in $VAR` does not split on newlines, a variable named `path` *is* `PATH`, and
`$TAG:file` applies a history modifier — use `while read`, never name a variable `path`,
write `${TAG}:file`.
## Git LFS — the part that bites
Goldens, compound screenshots, `docs/images-lfs`, and `libraries/mediaupload/impl` test assets
are LFS-tracked (`.gitattributes`, ~4500 files). A fresh clone holds pointers only.
- origin is a standalone repo; upstream's LFS objects are not there. GitHub rejects a push
whose *new commits* reference LFS objects it lacks (`GH008 … unknown Git LFS objects`). The
rebased branch carries every upstream commit since the old tag. Fetch the LFS tree of each
upstream commit that touched LFS paths, then push the objects before the branch:
```bash
git log --format=%H v<old>..v<new> -- '*.png' '*.jpg' '*.gif' '*.mp4' '*.m4a' '*.webp' \
| sort -u | while read c; do git lfs fetch upstream "$c"; done
git lfs push origin <scratch> # must report no "(missing)"; refetch that commit
git push origin <scratch>
```
26.10.0: 210 such commits, 9837 objects, 525 MB locally, 3809 uploaded (174 MB). Run the loop
detached (`nohup`) — an agent's tool call times out at 10 minutes — and keep a done-file so it
resumes. Objects still "(missing)" after the loop are usually goldens from the fork's *own*
commits that a pointer-only clone never held: `git lfs fetch origin <original commit sha>`
for each commit on the old branch that touched them. The dry run lists every object until
the branch itself is pushed; verify presence with the LFS batch API or trust a clean upload.
- `git lfs push <branch>` only covers objects introduced by commits absent from the remote. 26.10.0:
167 objects referenced by the v26.10.0 tree were outside that set and `Validate Git LFS` failed
with "Object does not exist on the server". Read the oids from that job's log and
`git lfs push --object-id origin <oid…>` them (they are local after the fetch loop). Treat a
green `Validate Git LFS` on the PR as the only proof the store is complete.
- The recorder commits as ElementBot, a non-member: its CI runs land in `action_required` and
need `gh api -X POST repos/<repo>/actions/runs/<id>/approve` (or the PR's "Approve and run").
Never push to the branch while a recording runs: the recorder's final `git push` is a plain
push and is rejected once the tip has moved.
- `git lfs checkout` before running `libraries/mediaupload/impl` tests locally; pointer files
fail them. The screenshot tests are never run locally (`AGENTS.md`).
- `validate-lfs.yml` fails a PR that commits a PNG outside LFS.
## Screenshots
Take upstream's goldens on every conflict and never commit locally rendered ones (macOS
renders differ). After the push, add the `Record-Screenshots` label to the PR or
`workflow_dispatch` `recordScreenshots.yml`; the Linux runner records and commits all goldens.
Review only the HealthChat ones. A PR whose base is `release/healthchat` is *conflicting* by
construction (old and new stack touch the same files) and GitHub runs no `pull_request`
workflow on a conflicting PR — open the PR against a branch at the upstream tag
(`upstream/v<new>`, pushed by the owner) so the PR shows the 19-odd stack commits and CI runs.
Retargeting a PR is an `edited` event: tests/quality/build only start on the next push.
The label path needs `persist-credentials: true` on the LFS checkout steps (private fork,
fixed in 26.10.0). If the job dies on the LayoutLib static-initialiser crash,
restore the "one JVM per screenshot test class" workaround (`forkEvery = 1L` in the root
`build.gradle.kts`, dropped in 26.10.0 because upstream's Paparazzi bump fixed it).
## Verification of the tip
Local, all green before handover (26.10.0: 1200 unit tests):
```bash
./gradlew :tests:uitests:compileDebugUnitTestKotlin :app:compileGplayDebugKotlin
./gradlew :features:healthchat:impl:testDebugUnitTest :features:home:impl:testDebugUnitTest \
:features:preferences:impl:testDebugUnitTest :features:enterprise:impl-foss:testDebugUnitTest \
:features:login:impl:testDebugUnitTest :libraries:matrix:impl:testDebugUnitTest :appnav:testDebugUnitTest
./gradlew :tests:konsist:test ktlintCheck detekt lint
```
CI on the branch (tests, quality, screenshots) must be green on the self-hosted runners.
Device matrix (owner, debug and release build): MAS OIDC login on a fresh install — chooser
skipped, client URI `https://healthchat.ch` without appended path — is the gate for everything
else; previous release → new keeps the session; last Classic build → new lands on login without
crashing; member directory, profile editor, invites, status banners; deeplinks on HealthChat
hosts and `to.healthchat.ch` share links; push in background and killed; release build under
R8 exercising the suspend Retrofit calls; whatever upstream switched on (26.10.0: German
whole-word search in an encrypted room).
## Force-push, rewind
```bash
git push origin release/healthchat:refs/heads/backup/release-healthchat-before-<new>
git push --force-with-lease=release/healthchat:<old tip> origin <scratch>:release/healthchat
git checkout release/healthchat && git reset --hard origin/release/healthchat # every clone
```
The branch is unprotected: the push lands immediately. Old `healthchat-v*` tags keep their
commits alive and off the branch line. Rewind =
`git push --force origin healthchat-v<last>^{commit}:release/healthchat`. A rewind does not
remove a tag or Play build made on the new history — tag only after the device tests. Then set
the version, update `HEALTHCHAT_CHANGES.md`, the releasing skill ("Known branch divergence")
and this skill with what changed.
## What not to do
- Merge upstream into `release/healthchat`, or squash the fork into a side branch and merge
upstream there: both duplicate every fork commit and conflict on every touched file.
- Pull on a rewritten branch (merges the old history back in). Fetch and reset.
- Commit goldens from a Mac, or resolve a golden conflict by hand — take upstream's and record.
- Amend compile fixes into owner commits mid-rebase; collect them in the adaptation commit.
- Touch `release/healthchat` before the owner has reviewed the range-diff.