1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# The Bot API server's own release pipeline.
#
# This publishes this repository's builds of `tdlib/telegram-bot-api` — upstream
# publishes source only, with no releases, no binaries and no container — for the
# five platforms the product installs the server on. Its tag prefix is `tgapi-v`,
# a stream of its own, apart from the product's releases (`v<version>`, which
# `release.yml` owns), and nothing here runs on a `v*` tag.
#
# Every release in this stream is a prerelease, and that is what keeps the
# product's own update channel untouched: `releases/latest` — read by
# `src/self_update.rs`, `install.sh` and `install.ps1` — never names one, while a
# release asset stays downloadable by a plain HTTP client, which is how the
# product's installer (`src/channels/telegram_local/install.rs`) fetches the
# server.
#
# One release carries all five of these, built natively, or none of them:
#
# telegram-bot-api-<version>-linux-x86_64.tar.gz
# telegram-bot-api-<version>-linux-aarch64.tar.gz
# telegram-bot-api-<version>-macos-aarch64.tar.gz
# telegram-bot-api-<version>-macos-x86_64.tar.gz
# telegram-bot-api-<version>-windows-x86_64.zip
# SHA256SUMS one `<sha256> <file>` line per archive
#
# Each archive holds one regular file named `telegram-bot-api`
# (`telegram-bot-api.exe` on Windows); the installer finds it by name at any
# depth and swaps it into place.
#
# `<version>` is the Bot API version the build implements, and it is never
# written down in this file: it is read back out of the built binary's own
# `--version` (`Bot API <major>.<minor>`), so a tag, an archive name and the
# binary inside it cannot disagree. Bumping `TDLIB_COMMIT` to a newer upstream
# revision — and dispatching this workflow — is therefore the only way a new
# server version is published.
#
# No code signing and no notarization: each leg builds on the platform's own
# machine, and the product downloads the artifact itself, so no quarantine
# attribute is ever applied to it. Windows is built against the static C runtime
# with static OpenSSL and zlib, so the owner needs neither a C++ redistributable
# nor a DLL beside the exe.
name: Telegram Bot API server
# Dispatch only, on purpose: a `v*` tag is the product's own release mark, and a
# server release is started by hand from a revision chosen by hand.
on:
workflow_dispatch:
inputs:
commit:
description: 'tdlib/telegram-bot-api commit to build instead of the pinned one'
required: false
type: string
env:
# The pinned upstream revision every artifact is built from. It must stay at or
# after d7aff88 (2026-08-23), the revision that added `--files-dir`: the product
# spawns the server with that flag and an older binary refuses to start. Nothing
# else here names a version — the number comes out of the binary itself.
TDLIB_COMMIT: ${{ inputs.commit || 'e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1' }}
permissions:
contents: write
# One dispatch at a time: two runs publishing the same version would race for the
# same tag, and the one that arrives second is meant to find the first one's
# release and stop.
concurrency:
group: tgapi-release
cancel-in-progress: false
jobs:
# ── The five archives ─────────────────────────────────────────────────────
build:
name: Build ${{ matrix.target_os }}-${{ matrix.target_arch }}
runs-on: ${{ matrix.runner }}
env:
# Spelled once: every archive name below is built from these two.
ASSET_OS: ${{ matrix.target_os }}
ASSET_ARCH: ${{ matrix.target_arch }}
strategy:
# Every leg runs to its own end even when one fails: a release needs all
# five, and the failing leg's own log is what the fix starts from.
fail-fast: false
matrix:
include:
- runner: ubuntu-22.04
target_os: linux
target_arch: x86_64
- runner: ubuntu-22.04-arm
target_os: linux
target_arch: aarch64
- runner: macos-14
target_os: macos
target_arch: aarch64
# Intel macOS. `macos-13` has been retired, so the image `release.yml`
# already builds its own x86_64 file on is the one used here.
- runner: macos-15-intel
target_os: macos
target_arch: x86_64
- runner: windows-latest
target_os: windows
target_arch: x86_64
steps:
# A commit, never a branch: repeating the same dispatch must build the same
# server. The TDLib sources are a submodule, so they come along.
- uses: actions/checkout@v4
with:
repository: tdlib/telegram-bot-api
ref: ${{ env.TDLIB_COMMIT }}
submodules: recursive
# OpenSSL, zlib and gperf are what the upstream tree needs, and CMake is
# what builds it. `libssl-dev` and `zlib1g-dev` carry the static archives
# the configure step insists on, so the artifact links nothing that must be
# present on the owner's machine.
- name: Install the build prerequisites (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential cmake gperf libssl-dev zlib1g-dev
# macOS ships zlib as a dynamic library only, and Homebrew's `zlib` is what
# carries the static one; `openssl@3` is keg-only, so its prefix is handed
# to CMake below instead of being linked through the default paths.
- name: Install the build prerequisites (macOS)
if: runner.os == 'macOS'
run: brew install cmake gperf openssl@3 zlib
# The static triplets are the point: `x64-windows-static` OpenSSL and zlib
# are the archives linked below, so the exe needs no DLL beside it. gperf
# is a build tool, taken from the dynamic triplet the upstream instructions
# name and handed to CMake by its full path, because vcpkg does not put
# tool ports on PATH.
- name: Install the build prerequisites (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
vcpkg install gperf:x64-windows `
openssl:x64-windows-static zlib:x64-windows-static
- name: Configure (Linux)
if: runner.os == 'Linux'
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/install" \
-DOPENSSL_USE_STATIC_LIBS=TRUE \
-DZLIB_USE_STATIC_LIBS=TRUE
- name: Configure (macOS)
if: runner.os == 'macOS'
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/install" \
-DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" \
-DOPENSSL_USE_STATIC_LIBS=TRUE \
-DZLIB_ROOT="$(brew --prefix zlib)" \
-DZLIB_USE_STATIC_LIBS=TRUE
# The static runtime, three ways: the target property, the release flags
# themselves, and the libraries above, which are `/MT` builds. The property
# needs CMP0091 on, because upstream declares `cmake_minimum_required(3.10)`
# and would otherwise leave it unused — hence the policy default and the
# flag, so whichever of the two takes effect the exe is the same.
- name: Configure (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$static = Join-Path $env:VCPKG_INSTALLATION_ROOT 'installed\x64-windows-static'
$gperf = Join-Path $env:VCPKG_INSTALLATION_ROOT 'installed\x64-windows\tools\gperf\gperf.exe'
if (-not (Test-Path $gperf)) { throw "vcpkg installed no gperf at $gperf" }
cmake -S . -B build -A x64 `
-DCMAKE_BUILD_TYPE=Release `
"-DCMAKE_INSTALL_PREFIX=$env:RUNNER_TEMP\install" `
"-DOPENSSL_ROOT_DIR=$static" `
-DOPENSSL_USE_STATIC_LIBS=TRUE `
"-DZLIB_ROOT=$static" `
-DZLIB_USE_STATIC_LIBS=TRUE `
-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded `
-DCMAKE_POLICY_DEFAULT_CMP0091=NEW `
"-DCMAKE_CXX_FLAGS_RELEASE=/MT /O2" `
"-DGPERF_EXECUTABLE=$gperf"
# `--config Release` is what picks the configuration on the Windows
# generator, which takes it here rather than from `CMAKE_BUILD_TYPE`; the
# single-configuration generators ignore it.
- name: Build
run: cmake --build build --config Release
- name: Install into the staging directory
run: cmake --install build --config Release
# The version every name in this release carries, read from the binary that
# will be published rather than declared anywhere: a build of a different
# upstream revision tags and names itself accordingly.
- name: Read the Bot API version (Linux/macOS)
if: runner.os != 'Windows'
run: |
set -euo pipefail
binary="$RUNNER_TEMP/install/bin/telegram-bot-api"
banner="$("$binary" --version 2>&1 | tr -d '\r')"
version="$(printf '%s\n' "$banner" \
| sed -nE 's/^Bot API ([0-9]+\.[0-9]+)$/\1/p' | tail -n 1)"
if [ -z "$version" ]; then
echo "::error::$binary --version printed no 'Bot API <major>.<minor>' line:"
printf '%s\n' "$banner"
exit 1
fi
echo "VERSION=$version" >> "$GITHUB_ENV"
echo "The artifact implements Bot API $version"
- name: Read the Bot API version (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$binary = Join-Path $env:RUNNER_TEMP 'install\bin\telegram-bot-api.exe'
$banner = ((& $binary --version 2>&1 | Out-String) -replace "`r", '')
if ($banner -notmatch '(?m)^Bot API ([0-9]+)\.([0-9]+)$') {
Write-Host $banner
throw "$binary --version printed no 'Bot API <major>.<minor>' line"
}
$version = "$($Matches[1]).$($Matches[2])"
"VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
Write-Host "The artifact implements Bot API $version"
# The archive holds one regular file, under a folder named after the
# archive; the installer looks it up by name at any depth.
- name: Package the archive (Linux/macOS)
if: runner.os != 'Windows'
run: |
set -euo pipefail
name="telegram-bot-api-$VERSION-$ASSET_OS-$ASSET_ARCH"
mkdir -p "$RUNNER_TEMP/stage/$name" "$RUNNER_TEMP/dist"
cp "$RUNNER_TEMP/install/bin/telegram-bot-api" "$RUNNER_TEMP/stage/$name/telegram-bot-api"
chmod 0755 "$RUNNER_TEMP/stage/$name/telegram-bot-api"
# `COPYFILE_DISABLE` keeps macOS `tar` from adding `._` extended
# attribute entries beside the file.
COPYFILE_DISABLE=1 tar -czf "$RUNNER_TEMP/dist/$name.tar.gz" \
-C "$RUNNER_TEMP/stage" "$name"
cd "$RUNNER_TEMP/dist"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$name.tar.gz" > "$name.tar.gz.sha256"
else
shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256"
fi
cat "$name.tar.gz.sha256"
# One file at the archive's root, so no path separator has to be read back
# out of a zip entry name.
- name: Package the archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$name = "telegram-bot-api-$env:VERSION-$env:ASSET_OS-$env:ASSET_ARCH"
$dist = Join-Path $env:RUNNER_TEMP 'dist'
New-Item -ItemType Directory -Force -Path $dist | Out-Null
$exe = Join-Path $env:RUNNER_TEMP 'install\bin\telegram-bot-api.exe'
$zip = Join-Path $dist "$name.zip"
Compress-Archive -Path $exe -DestinationPath $zip
$hash = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLowerInvariant()
# Two spaces between digest and name: `sha256sum`'s own shape.
"$hash $name.zip" | Out-File -FilePath "$zip.sha256" -Encoding ascii
Get-Content "$zip.sha256"
# Workflow artifacts are the hand-off to the job below only; what announces
# the server to the product is the release the next job publishes.
- name: Hand the archive to the publish job
uses: actions/upload-artifact@v4
with:
name: tgapi-${{ matrix.target_os }}-${{ matrix.target_arch }}
path: ${{ runner.temp }}/dist/*
if-no-files-found: error
# ── The release ───────────────────────────────────────────────────────────
# All five archives in one prerelease, plus the single checksum list that covers
# them. Nothing becomes visible before every file is here, and the tag is
# derived from what arrived, so it cannot name a version no artifact implements.
publish:
name: Publish the server prerelease
needs: build
runs-on: ubuntu-22.04
steps:
- name: Download the five archives
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/dist
merge-multiple: true
# One version, five platforms, one line per archive — and every digest
# checked against the file beside it, so a truncated upload ships nothing.
- name: Assemble SHA256SUMS
run: |
set -euo pipefail
cd "$RUNNER_TEMP/dist"
shopt -s nullglob
archives=(telegram-bot-api-*.tar.gz telegram-bot-api-*.zip)
if [ "${#archives[@]}" -ne 5 ]; then
echo "::error::five archives were expected, ${#archives[@]} arrived"
exit 1
fi
pattern='^telegram-bot-api-([0-9]+\.[0-9]+)'
pattern+='-(linux|macos|windows)-(x86_64|aarch64)\.(tar\.gz|zip)$'
version=""
: > SHA256SUMS
for archive in "${archives[@]}"; do
if [[ ! "$archive" =~ $pattern ]]; then
echo "::error::$archive is not telegram-bot-api-<version>-<os>-<arch>.<ext>"
exit 1
fi
if [ -z "$version" ]; then
version="${BASH_REMATCH[1]}"
elif [ "$version" != "${BASH_REMATCH[1]}" ]; then
echo "::error::$archive implements Bot API ${BASH_REMATCH[1]}, not $version"
exit 1
fi
sidecar="$archive.sha256"
if [ ! -f "$sidecar" ]; then
echo "::error::$archive arrived without its checksum line"
exit 1
fi
line="$(tr -d '\r' < "$sidecar" | sed '/^$/d')"
digest="^[0-9a-f]{64} ${archive//./\\.}\$"
if [[ ! "$line" =~ $digest ]]; then
echo "::error::$sidecar does not hold one '<sha256> $archive' line: $line"
exit 1
fi
printf '%s\n' "$line" >> SHA256SUMS
done
missing=0
for platform in linux-x86_64 linux-aarch64 macos-aarch64 macos-x86_64 windows-x86_64; do
case "$platform" in
windows-*) extension=zip ;;
*) extension=tar.gz ;;
esac
name="telegram-bot-api-$version-$platform.$extension"
if [ ! -f "$name" ]; then
echo "::error::$name did not arrive"
missing=1
fi
done
[ "$missing" -eq 0 ]
if ! sha256sum -c SHA256SUMS; then
echo "::error::an archive does not match the checksum line beside it"
exit 1
fi
echo "VERSION=$version" >> "$GITHUB_ENV"
echo "Bot API $version: five archives, one checksum list"
- name: Publish the prerelease
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
cd "$RUNNER_TEMP/dist"
tag="tgapi-v$VERSION"
notes="Bot API $VERSION server built from tdlib/telegram-bot-api@$TDLIB_COMMIT"
# An existing release is left alone: the product's installer compares
# versions, and assets changing under one tag would hand the owner a
# different build at the same version. A new server version means a
# newer upstream revision, and so a newer Bot API version.
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::error::$tag already exists — bump TDLIB_COMMIT to a newer upstream revision to publish a new server version, or delete that release by hand if it should never have existed"
exit 1
fi
# A prerelease, so `releases/latest` keeps naming the product's own
# release; the tag points at the commit this run started from.
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--prerelease \
--title "Bot API $VERSION server" \
--notes "$notes" \
telegram-bot-api-*.tar.gz \
telegram-bot-api-*.zip \
SHA256SUMS