on main help sign in
5 files changed+1063−5
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+42−2 .github/workflows/release.yml+242 README.md+130−1 scripts/selftest-sync.sh+646 scripts/sync-report.py+3−2
.github/workflows/build.yml+42−2
@@ -20,6 +20,10 @@ on:
2020 apk:
2121 type: boolean
2222 default: false
23+ build_number:
24+ description: MultiGram build number (0 to 9), the fourth part of the version (release.yml sets it)
25+ type: string
26+ default: '0'
2327
2428permissions:
2529 contents: read
@@ -67,6 +71,15 @@ jobs:
6771 with:
6872 tool-cache: false
6973 android: false
74+ - name: Add swap space
75+ # Runners for private repositories have about 8 GB of memory; the native build and the
76+ # Gradle and Kotlin daemons together can use more, and the runner dies without a log.
77+ run: |
78+ sudo fallocate -l 8G /multigram-swap
79+ sudo chmod 600 /multigram-swap
80+ sudo mkswap /multigram-swap
81+ sudo swapon /multigram-swap
82+ free -h
7083 - uses: actions/setup-java@v4
7184 with:
7285 distribution: temurin
@@ -81,16 +94,43 @@ jobs:
8194 env:
8295 APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
8396 APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
97+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
98+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
99+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
100+ KEY_PASSWORD: ${{ secrets.MULTIGRAM_KEY_PASSWORD }}
101+ BUILD_NUMBER: ${{ inputs.build_number }}
84102 run: |
85103 vars=gradle.properties
104+ # Forkgram's versioning: versionName APP_VERSION_NAME.N and versionCode
105+ # (APP_VERSION_CODE * 10 + N) * 10 + the flavor's ABI code, with N = ADDITIONAL_BUILD_NUMBER.
106+ case "${BUILD_NUMBER:-0}" in [0-9]) ;; *) echo "::error::build_number must be one digit"; exit 1;; esac
107+ echo "ADDITIONAL_BUILD_NUMBER=${BUILD_NUMBER:-0}" >> $vars
86108 echo "org.gradle.workers.max=1" >> $vars
109+ # Forkgram asks for an 8 GB heap, sized for the bigger runners of public repositories.
110+ echo "org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8" >> $vars
111+ echo "kotlin.daemon.jvmargs=-Xmx2g" >> $vars
112+ echo "org.gradle.parallel=false" >> $vars
87113 [ -n "$APP_ID" ] && echo "APP_ID=$APP_ID" >> $vars
88114 [ -n "$APP_HASH" ] && echo "APP_HASH=$APP_HASH" >> $vars
89− echo "RELEASE_KEYSTORE_FILE=$PWD/TMessagesProj/config/release.keystore" >> $vars
115+ signing=()
116+ if [ -n "$KEYSTORE_BASE64" ]; then
117+ # Your own release key, from the MULTIGRAM_KEYSTORE_* secrets; it never touches the repository.
118+ echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
119+ signing=(
120+ "-PRELEASE_KEYSTORE_FILE=$RUNNER_TEMP/release.keystore"
121+ "-PRELEASE_STORE_PASSWORD=$KEYSTORE_PASSWORD"
122+ "-PRELEASE_KEY_ALIAS=$KEY_ALIAS"
123+ "-PRELEASE_KEY_PASSWORD=${KEY_PASSWORD:-$KEYSTORE_PASSWORD}"
124+ )
125+ else
126+ # Without RELEASE_KEYSTORE_FILE the build signs with the public test key in
127+ # TMessagesProj/config/test.keystore: fine for testing, not for an APK you give out.
128+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the APK is signed with the public test key."
129+ fi
90130 sudo apt update
91131 sudo apt -y install ninja-build meson nasm gperf pkg-config unzip
92132 rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
93− ./gradlew :TMessagesProj_App:assembleAfatRelease
133+ ./gradlew "${signing[@]}" :TMessagesProj_App:assembleAfatRelease
94134 - uses: actions/upload-artifact@v4
95135 with:
96136 name: multigram-apk
.github/workflows/release.ymlnew file+242
@@ -0,0 +1,242 @@
1+name: Release MultiGram
2+run-name: Release ${{ inputs.ref }}${{ inputs.dry_run && ' (dry run)' || '' }}
3+
4+# Builds the release APK of a MultiGram commit and publishes it as a GitHub Release.
5+#
6+# Version: Forkgram's own scheme, APP_VERSION_NAME.N (for example 12.10.6.1), where APP_VERSION_NAME
7+# comes from gradle.properties of the Forkgram snapshot and N (1 to 9) counts MultiGram releases on
8+# that Forkgram version. The Android versionCode is (APP_VERSION_CODE * 10 + N) * 10 + 9, so every
9+# release installs as an update over the one before, and a new Forkgram version starts again at N = 1.
10+# The tag is the version itself, like Forkgram's tags.
11+
12+on:
13+ workflow_dispatch:
14+ inputs:
15+ ref:
16+ description: Branch, tag or commit to release
17+ default: multigram
18+ required: true
19+ number:
20+ description: MultiGram build number for this Forkgram version (1 to 9). Empty picks the next free one.
21+ required: false
22+ default: ''
23+ prerelease:
24+ description: Mark it as a pre-release (always the case without your own signing key)
25+ type: boolean
26+ default: false
27+ dry_run:
28+ description: Only work out the version and the release notes; build and publish nothing
29+ type: boolean
30+ default: false
31+
32+permissions:
33+ contents: read
34+
35+concurrency:
36+ group: release
37+ cancel-in-progress: false
38+
39+jobs:
40+ check_secrets:
41+ # A job of its own, so that no secret value is registered in the job that writes the release
42+ # notes: the runner drops any job output that contains a secret's value (a key alias like
43+ # "release" would otherwise blank the notes).
44+ name: Check the secrets
45+ runs-on: ubuntu-latest
46+ outputs:
47+ signed: ${{ steps.s.outputs.signed }}
48+ steps:
49+ - name: Check the secrets
50+ id: s
51+ env:
52+ DRY_RUN: ${{ inputs.dry_run }}
53+ APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
54+ APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
55+ KEYSTORE_BASE64: ${{ secrets.MULTIGRAM_KEYSTORE_BASE64 }}
56+ KEYSTORE_PASSWORD: ${{ secrets.MULTIGRAM_KEYSTORE_PASSWORD }}
57+ KEY_ALIAS: ${{ secrets.MULTIGRAM_KEY_ALIAS }}
58+ run: |
59+ set -euo pipefail
60+ # Stop here, in seconds, rather than after the long build. A dry run only warns.
61+ problem() { if [ "$DRY_RUN" = true ]; then echo "::warning::$1"; else echo "::error::$1"; exit 1; fi; }
62+ if [ -z "$APP_ID" ] || [ -z "$APP_HASH" ]; then
63+ problem "The MULTIGRAM_APP_ID and MULTIGRAM_APP_HASH secrets are not set, so the APK could not log in. Add them (Settings > Secrets and variables > Actions) and run this again."
64+ fi
65+ if [ -n "$KEYSTORE_BASE64" ]; then
66+ if [ -z "$KEYSTORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
67+ problem "MULTIGRAM_KEYSTORE_BASE64 is set, but MULTIGRAM_KEYSTORE_PASSWORD or MULTIGRAM_KEY_ALIAS is not: signing would fail at the end of the build. Add both."
68+ fi
69+ echo "signed=true" >> "$GITHUB_OUTPUT"
70+ else
71+ echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the release will be signed with Forkgram's public test key and published as a pre-release."
72+ echo "signed=false" >> "$GITHUB_OUTPUT"
73+ fi
74+
75+ prepare:
76+ name: Work out the version
77+ needs: check_secrets
78+ runs-on: ubuntu-latest
79+ outputs:
80+ sha: ${{ steps.v.outputs.sha }}
81+ number: ${{ steps.v.outputs.number }}
82+ version: ${{ steps.v.outputs.version }}
83+ version_code: ${{ steps.v.outputs.version_code }}
84+ prerelease: ${{ steps.v.outputs.prerelease }}
85+ notes: ${{ steps.v.outputs.notes }}
86+ steps:
87+ - uses: actions/checkout@v4
88+ with:
89+ ref: ${{ inputs.ref }}
90+ fetch-depth: 0
91+ - name: Version, secrets and release notes
92+ id: v
93+ env:
94+ NUMBER: ${{ inputs.number }}
95+ PRERELEASE: ${{ inputs.prerelease }}
96+ DRY_RUN: ${{ inputs.dry_run }}
97+ SIGNED: ${{ needs.check_secrets.outputs.signed }}
98+ run: |
99+ set -euo pipefail
100+ fail() { echo "::error::$1"; exit 1; }
101+ sha=$(git rev-parse HEAD)
102+ name=$(sed -n 's/^APP_VERSION_NAME=//p' gradle.properties | tail -n 1)
103+ code=$(sed -n 's/^APP_VERSION_CODE=//p' gradle.properties | tail -n 1)
104+ [[ "$name" =~ ^[0-9]+(\.[0-9]+)*$ ]] || fail "APP_VERSION_NAME in gradle.properties is '$name', not a version number."
105+ [[ "$code" =~ ^[0-9]+$ ]] || fail "APP_VERSION_CODE in gradle.properties is '$code', not a number."
106+
107+ # The next free N for this Forkgram version, from the tags that already exist.
108+ last=$(git tag -l "$name.*" | sed -n "s/^${name//./\\.}\.\([0-9]\)$/\1/p" | sort -n | tail -n 1)
109+ if [ -n "$NUMBER" ]; then
110+ n=$NUMBER
111+ else
112+ n=$(( ${last:-0} + 1 ))
113+ fi
114+ [[ "$n" =~ ^[1-9]$ ]] || fail "The MultiGram build number must be 1 to 9 (got '$n'). Nine releases on Forkgram $name are the most its version codes allow; wait for the next Forkgram version."
115+ version="$name.$n"
116+ if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then
117+ fail "Tag $version already exists: MultiGram $version is already released. Leave the number empty to take the next free one."
118+ fi
119+ if [ -n "$last" ] && [ "$n" -le "$last" ]; then
120+ echo "::warning::$name.$last is already released, so $version has a lower version code and will not install as an update over it."
121+ fi
122+ version_code=$(( (code * 10 + n) * 10 + 9 ))
123+
124+ # What it is built on, from the snapshot commit the sync made.
125+ snap=$(git log -n 1 --format=%H --grep='^Forkgram-Version: ' HEAD || true)
126+ fg_version=unknown; tg_version=unknown
127+ if [ -n "$snap" ]; then
128+ msg=$(git log -1 --format=%B "$snap")
129+ fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version: //p' | head -n 1)
130+ tg_version=$(printf '%s\n' "$msg" | sed -n 's/^Telegram-Base: .*(update to \([0-9.]*\).*/\1/p' | head -n 1)
131+ fg_version=${fg_version:-unknown}; tg_version=${tg_version:-unknown}
132+ fi
133+
134+ signed=${SIGNED:-false}
135+ prerelease=$PRERELEASE
136+ [ "$signed" = true ] || prerelease=true
137+
138+ notes=$(mktemp)
139+ {
140+ echo "MultiGram $version is Forkgram $fg_version (Telegram $tg_version) with the MultiGram changes listed below."
141+ echo
142+ if [ "$signed" = true ]; then
143+ echo "**Signing:** signed with the MultiGram release key. It installs as an update over earlier MultiGram releases signed with the same key."
144+ else
145+ echo "**Signing: public test key.** This APK is signed with Forkgram's public test key (\`TMessagesProj/config/test.keystore\`), because the repository has no \`MULTIGRAM_KEYSTORE_BASE64\` secret. Anyone can sign an \"update\" for it, and a later release signed with a real key cannot update it. Use it for testing only."
146+ fi
147+ echo
148+ echo "**Version:** $version (Android version code $version_code). Built from $sha."
149+ echo
150+ echo "**MultiGram changes on top of Forkgram $fg_version:**"
151+ echo
152+ if [ -n "$snap" ]; then
153+ git log --reverse --format='- %s (%h)' "$snap..HEAD"
154+ else
155+ echo "- (no Forkgram snapshot found in the history)"
156+ fi
157+ } > "$notes"
158+
159+ {
160+ echo "sha=$sha"
161+ echo "number=$n"
162+ echo "version=$version"
163+ echo "version_code=$version_code"
164+ echo "prerelease=$prerelease"
165+ echo "notes<<MULTIGRAM_NOTES_EOF"
166+ cat "$notes"
167+ echo "MULTIGRAM_NOTES_EOF"
168+ } >> "$GITHUB_OUTPUT"
169+ {
170+ if [ "$DRY_RUN" = true ]; then echo "## MultiGram $version (dry run: nothing built or published)"; else echo "## MultiGram $version"; fi
171+ echo
172+ echo "Pre-release: $prerelease"
173+ echo
174+ cat "$notes"
175+ } >> "$GITHUB_STEP_SUMMARY"
176+ cat "$notes"
177+
178+ build:
179+ name: Build
180+ needs: [check_secrets, prepare]
181+ if: ${{ !inputs.dry_run }}
182+ uses: ./.github/workflows/build.yml
183+ with:
184+ ref: ${{ needs.prepare.outputs.sha }}
185+ apk: true
186+ build_number: ${{ needs.prepare.outputs.number }}
187+ secrets: inherit
188+
189+ publish:
190+ name: Publish the release
191+ needs: [prepare, build]
192+ runs-on: ubuntu-latest
193+ permissions:
194+ contents: write
195+ steps:
196+ - uses: actions/download-artifact@v4
197+ with:
198+ name: multigram-apk
199+ path: apk
200+ - name: Check the APK and publish it
201+ env:
202+ GH_TOKEN: ${{ github.token }}
203+ SHA: ${{ needs.prepare.outputs.sha }}
204+ VERSION: ${{ needs.prepare.outputs.version }}
205+ VERSION_CODE: ${{ needs.prepare.outputs.version_code }}
206+ PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
207+ NOTES: ${{ needs.prepare.outputs.notes }}
208+ run: |
209+ set -euo pipefail
210+ fail() { echo "::error::$1"; exit 1; }
211+ shopt -s nullglob
212+ apks=(apk/*.apk)
213+ [ "${#apks[@]}" -eq 1 ] || fail "Expected one APK in the build artifact, found ${#apks[@]}."
214+ file="MultiGram-$VERSION.apk"
215+ mv "${apks[0]}" "$file"
216+
217+ tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n 1)
218+ badging=$("$tools/aapt2" dump badging "$file" | head -n 1)
219+ echo "$badging"
220+ got_name=$(printf '%s' "$badging" | sed -n "s/.* versionName='\([^']*\)'.*/\1/p")
221+ got_code=$(printf '%s' "$badging" | sed -n "s/.* versionCode='\([^']*\)'.*/\1/p")
222+ [ "$got_name" = "$VERSION" ] || fail "The APK says versionName $got_name, expected $VERSION."
223+ [ "$got_code" = "$VERSION_CODE" ] || fail "The APK says versionCode $got_code, expected $VERSION_CODE."
224+ cert=$("$tools/apksigner" verify --print-certs "$file" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -n 1)
225+ [ -n "$cert" ] || fail "apksigner could not verify the APK's signature."
226+ sha256sum "$file" > SHA256SUMS
227+
228+ {
229+ printf '%s\n' "$NOTES"
230+ echo
231+ echo "**Checksums:** \`$(cut -d' ' -f1 SHA256SUMS)\` ($file, SHA-256). Signing certificate SHA-256: \`$cert\`."
232+ } > notes.md
233+
234+ flags=()
235+ if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi
236+ gh release create "$VERSION" "$file" SHA256SUMS \
237+ --repo "$GITHUB_REPOSITORY" \
238+ --target "$SHA" \
239+ --title "MultiGram $VERSION" \
240+ --notes-file notes.md \
241+ "${flags[@]}"
242+ echo "Published https://github.com/$GITHUB_REPOSITORY/releases/tag/$VERSION" | tee -a "$GITHUB_STEP_SUMMARY"
README.md+130−1
@@ -8,4 +8,133 @@ MultiGram is Forkgram (https://github.com/forkgram/TelegramAndroid) plus a small
88| `multigram` | The app: the latest `forkgram` snapshot plus the MultiGram commits on top. Build this branch. |
99| `main` | This README, the automation that keeps `multigram` in step with Forkgram, and helper scripts. |
1010
11−Setup is in progress.
11+The sync also uses `multigram-next` (the next `multigram`, while it is being checked) and tags
12+the `multigram` it replaces as `multigram-before-<version>`.
13+
14+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md) on the `docs` branch says what
15+to do after a Telegram update when the sync needs a hand, and lists every MultiGram change with
16+the exact code, so the stack can be re-applied by hand.
17+
18+## Build
19+
20+**Actions > Build MultiGram > Run workflow**, ref `multigram`. It compiles the Java and Kotlin
21+code (a few minutes). Tick **APK** to also build a release APK; it compiles the native code
22+too, so it takes much longer, and the APK is attached to the run as `multigram-apk`.
23+
24+## Releases
25+
26+**Actions > Release MultiGram > Run workflow** builds the release APK of `multigram` and publishes
27+it on the Releases page (about 80 minutes; tick **dry_run** to see only the version and notes).
28+Versions follow Forkgram's scheme: `12.10.6.1` is the first MultiGram release on Forkgram 12.10.6,
29+`12.10.6.2` the second, and after a Forkgram update the count starts again at 1 (at most 9 per
30+Forkgram version). The tag is the version. It needs the API id and hash secrets below. Without the
31+signing secrets the APK is signed with Forkgram's public test key and published as a pre-release
32+that says so. Details: section 10.6 of
33+[PATCH.md](https://github.com/iko-soy/Multigram/blob/docs/PATCH.md).
34+
35+## Secrets
36+
37+Set these in **Settings > Secrets and variables > Actions**:
38+
39+- `MULTIGRAM_APP_ID` and `MULTIGRAM_APP_HASH`: your own API id and hash from
40+ https://my.telegram.org (API development tools). Without them the APK cannot log in.
41+- Signing (optional): `MULTIGRAM_KEYSTORE_BASE64` (your release keystore, `base64 -w0 release.keystore`),
42+ `MULTIGRAM_KEYSTORE_PASSWORD`, `MULTIGRAM_KEY_ALIAS` and `MULTIGRAM_KEY_PASSWORD` (if it differs
43+ from the store password). Without them the APK is signed with Forkgram's public test key
44+ (`TMessagesProj/config/test.keystore`), which is fine for testing but lets anyone sign an
45+ "update" for it. Keep the same key for every build you give out, or phones can't update. The
46+ rebrand toolkit can make a key for you (see "Signing and updates" in `multigram/rebrand/README.md`).
47+
48+## How the sync works
49+
50+The **Sync with Forkgram** workflow runs every 6 hours (and by hand):
51+
52+1. If Forkgram's `dev` has a new tip (Forkgram rebases `dev` onto every Telegram release, so each
53+ new tip is a release candidate), it adds a snapshot commit to `forkgram`: that tree without
54+ `.github`, with `Forkgram-Commit`, `Forkgram-Version` and `Telegram-Base` lines in the message.
55+2. It replays the MultiGram commits (`forkgram..multigram`) onto the snapshot as `multigram-next`
56+ (`git rebase --onto`).
57+3. It compiles `multigram-next` with the Build MultiGram workflow.
58+4. If that passes, it tags the old `multigram` as `multigram-before-<version>`, moves `multigram`
59+ to `multigram-next`, deletes `multigram-next` and closes the sync issue.
60+5. On a conflict or a failed compile it opens an issue labelled `forkgram-sync` (or comments on the
61+ open one) with the details; `multigram` stays as it was.
62+
63+It never replaces a `multigram-next` that has commits it did not make (your fix, say): it then
64+changes nothing, not even `forkgram`, and says so on the issue until you run **use_next** or
65+delete `multigram-next`. It records the `multigram-next` it built last in the ref
66+`refs/multigram-sync/next`. Keep `multigram` linear (rebase, do not merge): the replay flattens
67+merges, so a merge commit with changes of its own stops the sync.
68+
69+Run it by hand with **force** to rebuild `multigram-next` without a new release (to test the
70+pipeline, or to retry), or with **use_next** to compile and promote a `multigram-next` you fixed
71+yourself. **use_next** checks that `multigram-next` still has every commit on `multigram` (a
72+commit pushed to `multigram` after you started the fix would otherwise be lost); tick
73+**allow_drop** as well if it leaves some out on purpose.
74+
75+The work is done by `scripts/sync-forkgram.sh` (see `--help`). It also runs locally without
76+pushing anything: from a checkout of `main`, `bash scripts/sync-forkgram.sh --remote origin`
77+leaves the results in your local `forkgram` and `multigram-next` branches.
78+`bash scripts/selftest-sync.sh` tests it against fake repositories.
79+
80+rerere (git's memory of conflict fixes) only knows the fixes recorded in the clone it runs in.
81+It helps when you redo a rebase or run the script in your own clone; the scheduled sync starts
82+from a fresh clone every time, so it reports a conflict again until `multigram` carries the fix.
83+
84+## When the sync issue opens
85+
86+The issue has the exact commands; in short:
87+
88+**Conflict.** Replay the stack yourself, fix the files, push the result to `multigram-next`:
89+
90+```sh
91+git fetch origin
92+git switch -C multigram-next origin/multigram
93+git config rerere.enabled true # reuses your fixes if you redo this rebase here
94+git rebase origin/forkgram
95+# fix the files git names, then: git add <files> && git rebase --continue (repeat)
96+git push --force origin multigram-next
97+```
98+
99+**Compile failure.** Fix the build on top of `multigram-next` and fold the fix into the commit it
100+belongs to:
101+
102+```sh
103+git fetch origin
104+git switch -C multigram-next origin/multigram-next
105+# edit, then, once per MultiGram commit your fix touches (name only that commit's files):
106+git commit --fixup=<the MultiGram commit it belongs to> -- <its files>
107+GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash origin/forkgram
108+git push --force origin multigram-next
109+```
110+
111+Then **Actions > Sync with Forkgram > Run workflow** with **use_next** ticked: it compiles
112+`multigram-next` and, if that passes, moves `multigram` to it and closes the issue. If the
113+compile failed because of a runner or network hiccup, **Re-run failed jobs** on the run page
114+is enough. Until then the sync leaves your `multigram-next` alone; if you give up on it, delete
115+it (`git push origin --delete multigram-next`) and the next sync starts over from `multigram`.
116+
117+## Patches
118+
119+`bash scripts/export-patches.sh --base origin/forkgram --head origin/multigram` writes the stack
120+as numbered patches to `multigram-patches/`, with `APPLY.txt` saying how to apply them onto a
121+plain Forkgram checkout (`git am -3`).
122+
123+## What is on the multigram branch
124+
125+- `multigram/rebrand/`: the rebrand toolkit that gives a build its own name, applicationId,
126+ launcher icon and signing key (instructions in its `README.md`).
127+- `multigram/hide-search-bar/`: the "Hide chat list search" option in Fork Client Settings >
128+ Chat list view (off by default), which removes the search bar and search icon from the chat list.
129+- `multigram/`: the other MultiGram tools and notes, such as the per-install random style and
130+ its palette check; the app code is in
131+ `TMessagesProj/src/main/java/org/telegram/messenger/multigram/`.
132+
133+## Repository settings
134+
135+- `main` must be the default branch: scheduled workflows only run from it.
136+- The workflows ask for the token permissions they need, so the default workflow permissions
137+ can stay read-only.
138+- Issues must be enabled: the sync reports on an issue labelled `forkgram-sync`.
139+- If you protect `forkgram`, `multigram`, `multigram-next` or `multigram-before-*` tags with
140+ branch protection or rulesets, let GitHub Actions push and force-push them.
scripts/selftest-sync.shnew file+646
@@ -0,0 +1,646 @@
1+#!/usr/bin/env bash
2+# Self-test for sync-forkgram.sh and export-patches.sh. Builds a fake Forkgram remote and a
3+# fake MultiGram remote in a temporary folder (nothing touches GitHub or your clone) and
4+# runs the sync through its cases: no new release, --force, a release that applies, a
5+# release that conflicts, the hand fix (rerere, a multigram-next the sync must not replace,
6+# --use-next and the check that it carries all of multigram), promote, the .github guard,
7+# a missing multigram branch, local branches that must not move, merge commits, a full
8+# clone that must stay full, and the patch export.
9+#
10+# bash scripts/selftest-sync.sh [--keep] (--keep leaves the folder for a look)
11+set -euo pipefail
12+
13+HERE=$(cd "$(dirname "$0")" && pwd)
14+SYNC=$HERE/sync-forkgram.sh
15+EXPORT=$HERE/export-patches.sh
16+T=$(mktemp -d "${TMPDIR:-/tmp}/multigram-selftest.XXXXXX")
17+KEEP=no
18+[ "${1:-}" != --keep ] || KEEP=yes
19+trap '[ $KEEP = yes ] && echo "left in $T" || rm -rf "$T"' EXIT
20+
21+# A clean git setup, whatever the user's own config says.
22+export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=$T/gitconfig
23+git config --global user.name "Self Test"
24+git config --global user.email selftest@example.invalid
25+git config --global init.defaultBranch main
26+git config --global advice.detachedHead false
27+unset GITHUB_ACTIONS GITHUB_OUTPUT GITHUB_STEP_SUMMARY RUNNER_TEMP FORKGRAM_URL FORKGRAM_BRANCH || true
28+export FORKGRAM_URL=file://$T/forkgram.git FORKGRAM_BRANCH=dev
29+
30+PASS=0
31+FAIL=0
32+ok() { PASS=$((PASS + 1)); printf ' ok %s\n' "$1"; }
33+bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s\n' "$1"; }
34+eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: got [$2], want [$3]"; fi; }
35+has() { case $2 in *"$3"*) ok "$1" ;; *) bad "$1: [$3] not in [$2]" ;; esac; }
36+title() { printf '\n== %s\n' "$*"; }
37+
38+# Runs the sync script; keeps its exit code in RC and its outputs in $T/out.
39+run() { # dir args...
40+ local d=$1
41+ shift
42+ : >"$T/out"
43+ RC=0
44+ (cd "$d" && GITHUB_OUTPUT=$T/out bash "$SYNC" "$@") >"$T/log" 2>&1 || RC=$?
45+ sed -e '/^---- outputs ----$/,$d' -e 's/^/ | /' "$T/log"
46+}
47+o() { # key -> value from the last run's outputs
48+ python3 - "$T/out" "$1" <<'PY'
49+import sys
50+path, key = sys.argv[1], sys.argv[2]
51+lines = open(path).read().split('\n')
52+vals, i = {}, 0
53+while i < len(lines):
54+ line = lines[i]
55+ if '<<' in line:
56+ k, d = line.split('<<', 1)
57+ j = lines.index(d, i + 1)
58+ vals[k] = '\n'.join(lines[i + 1:j])
59+ i = j + 1
60+ else:
61+ i += 1
62+print(vals.get(key, ''), end='')
63+PY
64+}
65+rref() { git --git-dir="$T/multigram.git" rev-parse -q --verify "$1" || true; }
66+
67+# ------------------------------------------------------------------ fake Forkgram
68+
69+git init -q --bare "$T/forkgram.git"
70+git --git-dir="$T/forkgram.git" config uploadpack.allowFilter true
71+git --git-dir="$T/forkgram.git" config uploadpack.allowAnySHA1InWant true
72+git init -q "$T/fg"
73+cd "$T/fg"
74+
75+main_java() { # name line, tail line
76+ printf 'class Main {\n String name = "%s";\n int a = 1;\n int b = 2;\n int c = 3;\n int d = 4;\n // %s\n}\n' "$1" "$2"
77+}
78+props() { printf 'APP_VERSION_CODE=%s\nAPP_VERSION_NAME=%s\nAPP_PACKAGE=org.example\n' "$1" "$2"; }
79+gitlink() { git update-index --add --cacheinfo "160000,$1,$2"; }
80+subst() { sed "$1" "$2" >"$2.new" && mv "$2.new" "$2"; } # sed -i, on any sed
81+setver() { subst "s/^APP_VERSION_NAME=.*/APP_VERSION_NAME=$1/" gradle.properties; }
82+upstream() { # version code name tail sublink : a Telegram "update to" commit
83+ mkdir -p app
84+ props "$2" "$1" >gradle.properties
85+ main_java "$3" "$4" >app/Main.java
86+ printf 'class Util {}\n' >app/Util.java
87+ printf '[submodule "lib/sub"]\n\tpath = lib/sub\n\turl = https://example.invalid/sub.git\n[submodule "topsub"]\n\tpath = topsub\n\turl = https://example.invalid/top.git\n' >.gitmodules
88+ git add -A
89+ gitlink "$5" lib/sub
90+ gitlink 2222222222222222222222222222222222222222 topsub
91+ git commit -q -m "update to $1 ($2)"
92+}
93+fork_commits() { # version : Forkgram's own commits, then the release commit
94+ mkdir -p .github/workflows
95+ printf 'on: push\n' >.github/workflows/ci.yml
96+ printf 'class Fork {}\n' >app/Fork.java
97+ git add -A
98+ git commit -q -m "Added fork settings."
99+ setver "$1"
100+ printf '// release %s\n' "$1" >>app/Fork.java
101+ git commit -q -am "[Release] $1."
102+}
103+
104+upstream 1.0.0 100 Fork "end" 1111111111111111111111111111111111111111
105+fork_commits 1.0.1
106+git push -q "$T/forkgram.git" HEAD:refs/heads/dev
107+R1=$(git rev-parse HEAD)
108+U1=$(git rev-parse HEAD~2)
109+
110+# ------------------------------------------------------------------ fake MultiGram
111+
112+git init -q --bare "$T/multigram.git"
113+git --git-dir="$T/multigram.git" config uploadpack.allowFilter true
114+git --git-dir="$T/multigram.git" config uploadpack.allowAnySHA1InWant true
115+git init -q "$T/mgsetup"
116+cd "$T/mgsetup"
117+git fetch -q "$T/forkgram.git" dev
118+tree=$(git ls-tree FETCH_HEAD | grep -v " .github$" | git mktree)
119+S1=$(printf 'Forkgram 1.0.1 snapshot\n\nSource: https://github.com/forkgram/TelegramAndroid/commit/%s\nForkgram-Commit: %s\nForkgram-Version: 1.0.1\nTelegram-Base: %s (update to 1.0.0)\n\nTree is Forkgram'"'"'s dev branch at that commit with the .github folder removed.\n' "$R1" "$R1" "$U1" | git commit-tree "$tree")
120+git switch -q -c multigram "$S1"
121+mkdir -p multigram
122+printf 'MultiGram notes\n' >multigram/README.md
123+git add -A && git commit -q -m "Add MultiGram notes"
124+subst 's/"Fork"/"MultiGram"/' app/Main.java
125+git commit -q -am "Rename the app"
126+printf 'class Util { /* MultiGram: hook */ }\n' >app/Util.java
127+git commit -q -am "Add a MultiGram hook"
128+git switch -q --orphan main
129+printf 'main\n' >README.md
130+git add README.md && git commit -q -m "Start main"
131+git push -q "$T/multigram.git" main "$S1:refs/heads/forkgram" multigram
132+MG0=$(rref multigram)
133+
134+# "CI": a blobless, depth-1 clone of main, like actions/checkout with filter: blob:none.
135+ci_clone() {
136+ rm -rf "$T/ci"
137+ git clone -q --filter=blob:none --depth 1 --branch main "file://$T/multigram.git" "$T/ci"
138+ git -C "$T/ci" config user.name "github-actions[bot]"
139+ git -C "$T/ci" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
140+}
141+
142+# ------------------------------------------------------------------ A: no new release
143+title "A. No new Forkgram release"
144+ci_clone
145+run "$T/ci" --remote origin --push
146+eq "exit code" "$RC" 0
147+eq "status" "$(o status)" up-to-date
148+eq "new_snapshot" "$(o new_snapshot)" no
149+eq "forkgram unchanged" "$(rref forkgram)" "$S1"
150+eq "no multigram-next" "$(rref multigram-next)" ""
151+
152+# ------------------------------------------------------------------ B: --force
153+title "B. --force with no new release, then promote"
154+ci_clone
155+run "$T/ci" --remote origin --push --force
156+eq "exit code" "$RC" 0
157+eq "status" "$(o status)" candidate
158+eq "new_snapshot" "$(o new_snapshot)" no
159+eq "candidate is multigram (nothing to replay)" "$(o candidate)" "$MG0"
160+eq "multigram-next pushed" "$(rref multigram-next)" "$MG0"
161+eq "pushed" "$(o pushed)" multigram-next
162+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
163+ci_clone
164+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
165+eq "promote exit code" "$RC" 0
166+eq "promote status" "$(o status)" unchanged
167+eq "multigram unchanged" "$(rref multigram)" "$MG0"
168+eq "multigram-next deleted" "$(rref multigram-next)" ""
169+
170+# ------------------------------------------------------------------ C: clean release
171+title "C. New Forkgram release 1.1.0 that applies cleanly"
172+cd "$T/fg"
173+git reset -q --hard "$U1"
174+printf 'class Other {}\n' >app/Other.java
175+git add -A
176+upstream 1.1.0 110 Fork "end, 1.1.0" 3333333333333333333333333333333333333333
177+U2=$(git rev-parse HEAD)
178+fork_commits 1.1.0
179+R2=$(git rev-parse HEAD)
180+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
181+ci_clone
182+run "$T/ci" --remote origin --push
183+eq "exit code" "$RC" 0
184+eq "status" "$(o status)" candidate
185+eq "new_snapshot" "$(o new_snapshot)" yes
186+eq "forkgram_commit" "$(o forkgram_commit)" "$R2"
187+eq "forkgram_version" "$(o forkgram_version)" 1.1.0
188+eq "telegram_base" "$(o telegram_base)" "$U2 (update to 1.1.0)"
189+eq "previous_version" "$(o previous_version)" 1.0.1
190+S2=$(rref forkgram)
191+eq "forkgram pushed" "$S2" "$(o snapshot)"
192+eq "snapshot parent is the previous snapshot" "$(git --git-dir="$T/multigram.git" rev-parse "$S2^")" "$S1"
193+eq "snapshot has no .github" "$(git --git-dir="$T/multigram.git" ls-tree --name-only "$S2" .github)" ""
194+want=$(git -C "$T/fg" ls-tree "$R2" | grep -v " .github$")
195+eq "snapshot tree = Forkgram tree minus .github" "$(git --git-dir="$T/multigram.git" ls-tree "$S2")" "$want"
196+eq "gitlink lib/sub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" lib/sub | cut -f1)" "160000 commit 3333333333333333333333333333333333333333"
197+eq "gitlink topsub kept" "$(git --git-dir="$T/multigram.git" ls-tree "$S2" topsub | cut -f1)" "160000 commit 2222222222222222222222222222222222222222"
198+msg=$(git --git-dir="$T/multigram.git" log -1 --format=%B "$S2")
199+has "message title" "$msg" "Forkgram 1.1.0 snapshot"
200+has "message Source" "$msg" "Source: file://$T/forkgram.git (dev) at $R2"
201+has "message Forkgram-Commit" "$msg" "Forkgram-Commit: $R2"
202+has "message Forkgram-Version" "$msg" "Forkgram-Version: 1.1.0"
203+has "message Telegram-Base" "$msg" "Telegram-Base: $U2 (update to 1.1.0)"
204+has "message tail" "$msg" "Tree is Forkgram's dev branch at that commit with the .github folder removed."
205+C1=$(rref multigram-next)
206+eq "multigram-next = candidate" "$C1" "$(o candidate)"
207+eq "candidate has 3 commits on the snapshot" "$(git --git-dir="$T/multigram.git" rev-list --count "$S2..$C1")" 3
208+eq "candidate subjects" "$(git --git-dir="$T/multigram.git" log --reverse --format=%s "$S2..$C1" | tr '\n' '|')" "Add MultiGram notes|Rename the app|Add a MultiGram hook|"
209+eq "candidate Main.java: stack change on the new upstream" "$(git --git-dir="$T/multigram.git" show "$C1:app/Main.java" | sed -n '2p;7p' | tr '\n' '|')" ' String name = "MultiGram";| // end, 1.1.0|'
210+eq "candidate keeps upstream's new file" "$(git --git-dir="$T/multigram.git" show "$C1:app/Other.java")" "class Other {}"
211+eq "candidate tree = snapshot + stack diff" \
212+ "$(git --git-dir="$T/multigram.git" diff --stat "$S2" "$C1" | tail -n1)" \
213+ "$(git --git-dir="$T/multigram.git" diff --stat "$S1" "$MG0" | tail -n1)"
214+eq "multigram not moved yet" "$(rref multigram)" "$MG0"
215+eq "the sync recorded the multigram-next it built" "$(rref refs/multigram-sync/next)" "$C1"
216+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
217+ci_clone
218+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
219+eq "promote exit code" "$RC" 0
220+eq "promote status" "$(o status)" promoted
221+eq "multigram moved to the candidate" "$(rref multigram)" "$C1"
222+eq "old multigram tagged" "$(rref refs/tags/multigram-before-1.1.0)" "$MG0"
223+eq "multigram-next deleted" "$(rref multigram-next)" ""
224+
225+# ------------------------------------------------------------------ D: conflicting release
226+title "D. New Forkgram release 1.2.0 that conflicts"
227+cd "$T/fg"
228+git reset -q --hard "$U2"
229+upstream 1.2.0 120 "Fork 2" "end, 1.1.0" 3333333333333333333333333333333333333333
230+fork_commits 1.2.0
231+R3=$(git rev-parse HEAD)
232+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
233+MG1=$(rref multigram)
234+ci_clone
235+run "$T/ci" --remote origin --push
236+eq "exit code" "$RC" 2
237+eq "status" "$(o status)" conflict
238+S3=$(rref forkgram)
239+eq "snapshot pushed anyway" "$S3" "$(o snapshot)"
240+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
241+eq "conflict commit is the rename" "$(o conflict_commit)" "$(git --git-dir="$T/multigram.git" rev-parse "$MG1~1")"
242+eq "conflict subject" "$(o conflict_subject)" "Rename the app"
243+eq "conflict position" "$(o conflict_position)" "2 of 3"
244+eq "conflict files" "$(o conflict_files)" app/Main.java
245+eq "conflict details" "$(o conflict_details)" "changed on both sides: app/Main.java"
246+eq "multigram untouched" "$(rref multigram)" "$MG1"
247+eq "no multigram-next" "$(rref multigram-next)" ""
248+eq "no candidate" "$(o candidate)" ""
249+eq "pushed" "$(o pushed)" forkgram
250+
251+# ------------------------------------------------------------------ E: behind
252+title "E. Next run, no new release: multigram is behind"
253+ci_clone
254+run "$T/ci" --remote origin --push
255+eq "exit code" "$RC" 0
256+eq "status" "$(o status)" behind
257+eq "multigram_base is the old snapshot" "$(o multigram_base)" "$S2"
258+eq "nothing pushed" "$(o pushed)" ""
259+
260+# ------------------------------------------------------------------ F: hand fix with rerere
261+title "F. Hand fix in a local clone; rerere replays it on the next local run"
262+git clone -q "file://$T/multigram.git" "$T/owner"
263+cd "$T/owner"
264+git config rerere.enabled true
265+git switch -q -C multigram-next origin/multigram
266+if git rebase -q --onto "$S3" "$S2" >/dev/null 2>&1; then bad "hand rebase should conflict"; fi
267+main_java "MultiGram 2" "end, 1.1.0" >app/Main.java
268+git add app/Main.java
269+GIT_EDITOR=true git rebase --continue >/dev/null 2>&1
270+FIX=$(git rev-parse HEAD)
271+eq "hand fix has 3 commits on the new snapshot" "$(git rev-list --count "$S3..$FIX")" 3
272+git switch -q --detach
273+git branch -q -D multigram-next
274+# The owner's own branches, with rebase.updateRefs on: the replay must not move them.
275+git config rebase.updateRefs true
276+git branch -q -f multigram origin/multigram
277+git branch -q -f wip origin/multigram~1
278+run "$T/owner" --remote origin --force
279+git config --unset rebase.updateRefs
280+eq "exit code" "$RC" 0
281+eq "status" "$(o status)" candidate
282+eq "local multigram not moved (rebase.updateRefs)" "$(git rev-parse multigram)" "$MG1"
283+eq "local wip not moved (rebase.updateRefs)" "$(git rev-parse wip)" "$(git rev-parse "$MG1~1")"
284+eq "local record = local multigram-next" "$(git rev-parse refs/multigram-sync/next)" "$(git rev-parse multigram-next)"
285+has "rerere_resolved names the file" "$(o rerere_resolved)" "app/Main.java"
286+eq "local multigram-next has the recorded resolution" "$(git show multigram-next:app/Main.java | sed -n 2p)" ' String name = "MultiGram 2";'
287+eq "same tree as the hand fix" "$(git rev-parse "multigram-next^{tree}")" "$(git rev-parse "$FIX^{tree}")"
288+eq "local forkgram = latest snapshot" "$(git rev-parse forkgram)" "$S3"
289+eq "nothing pushed without --push" "$(rref multigram-next)" ""
290+
291+# ------------------------------------------------------------------ G: --use-next
292+title "G. The hand fix on multigram-next: never replaced; --use-next checks it, then promote"
293+git push -q -f origin "$FIX:refs/heads/multigram-next"
294+ci_clone
295+run "$T/ci" --remote origin --push --force
296+eq "--force: exit code" "$RC" 0
297+eq "--force: status" "$(o status)" next-edited
298+eq "--force: existing_next" "$(o existing_next)" "$FIX"
299+eq "--force: multigram-next kept" "$(rref multigram-next)" "$FIX"
300+eq "--force: nothing pushed" "$(o pushed)" ""
301+cd "$T/fg"
302+setver 1.2.1
303+git commit -q -am "[Release] 1.2.1."
304+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
305+ci_clone
306+run "$T/ci" --remote origin --push
307+eq "new release: status" "$(o status)" next-edited
308+eq "new release: forkgram_tip is the new commit" "$(o forkgram_tip)" "$(git -C "$T/fg" rev-parse HEAD)"
309+eq "new release: no new snapshot" "$(rref forkgram)" "$S3"
310+eq "new release: multigram-next kept" "$(rref multigram-next)" "$FIX"
311+has "new release: message says why" "$(o message)" "has commits the sync did not make"
312+git reset -q --hard "$R3"
313+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
314+cd "$T/owner"
315+git fetch -q origin
316+git switch -q -C late origin/multigram
317+printf 'late\n' >app/Late.java
318+git add -A && GIT_AUTHOR_DATE="2030-01-01T00:00:00Z" git commit -q -m "Late MultiGram commit"
319+LATE=$(git rev-parse HEAD)
320+git push -q origin late:multigram
321+git switch -q --detach
322+git branch -q -D late
323+ci_clone
324+run "$T/ci" --remote origin --push --use-next
325+eq "late commit: exit code" "$RC" 1
326+eq "late commit: status" "$(o status)" error
327+eq "late commit: missing" "$(o missing)" "$LATE Late MultiGram commit"
328+has "late commit: error names it" "$(o error)" "Late MultiGram commit"
329+has "late commit: error offers allow_drop" "$(o error)" "allow_drop"
330+ci_clone
331+run "$T/ci" --remote origin --push --use-next --allow-drop
332+eq "--allow-drop: status" "$(o status)" candidate
333+eq "--allow-drop: dropped" "$(o dropped)" "$LATE Late MultiGram commit"
334+ci_clone
335+run "$T/ci" --remote origin --push --allow-drop
336+eq "--allow-drop without --use-next: exit code" "$RC" 1
337+git -C "$T/owner" push -q -f origin "$MG1:refs/heads/multigram"
338+ci_clone
339+run "$T/ci" --remote origin --push --use-next
340+eq "exit code" "$RC" 0
341+eq "status" "$(o status)" candidate
342+eq "candidate is the hand fix" "$(o candidate)" "$FIX"
343+eq "the hand fix carries the whole stack" "$(o missing)$(o dropped)" ""
344+eq "forkgram_version" "$(o forkgram_version)" 1.2.0
345+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
346+ci_clone
347+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
348+eq "promote status" "$(o status)" promoted
349+eq "multigram = hand fix" "$(rref multigram)" "$FIX"
350+eq "tag" "$(rref refs/tags/multigram-before-1.2.0)" "$MG1"
351+ci_clone
352+run "$T/ci" --remote origin --push
353+eq "and now up to date" "$(o status)" up-to-date
354+
355+# ------------------------------------------------------------------ H: dropped commit
356+title "H. Release 1.3.0 already has one MultiGram change: that commit is dropped"
357+cd "$T/fg"
358+git reset -q --hard "$R3"
359+mkdir -p multigram && printf 'MultiGram notes\n' >multigram/README.md
360+git add -A && git commit -q -m "Take the notes file"
361+setver 1.3.0
362+git commit -q -am "[Release] 1.3.0."
363+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
364+ci_clone
365+run "$T/ci" --remote origin --push
366+eq "status" "$(o status)" candidate
367+eq "telegram_base still found" "$(o telegram_base)" "$(git -C "$T/fg" rev-parse "HEAD~4") (update to 1.2.0)"
368+eq "stack_count" "$(o stack_count)" 3
369+eq "candidate_count" "$(o candidate_count)" 2
370+if [ -n "$(o dropped)" ]; then ok "dropped is reported"; else bad "dropped is empty"; fi
371+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
372+
373+# ------------------------------------------------------------------ I: promote lease
374+title "I. Promote refuses when multigram moved after the sync"
375+cd "$T/owner"
376+git fetch -q origin
377+git commit -q --allow-empty -m "Someone else's commit" && git push -q -f origin "HEAD:refs/heads/multigram"
378+ci_clone
379+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
380+eq "exit code" "$RC" 1
381+eq "status" "$(o status)" error
382+has "error says why" "$(o error)" "pushed to meanwhile"
383+eq "multigram-next kept" "$(rref multigram-next)" "$cand"
384+git push -q -f origin "$prev:refs/heads/multigram"
385+ci_clone
386+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
387+eq "promote after restoring multigram" "$(o status)" promoted
388+
389+# ------------------------------------------------------------------ J: .github guard
390+title "J. A stack commit that adds .github is refused"
391+cd "$T/owner"
392+git fetch -q origin
393+git switch -q -C multigram origin/multigram
394+mkdir -p .github && printf 'x\n' >.github/extra.yml
395+git add -A && git commit -q -m "Add a workflow file"
396+git push -q -f origin multigram
397+BAD=$(git rev-parse HEAD)
398+F_BEFORE=$(rref forkgram)
399+ci_clone
400+run "$T/ci" --remote origin --push --force
401+eq "exit code" "$RC" 1
402+eq "status" "$(o status)" error
403+has "error names .github" "$(o error)" ".github"
404+eq "nothing pushed" "$(rref multigram-next)" ""
405+eq "forkgram unchanged" "$(rref forkgram)" "$F_BEFORE"
406+git push -q -f origin "$BAD~1:refs/heads/multigram"
407+git switch -q --detach
408+
409+# ------------------------------------------------------------------ K: guards
410+title "K. Guards: branch checked out, no multigram branch"
411+git switch -q -C forkgram origin/forkgram
412+run "$T/owner" --force
413+eq "checked-out forkgram: exit code" "$RC" 1
414+has "checked-out forkgram: error" "$(o error)" "checked out in a worktree"
415+git switch -q --detach
416+git init -q --bare "$T/empty.git"
417+git -C "$T/owner" push -q "$T/empty.git" "origin/forkgram:refs/heads/forkgram"
418+git -C "$T/owner" remote add empty "$T/empty.git"
419+run "$T/owner" --remote empty --push
420+eq "no multigram: exit code" "$RC" 1
421+has "no multigram: error" "$(o error)" "has no multigram branch yet"
422+run "$T/owner" --remote empty --use-next --force
423+eq "--force with --use-next: exit code" "$RC" 1
424+git fetch -q origin
425+UNPUSHED=$(git commit-tree -p origin/multigram -m "Unpushed fix" "origin/multigram^{tree}")
426+git branch -q -f multigram-next "$UNPUSHED"
427+F_BEFORE=$(git rev-parse forkgram)
428+run "$T/owner" --remote origin --force
429+eq "unpushed local multigram-next: exit code" "$RC" 1
430+has "unpushed local multigram-next: error" "$(o error)" "on no other branch or tag"
431+eq "unpushed local multigram-next: not moved" "$(git rev-parse multigram-next)" "$UNPUSHED"
432+eq "unpushed local multigram-next: local forkgram not moved" "$(git rev-parse forkgram)" "$F_BEFORE"
433+git branch -q -D multigram-next
434+
435+# ------------------------------------------------------------------ L: local mode
436+title "L. Local branches, no remote"
437+cd "$T/owner"
438+git fetch -q origin
439+git branch -q -f forkgram origin/forkgram
440+git branch -q -f multigram origin/multigram
441+run "$T/owner"
442+eq "status" "$(o status)" up-to-date
443+HAND=$(git commit-tree -p multigram -m "Hand fix" "multigram^{tree}")
444+git branch -q -f multigram-next "$HAND"
445+run "$T/owner" --force
446+eq "hand-made local multigram-next: exit code" "$RC" 0
447+eq "hand-made local multigram-next: status" "$(o status)" next-edited
448+eq "hand-made local multigram-next: kept" "$(git rev-parse multigram-next)" "$HAND"
449+git branch -q -D multigram-next
450+
451+# ------------------------------------------------------------------ M: export-patches
452+title "M. export-patches.sh, applied onto a plain Forkgram checkout with git am -3"
453+cd "$T/owner"
454+EXP_RC=0
455+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >"$T/log" 2>&1 || EXP_RC=$?
456+sed 's/^/ | /' "$T/log" | head -n 8
457+eq "export exit code" "$EXP_RC" 0
458+eq "patch files" "$(cd "$T/patches" && ls ./*.patch | wc -l | tr -d ' ')" 2
459+if [ -f "$T/patches/APPLY.txt" ]; then ok "APPLY.txt written"; else bad "APPLY.txt missing"; fi
460+has "APPLY.txt names the Forkgram commit" "$(cat "$T/patches/APPLY.txt")" "$(git -C "$T/fg" rev-parse HEAD)"
461+git clone -q -b dev "$T/forkgram.git" "$T/plain"
462+cd "$T/plain"
463+git switch -q -c multigram
464+if git am -q -3 "$T/patches"/*.patch >/dev/null 2>&1; then ok "git am -3 applies"; else bad "git am -3 failed"; fi
465+eq "result = multigram + Forkgram's .github" \
466+ "$(git ls-tree -r HEAD | grep -v ' .github/')" \
467+ "$(git -C "$T/owner" ls-tree -r origin/multigram)"
468+EXP_RC=0
469+bash "$EXPORT" --base origin/forkgram --head origin/multigram "$T/patches" >/dev/null 2>&1 || EXP_RC=$?
470+eq "refuses a non-empty folder without --force" "$EXP_RC" 1
471+
472+# ------------------------------------------------------------------ N: Forkgram already fetched
473+title "N. Forkgram's tip already in the local clone; a history too short for Telegram-Base"
474+cd "$T/fg"
475+printf 'class Later {}\n' >app/Later.java
476+git add -A && git commit -q -m "Added a later feature."
477+setver 1.5.0
478+git commit -q -am "[Release] 1.5.0."
479+git push -q -f "$T/forkgram.git" HEAD:refs/heads/dev
480+R5=$(git rev-parse HEAD)
481+git clone -q "file://$T/multigram.git" "$T/full"
482+run "$T/full" --remote origin
483+eq "full clone: status" "$(o status)" candidate
484+has "full clone: fetched into a scratch repository" "$(cat "$T/log")" "into a scratch repository"
485+eq "full clone: still not shallow" "$(git -C "$T/full" rev-parse --is-shallow-repository)" false
486+eq "full clone: snapshot tree" "$(git -C "$T/full" ls-tree forkgram)" "$(git -C "$T/fg" ls-tree "$R5" | grep -v " .github$")"
487+eq "full clone: version from gradle.properties" "$(o forkgram_version)" 1.5.0
488+if git -C "$T/full" fsck --no-progress >"$T/fsck" 2>&1; then ok "full clone: fsck is clean"; else bad "full clone: fsck: $(head -n 3 "$T/fsck")"; fi
489+cd "$T/owner"
490+git fetch -q "$T/forkgram.git" "+refs/heads/dev:refs/forkgram-full/dev"
491+git fetch -q origin
492+git branch -q -f forkgram origin/forkgram
493+git branch -q -f multigram origin/multigram
494+FORKGRAM_HISTORY_DEPTH=1 run "$T/owner" --remote origin
495+eq "status" "$(o status)" candidate
496+has "used the local copy" "$(cat "$T/log")" "already here"
497+eq "no Telegram-Base when the history is too short" "$(o telegram_base)" ""
498+if git log -1 --format=%B forkgram | grep -q '^Telegram-Base:'; then bad "Telegram-Base line written"; else ok "no Telegram-Base line"; fi
499+eq "snapshot tree" "$(git ls-tree forkgram)" "$(git ls-tree "$R5" | grep -v " .github$")"
500+eq "local forkgram is the new snapshot, origin untouched" "$(git rev-parse forkgram^)" "$(rref forkgram)"
501+eq "not a shallow repository" "$(git rev-parse --is-shallow-repository)" false
502+
503+# ------------------------------------------------------------------ O: tag already taken
504+title "O. A second promotion for the same Forkgram version gets its own tag"
505+cd "$T/owner"
506+git fetch -q origin
507+git switch -q -C multigram-next origin/multigram
508+printf 'more\n' >>multigram/README.md 2>/dev/null || { mkdir -p multigram; printf 'more\n' >multigram/README.md; }
509+git add -A && git commit -q -m "Another MultiGram change"
510+git push -q -f origin multigram-next
511+git switch -q --detach
512+ci_clone
513+run "$T/ci" --remote origin --push --use-next
514+cand=$(o candidate) prev=$(o multigram) tv=$(o tag_version)
515+eq "same version as the last promotion" "$tv" 1.3.0
516+ci_clone
517+run "$T/ci" promote --remote origin --candidate "$cand" --previous "$prev" --version "$tv" --shallow
518+eq "status" "$(o status)" promoted
519+eq "tag with a suffix" "$(o tag)" multigram-before-1.3.0-2
520+eq "tag points at the old multigram" "$(rref refs/tags/multigram-before-1.3.0-2)" "$prev"
521+eq "first tag untouched" "$(rref refs/tags/multigram-before-1.3.0)" "$FIX"
522+
523+# ------------------------------------------------------------------ Q: merge commits
524+title "Q. Merge commits on multigram: a plain merge is flattened, one with its own changes stops the sync"
525+cd "$T/owner"
526+git fetch -q origin
527+MGQ=$(git rev-parse origin/multigram)
528+git switch -q -C side origin/forkgram
529+printf 'class Side {}\n' >app/Side.java
530+git add -A && git commit -q -m "Side feature"
531+git switch -q -C multigram "$MGQ"
532+git merge -q --no-ff --no-edit side
533+git push -q -f origin multigram
534+run "$T/owner" --remote origin --force
535+eq "plain merge: exit code" "$RC" 0
536+eq "plain merge: status" "$(o status)" candidate
537+has "plain merge: warned" "$(cat "$T/log")" "only join their parents"
538+eq "plain merge: candidate = snapshot + everything multigram changes" \
539+ "$(git diff "$(o snapshot)" multigram-next -- | grep -v '^index ')" "$(git diff origin/forkgram multigram -- | grep -v '^index ')"
540+eq "plain merge: candidate is linear" "$(git rev-list --merges "origin/forkgram..multigram-next")" ""
541+git reset -q --hard "$MGQ"
542+git merge -q --no-ff --no-commit side
543+printf 'class Glue {}\n' >app/Glue.java
544+git add -A && git commit -q -m "Merge side with glue"
545+git push -q -f origin multigram
546+run "$T/owner" --remote origin --force
547+eq "merge with changes: exit code" "$RC" 1
548+has "merge with changes: error names it" "$(o error)" "Merge side with glue"
549+git push -q -f origin "$MGQ:refs/heads/multigram"
550+git switch -q --detach
551+git branch -q -D side multigram
552+
553+# ------------------------------------------------------------------ P: sync-report.py
554+title "P. sync-report.py against a fake gh"
555+mkdir -p "$T/bin"
556+cat >"$T/bin/gh" <<'PY'
557+#!/usr/bin/env python3
558+# A tiny stand-in for the gh CLI: keeps issues in a JSON file.
559+import json, os, sys
560+path = os.environ['FAKE_GH_STATE']
561+st = json.load(open(path)) if os.path.exists(path) else {'issues': [], 'labels': []}
562+a = sys.argv[1:]
563+def opt(name):
564+ return a[a.index(name) + 1] if name in a else None
565+def body():
566+ f = opt('--body-file')
567+ return open(f).read() if f else ''
568+def issue(n):
569+ return next(i for i in st['issues'] if i['number'] == int(n))
570+if a[:2] == ['label', 'create']:
571+ st['labels'].append(a[2])
572+elif a[:2] == ['issue', 'list']:
573+ print(json.dumps([{'number': i['number'], 'title': i['title']} for i in st['issues']
574+ if i['state'] == 'open' and opt('--label') in i['labels']]))
575+elif a[:2] == ['issue', 'view']:
576+ i = issue(a[2])
577+ print(json.dumps({'body': i['body'], 'comments': [{'body': c} for c in i['comments']]}))
578+elif a[:2] == ['issue', 'create']:
579+ if opt('--label') not in st['labels']:
580+ sys.exit('label missing')
581+ n = len(st['issues']) + 1
582+ st['issues'].append({'number': n, 'title': opt('--title'), 'body': body(), 'labels': [opt('--label')],
583+ 'state': 'open', 'comments': []})
584+ print(f'https://example.invalid/issues/{n}')
585+elif a[:2] == ['issue', 'comment']:
586+ issue(a[2])['comments'].append(body())
587+elif a[:2] == ['issue', 'edit']:
588+ issue(a[2])['title'] = opt('--title')
589+elif a[:2] == ['issue', 'close']:
590+ i = issue(a[2]); i['state'] = 'closed'; i['comments'].append(opt('--comment'))
591+else:
592+ sys.exit('fake gh: unsupported ' + ' '.join(a))
593+json.dump(st, open(path, 'w'))
594+PY
595+chmod +x "$T/bin/gh"
596+export REPORT_GH=$T/bin/gh FAKE_GH_STATE=$T/gh.json RUN_URL=https://example.invalid/runs/1
597+report() { # VAR=value... : runs sync-report.py with only these job results and outputs
598+ env -i PATH="$PATH" REPORT_GH="$REPORT_GH" FAKE_GH_STATE="$FAKE_GH_STATE" RUN_URL="$RUN_URL" "$@" \
599+ python3 "$HERE/sync-report.py" 2>&1 | sed 's/^/ | /'
600+}
601+gh_state() { python3 -c "import json,sys; st=json.load(open('$T/gh.json')); print(eval(sys.argv[1]))" "$1"; }
602+A40=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa B40=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb C40=cccccccccccccccccccccccccccccccccccccccc
603+conflict=(SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=conflict
604+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_SNAPSHOT=$B40 SYNC_PREVIOUS_SNAPSHOT=$C40
605+ SYNC_CONFLICT_COMMIT=$C40 "SYNC_CONFLICT_SUBJECT=Rename the \`app\`" "SYNC_CONFLICT_POSITION=2 of 3"
606+ SYNC_CONFLICT_FILES=app/Main.java "SYNC_CONFLICT_DETAILS=changed on both sides: app/Main.java")
607+report "${conflict[@]}"
608+eq "conflict opens an issue" "$(gh_state "len(st['issues'])")" 1
609+eq "issue title" "$(gh_state "st['issues'][0]['title']")" "Forkgram sync needs a hand (Forkgram 1.2.0)"
610+has "issue body has the rebase command" "$(gh_state "st['issues'][0]['body']")" "git rebase --onto $B40 $C40"
611+has "issue body has the file" "$(gh_state "st['issues'][0]['body']")" "changed on both sides: app/Main.java"
612+has "backticks in the subject are neutralised" "$(gh_state "st['issues'][0]['body']")" "Rename the 'app'"
613+report "${conflict[@]}"
614+eq "the same conflict is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 0
615+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.2.0
616+eq "behind with an open issue adds nothing" "$(gh_state "len(st['issues'][0]['comments'])")" 0
617+report SYNC_RESULT=success COMPILE_RESULT=failure PROMOTE_RESULT=skipped SYNC_STATUS=candidate \
618+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 SYNC_SNAPSHOT=$B40
619+eq "compile failure comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 1
620+has "comment has the autosquash fix" "$(gh_state "st['issues'][0]['comments'][0]")" "git rebase -i --autosquash $B40"
621+next_edited=(SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=next-edited
622+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_FORKGRAM_COMMIT=$A40 SYNC_FORKGRAM_TIP=$B40 SYNC_EXISTING_NEXT=$C40)
623+report "${next_edited[@]}"
624+eq "next-edited comments on the open issue" "$(gh_state "len(st['issues'][0]['comments'])")" 2
625+has "next-edited comment says what to do" "$(gh_state "st['issues'][0]['comments'][1]")" "use_next"
626+has "next-edited comment names the new Forkgram commit" "$(gh_state "st['issues'][0]['comments'][1]")" "${B40:0:10}"
627+report "${next_edited[@]}"
628+eq "the same next-edited is not reported twice" "$(gh_state "len(st['issues'][0]['comments'])")" 2
629+report SYNC_RESULT=success COMPILE_RESULT=success PROMOTE_RESULT=success SYNC_STATUS=candidate \
630+ SYNC_FORKGRAM_VERSION=1.2.0 SYNC_CANDIDATE=$A40 PROMOTE_TAG=multigram-before-1.2.0 "SYNC_DROPPED=$C40 Late \`commit\`"
631+eq "promotion closes the issue" "$(gh_state "st['issues'][0]['state']")" closed
632+has "closing note names the tag" "$(gh_state "st['issues'][0]['comments'][-1]")" "multigram-before-1.2.0"
633+has "closing note lists the dropped commits" "$(gh_state "st['issues'][0]['comments'][-1]")" "$C40 Late 'commit'"
634+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
635+eq "up to date with no open issue: nothing" "$(gh_state "len(st['issues'])")" 1
636+report SYNC_RESULT=failure COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=error "SYNC_ERROR=origin has no multigram branch yet."
637+eq "a sync error opens a new issue" "$(gh_state "len(st['issues'])")" 2
638+has "error text in the issue" "$(gh_state "st['issues'][1]['body']")" "origin has no multigram branch yet."
639+report SYNC_RESULT=cancelled COMPILE_RESULT=skipped PROMOTE_RESULT=skipped
640+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=up-to-date SYNC_FORKGRAM_VERSION=1.2.0
641+eq "in step again closes it" "$(gh_state "st['issues'][1]['state']")" closed
642+report SYNC_RESULT=success COMPILE_RESULT=skipped PROMOTE_RESULT=skipped SYNC_STATUS=behind SYNC_FORKGRAM_VERSION=1.3.0
643+eq "behind with no open issue opens one" "$(gh_state "[i['state'] for i in st['issues']]")" "['closed', 'closed', 'open']"
644+
645+printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
646+[ "$FAIL" -eq 0 ]
scripts/sync-report.py+3−2
@@ -152,8 +152,9 @@ If the log shows a runner or network hiccup, use **Re-run failed jobs** on the r
152152```sh
153153git fetch origin
154154git switch -C multigram-next origin/multigram-next
155−# fix the build, then fold the fix into the MultiGram commit it belongs to:
156−git commit -a --fixup=<that commit>
155+# fix the build, then fold each fix into the MultiGram commit it belongs to,
156+# one fixup per commit, naming that commit's files (PATCH.md 6.3 on the docs branch):
157+git commit --fixup=<that commit> -- <its files>
157158GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
158159git push --force origin multigram-next
159160```