on 3e8b2ea help sign in
.github/workflows/build.yml+2−43
since splitsnapshots
unifiedsplit
@@ -1,5 +1,4 @@
1name: Build MultiGram
1name: Build MultiGram
2−run-name: Build ${{ inputs.ref }}${{ inputs.apk && ' (APK)' || '' }}
3
2
4on:
3on:
5 workflow_dispatch:
4 workflow_dispatch:
@@ -20,10 +19,6 @@ on:
20 apk:
19 apk:
21 type: boolean
20 type: boolean
22 default: false
21 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'
27
22
28permissions:
23permissions:
29 contents: read
24 contents: read
@@ -71,15 +66,6 @@ jobs:
71 with:
66 with:
72 tool-cache: false
67 tool-cache: false
73 android: false
68 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
83 - uses: actions/setup-java@v4
69 - uses: actions/setup-java@v4
84 with:
70 with:
85 distribution: temurin
71 distribution: temurin
@@ -94,43 +80,16 @@ jobs:
94 env:
80 env:
95 APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
81 APP_ID: ${{ secrets.MULTIGRAM_APP_ID }}
96 APP_HASH: ${{ secrets.MULTIGRAM_APP_HASH }}
82 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 }}
102 run: |
83 run: |
103 vars=gradle.properties
84 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
108 echo "org.gradle.workers.max=1" >> $vars
85 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
113 [ -n "$APP_ID" ] && echo "APP_ID=$APP_ID" >> $vars
86 [ -n "$APP_ID" ] && echo "APP_ID=$APP_ID" >> $vars
114 [ -n "$APP_HASH" ] && echo "APP_HASH=$APP_HASH" >> $vars
87 [ -n "$APP_HASH" ] && echo "APP_HASH=$APP_HASH" >> $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.
88+ echo "RELEASE_KEYSTORE_FILE=$PWD/TMessagesProj/config/release.keystore" >> $vars
128− echo "::warning::No MULTIGRAM_KEYSTORE_BASE64 secret: the APK is signed with the public test key."
129− fi
130 sudo apt update
89 sudo apt update
131 sudo apt -y install ninja-build meson nasm gperf pkg-config unzip
90 sudo apt -y install ninja-build meson nasm gperf pkg-config unzip
132 rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
91 rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
133− ./gradlew "${signing[@]}" :TMessagesProj_App:assembleAfatRelease
92+ ./gradlew :TMessagesProj_App:assembleAfatRelease
134 - uses: actions/upload-artifact@v4
93 - uses: actions/upload-artifact@v4
135 with:
94 with:
136 name: multigram-apk
95 name: multigram-apk
.github/workflows/release.ymldeleted−242
@@ -1,242 +0,0 @@
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"
.github/workflows/sync-forkgram.ymldeleted−177
@@ -1,177 +0,0 @@
1−name: Sync with Forkgram
2−run-name: Sync with Forkgram${{ inputs.use_next && ' (use multigram-next)' || '' }}${{ inputs.allow_drop && ' (allow drop)' || '' }}${{ inputs.force && ' (force)' || '' }}
3−
4−# Every 6 hours: snapshot a new Forkgram release onto "forkgram", replay the MultiGram
5−# stack onto it as "multigram-next", compile that, and only if it compiles move "multigram"
6−# to it. Conflicts and failures go to an issue labelled forkgram-sync; multigram stays as
7−# it was. A multigram-next changed by hand is never replaced. See README.md and
8−# scripts/sync-forkgram.sh.
9−on:
10− schedule:
11− - cron: '23 */6 * * *'
12− workflow_dispatch:
13− inputs:
14− force:
15− description: Rebuild multigram-next even without a new Forkgram release (tests the pipeline, or retries after a failed compile)
16− type: boolean
17− default: false
18− use_next:
19− description: Skip the rebase; compile and promote the multigram-next branch you fixed by hand
20− type: boolean
21− default: false
22− allow_drop:
23− description: Only with use_next. Accept a multigram-next that leaves out some of multigram's commits (Forkgram has them now, or you dropped them on purpose)
24− type: boolean
25− default: false
26−
27−permissions: {}
28−
29−concurrency:
30− group: forkgram-sync
31− cancel-in-progress: false
32−
33−jobs:
34− sync:
35− name: Snapshot Forkgram and replay the stack
36− runs-on: ubuntu-latest
37− timeout-minutes: 60
38− permissions:
39− contents: write # push forkgram, multigram-next and refs/multigram-sync/next
40− issues: read # see whether the report job has anything to do
41− outputs:
42− status: ${{ steps.sync.outputs.status }}
43− message: ${{ steps.sync.outputs.message }}
44− error: ${{ steps.sync.outputs.error }}
45− new_snapshot: ${{ steps.sync.outputs.new_snapshot }}
46− forkgram_commit: ${{ steps.sync.outputs.forkgram_commit }}
47− forkgram_version: ${{ steps.sync.outputs.forkgram_version }}
48− snapshot: ${{ steps.sync.outputs.snapshot }}
49− previous_snapshot: ${{ steps.sync.outputs.previous_snapshot }}
50− multigram: ${{ steps.sync.outputs.multigram }}
51− multigram_base: ${{ steps.sync.outputs.multigram_base }}
52− candidate: ${{ steps.sync.outputs.candidate }}
53− tag_version: ${{ steps.sync.outputs.tag_version }}
54− conflict_commit: ${{ steps.sync.outputs.conflict_commit }}
55− conflict_subject: ${{ steps.sync.outputs.conflict_subject }}
56− conflict_position: ${{ steps.sync.outputs.conflict_position }}
57− conflict_files: ${{ steps.sync.outputs.conflict_files }}
58− conflict_details: ${{ steps.sync.outputs.conflict_details }}
59− dropped: ${{ steps.sync.outputs.dropped }}
60− existing_next: ${{ steps.sync.outputs.existing_next }}
61− forkgram_tip: ${{ steps.sync.outputs.forkgram_tip }}
62− report: ${{ steps.need.outputs.report }}
63− steps:
64− # Blobless: the history and trees of forkgram and multigram come cheap, and file
65− # contents are fetched only when the rebase needs them.
66− - uses: actions/checkout@v4
67− with:
68− fetch-depth: 1
69− filter: blob:none
70− - name: Sync
71− id: sync
72− env:
73− FORCE: ${{ inputs.force }}
74− USE_NEXT: ${{ inputs.use_next }}
75− ALLOW_DROP: ${{ inputs.allow_drop }}
76− run: |
77− git config user.name 'github-actions[bot]'
78− git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
79− args=(--remote origin --push)
80− if [ "$FORCE" = true ]; then args+=(--force); fi
81− if [ "$USE_NEXT" = true ]; then args+=(--use-next); fi
82− if [ "$ALLOW_DROP" = true ]; then args+=(--allow-drop); fi
83− bash scripts/sync-forkgram.sh "${args[@]}"
84− # Most scheduled runs find nothing new. Skip the report job then, since every job
85− # costs at least a billed minute: it only has work when an issue is open to close
86− # (up-to-date), or none is open yet (behind).
87− - name: Check whether the report job has anything to do
88− id: need
89− if: steps.sync.outputs.status == 'up-to-date' || steps.sync.outputs.status == 'behind'
90− env:
91− GH_TOKEN: ${{ github.token }}
92− GH_REPO: ${{ github.repository }}
93− STATUS: ${{ steps.sync.outputs.status }}
94− run: |
95− open=$(gh issue list --label forkgram-sync --state open --json number --jq length) || open=unknown
96− echo "Open forkgram-sync issues: $open"
97− case "$STATUS:$open" in
98− up-to-date:0 | behind:[1-9]*) echo report=no >>"$GITHUB_OUTPUT" ;;
99− *) echo report=yes >>"$GITHUB_OUTPUT" ;;
100− esac
101−
102− compile:
103− name: Compile multigram-next
104− needs: sync
105− if: needs.sync.outputs.candidate != ''
106− permissions:
107− contents: read
108− uses: ./.github/workflows/build.yml
109− with:
110− ref: ${{ needs.sync.outputs.candidate }}
111−
112− promote:
113− name: Move multigram to multigram-next
114− needs: [sync, compile]
115− if: needs.compile.result == 'success'
116− runs-on: ubuntu-latest
117− timeout-minutes: 15
118− permissions:
119− contents: write # move multigram, tag its old tip, delete multigram-next
120− outputs:
121− status: ${{ steps.promote.outputs.status }}
122− tag: ${{ steps.promote.outputs.tag }}
123− steps:
124− - uses: actions/checkout@v4
125− with:
126− fetch-depth: 1
127− filter: blob:none
128− - name: Promote
129− id: promote
130− env:
131− CANDIDATE: ${{ needs.sync.outputs.candidate }}
132− PREVIOUS: ${{ needs.sync.outputs.multigram }}
133− VERSION: ${{ needs.sync.outputs.tag_version }}
134− run: |
135− bash scripts/sync-forkgram.sh promote --remote origin --shallow \
136− --candidate "$CANDIDATE" --previous "$PREVIOUS" --version "$VERSION"
137−
138− report:
139− name: Report on the forkgram-sync issue
140− needs: [sync, compile, promote]
141− if: ${{ !cancelled() && needs.sync.outputs.report != 'no' }}
142− runs-on: ubuntu-latest
143− timeout-minutes: 10
144− permissions:
145− contents: read # the report script
146− issues: write # open, comment on and close the forkgram-sync issue
147− steps:
148− - uses: actions/checkout@v4
149− with:
150− sparse-checkout: scripts
151− - name: Open, update or close the issue
152− env:
153− GH_TOKEN: ${{ github.token }}
154− GH_REPO: ${{ github.repository }}
155− RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
156− SYNC_RESULT: ${{ needs.sync.result }}
157− COMPILE_RESULT: ${{ needs.compile.result }}
158− PROMOTE_RESULT: ${{ needs.promote.result }}
159− PROMOTE_TAG: ${{ needs.promote.outputs.tag }}
160− SYNC_STATUS: ${{ needs.sync.outputs.status }}
161− SYNC_MESSAGE: ${{ needs.sync.outputs.message }}
162− SYNC_ERROR: ${{ needs.sync.outputs.error }}
163− SYNC_FORKGRAM_COMMIT: ${{ needs.sync.outputs.forkgram_commit }}
164− SYNC_FORKGRAM_VERSION: ${{ needs.sync.outputs.forkgram_version }}
165− SYNC_SNAPSHOT: ${{ needs.sync.outputs.snapshot }}
166− SYNC_PREVIOUS_SNAPSHOT: ${{ needs.sync.outputs.previous_snapshot }}
167− SYNC_MULTIGRAM_BASE: ${{ needs.sync.outputs.multigram_base }}
168− SYNC_CANDIDATE: ${{ needs.sync.outputs.candidate }}
169− SYNC_CONFLICT_COMMIT: ${{ needs.sync.outputs.conflict_commit }}
170− SYNC_CONFLICT_SUBJECT: ${{ needs.sync.outputs.conflict_subject }}
171− SYNC_CONFLICT_POSITION: ${{ needs.sync.outputs.conflict_position }}
172− SYNC_CONFLICT_FILES: ${{ needs.sync.outputs.conflict_files }}
173− SYNC_CONFLICT_DETAILS: ${{ needs.sync.outputs.conflict_details }}
174− SYNC_DROPPED: ${{ needs.sync.outputs.dropped }}
175− SYNC_EXISTING_NEXT: ${{ needs.sync.outputs.existing_next }}
176− SYNC_FORKGRAM_TIP: ${{ needs.sync.outputs.forkgram_tip }}
177− run: python3 scripts/sync-report.py
README.md+1−130
@@ -8,133 +8,4 @@ MultiGram is Forkgram (https://github.com/forkgram/TelegramAndroid) plus a small
8| `multigram` | The app: the latest `forkgram` snapshot plus the MultiGram commits on top. Build this branch. |
8| `multigram` | The app: the latest `forkgram` snapshot plus the MultiGram commits on top. Build this branch. |
9| `main` | This README, the automation that keeps `multigram` in step with Forkgram, and helper scripts. |
9| `main` | This README, the automation that keeps `multigram` in step with Forkgram, and helper scripts. |
10
10
11−The sync also uses `multigram-next` (the next `multigram`, while it is being checked) and tags
11+Setup is in progress.
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/export-patches.shdeleted−86
@@ -1,86 +0,0 @@
1−#!/usr/bin/env bash
2−# Write the MultiGram stack as numbered patches (git format-patch forkgram..multigram) into
3−# a folder, with APPLY.txt saying how to apply them onto a plain Forkgram checkout.
4−#
5−# scripts/export-patches.sh [--base REF] [--head REF] [--force] [FOLDER]
6−#
7−# --base REF the snapshot branch (default: forkgram)
8−# --head REF the app branch (default: multigram)
9−# --force replace the patches of an earlier export in FOLDER
10−# FOLDER where to write them (default: multigram-patches)
11−#
12−# For branches you have not checked out locally, pass --base origin/forkgram
13−# --head origin/multigram after a git fetch.
14−set -euo pipefail
15−
16−die() { printf 'export-patches: %s\n' "$*" >&2; exit 1; }
17−
18−base=forkgram head=multigram force=no dir=multigram-patches
19−while [ $# -gt 0 ]; do
20− case $1 in
21− --base) base=${2:?--base needs a ref}; shift 2 ;;
22− --head) head=${2:?--head needs a ref}; shift 2 ;;
23− --force) force=yes; shift ;;
24− -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
25− -*) die "unknown option: $1 (see --help)" ;;
26− *) dir=$1; shift ;;
27− esac
28−done
29−
30−git rev-parse --git-dir >/dev/null 2>&1 || die "run this inside a clone of the MultiGram repository"
31−base_c=$(git rev-parse -q --verify "$base^{commit}") || die "no such ref: $base"
32−head_c=$(git rev-parse -q --verify "$head^{commit}") || die "no such ref: $head"
33−count=$(git rev-list --count "$base_c..$head_c")
34−[ "$count" -gt 0 ] || die "$head has no commits on top of $base"
35−[ -z "$(git rev-list --merges "$base_c..$head_c")" ] ||
36− die "$head has merge commits on top of $base; git format-patch cannot export them"
37−
38−# The snapshot the stack sits on, and the Forkgram commit it was made from.
39−snap=$(git merge-base "$base_c" "$head_c") || die "$head shares no history with $base"
40−msg=$(git log -1 --format=%B "$snap")
41−fg_commit=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\).*/\1/p' | tail -n 1)
42−fg_version=$(printf '%s\n' "$msg" | sed -n 's/^Forkgram-Version:[[:space:]]*\([0-9A-Za-z._+-]*\).*/\1/p' | tail -n 1)
43−[ -n "$fg_commit" ] || die "$(git rev-parse --short "$snap") (where $head meets $base) is not a Forkgram snapshot"
44−[ -n "$fg_version" ] || fg_version=unknown
45−if [ "$snap" != "$base_c" ]; then
46− printf 'export-patches: note: %s is based on an older snapshot than %s; the patches are for Forkgram %s\n' \
47− "$head" "$base" "$fg_version" >&2
48−fi
49−
50−mkdir -p "$dir"
51−if [ -n "$(ls -A "$dir")" ]; then
52− [ "$force" = yes ] || die "$dir is not empty (use --force to replace an earlier export)"
53− find "$dir" -mindepth 1 -maxdepth 1 \( -name '[0-9][0-9][0-9][0-9]-*.patch' -o -name APPLY.txt \) -exec rm -f {} +
54− [ -z "$(ls -A "$dir")" ] || die "$dir holds files this script did not write; pick another folder"
55−fi
56−
57−git format-patch --quiet --full-index --binary -o "$dir" "$base_c..$head_c"
58−abs=$(cd "$dir" && pwd)
59−
60−cat >"$dir/APPLY.txt" <<EOF
61−MultiGram patches
62−=================
63−
64−$count patch(es): the MultiGram commits of $head ($(git rev-parse --short "$head_c")), made on the
65−forkgram snapshot $(git rev-parse --short "$snap"), which is Forkgram $fg_version with the .github folder removed:
66−
67− Forkgram commit $fg_commit
68−
69−Apply them onto a plain Forkgram checkout of that commit. Forkgram tags its releases
70−<version>.0 (so $fg_version.0 here); if the tag is missing, fetch the commit instead.
71−
72− git clone --depth 1 --branch $fg_version.0 https://github.com/forkgram/TelegramAndroid.git
73− cd TelegramAndroid
74− git rev-parse HEAD # should print $fg_commit
75− git switch -c multigram
76− git am -3 $abs/*.patch
77−
78−-3 lets git fall back to a three-way merge when a patch does not apply as it is.
79−If git am still stops, it names the patch and the files: fix them, "git add" them and
80−run "git am --continue" (or "git am --skip" to leave that patch out, "git am --abort"
81−to go back). The same commands work on a newer Forkgram release (expect more stops);
82−clone without --depth 1 then, so -3 can find the older versions of the files.
83−EOF
84−
85−printf '%s\n' "Wrote $count patch(es) and APPLY.txt to $abs"
86−sed -n '/^ git clone/,/^ git am/p' "$dir/APPLY.txt"
scripts/selftest-sync.shdeleted−646
@@ -1,646 +0,0 @@
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-forkgram.shdeleted−848
@@ -1,848 +0,0 @@
1−#!/usr/bin/env bash
2−# Keep MultiGram in step with Forkgram.
3−#
4−# scripts/sync-forkgram.sh [sync] [--remote NAME [--push]] [--force | --use-next [--allow-drop]]
5−# [--url URL] [--branch NAME] [--history-depth N]
6−# scripts/sync-forkgram.sh promote --remote NAME --candidate SHA --previous SHA
7−# --version VERSION [--shallow]
8−#
9−# sync (the default)
10−# 1. Reads the tip of Forkgram's dev branch. If it is the commit the latest "forkgram"
11−# snapshot was made from, there is nothing to do (exit 0), unless --force.
12−# 2. Otherwise it makes a snapshot commit on "forkgram": the tip's tree without the
13−# .github folder (.gitmodules and the submodule links stay), parented on the previous
14−# snapshot, with the Forkgram-Commit, Forkgram-Version and Telegram-Base lines.
15−# 3. It replays the MultiGram stack (previous-snapshot..multigram) onto the snapshot with
16−# "git rebase --onto", in a temporary worktree, as branch "multigram-next". rerere is on,
17−# so conflicts already resolved in this clone are resolved the same way (a fresh CI clone
18−# has no recorded resolutions). On a conflict it aborts and reports the stack commit and
19−# the files (exit 2). The stack must be linear: a merge commit that carries changes of
20−# its own stops the sync, because the replay flattens merges.
21−# It never replaces a multigram-next that holds commits it did not make (a fix by hand):
22−# it then stops with status next-edited and changes nothing. refs/multigram-sync/next
23−# (pushed with multigram-next, and kept locally) records the last multigram-next it built.
24−# --remote NAME read forkgram and multigram from that remote (default: the local branches)
25−# --push push forkgram and multigram-next to NAME (one atomic push, with leases)
26−# --force no new Forkgram release: rebuild multigram-next on the current snapshot
27−# (tests the pipeline, or retries after a failed compile)
28−# --use-next take the multigram-next branch as it is (a stack fixed by hand) as the
29−# candidate; nothing is fetched from Forkgram and nothing is rebuilt. It
30−# must still carry every commit of multigram's stack (matched by author and
31−# subject, or by patch id); --allow-drop accepts one that leaves some out.
32−# The results are left in the local branches "forkgram" and "multigram-next". The
33−# checked-out worktree is never touched, a branch checked out anywhere is not moved, and
34−# a local multigram-next whose commits are on no other branch or tag is not moved either.
35−#
36−# promote
37−# Moves "multigram" to a candidate that passed the compile check: tags the old tip
38−# multigram-before-VERSION, moves multigram with --force-with-lease and deletes
39−# multigram-next, in one atomic push. --shallow fetches only the two tips.
40−#
41−# Environment: FORKGRAM_URL (default https://github.com/forkgram/TelegramAndroid),
42−# FORKGRAM_BRANCH (default dev), FORKGRAM_HISTORY_DEPTH (default 600: how many Forkgram
43−# commits to search for Telegram's "update to X" commit; 0 = do not search).
44−# When GITHUB_OUTPUT / GITHUB_STEP_SUMMARY are set, the outputs and a summary go there.
45−#
46−# Outputs (key=value, printed at the end and appended to $GITHUB_OUTPUT):
47−# status up-to-date | behind | next-edited | candidate | conflict | error (sync)
48−# promoted | unchanged | error (promote)
49−# message one line for people
50−# new_snapshot yes | no
51−# forkgram_commit, forkgram_version, telegram_base, snapshot
52−# the Forkgram commit, its version, its Telegram base and the forkgram
53−# snapshot the candidate is built on (new or current)
54−# previous_snapshot, previous_forkgram_commit, previous_version
55−# the forkgram tip before this run
56−# multigram the multigram tip the candidate was built from
57−# multigram_base the snapshot multigram is based on (differs from previous_snapshot
58−# when multigram is behind)
59−# stack_count MultiGram commits on multigram; candidate_count: on multigram-next
60−# candidate multigram-next's commit, empty when there is none
61−# existing_next the multigram-next this run found (next-edited: the one it left alone)
62−# tag_version the version, safe for a tag name
63−# dropped MultiGram commits the candidate leaves out ("sha subject" lines with
64−# --allow-drop, else git's lines about commits Forkgram already has)
65−# missing --use-next without --allow-drop: the commits multigram-next lacks
66−# rerere_resolved conflicts resolved from recorded resolutions ("commit path" lines)
67−# conflict_commit, conflict_subject, conflict_position,
68−# conflict_files (paths), conflict_details ("what happened: path" lines)
69−# forkgram_tip the tip of Forkgram's branch this run saw
70−# pushed the branches pushed; tag (promote): the tag made for the old multigram
71−# error what went wrong
72−# Exit codes: 0 done (see status), 1 error, 2 conflict.
73−set -euo pipefail
74−
75−FORKGRAM_URL=${FORKGRAM_URL:-https://github.com/forkgram/TelegramAndroid}
76−FORKGRAM_BRANCH=${FORKGRAM_BRANCH:-dev}
77−FORKGRAM_HISTORY_DEPTH=${FORKGRAM_HISTORY_DEPTH:-600}
78−
79−FG=forkgram
80−MG=multigram
81−NEXT=multigram-next
82−TIP_REF=refs/multigram-sync/forkgram-tip
83−REC_REF=refs/multigram-sync/next # the last multigram-next this script built by replaying
84−export GIT_TERMINAL_PROMPT=0
85−
86−NL=$'\n'
87−WORK=
88−WT=
89−ERROR_MSG=
90−OUT_KEYS=()
91−GITID=()
92−
93−# ---------------------------------------------------------------- output and logging
94−
95−log() { printf 'sync: %s\n' "$*" >&2; }
96−
97−annotate() { # level message
98− if [ -n "${GITHUB_ACTIONS:-}" ]; then
99− local m=$2
100− m=${m//'%'/'%25'}; m=${m//$'\r'/'%0D'}; m=${m//$'\n'/'%0A'}
101− printf '::%s::%s\n' "$1" "$m"
102− else
103− printf 'sync: %s: %s\n' "$1" "$2" >&2
104− fi
105−}
106−
107−warn() { annotate warning "$*"; }
108−
109−die() {
110− ERROR_MSG=$*
111− out status error
112− out error "$ERROR_MSG"
113− out message "$ERROR_MSG"
114− exit 1
115−}
116−
117−out() { # key value...
118− local k=$1 have
119− shift
120− printf -v "o_$k" '%s' "$*"
121− have=" ${OUT_KEYS[*]-} "
122− case $have in *" $k "*) ;; *) OUT_KEYS+=("$k") ;; esac
123−}
124−
125−get() { local n="o_$1"; printf '%s' "${!n-}"; }
126−
127−random_hex() { od -An -N16 -tx1 /dev/urandom | tr -d ' \n'; }
128−
129−# Prints text that may hold other people's words (commit subjects, file names) so that the
130−# Actions runner does not read it as workflow commands.
131−print_untrusted() {
132− if [ -n "${GITHUB_ACTIONS:-}" ]; then
133− local token
134− token=$(random_hex)
135− printf '::stop-commands::%s\n%s\n::%s::\n' "$token" "$1" "$token"
136− else
137− printf '%s\n' "$1"
138− fi
139−}
140−
141−emit_outputs() {
142− [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
143− local k v delim text=
144− delim=ghadelimiter_$(random_hex)
145− for k in "${OUT_KEYS[@]}"; do
146− v=$(get "$k")
147− if [ -n "${GITHUB_OUTPUT:-}" ]; then
148− printf '%s<<%s\n%s\n%s\n' "$k" "$delim" "$v" "$delim" >>"$GITHUB_OUTPUT"
149− fi
150− case $v in
151− *$'\n'*) text+="$k:"$'\n'"$(printf '%s\n' "$v" | sed 's/^/ /')"$'\n' ;;
152− *) text+="$k=$v"$'\n' ;;
153− esac
154− done
155− print_untrusted "---- outputs ----"$'\n'"${text%$'\n'}"
156−}
157−
158−md() { printf '%s' "$1" | tr '`' "'"; } # untrusted text for a Markdown code span or block
159−
160−write_summary() {
161− [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ ${#OUT_KEYS[@]} -gt 0 ] || return 0
162− local k v
163− {
164− printf '### Forkgram sync: %s\n\n' "$(get status)"
165− printf '```text\n%s\n```\n\n' "$(md "$(get message)")"
166− printf '| | |\n|---|---|\n'
167− for k in forkgram_version forkgram_commit telegram_base new_snapshot snapshot previous_version \
168− previous_snapshot forkgram_tip multigram multigram_base stack_count existing_next candidate \
169− candidate_count pushed tag; do
170− v=$(get "$k")
171− [ -z "$v" ] || printf '| %s | `%s` |\n' "$k" "$(md "$v" | tr '|' '/')"
172− done
173− for k in conflict_commit conflict_subject conflict_position conflict_details dropped missing rerere_resolved error; do
174− v=$(get "$k")
175− [ -z "$v" ] || printf '\n**%s**\n```text\n%s\n```\n' "$k" "$(md "$v")"
176− done
177− } >>"$GITHUB_STEP_SUMMARY"
178−}
179−
180−cleanup() {
181− local rc=$?
182− set +e
183− if [ -n "$WT" ] && [ -e "$WT" ]; then
184− git -C "$WT" rebase --abort >/dev/null 2>&1
185− git worktree remove --force "$WT" >/dev/null 2>&1 || { rm -rf "$WT"; git worktree prune; }
186− fi
187− git update-ref -d "$TIP_REF" >/dev/null 2>&1
188− [ -z "$WORK" ] || rm -rf "$WORK"
189− if [ $rc -ne 0 ] && [ -z "$(get status)" ]; then
190− out status error
191− out error "${ERROR_MSG:-the script stopped unexpectedly (exit $rc); see the log above}"
192− fi
193− if [ "$(get status)" = error ]; then annotate error "$(get error)"; fi
194− emit_outputs
195− write_summary
196− exit $rc
197−}
198−trap cleanup EXIT
199−
200−# ---------------------------------------------------------------- git helpers
201−
202−short() { git rev-parse --short "$1" 2>/dev/null || printf '%s' "${1:0:10}"; }
203−
204−enter_repo() {
205− local top
206− top=$(git rev-parse --show-toplevel 2>/dev/null) || die "Run this inside a clone of the MultiGram repository."
207− cd "$top"
208− WORK=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/multigram-sync.XXXXXX")
209− # Commits made here (the snapshot, the replayed stack) need a committer identity.
210− if ! git config user.email >/dev/null 2>&1; then
211− GITID=(-c "user.name=MultiGram sync" -c "user.email=multigram-sync@users.noreply.github.com")
212− fi
213−}
214−
215−# git for the commits this script makes: the fallback identity, and no signing (a local
216−# signing setup could prompt, or fail, in the middle of the replay).
217−g() { git -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"} "$@"; }
218−
219−remote_ref() { # "ls-remote output" refname -> sha or empty
220− printf '%s\n' "$1" | awk -v r="$2" '$2 == r { print $1; exit }'
221−}
222−
223−check_free_branches() { # branch... : this script moves these branches
224− local list b
225− list=$(git worktree list --porcelain)
226− for b; do
227− if grep -qxF "branch refs/heads/$b" <<<"$list"; then
228− die "The branch $b is checked out in a worktree. Switch that worktree to another branch first; this script moves $b."
229− fi
230− done
231−}
232−
233−set_branch() { # branch sha why
234− local old
235− old=$(git rev-parse -q --verify "refs/heads/$1" || true)
236− [ "$old" != "$2" ] || return 0
237− git update-ref -m "sync-forkgram: $3" "refs/heads/$1" "$2"
238− if [ -n "$old" ]; then
239− log "local branch $1: $(short "$old") -> $(short "$2")"
240− else
241− log "local branch $1: created at $(short "$2")"
242− fi
243−}
244−
245−# True when replacing multigram-next loses nothing a person made: there is none, it is the
246−# one this script built last (the record), or multigram already has all of it.
247−next_is_ours() { # next record multigram
248− [ -z "$1" ] || [ "$1" = "$2" ] || git merge-base --is-ancestor "$1" "$3" 2>/dev/null
249−}
250−
251−# Stops before the local multigram-next moves away from commits that are on no other
252−# branch, tag or record (say a fix by hand that was not pushed yet).
253−check_local_next() { # [the sha it would move to]
254− local cur others
255− cur=$(git rev-parse -q --verify "refs/heads/$NEXT" || true)
256− [ -n "$cur" ] && [ "$cur" != "${1:-}" ] || return 0
257− others=$(git for-each-ref --format='%(refname)' --contains "$cur" | grep -vxF "refs/heads/$NEXT" || true)
258− [ -n "$others" ] ||
259− die "Your local $NEXT ($(short "$cur")) has commits that are on no other branch or tag, so this script does not move it. Push it, or keep it under another name (git branch -m $NEXT <name>), then run again."
260−}
261−
262−# Prints "sha subject" for each commit of multigram's stack (base..mg) that the hand-made
263−# multigram-next (old..next) does not carry. A commit counts as carried when next has one
264−# with the same author (e-mail and time) and subject, which a rebase, a conflict fix, a
265−# reword-free fixup or git am all keep, or one with the same patch id.
266−missing_commits() { # base mg old next
267− local f=$WORK/missing
268− {
269− git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$3..$4" | git patch-id --stable | awk '{ print "P\t" $1 }'
270− git log --no-merges --format='N%x09%ae %at%x09%s' "$3..$4"
271− git log --no-merges --no-color --no-ext-diff --format='commit %H' -p "$1..$2" | git patch-id --stable | awk '{ print "Q\t" $2 "\t" $1 }'
272− git log --no-merges --reverse --format='M%x09%H%x09%ae %at%x09%s' "$1..$2"
273− } >"$f"
274− awk -F'\t' '
275− $1 == "P" { pid[$2] = 1; next }
276− $1 == "N" { k = $0; sub(/^[^\t]*\t/, "", k); have[k] = 1; next }
277− $1 == "Q" { mine[$2] = $3; next }
278− $1 == "M" {
279− k = $0; sub(/^[^\t]*\t[^\t]*\t/, "", k)
280− if ((k in have) || (($2 in mine) && (mine[$2] in pid))) next
281− s = k; sub(/^[^\t]*\t/, "", s)
282− print $2 " " s
283− }' "$f"
284−}
285−
286−# The replay flattens merge commits (git rebase without --rebase-merges). That loses nothing
287−# when a merge only joins its parents, so those just get a warning; a merge with changes of
288−# its own (a conflict fix, glue code) would be lost, so it stops the sync.
289−check_merges() { # old mg
290− local m parents tree bad= clean=0
291− for m in $(git rev-list --merges "$1..$2"); do
292− parents=$(git rev-list --parents -n 1 "$m" | cut -d' ' -f2-)
293− if [ "$(printf '%s\n' $parents | grep -c .)" -eq 2 ] &&
294− tree=$(git merge-tree --write-tree --no-messages $parents 2>/dev/null) &&
295− [ "${tree%%"$NL"*}" = "$(git rev-parse "$m^{tree}")" ]; then
296− clean=$((clean + 1))
297− continue
298− fi
299− bad+="$(short "$m") $(git log -1 --format=%s "$m")$NL"
300− done
301− if [ -n "$bad" ]; then
302− die "$MG has merge commits with changes of their own (the check needs git 2.38 or newer):$NL$bad""The sync replays the stack without merges, so those changes would be lost. Make $MG linear first and keep what the merges added as a commit of its own: git switch -C $MG origin/$MG && git rebase origin/$FG (fix any conflicts), then git restore --source=origin/$MG --staged --worktree :/ && git commit -m 'Keep the changes made in merges', and push it with git push --force-with-lease origin $MG."
303− fi
304− [ "$clean" -eq 0 ] || warn "$MG has $clean merge commit(s) on top of $FG that only join their parents; the replay flattens them into a linear stack."
305−}
306−
307−safe_version() { # prints the version, or "unknown" when it holds odd characters
308− if [[ $1 =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$ ]]; then printf '%s' "$1"; else printf unknown; fi
309−}
310−
311−tag_version() { # version fallback -> a string that is safe in a tag name
312− local v
313− v=$(printf '%s' "$1" | tr -c '0-9A-Za-z._-' '-')
314− if [ "$v" = unknown ] || ! git check-ref-format "refs/tags/multigram-before-$v"; then v=$2; fi
315− printf '%s' "$v"
316−}
317−
318−has_github() { [ -n "$(git ls-tree --name-only "$1" -- .github)" ]; }
319−
320−check_no_github() { # from to what : every commit in from..to
321− local c
322− for c in $(git rev-list "$1..$2"); do
323− if has_github "$c"; then
324− die "Refusing to go on: $3 would contain a .github folder (commit $(short "$c")). The forkgram snapshots and the MultiGram stack must never carry .github (GITHUB_TOKEN cannot push workflow files, and the workflows live on main)."
325− fi
326− done
327−}
328−
329−# ---------------------------------------------------------------- snapshot helpers
330−
331−S_COMMIT= S_VERSION= S_BASE=
332−read_snapshot() { # commit : reads the Forkgram-* lines of a snapshot commit
333− local body
334− body=$(git log -1 --format=%B "$1")
335− S_COMMIT=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Commit:[[:space:]]*\([0-9a-f]\{40\}\)[[:space:]]*$/\1/p' | tail -n 1)
336− S_VERSION=$(printf '%s\n' "$body" | sed -n 's/^Forkgram-Version:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
337− S_BASE=$(printf '%s\n' "$body" | sed -n 's/^Telegram-Base:[[:space:]]*//p' | tail -n 1 | tr -d '\r')
338− [ -n "$S_COMMIT" ] || return 1
339− S_VERSION=$(safe_version "$S_VERSION")
340−}
341−
342−forkgram_version() { # tree subject -> APP_VERSION_NAME from gradle.properties, else the "[Release] X." subject
343− local v
344− v=$(git cat-file -p "$1:gradle.properties" 2>/dev/null |
345− sed -n 's/^[[:space:]]*APP_VERSION_NAME[[:space:]]*=[[:space:]]*//p' | tail -n 1 | tr -d '\r' || true)
346− v=${v%%[[:space:]]*}
347− if [ -z "$v" ]; then
348− v=$(printf '%s\n' "$2" | sed -n 's/^\[Release\][[:space:]]*\([0-9][0-9A-Za-z._-]*[0-9A-Za-z]\)\.\{0,1\}[[:space:]]*$/\1/p')
349− fi
350− safe_version "$v"
351−}
352−
353−TBASE=
354−find_telegram_base() { # tip : sets TBASE to "sha (update to X)" when Forkgram's history shows it
355− local tip=$1 hist=$WORK/history.git lines line sha ver
356− TBASE=
357− [ "$FORKGRAM_HISTORY_DEPTH" -gt 0 ] || return 0
358− # Commits only (no trees or files): cheap even a few hundred commits deep.
359− if ! git clone --quiet --bare --filter=tree:0 --depth "$FORKGRAM_HISTORY_DEPTH" --single-branch \
360− --branch "$FORKGRAM_BRANCH" --no-tags "$FORKGRAM_URL" "$hist" >"$WORK/history.log" 2>&1; then
361− warn "Could not read Forkgram's history, so the snapshot has no Telegram-Base line."
362− return 0
363− fi
364− if ! git -C "$hist" cat-file -e "$tip^{commit}" 2>/dev/null; then
365− warn "Forkgram's $FORKGRAM_BRANCH moved during the sync, so the snapshot has no Telegram-Base line."
366− return 0
367− fi
368− lines=$(git -C "$hist" log --first-parent --format='%H %s' "$tip")
369− line=$(printf '%s\n' "$lines" | grep -m 1 -E '^[0-9a-f]{40} update to [0-9]' || true)
370− if [ -z "$line" ]; then
371− warn "No Telegram \"update to X\" commit in the last $FORKGRAM_HISTORY_DEPTH Forkgram commits, so the snapshot has no Telegram-Base line."
372− return 0
373− fi
374− sha=${line%% *}
375− ver=$(printf '%s\n' "${line#* }" | sed -n 's/^update to \([0-9][0-9A-Za-z._-]*\).*/\1/p')
376− TBASE="$sha (update to $(safe_version "$ver"))"
377−}
378−
379−source_link() { # commit
380− local u=${FORKGRAM_URL%/}
381− u=${u%.git}
382− case $u in
383− https://github.com/*/*) printf '%s/commit/%s' "$u" "$1" ;;
384− *) printf '%s (%s) at %s' "$FORKGRAM_URL" "$FORKGRAM_BRANCH" "$1" ;;
385− esac
386−}
387−
388−snapshot_tree() { # tree -> the same tree without the top-level .github
389− local entry
390− while IFS= read -r -d '' entry; do
391− [ "${entry#*$'\t'}" = .github ] && continue
392− printf '%s\0' "$entry"
393− done < <(git ls-tree -z "$1") | git mktree -z
394−}
395−
396−NEW_SNAPSHOT=
397−make_snapshot() { # tip tip_tree parent version telegram_base : sets NEW_SNAPSHOT
398− local tip=$1 tree msg=$WORK/snapshot-message
399− tree=$(snapshot_tree "$2") || die "Could not build the snapshot tree of $tip."
400− {
401− printf 'Forkgram %s snapshot\n\n' "$4"
402− printf 'Source: %s\n' "$(source_link "$tip")"
403− printf 'Forkgram-Commit: %s\n' "$tip"
404− printf 'Forkgram-Version: %s\n' "$4"
405− if [ -n "$5" ]; then printf 'Telegram-Base: %s\n' "$5"; fi
406− printf "\\nTree is Forkgram's %s branch at that commit with the .github folder removed.\\n" "$FORKGRAM_BRANCH"
407− } >"$msg"
408− NEW_SNAPSHOT=$(g commit-tree "$tree" -p "$3" -F "$msg") || die "Could not create the snapshot commit."
409−}
410−
411−# Gets Forkgram's tip commit's files into this repository. Sets TIP (the commit, re-read
412−# from the fetch), TIP_TREE and TIP_SUBJECT.
413−TIP= TIP_TREE= TIP_SUBJECT=
414−fetch_tip() { # sha from ls-remote
415− local tipgit=(git)
416− TIP=$1
417− if [ -z "$(git config extensions.partialClone || true)" ] && git cat-file -e "$TIP^{commit}" 2>/dev/null; then
418− log "Forkgram ${TIP:0:10} is already here; not fetching it"
419− elif [ "$(git rev-parse --is-shallow-repository)" = true ]; then
420− # A shallow clone (as in CI) takes one more shallow commit in its stride.
421− log "Fetching Forkgram ${TIP:0:10}"
422− git fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:$TIP_REF" ||
423− die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
424− TIP=$(git rev-parse --verify "$TIP_REF^{commit}")
425− else
426− # A full clone: a shallow fetch would turn it into a shallow repository. Fetch into a
427− # scratch repository instead and copy only the tip's tree (its files) into this one.
428− log "Fetching Forkgram ${TIP:0:10} (into a scratch repository)"
429− tipgit=(git -C "$WORK/tip.git")
430− git init --quiet --bare "$WORK/tip.git"
431− "${tipgit[@]}" fetch --no-tags --quiet --depth=1 "$FORKGRAM_URL" "+refs/heads/$FORKGRAM_BRANCH:refs/tip" ||
432− die "Could not fetch Forkgram's $FORKGRAM_BRANCH from $FORKGRAM_URL."
433− TIP=$("${tipgit[@]}" rev-parse --verify "refs/tip^{commit}")
434− "${tipgit[@]}" rev-list --objects "$TIP^{tree}" | "${tipgit[@]}" pack-objects --quiet --stdout |
435− git index-pack --stdin >/dev/null || die "Could not copy Forkgram's files into this repository."
436− fi
437− TIP_TREE=$("${tipgit[@]}" rev-parse --verify "$TIP^{tree}")
438− TIP_SUBJECT=$("${tipgit[@]}" log -1 --format=%s "$TIP")
439−}
440−
441−set_snapshot_outputs() { # snapshot new_snapshot(yes|no)
442− read_snapshot "$1" || die "The snapshot $(short "$1") has no Forkgram-Commit line."
443− out snapshot "$1"
444− out new_snapshot "$2"
445− out forkgram_commit "$S_COMMIT"
446− out forkgram_version "$S_VERSION"
447− out telegram_base "$S_BASE"
448− out tag_version "$(tag_version "$S_VERSION" "$(git rev-parse --short=10 "$S_COMMIT" 2>/dev/null || printf '%s' "${S_COMMIT:0:10}")")"
449−}
450−
451−# ---------------------------------------------------------------- the replay
452−
453−rebase_in_progress() {
454− local d
455− for d in rebase-merge rebase-apply; do
456− [ ! -d "$(git -C "$WT" rev-parse --path-format=absolute --git-path "$d")" ] || return 0
457− done
458− return 1
459−}
460−
461−conflict_kinds() { # "stages<TAB>path" lines -> "what happened: path" lines
462− awk -F'\t' '{
463− k = $1
464− if (k == "123") w = "changed on both sides"
465− else if (k == "23") w = "added on both sides"
466− else if (k == "13") w = "Forkgram deleted it, MultiGram changed it"
467− else if (k == "12") w = "MultiGram deleted it, Forkgram changed it"
468− else if (k == "2") w = "only on the Forkgram side (rename or delete conflict)"
469− else if (k == "3") w = "only on the MultiGram side (rename or delete conflict)"
470− else w = "conflict (stages " k ")"
471− print w ": " $2
472− }'
473−}
474−
475−# Replays upstream..tip onto onto in a temporary worktree. Sets REPLAY to "ok" and
476−# CANDIDATE, or to "conflict" and the conflict_* outputs.
477−CANDIDATE=
478−REPLAY=
479−replay() { # onto upstream tip
480− local onto=$1 upstream=$2 tip=$3 rc=0 rounds=0 from=0 stopped unmerged stages files stack pos total resolved
481− local rlog=$WORK/rebase.log
482− WT=$WORK/replay
483− git worktree add --quiet --detach "$WT" "$tip" >"$WORK/worktree.log" 2>&1 ||
484− { cat "$WORK/worktree.log" >&2; die "Could not create a temporary worktree for the rebase."; }
485− # The caller's git config must not change the replay: no update-refs (it would move the
486− # caller's own branches), no merge-preserving rebase, no autostash, no hooks, no signing.
487− local rb=(git -C "$WT" -c rerere.enabled=true -c rerere.autoUpdate=true -c rebase.updateRefs=false
488− -c rebase.rebaseMerges=false -c rebase.autoStash=false -c rebase.backend=merge
489− -c core.hooksPath=/dev/null -c commit.gpgSign=false ${GITID[@]+"${GITID[@]}"})
490− log "Replaying $(git rev-list --count "$upstream..$tip") MultiGram commit(s) onto $(short "$onto")"
491− # LC_ALL=C: the messages below are read back (rerere and dropped commits).
492− GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --no-autosquash --empty=drop --onto "$onto" "$upstream" >"$rlog" 2>&1 || rc=$?
493− while [ $rc -ne 0 ]; do
494− if ! rebase_in_progress; then
495− print_untrusted "$(tail -n 40 "$rlog" | tr '\r' '\n')"
496− die "git rebase failed without a conflict; see the log above."
497− fi
498− unmerged=$(git -C "$WT" ls-files -u)
499− [ -z "$unmerged" ] || break
500− # rerere resolved every conflict of this commit from a recorded resolution: go on.
501− stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || echo unknown)
502− resolved=$(tail -c +$((from + 1)) "$rlog" | tr '\r' '\n' |
503− sed -n -E "s/^(Resolved|Staged) '(.*)' using previous resolution\.\$/$(short "$stopped") \2/p")
504− [ -n "$resolved" ] || resolved="$(short "$stopped") (files not named by git)"
505− out rerere_resolved "$(get rerere_resolved)${o_rerere_resolved:+$NL}$resolved"
506− rounds=$((rounds + 1))
507− [ $rounds -le 1000 ] || die "git rebase keeps stopping; giving up."
508− log "rerere resolved the conflicts in $(short "$stopped") from a recorded resolution; continuing"
509− rc=0
510− from=$(wc -c <"$rlog")
511− GIT_EDITOR=true LC_ALL=C "${rb[@]}" rebase --continue >>"$rlog" 2>&1 || rc=$?
512− done
513−
514− local dropped
515− dropped=$(tr '\r' '\n' <"$rlog" | grep -E '^(dropping |warning: skipped previously applied commit)' || true)
516− [ -z "$dropped" ] || out dropped "$dropped"
517−
518− if [ $rc -eq 0 ]; then
519− CANDIDATE=$(git -C "$WT" rev-parse HEAD)
520− REPLAY=ok
521− return 0
522− fi
523−
524− stopped=$(git -C "$WT" rev-parse -q --verify REBASE_HEAD || true)
525− stages=$(printf '%s\n' "$unmerged" | awk -F'\t' '{ split($1, a, " "); s[$2] = s[$2] a[3] } END { for (p in s) print s[p] "\t" p }' | sort -t "$(printf '\t')" -k 2)
526− files=$(printf '%s\n' "$stages" | cut -f 2-)
527− stack=$(git rev-list --reverse "$upstream..$tip")
528− total=$(printf '%s\n' "$stack" | grep -c . || true)
529− pos=$(printf '%s\n' "$stack" | grep -n -x -F "${stopped:-none}" | cut -d: -f1 || true)
530− out conflict_commit "$stopped"
531− out conflict_subject "$( [ -z "$stopped" ] || git log -1 --format=%s "$stopped")"
532− out conflict_position "${pos:-?} of $total"
533− out conflict_files "$files"
534− out conflict_details "$(printf '%s\n' "$stages" | conflict_kinds)"
535− print_untrusted "$(tr '\r' '\n' <"$rlog" | grep -v '^Rebasing (' | tail -n 40)"
536− git -C "$WT" rebase --abort >/dev/null 2>&1 || true
537− REPLAY=conflict
538−}
539−
540−# ---------------------------------------------------------------- sync
541−
542−sync_main() {
543− local remote= push=no force=no use_next=no allow_drop=no
544− while [ $# -gt 0 ]; do
545− case $1 in
546− --remote) remote=${2:?--remote needs a name}; shift 2 ;;
547− --remote=*) remote=${1#*=}; shift ;;
548− --push) push=yes; shift ;;
549− --force) force=yes; shift ;;
550− --use-next) use_next=yes; shift ;;
551− --allow-drop) allow_drop=yes; shift ;;
552− --url) FORKGRAM_URL=${2:?--url needs a URL}; shift 2 ;;
553− --url=*) FORKGRAM_URL=${1#*=}; shift ;;
554− --branch) FORKGRAM_BRANCH=${2:?--branch needs a name}; shift 2 ;;
555− --branch=*) FORKGRAM_BRANCH=${1#*=}; shift ;;
556− --history-depth) FORKGRAM_HISTORY_DEPTH=${2:?--history-depth needs a number}; shift 2 ;;
557− --history-depth=*) FORKGRAM_HISTORY_DEPTH=${1#*=}; shift ;;
558− -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
559− *) die "Unknown option: $1 (see --help)" ;;
560− esac
561− done
562− [ "$push" = no ] || [ -n "$remote" ] || die "--push needs --remote."
563− [ "$force" = no ] || [ "$use_next" = no ] || die "--force and --use-next do not go together."
564− [ "$allow_drop" = no ] || [ "$use_next" = yes ] || die "--allow-drop only goes with --use-next."
565− [[ $FORKGRAM_HISTORY_DEPTH =~ ^[0-9]+$ ]] || die "--history-depth must be a number."
566− enter_repo
567− check_free_branches "$FG" "$NEXT"
568−
569− # 1. Where forkgram, multigram and multigram-next are now, and the multigram-next this
570− # script built last (rec_prev).
571− local old mg next_prev= rec_prev=
572− if [ -n "$remote" ]; then
573− local heads specs what
574− heads=$(git ls-remote "$remote" "refs/heads/$FG" "refs/heads/$MG" "refs/heads/$NEXT" "$REC_REF") ||
575− die "Could not read the branches of $remote."
576− [ -n "$(remote_ref "$heads" "refs/heads/$FG")" ] ||
577− die "$remote has no $FG branch. It holds the Forkgram snapshots; create it first (see README.md)."
578− [ -n "$(remote_ref "$heads" "refs/heads/$MG")" ] ||
579− die "$remote has no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits) and push it; the sync replays that stack and cannot start without it."
580− next_prev=$(remote_ref "$heads" "refs/heads/$NEXT")
581− rec_prev=$(remote_ref "$heads" "$REC_REF")
582− specs=("+refs/heads/$FG:refs/remotes/$remote/$FG" "+refs/heads/$MG:refs/remotes/$remote/$MG")
583− what="$FG and $MG"
584− if [ -n "$next_prev" ]; then
585− specs+=("+refs/heads/$NEXT:refs/remotes/$remote/$NEXT")
586− what="$FG, $MG and $NEXT"
587− fi
588− log "Fetching $what from $remote"
589− git fetch --no-tags --quiet "$remote" "${specs[@]}" || die "Could not fetch $what from $remote."
590− old=$(git rev-parse --verify "refs/remotes/$remote/$FG^{commit}")
591− mg=$(git rev-parse --verify "refs/remotes/$remote/$MG^{commit}")
592− [ -z "$next_prev" ] || next_prev=$(git rev-parse --verify "refs/remotes/$remote/$NEXT^{commit}")
593− else
594− old=$(git rev-parse -q --verify "refs/heads/$FG^{commit}") ||
595− die "There is no $FG branch here. It holds the Forkgram snapshots; fetch it or pass --remote."
596− mg=$(git rev-parse -q --verify "refs/heads/$MG^{commit}") ||
597− die "There is no $MG branch yet. Create it (the latest $FG snapshot plus the MultiGram commits); the sync replays that stack and cannot start without it."
598− next_prev=$(git rev-parse -q --verify "refs/heads/$NEXT^{commit}" || true)
599− rec_prev=$(git rev-parse -q --verify "$REC_REF^{commit}" || true)
600− fi
601− out existing_next "$next_prev"
602−
603− # 2. The latest snapshot and the stack on top of it.
604− read_snapshot "$old" ||
605− die "The tip of $FG ($(short "$old")) has no Forkgram-Commit line, so it is not a Forkgram snapshot."
606− local old_commit=$S_COMMIT old_version=$S_VERSION base behind stack_count
607− out previous_snapshot "$old"
608− out previous_forkgram_commit "$old_commit"
609− out previous_version "$old_version"
610− out multigram "$mg"
611− base=$(git merge-base "$old" "$mg") || die "$MG shares no history with $FG."
612− out multigram_base "$base"
613− if [ "$base" = "$old" ]; then behind=no; else behind=yes; fi
614− if [ "$behind" = yes ] && ! read_snapshot "$base"; then
615− warn "$MG is based on $(short "$base"), which is not a $FG snapshot."
616− fi
617− stack_count=$(git rev-list --count "$old..$mg")
618− out stack_count "$stack_count"
619− [ "$stack_count" -gt 0 ] || warn "$MG has no commits on top of $FG."
620−
621− # 3a. A stack fixed by hand, already pushed to multigram-next.
622− if [ "$use_next" = yes ]; then
623− local missing n
624− [ -n "$next_prev" ] || die "There is no $NEXT branch. Push the fixed stack to $NEXT first (see README.md)."
625− git merge-base --is-ancestor "$old" "$next_prev" ||
626− die "$NEXT is not based on the latest $FG snapshot ($(short "$old"), Forkgram $old_version). Rebase it onto $FG first."
627− [ "$next_prev" != "$old" ] || die "$NEXT has no commits on top of $FG."
628− set_snapshot_outputs "$old" no
629− check_no_github "$old" "$next_prev" "$NEXT"
630− # Everything on multigram must still be there: commits pushed to multigram after the fix
631− # was started would otherwise leave the app branch at the promotion.
632− missing=$(missing_commits "$base" "$mg" "$old" "$next_prev")
633− if [ -n "$missing" ]; then
634− n=$(printf '%s\n' "$missing" | grep -c .)
635− if [ "$allow_drop" = yes ]; then
636− out dropped "$missing"
637− warn "$NEXT leaves out $n of $MG's commit(s); --allow-drop accepts that (see the dropped output)."
638− else
639− out missing "$missing"
640− die "$NEXT does not carry $n of $MG's commit(s):$NL$missing${NL}They were probably pushed to $MG after the fix on $NEXT was started. Add them to $NEXT (git switch $NEXT && git cherry-pick <commit>...), push it and run with use_next again. If $NEXT leaves them out on purpose (Forkgram has them now, or you folded or dropped them), run use_next with allow_drop (--allow-drop)."
641− fi
642− fi
643− CANDIDATE=$next_prev
644− out candidate "$CANDIDATE"
645− out candidate_count "$(git rev-list --count "$old..$CANDIDATE")"
646− check_local_next "$CANDIDATE"
647− set_branch "$FG" "$old" "Forkgram $old_version snapshot"
648− set_branch "$NEXT" "$CANDIDATE" "use multigram-next as the candidate"
649− out pushed ""
650− out status candidate
651− out message "Using $NEXT ($(short "$CANDIDATE")) as it is: $(get candidate_count) commit(s) on the Forkgram $old_version snapshot."
652− log "$(get message)"
653− return 0
654− fi
655−
656− # 3b. Forkgram's tip.
657− local tip new new_snapshot tbase version
658− log "Reading $FORKGRAM_URL ($FORKGRAM_BRANCH)"
659− tip=$(git ls-remote "$FORKGRAM_URL" "refs/heads/$FORKGRAM_BRANCH") || die "Could not reach Forkgram at $FORKGRAM_URL."
660− tip=$(remote_ref "$tip" "refs/heads/$FORKGRAM_BRANCH")
661− [ -n "$tip" ] || die "Forkgram has no $FORKGRAM_BRANCH branch at $FORKGRAM_URL."
662− out forkgram_tip "$tip"
663−
664− if [ "$tip" = "$old_commit" ] && [ "$force" = no ]; then
665− set_snapshot_outputs "$old" no
666− out candidate ""
667− out pushed ""
668− if [ "$behind" = no ]; then
669− out status up-to-date
670− out message "Nothing to do: Forkgram's $FORKGRAM_BRANCH is still $(short "$tip") (Forkgram $old_version), the latest $FG snapshot, and $MG is based on it."
671− log "$(get message)"
672− else
673− out status behind
674− out message "No new Forkgram release, but $MG is still based on $(short "$base"), not on the Forkgram $old_version snapshot ($(short "$old")): the last sync could not move it. Fix the stack on $NEXT and run with --use-next, or retry with --force."
675− warn "$(get message)"
676− fi
677− return 0
678− fi
679−
680− # From here on the run builds a new multigram-next. It never replaces one that holds
681− # commits it did not make (a fix by hand): that one waits for --use-next, or for someone
682− # to delete it. Nothing is snapshotted meanwhile, so the fix stays based on the latest
683− # snapshot and --use-next can take it.
684− if ! next_is_ours "$next_prev" "$rec_prev" "$mg"; then
685− set_snapshot_outputs "$old" no
686− out candidate ""
687− out pushed ""
688− out status next-edited
689− if [ "$tip" = "$old_commit" ]; then
690− out message "$NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so --force does not replace it. Run with --use-next to compile and promote it, or delete $NEXT to let the sync rebuild it from $MG."
691− else
692− out message "Forkgram's $FORKGRAM_BRANCH has moved to $(short "$tip"), but $NEXT ($(short "$next_prev")) has commits the sync did not make (a fix by hand?), so the sync changes nothing: no new snapshot, no new $NEXT. Run with --use-next to compile and promote $NEXT, or delete it to let the sync rebuild it from $MG; the next run then takes the new Forkgram commit."
693− fi
694− warn "$(get message)"
695− return 0
696− fi
697− check_local_next
698− check_merges "$old" "$mg"
699−
700− if [ "$tip" = "$old_commit" ]; then
701− log "No new Forkgram release; --force: rebuilding $NEXT on the current snapshot"
702− new=$old
703− new_snapshot=no
704− else
705− fetch_tip "$tip"
706− [ "$TIP" != "$old_commit" ] ||
707− die "Forkgram's $FORKGRAM_BRANCH moved back to the latest snapshot's commit during the sync; run it again."
708− tip=$TIP
709− out forkgram_tip "$tip"
710− version=$(forkgram_version "$TIP_TREE" "$TIP_SUBJECT")
711− [ "$version" != unknown ] || warn "Could not read the Forkgram version (APP_VERSION_NAME in gradle.properties)."
712− find_telegram_base "$tip"
713− tbase=$TBASE
714− make_snapshot "$tip" "$TIP_TREE" "$old" "$version" "$tbase"
715− new=$NEW_SNAPSHOT
716− new_snapshot=yes
717− log "New snapshot $(short "$new"): Forkgram $version ($(short "$tip")), Telegram base: ${tbase:-not found}"
718− if [ "$version" = "$old_version" ]; then
719− warn "Forkgram's $FORKGRAM_BRANCH moved but its version is still $version."
720− fi
721− fi
722− set_snapshot_outputs "$new" "$new_snapshot"
723− version=$(get forkgram_version)
724− check_no_github "$old" "$new" "$FG"
725−
726− # 4. The replay.
727− replay "$new" "$old" "$mg"
728− if [ "$REPLAY" = ok ]; then
729− check_no_github "$new" "$CANDIDATE" "$NEXT"
730− check_local_next "$CANDIDATE"
731− fi
732− set_branch "$FG" "$new" "Forkgram $version snapshot"
733− if [ "$REPLAY" = ok ]; then
734− set_branch "$NEXT" "$CANDIDATE" "replay of $MG onto Forkgram $version"
735− git update-ref -m "sync-forkgram: the $NEXT it built" "$REC_REF" "$CANDIDATE"
736− out candidate "$CANDIDATE"
737− out candidate_count "$(git rev-list --count "$new..$CANDIDATE")"
738− else
739− out candidate ""
740− fi
741−
742− # 5. The push. refs/multigram-sync/next records the multigram-next the sync built, so a
743− # later run can tell it from one changed by hand.
744− local refspecs=() leases=() pushed=()
745− if [ "$new_snapshot" = yes ]; then
746− refspecs+=("$new:refs/heads/$FG")
747− leases+=("--force-with-lease=refs/heads/$FG:$old")
748− pushed+=("$FG")
749− fi
750− if [ -n "$CANDIDATE" ]; then
751− refspecs+=("$CANDIDATE:refs/heads/$NEXT" "$CANDIDATE:$REC_REF")
752− leases+=("--force-with-lease=refs/heads/$NEXT:$next_prev" "--force-with-lease=$REC_REF:$rec_prev")
753− pushed+=("$NEXT")
754− fi
755− if [ "$push" = yes ] && [ ${#refspecs[@]} -gt 0 ]; then
756− log "Pushing ${pushed[*]} to $remote"
757− git push --atomic --quiet "${leases[@]}" "$remote" "${refspecs[@]}" || die "The push of ${pushed[*]} to $remote failed."
758− out pushed "${pushed[*]}"
759− else
760− out pushed ""
761− fi
762−
763− if [ "$REPLAY" = ok ]; then
764− out status candidate
765− out message "$NEXT ($(short "$CANDIDATE")) is $MG's $(get stack_count) commit(s) replayed onto Forkgram $version ($(short "$(get forkgram_commit)")); $(get candidate_count) commit(s) on top of the snapshot."
766− log "$(get message)"
767− [ -z "$(get dropped)" ] || warn "Some MultiGram commits were left out because Forkgram already has them; see the dropped output."
768− return 0
769− fi
770− out status conflict
771− out message "The MultiGram stack does not apply to Forkgram $version: commit $(short "$(get conflict_commit)") ($(get conflict_position)) conflicts in $(printf '%s\n' "$(get conflict_files)" | grep -c .) file(s). $MG is unchanged."
772− annotate error "$(get message)"
773− return 2
774−}
775−
776−# ---------------------------------------------------------------- promote
777−
778−promote_main() {
779− local remote= cand= prev= version= shallow=no
780− while [ $# -gt 0 ]; do
781− case $1 in
782− --remote) remote=${2:?}; shift 2 ;;
783− --candidate) cand=${2:?}; shift 2 ;;
784− --previous) prev=${2:?}; shift 2 ;;
785− --version) version=${2:?}; shift 2 ;;
786− --shallow) shallow=yes; shift ;;
787− -h | --help) sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
788− *) die "Unknown option: $1 (see --help)" ;;
789− esac
790− done
791− [ -n "$remote" ] || die "promote needs --remote."
792− [[ $cand =~ ^[0-9a-f]{40}$ ]] || die "promote needs --candidate with a full commit id."
793− [[ $prev =~ ^[0-9a-f]{40}$ ]] || die "promote needs --previous with a full commit id."
794− [[ $version =~ ^[0-9A-Za-z._-]+$ ]] || die "promote needs --version (letters, digits, dot, dash)."
795− enter_repo
796−
797− local heads now_mg now_next depth=() tag base n existing
798− heads=$(git ls-remote "$remote" "refs/heads/$MG" "refs/heads/$NEXT") || die "Could not read the branches of $remote."
799− now_mg=$(remote_ref "$heads" "refs/heads/$MG")
800− now_next=$(remote_ref "$heads" "refs/heads/$NEXT")
801− [ "$now_mg" = "$prev" ] ||
802− die "$MG on $remote is ${now_mg:-missing}, not $prev that the candidate was built from: it was pushed to meanwhile. Not promoting."
803− [ "$now_next" = "$cand" ] ||
804− die "$NEXT on $remote is ${now_next:-missing}, not the compiled candidate $cand. Not promoting."
805− [ "$shallow" = no ] || depth=(--depth=1)
806− git fetch --no-tags --quiet ${depth[@]+"${depth[@]}"} "$remote" \
807− "+refs/heads/$MG:refs/remotes/$remote/$MG" "+refs/heads/$NEXT:refs/remotes/$remote/$NEXT" ||
808− die "Could not fetch $MG and $NEXT from $remote."
809− ! has_github "$cand" || die "Refusing to promote: the candidate contains a .github folder."
810− out candidate "$cand"
811− out previous_multigram "$prev"
812−
813− if [ "$cand" = "$prev" ]; then
814− git push --atomic --quiet "--force-with-lease=refs/heads/$NEXT:$cand" "$remote" ":refs/heads/$NEXT" ||
815− die "Could not delete $NEXT on $remote."
816− out status unchanged
817− out tag ""
818− out message "$MG already is the candidate ($(short "$cand")); deleted $NEXT."
819− log "$(get message)"
820− return 0
821− fi
822−
823− base=multigram-before-$version
824− existing=$(git ls-remote --tags "$remote" "refs/tags/$base*" | awk '{ print $2 }') ||
825− die "Could not read the tags of $remote."
826− tag=$base
827− n=1
828− while grep -qxF "refs/tags/$tag" <<<"$existing"; do
829− n=$((n + 1))
830− tag=$base-$n
831− done
832− log "Tagging $(short "$prev") as $tag, moving $MG to $(short "$cand"), deleting $NEXT"
833− git push --atomic --quiet \
834− "--force-with-lease=refs/heads/$MG:$prev" "--force-with-lease=refs/heads/$NEXT:$cand" \
835− "$remote" "$cand:refs/heads/$MG" "$prev:refs/tags/$tag" ":refs/heads/$NEXT" ||
836− die "The push that moves $MG failed; $MG is unchanged."
837− out status promoted
838− out tag "$tag"
839− out message "$MG moved from $(short "$prev") (tagged $tag) to $(short "$cand"); $NEXT deleted."
840− log "$(get message)"
841−}
842−
843−cmd=sync
844−case ${1:-} in
845− sync | promote) cmd=$1; shift ;;
846−esac
847−# A conflict makes sync_main return 2, which (set -e) ends the script with exit code 2.
848−"${cmd}_main" "$@"
scripts/sync-report.pydeleted−273
@@ -1,273 +0,0 @@
1−#!/usr/bin/env python3
2−"""Report a "Sync with Forkgram" run on the GitHub issue labelled forkgram-sync.
3−
4−Runs in the report job of .github/workflows/sync-forkgram.yml, after the sync, compile and
5−promote jobs. It reads their results and the sync outputs from the environment and:
6−
7− - conflict, compile failure, promote failure, a sync error, or a multigram-next changed by
8− hand that the sync would have replaced: opens an issue, or comments on the open one, with
9− what happened and the local commands to fix it (the same event is reported once, however
10− often the schedule runs);
11− - multigram still behind with no new release: opens an issue only if none is open;
12− - promoted, or multigram in step with Forkgram: closes the open issue with a note (naming
13− any MultiGram commits the new multigram leaves out).
14−
15−Environment: SYNC_RESULT, COMPILE_RESULT, PROMOTE_RESULT (job results); SYNC_* (the sync
16−job's outputs, e.g. SYNC_STATUS, SYNC_FORKGRAM_VERSION); PROMOTE_TAG; RUN_URL; GH_TOKEN
17−and GH_REPO for the gh CLI. REPORT_GH names another gh binary (the self-test uses a fake).
18−Standard library only.
19−"""
20−import hashlib
21−import json
22−import os
23−import re
24−import subprocess
25−import sys
26−import tempfile
27−
28−LABEL = 'forkgram-sync'
29−GH = os.environ.get('REPORT_GH', 'gh')
30−SHA = re.compile(r'^[0-9a-f]{40}$')
31−VERSION = re.compile(r'^[0-9A-Za-z][0-9A-Za-z._+-]{0,39}$')
32−
33−
34−def env(name):
35− return os.environ.get(name, '').strip()
36−
37−
38−def sha(name):
39− value = env(name)
40− return value if SHA.match(value) else ''
41−
42−
43−def short(value):
44− return value[:10] if value else '?'
45−
46−
47−def version():
48− value = env('SYNC_FORKGRAM_VERSION')
49− return value if VERSION.match(value) else 'unknown'
50−
51−
52−def block(text):
53− """Untrusted text (commit subjects, file names, errors) as a Markdown code block."""
54− text = text.replace('`', "'").strip() or '(none)'
55− return '```text\n' + text + '\n```'
56−
57−
58−def gh(*args):
59− run = subprocess.run([GH, *args], capture_output=True, text=True)
60− if run.returncode != 0:
61− sys.stderr.write(run.stderr)
62− raise SystemExit(f'gh {args[0]} {args[1] if len(args) > 1 else ""} failed')
63− return run.stdout
64−
65−
66−def gh_body(*args, body):
67− with tempfile.NamedTemporaryFile('w', suffix='.md', delete=False) as f:
68− f.write(body)
69− path = f.name
70− try:
71− return gh(*args, '--body-file', path)
72− finally:
73− os.unlink(path)
74−
75−
76−def open_issues():
77− issues = json.loads(gh('issue', 'list', '--label', LABEL, '--state', 'open',
78− '--json', 'number,title', '--limit', '20') or '[]')
79− return sorted(i['number'] for i in issues)
80−
81−
82−def open_issue():
83− numbers = open_issues()
84− return numbers[0] if numbers else None
85−
86−
87−def already_reported(number, key):
88− data = json.loads(gh('issue', 'view', str(number), '--json', 'body,comments'))
89− texts = [data.get('body') or ''] + [c.get('body') or '' for c in data.get('comments', [])]
90− return any(key in t for t in texts)
91−
92−
93−def marker(key):
94− return f'<!-- forkgram-sync-key: {key} -->'
95−
96−
97−def title():
98− return f'Forkgram sync needs a hand (Forkgram {version()})'
99−
100−
101−def run_line():
102− url = env('RUN_URL')
103− return f'Run: {url}' if url.startswith('https://') else ''
104−
105−
106−def use_next_step():
107− return ('Then open **Actions > Sync with Forkgram > Run workflow**, tick **use_next** and run '
108− 'it. It compiles `multigram-next` and, if that passes, moves `multigram` to it (the '
109− 'old tip is kept as the tag `multigram-before-<version>`) and closes this issue.')
110−
111−
112−def body_conflict():
113− snap, prev = sha('SYNC_SNAPSHOT') or '<new snapshot>', sha('SYNC_PREVIOUS_SNAPSHOT') or '<old snapshot>'
114− commit = sha('SYNC_CONFLICT_COMMIT')
115− return f"""**Forkgram {version()}** is out and is now on `forkgram` (snapshot `{short(snap)}`), but the MultiGram stack does not apply to it. `multigram` is unchanged.
116−
117−MultiGram commit `{short(commit)}` ({env('SYNC_CONFLICT_POSITION') or '?'}) stops the rebase:
118−{block(env('SYNC_CONFLICT_SUBJECT'))}
119−Files:
120−{block(env('SYNC_CONFLICT_DETAILS') or env('SYNC_CONFLICT_FILES'))}
121−{run_line()}
122−
123−### Fix it locally
124−
125−```sh
126−git fetch origin
127−git switch -C multigram-next origin/multigram
128−git config rerere.enabled true # reuses your fixes if you redo this rebase in this clone
129−git rebase --onto {snap} {prev}
130−# fix the files git names, then:
131−git add <files>
132−git rebase --continue # repeat until the rebase is done
133−git push --force origin multigram-next
134−```
135−
136−To leave a MultiGram commit out instead (Forkgram now does the same thing, say), run `git rebase --skip` at that commit.
137−
138−{use_next_step()}
139−"""
140−
141−
142−def body_compile():
143− snap, cand = sha('SYNC_SNAPSHOT') or '<snapshot>', sha('SYNC_CANDIDATE')
144− return f"""The MultiGram stack applied cleanly to **Forkgram {version()}**, but the compile check failed. `multigram` is unchanged; the candidate is on `multigram-next` (`{short(cand)}`).
145−
146−{run_line()}
147−
148−If the log shows a runner or network hiccup, use **Re-run failed jobs** on the run page. Otherwise:
149−
150−### Fix it locally
151−
152−```sh
153−git fetch origin
154−git switch -C multigram-next origin/multigram-next
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>
158−GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash {snap}
159−git push --force origin multigram-next
160−```
161−
162−{use_next_step()}
163−"""
164−
165−
166−def body_promote():
167− cand = sha('SYNC_CANDIDATE')
168− return f"""The candidate for **Forkgram {version()}** compiled, but moving `multigram` to it failed, usually because someone pushed to `multigram` or `multigram-next` while the sync ran. `multigram` is unchanged; the candidate is still on `multigram-next` (`{short(cand)}`).
169−
170−{run_line()}
171−
172−Check that `multigram-next` holds everything `multigram` has now (rebase any new commits onto it and push it), then run **Actions > Sync with Forkgram** with **use_next**.
173−"""
174−
175−
176−def body_error():
177− return f"""The sync stopped with an error. Nothing was pushed after the error; `multigram` is unchanged.
178−
179−{block(env('SYNC_ERROR') or env('SYNC_MESSAGE') or 'The sync job failed before it could say why; see the run log.')}
180−{run_line()}
181−
182−Fix the cause (the message says what it is), then run **Actions > Sync with Forkgram** again.
183−"""
184−
185−
186−def body_next_edited():
187− nxt, tip, fc = sha('SYNC_EXISTING_NEXT'), sha('SYNC_FORKGRAM_TIP'), sha('SYNC_FORKGRAM_COMMIT')
188− if tip and tip != fc:
189− what = f"Forkgram's `dev` has moved on (`{short(tip)}`), but the sync did not take it"
190− else:
191− what = 'The sync was asked to rebuild `multigram-next` (force), but did not'
192− return f"""{what}: `multigram-next` (`{short(nxt)}`) has commits the sync did not make, most likely a fix by hand, and the sync never replaces those. Nothing was pushed; `forkgram` and `multigram` are unchanged.
193−
194−{run_line()}
195−
196−- If `multigram-next` is ready, run **Actions > Sync with Forkgram > Run workflow** with **use_next**: it compiles `multigram-next` and, if that passes, moves `multigram` to it. The next scheduled sync then takes the new Forkgram commit.
197−- If you do not need it any more, delete it (`git push origin --delete multigram-next`); the next sync rebuilds it from `multigram`.
198−"""
199−
200−
201−def body_behind():
202− return f"""`forkgram` has **Forkgram {version()}** (snapshot `{short(sha('SYNC_SNAPSHOT'))}`), but `multigram` is still based on the older snapshot `{short(sha('SYNC_MULTIGRAM_BASE'))}`: an earlier sync could not move it and no issue is open about it.
203−
204−{run_line()}
205−
206−Run **Actions > Sync with Forkgram** with **force** to try again; if the stack still does not apply, this issue gets the details and the commands to fix it.
207−"""
208−
209−
210−def report(key, body):
211− number = open_issue()
212− text = marker(key) + '\n' + body
213− if number is None:
214− gh('label', 'create', LABEL, '--color', 'D93F0B', '--force',
215− '--description', 'The automatic sync with Forkgram needs a hand')
216− print(gh_body('issue', 'create', '--title', title(), '--label', LABEL, body=text).strip())
217− return
218− if key and already_reported(number, marker(key)):
219− print(f'issue #{number} already reports this; nothing to add')
220− return
221− gh('issue', 'edit', str(number), '--title', title())
222− gh_body('issue', 'comment', str(number), body=text)
223− print(f'commented on issue #{number}')
224−
225−
226−def close(note):
227− for number in open_issues():
228− gh('issue', 'close', str(number), '--reason', 'completed', '--comment', note)
229− print(f'closed issue #{number}')
230−
231−
232−def main():
233− results = [env('SYNC_RESULT'), env('COMPILE_RESULT'), env('PROMOTE_RESULT')]
234− status = env('SYNC_STATUS')
235− print(f'sync={results[0]} compile={results[1]} promote={results[2]} status={status or "-"}')
236− if 'cancelled' in results:
237− print('a job was cancelled; nothing to report')
238− return
239− fc = sha('SYNC_FORKGRAM_COMMIT')
240− cand = sha('SYNC_CANDIDATE')
241− if status == 'conflict':
242− report(f'conflict {fc} {sha("SYNC_CONFLICT_COMMIT")}', body_conflict())
243− elif results[0] != 'success':
244− err = env('SYNC_ERROR') or 'unknown'
245− report('error ' + hashlib.sha1(err.encode()).hexdigest()[:12], body_error())
246− elif status == 'next-edited':
247− report(f'next-edited {sha("SYNC_EXISTING_NEXT")} {sha("SYNC_FORKGRAM_TIP")}', body_next_edited())
248− elif status == 'candidate' and results[1] == 'failure':
249− report(f'compile {cand}', body_compile())
250− elif status == 'candidate' and results[1] == 'success' and results[2] == 'failure':
251− report(f'promote {cand}', body_promote())
252− elif status == 'candidate' and results[2] == 'success':
253− tag = env('PROMOTE_TAG')
254− moved = f'the old tip is tagged `{tag}`' if re.match(r'^[0-9A-Za-z._-]+$', tag) else 'it already was the candidate'
255− note = f'Done: `multigram` now carries Forkgram {version()} (`{short(cand)}`); {moved}. {run_line()}'.strip()
256− if env('SYNC_DROPPED'):
257− note += ('\n\nThese MultiGram commits are not on the new `multigram` (Forkgram already has '
258− 'them, or `multigram-next` left them out with allow_drop):\n' + block(env('SYNC_DROPPED')))
259− close(note)
260− elif status == 'behind':
261− if open_issue() is None:
262− report('', body_behind())
263− else:
264− print('multigram is behind and an issue is already open; nothing to add')
265− elif status == 'up-to-date':
266− if open_issue() is not None:
267− close(f'`multigram` is in step with Forkgram {version()} again. {run_line()}'.strip())
268− else:
269− print('nothing to report')
270−
271−
272−if __name__ == '__main__':
273− main()