Skip to content

XP Release.

XP Release. #7

Workflow file for this run

name: XP Release.
# Release build of the Windows XP port, for both of its targets:
#
# x86 Windows XP SP3. Subsystem 5.01.
# x64 Windows XP Professional x64 Edition, which is NT 5.2 - the Server 2003
# kernel - so its images declare subsystem 5.02 and a 5.01 link is
# rejected outright. It is a separate download and a separate feed key
# (winxp64 / txp64upd), because the x86 build ALSO runs there under
# WOW64 and offering one to the other would silently move a machine onto
# the 32-bit line for good.
#
# Everything below runs once per architecture; the `arch` dispatch input cuts it
# down to one while iterating. The two share only the runner image, the patched
# SDK 7.1A headers and the Qt source checkout - toolchain objects, libraries, Qt
# prefix and build tree are all per-architecture, in sibling directories.
#
# Two jobs with very different requirements:
#
# patches runs anywhere. It proves the repository is self-consistent: every
# patches/*.patch still applies to the submodule commit the tree pins.
# That is the failure this port actually suffers - a regenerated patch
# silently losing a hunk, or an upstream bump moving the code a patch
# targets - and it needs no toolchain, so it gates every push.
#
# build needs the XP toolchain, which GitHub-hosted runners cannot provide:
# the v141_xp 14.16 target, a PATCHED SDK 7.1A include tree, the
# fpcompat helper objects and the XP-pinned prebuilt dependencies
# (Qt 5.15.16 static, OpenSSL 1.0.2, FFmpeg 3.4, OpenAL, opus). None of
# that is installable from a hosted image, so this job targets a
# self-hosted runner that already carries them. See xp/README.md.
on:
workflow_dispatch:
inputs:
runner_labels:
description: 'JSON array of runner labels carrying the XP toolchain.'
type: string
default: '["self-hosted","windows","xp-toolchain"]'
arch:
description: 'Which target to build. x86 is XP SP3, x64 is XP Professional x64 Edition (NT 5.2).'
type: choice
options: [both, x86, x64]
default: both
rebuild_ffmpeg:
description: 'Rebuild the XP FFmpeg libraries first (~10 minutes).'
type: boolean
default: false
only:
description: 'Run just one stage while iterating (libraries, qt, build, publish) or all.'
type: choice
options: [all, libraries, qt, build, publish]
default: all
publish_from_run:
description: 'Publish the package built by this run id instead of building one.'
type: string
default: ''
publish_release:
description: 'Publish a GitHub Release with the portable archive.'
type: boolean
default: false
publish_update:
description: 'Sign the update package and publish it to the Telegram feed.'
type: choice
# Quoted: bare `off` is a YAML boolean, so the choice list would offer
# `false` and a dispatch with 'off' would be rejected.
#
# `package` signs the update and uploads it as an artifact without
# posting anything. It exists because packing was otherwise reachable
# only together with publishing, so the binary a machine would actually
# install could not be tried out before it went live. Build with
# `package`, install that artifact somewhere, then republish it with
# `only=publish` + `publish_from_run` - a minute instead of a rebuild.
options: ['off', package, rehearsal, testing, released]
default: 'off'
push:
tags:
- 'xp-v*'
pull_request:
paths:
- 'patches/**'
- 'xp/**'
- '.gitmodules'
- '.github/workflows/xp_release.yml'
permissions:
contents: write
concurrency:
# Per RUN, not per ref: the Qt stage takes hours and would otherwise queue
# every other dispatch behind it.
group: xp-release-${{ github.run_id }}
cancel-in-progress: false
jobs:
patches:
name: Patch set.
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
port: ${{ steps.detect.outputs.port }}
arches: ${{ steps.detect.outputs.arches }}
steps:
- name: Clone.
uses: actions/checkout@v7
with:
submodules: recursive
# GitHub only lets workflow_dispatch resolve a workflow that exists on the
# DEFAULT branch, while the run itself uses the file from the ref you pick.
# So a copy of this file has to live on a branch that carries no XP port at
# all - and running it there must not fail. Detect and stand down instead.
- name: Detect the port.
id: detect
env:
ARCH: ${{ github.event.inputs.arch }}
run: |
if [ -f patches/apply_xp_patches.cmake ] && [ -d xp ]; then
echo "port=true" >> $GITHUB_OUTPUT
echo "XP port present, checking it."
else
echo "port=false" >> $GITHUB_OUTPUT
echo "::notice::this ref carries no XP port - dispatch this workflow against an xp-port-* branch"
fi
# The build matrix, decided once here rather than in three copies of a
# nested ternary. Empty on a push or a pull_request, which is `both`.
case "$ARCH" in
x86) arches='["x86"]' ;;
x64) arches='["x64"]' ;;
*) arches='["x86","x64"]' ;;
esac
echo "arches=$arches" >> $GITHUB_OUTPUT
echo "building: $arches"
# git hands .patch files out with CRLF on a client that translates line
# endings, and then git apply rejects its own context lines while the base
# blobs match perfectly. patches/.gitattributes marks them -text; this
# verifies the result rather than trusting it.
- name: Line endings.
if: steps.detect.outputs.port == 'true'
run: |
bad=0
for patch in patches/*.patch; do
if grep -qU $'\r' "$patch"; then
echo "::error file=$patch::contains CR - patches/.gitattributes should keep it -text"
bad=1
fi
done
if [ "$bad" = "0" ]; then echo "all patches are LF-only."; fi
exit $bad
# The authoritative list lives in apply_xp_patches.cmake, so read it from
# there instead of repeating it here - a patch added without a CI update
# would otherwise go unchecked.
- name: Every patch applies to its pinned submodule.
if: steps.detect.outputs.port == 'true'
run: |
set -e
list=$(sed -n '/set(xp_patch_list/,/^)/p' patches/apply_xp_patches.cmake \
| grep -oE '"[^"]+"' | tr -d '"' | paste - -)
[ -n "$list" ] || { echo "::error::could not read xp_patch_list"; exit 1; }
failed=0
while IFS=$'\t' read -r dir file; do
[ -n "$dir" ] || continue
if [ ! -e "$dir/.git" ]; then
echo "::error::submodule $dir is not checked out"
failed=1
continue
fi
# A pristine checkout must take the patch cleanly. --check does not
# touch the tree, so the order of the loop does not matter.
if git -C "$dir" apply --check --binary "$GITHUB_WORKSPACE/patches/$file"; then
echo "ok $file -> $dir"
else
echo "::error file=patches/$file::does not apply to $dir at its pinned commit"
failed=1
fi
done <<< "$list"
exit $failed
- name: The build recipe is present.
if: steps.detect.outputs.port == 'true'
run: |
set -e
for required in \
xp/build_ffmpeg_xp.sh \
xp/cmake_xp.ps1 \
xp/xpsafe.ps1 \
xp/bootstrap_toolchain.ps1 \
xp/build_libraries.ps1 \
xp/build_qt.ps1 \
xp/xp_env.ps1 \
xp/fpcompat/xpfls.asm \
xp/fpcompat/xpfls64.asm \
xp/fpcompat/hoststub.c \
xp/README.md; do
[ -f "$required" ] || { echo "::error::$required is missing"; exit 1; }
done
echo "recipe complete."
libraries:
name: XP toolchain and libraries (${{ matrix.arch }}).
needs: patches
runs-on: windows-latest
timeout-minutes: 300
if: github.event_name != 'pull_request' && needs.patches.outputs.port == 'true'
&& (github.event.inputs.only == null || github.event.inputs.only == 'all' || github.event.inputs.only == 'libraries')
strategy:
# The two architectures share nothing but the runner image, so one failing
# must not cancel the other - and a partial cache is still worth saving.
fail-fast: false
matrix:
arch: ${{ fromJSON(needs.patches.outputs.arches) }}
env:
XP_TOOLCHAIN_ROOT: C:\xp-toolchain
# Set once per job: every script in xp/ takes its default architecture
# from here, so nothing downstream has to be told again.
XP_ARCH: ${{ matrix.arch }}
# Separate trees, not a switchable one. Each library writes into its own
# source checkout, so a 64-bit zlibstat.lib landing where CMake expects
# the 32-bit one would only show up as a link error hours later.
XP_LIBS_DIR_WIN: ${{ matrix.arch == 'x64' && 'C:\xp-toolchain\Libraries-x64' || 'C:\xp-toolchain\Libraries' }}
defaults:
run:
shell: pwsh
steps:
- name: Clone.
uses: actions/checkout@v7
# Everything the port needs that a hosted image lacks, built from
# Microsoft's own installers plus this repository's sources: the v141
# (14.16) target toolset, the 7.1A SDK through the VS2019 Build Tools
# bootstrapper, the patched include tree and fpcompat.
- name: Bootstrap the XP toolchain.
run: |
& "$PWD\xp\bootstrap_toolchain.ps1" -Root C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
# Prove the toolchain before spending hours on libraries: build a minimal
# program exactly the way Telegram is built and let xpsafe.ps1 confirm it
# could start on Windows XP.
- name: Compile a minimal XP binary.
run: |
$repoRoot = $PWD.Path
$probe = Join-Path $env:RUNNER_TEMP 'xpprobe'
New-Item -ItemType Directory -Force -Path $probe | Out-Null
@'
#include <windows.h>
#include <stdio.h>
#include <mutex>
#include <vector>
int main() {
std::mutex m;
std::lock_guard<std::mutex> guard(m);
std::vector<double> v{ 1.5, 2.5 };
printf("xp probe %d %.1f\n", (int)v.size(), v[0] + v[1]);
return 0;
}
'@ | Set-Content -Path "$probe\xpprobe.cpp" -Encoding ascii
& "$repoRoot\xp\xp_env.ps1" -Toolchain C:\xp-toolchain
$forced = Join-Path $repoRoot 'xp\xp-compat.h'
Push-Location $probe
& cl.exe /nologo /MT /EHsc /d2FH4- /FI"$forced" /c xpprobe.cpp 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) { Write-Error 'compile failed'; exit 1 }
& link.exe /nologo "/SUBSYSTEM:CONSOLE,$env:XP_SUBSYSTEM_VERSION" /FORCE:MULTIPLE xpprobe.obj `
"$env:XP_FPCOMPAT\fpcompat.lib" /OUT:xpprobe.exe 2>&1 | Write-Host
Write-Host "linked for $env:XP_ARCH, subsystem $env:XP_SUBSYSTEM_VERSION"
if ($LASTEXITCODE -ne 0) { Write-Error 'link failed'; exit 1 }
Pop-Location
& "$repoRoot\xp\xpsafe.ps1" -Exe "$probe\xpprobe.exe" -Dumpbin (
Get-ChildItem "$env:XP_TOOLSET_BINARY_DIR\bin\Hostx64\x64\dumpbin.exe").FullName
if ($LASTEXITCODE -ne 0) { exit 1 }
# The libraries are pinned by commit, so the key follows the recipe alone.
# Restore and save are split: actions/cache saves nothing when the job
# fails, and these builds make real progress worth banking either way.
- name: Libraries cache.
uses: actions/cache/restore@v6
with:
path: ${{ env.XP_LIBS_DIR_WIN }}
key: ${{ runner.os }}-xp-libs-${{ matrix.arch }}-${{ hashFiles('xp/build_libraries.ps1', 'xp/deps/**') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-xp-libs-${{ matrix.arch }}-${{ hashFiles('xp/build_libraries.ps1', 'xp/deps/**') }}-
- name: Build the pinned libraries.
run: |
& "$PWD\xp\build_libraries.ps1" -Root $env:XP_LIBS_DIR_WIN -Toolchain C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
# FFmpeg's configure is a shell script and wants make, nasm and pkg-config,
# so it runs under msys2 exactly as it does on the workstation. It has to
# come after opus, which it links.
- name: msys2.
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: false
path-type: inherit
install: >-
make
diffutils
nasm
pkgconf
# No architecture flag: ffmpeg's configure decides the subarch by compiling
# a _M_X64 probe with whichever cl.exe the environment provides, so it
# follows the toolchain rather than being told.
- name: FFmpeg.
run: |
$ff = Join-Path $env:XP_LIBS_DIR_WIN 'ffmpeg'
if (Test-Path "$ff\libavcodec\libavcodec.a") { Write-Host 'already built'; exit 0 }
if (-not (Test-Path "$ff\configure")) {
& git.exe clone --depth 1 -b release/3.4 https://github.com/FFmpeg/FFmpeg.git $ff
if ($LASTEXITCODE -ne 0) { Write-Error 'cloning FFmpeg failed'; exit 1 }
}
& "$PWD\xp\run_ffmpeg_recipe.ps1" -Root $env:XP_LIBS_DIR_WIN -Toolchain C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
- name: What got built.
if: always()
run: |
# Two of these move with the architecture, and both are named by
# cmake/external as well - zlib off build_win64, lzma off the platform
# subdirectory a .vcxproj adds. If they drift apart, the link fails.
$arch = $env:XP_ARCH
$platformDir = $(if ($arch -eq 'x64') { 'x64\' } else { '' })
$expected = @(
"zlib\contrib\vstudio\vc14\$arch\ZlibStatReleaseWithoutAsm\zlibstat.lib",
'zlib\contrib\minizip\Release\libminizips.lib',
"lzma\C\Util\LzmaLib\${platformDir}Release\LzmaLib.lib",
'opus\out\Release\opus.lib',
'openssl\out32\libeay32.lib',
'openssl\out32\ssleay32.lib',
'openal-soft\build\Release\OpenAL32.lib',
'ada\out\singleheader\Release\ada-singleheader-lib.lib',
'ffmpeg\libavcodec\libavcodec.a',
'ffmpeg\libavformat\libavformat.a',
'ffmpeg\libavfilter\libavfilter.a')
$missing = 0
foreach ($rel in $expected) {
$full = Join-Path $env:XP_LIBS_DIR_WIN $rel
if (Test-Path $full) {
Write-Host (" ok {0,12:N0} {1}" -f (Get-Item $full).Length, $rel)
} else {
Write-Host " MISSING $rel"
$missing++
}
}
if ($missing -gt 0) { Write-Error "$missing libraries missing"; exit 1 }
- name: Save the libraries cache.
if: always()
uses: actions/cache/save@v6
with:
path: ${{ env.XP_LIBS_DIR_WIN }}
key: ${{ runner.os }}-xp-libs-${{ matrix.arch }}-${{ hashFiles('xp/build_libraries.ps1', 'xp/deps/**') }}-${{ github.run_id }}
qt:
name: XP Qt (${{ matrix.arch }}).
needs: patches
runs-on: windows-latest
# A static Qt from scratch is hours. It only has to happen when the recipe or
# the pin changes; every later run restores the cache and skips straight
# past. Deliberately independent of the libraries job so both can run at once.
timeout-minutes: 350
if: github.event_name != 'pull_request' && needs.patches.outputs.port == 'true'
&& (github.event.inputs.only == null || github.event.inputs.only == 'all' || github.event.inputs.only == 'qt')
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON(needs.patches.outputs.arches) }}
env:
XP_ARCH: ${{ matrix.arch }}
# win32-msvc is the mkspec for both targets - Qt 5 takes the architecture
# from the compiler - so only the prefix tells the two builds apart.
XP_QT_PREFIX: ${{ matrix.arch == 'x64' && 'C:\xp-toolchain\qt-xp-static-prefix-x64' || 'C:\xp-toolchain\qt-xp-static-prefix' }}
defaults:
run:
shell: pwsh
steps:
- name: Clone.
uses: actions/checkout@v7
- name: Free up runner disk space.
run: |
function Show-Free($label) {
Write-Host ("{0}: {1:N1} GB free on C:" -f $label, ((Get-PSDrive C).Free / 1GB))
}
Show-Free 'before'
foreach ($target in @(
'C:\Android', 'C:\Miniconda', 'C:\Julia',
'C:\Program Files\MongoDB', 'C:\Program Files\MySQL',
'C:\Program Files\PostgreSQL', 'C:\Program Files\Amazon',
'C:\Program Files\Microsoft SQL Server',
'C:\Program Files (x86)\Microsoft SQL Server',
'C:\Program Files (x86)\Google', 'C:\Program Files\R',
'C:\hostedtoolcache\windows\Java_Temurin-Hotspot_jdk',
'C:\hostedtoolcache\windows\Ruby', 'C:\hostedtoolcache\windows\go',
'C:\hostedtoolcache\windows\node', 'C:\hostedtoolcache\windows\PyPy')) {
if (Test-Path $target) {
Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
}
}
Show-Free 'after'
- name: Qt cache.
id: cache-qt
uses: actions/cache/restore@v6
with:
path: ${{ env.XP_QT_PREFIX }}
key: ${{ runner.os }}-xp-qt-${{ matrix.arch }}-${{ hashFiles('xp/build_qt.ps1', 'xp/xp_env.ps1', 'xp/deps/qt5-xp.patch') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-xp-qt-${{ matrix.arch }}-${{ hashFiles('xp/build_qt.ps1', 'xp/xp_env.ps1', 'xp/deps/qt5-xp.patch') }}-
- name: Bootstrap the XP toolchain.
run: |
& "$PWD\xp\bootstrap_toolchain.ps1" -Root C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
- name: Build Qt.
run: |
& "$PWD\xp\build_qt.ps1" -Root C:\xp-toolchain -Toolchain C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
- name: What got built.
if: always()
run: |
$prefix = $env:XP_QT_PREFIX
$expected = @('lib\Qt5Core.lib', 'lib\Qt5Gui.lib', 'lib\Qt5Widgets.lib',
'lib\Qt5Network.lib', 'lib\Qt5Svg.lib', 'lib\qtmain.lib',
'plugins\platforms\qwindows.lib', 'plugins\imageformats\qwebp.lib')
$missing = 0
foreach ($rel in $expected) {
$full = Join-Path $prefix $rel
if (Test-Path $full) {
Write-Host (" ok {0,12:N0} {1}" -f (Get-Item $full).Length, $rel)
} else {
Write-Host " MISSING $rel"
$missing++
}
}
if ($missing -gt 0) { Write-Error "$missing Qt libraries missing"; exit 1 }
- name: Save the Qt cache.
if: always()
uses: actions/cache/save@v6
with:
path: ${{ env.XP_QT_PREFIX }}
key: ${{ runner.os }}-xp-qt-${{ matrix.arch }}-${{ hashFiles('xp/build_qt.ps1', 'xp/xp_env.ps1', 'xp/deps/qt5-xp.patch') }}-${{ github.run_id }}
build:
name: XP ${{ matrix.arch }} Release.
needs: [patches, libraries, qt]
runs-on: windows-latest
timeout-minutes: 350
# !cancelled() rather than the default: with `only`, libraries and qt are
# SKIPPED, and a skipped dependency would skip this job too. Their caches
# are what this stage actually consumes, so a skip is fine - a failure is not.
if: >-
!cancelled()
&& github.event_name != 'pull_request'
&& needs.patches.outputs.port == 'true'
&& needs.libraries.result != 'failure'
&& needs.qt.result != 'failure'
&& (github.event.inputs.only == null || github.event.inputs.only == 'all' || github.event.inputs.only == 'build')
# NOTE: needs.<job>.result is the AGGREGATE of a matrix, so if either
# architecture's libraries or Qt fails, neither build runs. That is the
# conservative reading and usually the right one - the two share a recipe.
# To carry on with the half that works, dispatch again with arch: x86 or
# arch: x64, which reduces the matrix to one leg.
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON(needs.patches.outputs.arches) }}
env:
BUILD_DIR: out/ci-xp
# Set once here instead of in five steps. cmake/variables.cmake reads the
# last two straight out of the environment.
XP_ARCH: ${{ matrix.arch }}
XP_QT_PREFIX: ${{ matrix.arch == 'x64' && 'C:\xp-toolchain\qt-xp-static-prefix-x64' || 'C:\xp-toolchain\qt-xp-static-prefix' }}
XP_LIBS_DIR_WIN: ${{ matrix.arch == 'x64' && 'C:\xp-toolchain\Libraries-x64' || 'C:\xp-toolchain\Libraries' }}
XP_COMPAT_HEADER: ${{ github.workspace }}\xp\xp-compat.h
defaults:
run:
shell: pwsh
steps:
- name: Clone.
uses: actions/checkout@v7
with:
submodules: recursive
- name: Free up runner disk space.
run: |
foreach ($target in @(
'C:\Android', 'C:\Miniconda', 'C:\Julia',
'C:\Program Files\MongoDB', 'C:\Program Files\MySQL',
'C:\Program Files\PostgreSQL', 'C:\Program Files\Amazon',
'C:\Program Files\Microsoft SQL Server',
'C:\Program Files (x86)\Microsoft SQL Server',
'C:\Program Files (x86)\Google', 'C:\Program Files\R',
'C:\hostedtoolcache\windows\Java_Temurin-Hotspot_jdk',
'C:\hostedtoolcache\windows\Ruby', 'C:\hostedtoolcache\windows\go',
'C:\hostedtoolcache\windows\node', 'C:\hostedtoolcache\windows\PyPy')) {
if (Test-Path $target) { Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue }
}
Write-Host ("C: {0:N1} GB free" -f ((Get-PSDrive C).Free / 1GB))
- name: Bootstrap the XP toolchain.
run: |
& "$PWD\xp\bootstrap_toolchain.ps1" -Root C:\xp-toolchain -Arch ${{ matrix.arch }}
if ($LASTEXITCODE -ne 0) { exit 1 }
- name: Libraries cache.
uses: actions/cache/restore@v6
with:
path: ${{ env.XP_LIBS_DIR_WIN }}
key: ${{ runner.os }}-xp-libs-${{ matrix.arch }}-${{ hashFiles('xp/build_libraries.ps1', 'xp/deps/**') }}-
fail-on-cache-miss: true
- name: Qt cache.
uses: actions/cache/restore@v6
with:
path: ${{ env.XP_QT_PREFIX }}
key: ${{ runner.os }}-xp-qt-${{ matrix.arch }}-${{ hashFiles('xp/build_qt.ps1', 'xp/xp_env.ps1', 'xp/deps/qt5-xp.patch') }}-
fail-on-cache-miss: true
# cmake/variables.cmake resolves the dependencies as a SIBLING of the source
# tree (../Libraries-walk), the way the workstation is laid out. A junction
# keeps that rule intact without teaching CMake about the runner's paths.
- name: Put the libraries where CMake looks for them.
run: |
$parent = Split-Path $env:GITHUB_WORKSPACE -Parent
$sibling = Join-Path $parent 'Libraries-walk'
# The junction is what makes one CMake rule ("../Libraries-walk") serve
# both architectures: the name stays put, the target moves.
if (-not (Test-Path $sibling)) {
New-Item -ItemType Junction -Path $sibling -Target $env:XP_LIBS_DIR_WIN | Out-Null
}
Write-Host "$sibling -> $env:XP_LIBS_DIR_WIN"
# ../Libraries has to exist as well, even empty. DESKTOP_APP_USE_PACKAGED
# is a cmake_dependent_option keyed on its presence, and when the
# condition is false the option is FORCED to ON - a -D on the command
# line cannot override that, and every external_* target then goes
# looking for system packages that are not there.
$packagedProbe = Join-Path $parent 'Libraries'
if (-not (Test-Path $packagedProbe)) {
New-Item -ItemType Directory -Path $packagedProbe | Out-Null
}
Write-Host "$packagedProbe exists: $(Test-Path $packagedProbe)"
# The root CMakeLists points Python3_EXECUTABLE at ../ThirdParty/python/
# Scripts/python - tdesktop's own layout, a virtualenv - and does it with
# a plain set(), so it overrides anything passed with -D. Give it exactly
# that: a venv in exactly that place.
$venv = Join-Path $parent 'ThirdParty\python'
if (-not (Test-Path (Join-Path $venv 'Scripts\python.exe'))) {
python -m venv $venv
if ($LASTEXITCODE -ne 0) { Write-Error 'creating the python venv failed'; exit 1 }
}
& (Join-Path $venv 'Scripts\python.exe') --version
Get-ChildItem $sibling | Select-Object -ExpandProperty Name
- name: Build tree cache.
uses: actions/cache/restore@v6
with:
path: ${{ env.BUILD_DIR }}
key: ${{ runner.os }}-xp-out-${{ matrix.arch }}-${{ github.sha }}
restore-keys: ${{ runner.os }}-xp-out-${{ matrix.arch }}-
# packer.cpp includes these two by relative path from OUTSIDE the checkout,
# the way tdesktop has always expected DesktopPrivate to sit next to it. The
# private key never reaches the disk of anything but this runner, and the
# public half it must pair with is committed in packer.cpp / config.h.
# Empty placeholders keep a secret-less fork compiling; packing then fails
# at the signature check rather than silently shipping an unsigned update.
- name: Set up DesktopPrivate.
env:
PACKER_KEY: ${{ secrets.PACKER_PRIVATE_KEY }}
PACKER_BETA_KEY: ${{ secrets.PACKER_PRIVATE_BETA_KEY }}
ALPHA_KEY: ${{ secrets.ALPHA_PRIVATE_KEY }}
run: |
$private = Join-Path (Split-Path $PWD -Parent) 'DesktopPrivate'
New-Item -ItemType Directory -Force -Path $private | Out-Null
function Literal($name, $pem, $static) {
$lines = @($pem -split "`r?`n" | Where-Object { $_.Trim() })
$body = ''
for ($i = 0; $i -lt $lines.Count; $i++) {
$body += $lines[$i].Trim() + $(if ($i -lt $lines.Count - 1) { '\n\' } else { '\' }) + "`n"
}
$prefix = if ($static) { '[[maybe_unused]] static ' } else { '' }
return "${prefix}const char *${name} = `"\`n${body}`";`n"
}
$packer = if ($env:PACKER_KEY) { Literal 'PrivateKey' $env:PACKER_KEY $false } else { "const char *PrivateKey = `"`";`n" }
$beta = if ($env:PACKER_BETA_KEY) { Literal 'PrivateBetaKey' $env:PACKER_BETA_KEY $false } else { "const char *PrivateBetaKey = `"`";`n" }
$alpha = if ($env:ALPHA_KEY) { Literal 'AlphaPrivateKey' $env:ALPHA_KEY $true } else { "[[maybe_unused]] static const char *AlphaPrivateKey = `"`";`n" }
Set-Content -Path (Join-Path $private 'packer_private.h') -Value ($packer + "`n" + $beta) -Encoding ascii
Set-Content -Path (Join-Path $private 'alpha_private.h') -Value $alpha -Encoding ascii
Write-Host ("signing keys: packer {0}, beta {1}, alpha {2}" -f `
$(if ($env:PACKER_KEY) { 'present' } else { 'ABSENT' }),
$(if ($env:PACKER_BETA_KEY) { 'present' } else { 'ABSENT' }),
$(if ($env:ALPHA_KEY) { 'present' } else { 'ABSENT' }))
# A key pair that does not match is invisible until the Packer refuses its
# own output - two hours into the build, at the very last step. The public
# half is public by definition, so derive it here and print both sides.
- name: The signing key matches the compiled-in public key.
if: inputs.publish_update != 'off' && inputs.publish_update != ''
env:
PACKER_KEY: ${{ secrets.PACKER_PRIVATE_KEY }}
run: |
if (-not $env:PACKER_KEY) {
Write-Error 'PACKER_PRIVATE_KEY is empty - nothing can be signed.'
exit 1
}
$rsa = [System.Security.Cryptography.RSA]::Create()
try {
$rsa.ImportFromPem($env:PACKER_KEY)
} catch {
Write-Error "PACKER_PRIVATE_KEY is not a PEM private key: $_"
exit 1
}
$derived = [Convert]::ToBase64String($rsa.ExportRSAPublicKey())
# The compiled-in key is a C string literal split over lines with \n\.
$source = Get-Content "$PWD\Telegram\SourceFiles\_other\packer.cpp" -Raw
$match = [regex]::Match($source, '(?s)const char \*PublicKey = "\\\r?\n(.*?)";')
if (-not $match.Success) { Write-Error 'no PublicKey literal in packer.cpp'; exit 1 }
$compiled = ($match.Groups[1].Value -replace '\\n\\', '' -replace '\\', '' `
-replace '-----[A-Z ]+-----', '' -replace '\s', '')
if ($derived -ne $compiled) {
Write-Host "derived from the secret : $derived"
Write-Host "compiled into the build : $compiled"
Write-Error ('PACKER_PRIVATE_KEY does not pair with the public key in ' +
'packer.cpp / config.h. Either point the secret at the key those were ' +
'taken from, or commit the public half of this one.')
exit 1
}
Write-Host "signing key pairs with the compiled-in public key."
- name: Configure.
env:
API_ID: ${{ secrets.TDESKTOP_API_ID }}
API_HASH: ${{ secrets.TDESKTOP_API_HASH }}
run: |
# A build with the wrong API credentials looks perfect - it compiles,
# passes every gate, starts on XP - and then never connects, which is
# only visible to a human in front of the machine. So refuse to produce
# one. (Telegram publishes 17349 for open source builds; it is rate
# limited to uselessness, so it is not a fallback worth having here.)
if (-not $env:API_ID -or -not $env:API_HASH) {
Write-Error ('TDESKTOP_API_ID / TDESKTOP_API_HASH are not set in this ' +
'repository. A build made without them cannot log in - see ' +
'https://core.telegram.org/api/obtaining_api_id')
exit 1
}
$id = $env:API_ID
$hash = $env:API_HASH
Write-Host "API credentials: from repository secrets (id $($id.Length) chars, hash $($hash.Length) chars)."
if ($hash.Length -ne 32) {
Write-Error "TDESKTOP_API_HASH must be 32 hex characters, this one is $($hash.Length)."
exit 1
}
# XP_QT_PREFIX, XP_COMPAT_HEADER and XP_ARCH come from the job env now:
# two of the three are per-architecture, and cmake/variables.cmake
# reads them straight out of the environment.
# DESKTOP_APP_USE_PACKAGED turns itself ON when no ../Libraries directory
# sits next to the checkout - exactly the case on a runner - and every
# external_* target then switches to find_package(). This port supplies
# its dependencies by path, so pin the switch off.
# Autoupdate is OFF by default for a target with no DESKTOP_APP_SPECIAL_
# TARGET, which is what this port is. Turn it on explicitly rather than
# claiming a special target - that would also switch on /GL + /LTCG and
# flip the crash-report default. BUILD_PACKER is this port's own option
# for the same reason.
& "$PWD\xp\cmake_xp.ps1" cmake -G Ninja `
-D CMAKE_BUILD_TYPE=Release `
-D DESKTOP_APP_USE_PACKAGED=OFF `
-D DESKTOP_APP_DISABLE_AUTOUPDATE=OFF `
-D DESKTOP_APP_BUILD_PACKER=ON `
-D TDESKTOP_API_ID=$id `
-D TDESKTOP_API_HASH=$hash `
-S . -B $env:BUILD_DIR
if ($LASTEXITCODE -ne 0) { exit 1 }
# The code generators are host tools built with the very same XP toolchain,
# so they have to RUN here, on the runner. Build and exercise one first:
# when it dies the ninja log only shows an exit code, and 0xC0000005 from a
# generator is indistinguishable from a compile error at that level.
# Does anything this toolchain produces run here at all? The code
# generators are XP-targeted binaries that have to execute on the runner,
# so separate "the toolchain cannot run here" from "this particular
# generator crashed" before reading anything into a ninja exit code.
- name: Can an XP binary run on the runner?
run: |
$repoRoot = $PWD.Path
$probe = Join-Path $env:RUNNER_TEMP 'runprobe'
New-Item -ItemType Directory -Force -Path $probe | Out-Null
# Streams, not printf: the generators crash inside the CRT's locale
# setup on the first use of a C++ stream, so the probe has to reach the
# same code. Built twice - with fpcompat and without - because that
# library carries objects lifted from a DIFFERENT CRT version, which is
# the obvious suspect for a locale structure being torn.
@'
#include <windows.h>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> v{ "xp", "runs", "here" };
std::ostringstream out;
for (const auto &s : v) { out << s << ' '; }
std::cout << out.str() << "(" << out.str().size() << ")" << std::endl;
return 0;
}
'@ | Set-Content -Path "$probe\runprobe.cpp" -Encoding ascii
& "$repoRoot\xp\xp_env.ps1" -Toolchain C:\xp-toolchain -Quiet
Push-Location $probe
& cl.exe /nologo /MT /EHsc /d2FH4- /FI"$repoRoot\xp\xp-compat.h" /c runprobe.cpp 2>&1 | Write-Host
& link.exe /nologo "/SUBSYSTEM:CONSOLE,$env:XP_SUBSYSTEM_VERSION" /FORCE:MULTIPLE runprobe.obj `
"$env:XP_FPCOMPAT\fpcompat.lib" /OUT:with_fpcompat.exe 2>&1 | Write-Host
& link.exe /nologo "/SUBSYSTEM:CONSOLE,$env:XP_SUBSYSTEM_VERSION" runprobe.obj /OUT:without_fpcompat.exe 2>&1 | Write-Host
Pop-Location
& "$probe\with_fpcompat.exe"
Write-Host "with fpcompat: exit code $LASTEXITCODE"
& "$probe\without_fpcompat.exe"
Write-Host "without fpcompat: exit code $LASTEXITCODE"
# Same program plus Qt5Core - the one thing the code generators have
# that these probes do not. If this crashes the problem is the Qt build;
# if it runs, it is how the generator itself is put together.
$qt = $env:XP_QT_PREFIX
@'
#include <QtCore/QString>
#include <QtCore/QStringList>
#include <iostream>
int main() {
QStringList parts;
parts << QString::fromLatin1("qt") << QString::fromLatin1("probe");
std::cout << parts.join(QChar(' ')).toStdString() << std::endl;
return 0;
}
'@ | Set-Content -Path "$probe\qtprobe.cpp" -Encoding ascii
Push-Location $probe
& cl.exe /nologo /MT /EHsc /d2FH4- /FI"$repoRoot\xp\xp-compat.h" `
/I"$qt\include" /I"$qt\include\QtCore" /DQT_STATIC /c qtprobe.cpp 2>&1 | Write-Host
if ($LASTEXITCODE -eq 0) {
& link.exe /nologo "/SUBSYSTEM:CONSOLE,$env:XP_SUBSYSTEM_VERSION" /FORCE:MULTIPLE qtprobe.obj `
"$qt\lib\Qt5Core.lib" "$qt\lib\qtpcre2.lib" "$env:XP_FPCOMPAT\fpcompat.lib" `
ws2_32.lib advapi32.lib shell32.lib ole32.lib user32.lib winmm.lib `
netapi32.lib userenv.lib version.lib mpr.lib /OUT:qtprobe.exe 2>&1 | Write-Host
}
Pop-Location
if (Test-Path "$probe\qtprobe.exe") {
& "$probe\qtprobe.exe"
Write-Host "qt probe: exit code $LASTEXITCODE"
} else {
Write-Host 'qt probe did not build'
}
# Which C runtime does Qt ask for? A static build must request LIBCMT; if
# any of it came out /MD the final image ends up with two CRT instances and
# the second one's locale state is what a std::cerr initializer trips over.
- name: Which CRT does Qt want?
run: |
& "$PWD\xp\xp_env.ps1" -Toolchain C:\xp-toolchain -Quiet
foreach ($lib in @('Qt5Core.lib', 'qtpcre2.lib')) {
$path = "$env:XP_QT_PREFIX\lib\$lib"
if (-not (Test-Path $path)) { Write-Host "$lib missing"; continue }
$directives = & dumpbin.exe /nologo /directives $path 2>&1 |
Select-String -Pattern 'DEFAULTLIB:"?(lib)?(cmt|cpmt|ucrt|vcruntime|msvcrt|msvcprt)' |
ForEach-Object { $_.ToString().Trim() } | Sort-Object -Unique
Write-Host "$lib wants:"
$directives | Select-Object -First 12 | ForEach-Object { Write-Host " $_" }
}
- name: Code generators.
run: |
# XP_QT_PREFIX, XP_COMPAT_HEADER and XP_ARCH come from the job env now:
# two of the three are per-architecture, and cmake/variables.cmake
# reads them straight out of the environment.
& "$PWD\xp\cmake_xp.ps1" ninja -C $env:BUILD_DIR codegen_lang
if ($LASTEXITCODE -ne 0) { Write-Error 'building codegen_lang failed'; exit 1 }
$tool = Get-ChildItem -Recurse -Filter codegen_lang.exe $env:BUILD_DIR |
Select-Object -First 1
Write-Host "tool: $($tool.FullName)"
& "$PWD\xp\cmake_xp.ps1" dumpbin /dependents $tool.FullName |
Select-String -Pattern '\.dll' | ForEach-Object { Write-Host " $_" }
# No arguments: it must print its usage and exit cleanly. A crash here
# is the toolchain, not the codegen input.
& $tool.FullName 2>&1 | Select-Object -First 5 | ForEach-Object { Write-Host " $_" }
Write-Host "exit code: $LASTEXITCODE"
# Without arguments the generator prints usage and returns non-zero,
# which is fine - only a CRASH matters here. Windows reports those as
# 0xC0000000-range codes, negative once PowerShell has signed them.
$crashed = ($LASTEXITCODE -lt -1000000)
if (-not $crashed) { Write-Host 'the generator runs on this machine.' }
# An exit code is not a diagnosis. The Windows kit ships cdb; run the
# tool under it and print the faulting frame, which is the only thing
# that says WHERE a 0xC0000005 comes from.
if ($crashed) {
# The debugger has to match the bitness of the process it opens.
$cdb = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\Debuggers\$env:XP_ARCH\cdb.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $cdb) {
$cdb = Get-ChildItem "C:\Program Files*\Windows Kits\10\Debuggers\*\cdb.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
}
if ($cdb) {
Write-Host "debugger: $($cdb.FullName)"
& $cdb.FullName -g -G -c '.lastevent; kb 40; q' $tool.FullName 2>&1 |
Select-Object -Last 70 | ForEach-Object { Write-Host " $_" }
} else {
Write-Host 'cdb not found on this image'
}
Write-Error 'the code generator cannot run here'
exit 1
}
# The usage exit above is the last command's code, and PowerShell would
# otherwise hand it to Actions as the step result.
exit 0
- name: Build.
run: |
# XP_QT_PREFIX, XP_COMPAT_HEADER and XP_ARCH come from the job env now:
# two of the three are per-architecture, and cmake/variables.cmake
# reads them straight out of the environment.
# Updater.exe applies an update, Packer.exe signs one. Both come from
# the same toolchain as the app and are cheap next to it.
& "$PWD\xp\cmake_xp.ps1" ninja -C $env:BUILD_DIR Telegram Updater Packer
if ($LASTEXITCODE -ne 0) { exit 1 }
- name: Save the build tree cache.
if: always()
uses: actions/cache/save@v6
with:
path: ${{ env.BUILD_DIR }}
key: ${{ runner.os }}-xp-out-${{ matrix.arch }}-${{ github.sha }}
# The gate that matters: a binary that links can still be unable to start on
# XP, through an absent DLL or a Vista+ entry point. Never publish without it.
- name: XP safety.
run: |
# No -Dumpbin: XP_TOOLSET_BINARY_DIR belongs to the process xp_env.ps1
# set up, and every step here is a fresh one, so passing it produced an
# empty path. xpsafe.ps1 finds dumpbin itself.
& "$PWD\xp\xpsafe.ps1" -Exe "$PWD\$env:BUILD_DIR\Telegram.exe"
if ($LASTEXITCODE -ne 0) { Write-Error 'the binary cannot start on XP'; exit 1 }
# Updater.exe is what REPLACES the running application, so an unsafe one
# turns a working install into a dead one with no way back. It ships in
# the update package, so it is gated exactly like the app.
& "$PWD\xp\xpsafe.ps1" -Exe "$PWD\$env:BUILD_DIR\Updater.exe"
if ($LASTEXITCODE -ne 0) { Write-Error 'the updater cannot start on XP'; exit 1 }
# The runtime half of the gate. xpsafe above proves the binary can START on
# XP; this runs the subsystems that have only ever failed later - FFmpeg
# registration and its codec set, the opus encoder's sample format, rlottie,
# the static image plugins. Most of that is not XP-specific, so the runner
# catches it minutes after the link instead of a human catching it in front
# of the VM. Exit code 3 means a probe hung and its watchdog fired.
- name: Self-test.
run: |
$report = Join-Path $env:RUNNER_TEMP 'xpselftest.txt'
$process = Start-Process -PassThru -FilePath "$PWD\$env:BUILD_DIR\Telegram.exe" `
-ArgumentList '-xpselftest', "`"$report`""
# Touching Handle keeps the process object able to report ExitCode after
# it dies; without it PowerShell can hand back an empty exit code and the
# gate would pass a failing self-test.
$null = $process.Handle
if (-not $process.WaitForExit(300000)) {
$process.Kill()
if (Test-Path $report) { Get-Content $report }
Write-Error 'the self-test did not finish in 5 minutes'
exit 1
}
if (Test-Path $report) { Get-Content $report } else { Write-Host 'no report written' }
if ($process.ExitCode -ne 0) {
Write-Error "the self-test failed with exit code $($process.ExitCode)"
exit 1
}
- name: Read version.
shell: bash
run: awk 'NF { print $1"="$2 }' Telegram/build/version >> $GITHUB_ENV
- name: Package.
run: |
$name = "Telegram_XP_$env:AppVersionStr" + "_$env:XP_ARCH"
$stage = Join-Path $env:RUNNER_TEMP $name
New-Item -ItemType Directory -Force -Path $stage | Out-Null
# Fully static, so the app is one file - plus Updater.exe, which is what
# applies a downloaded update. Without it next to the app the update is
# fetched and verified and then simply never installed.
Copy-Item "$PWD\$env:BUILD_DIR\Telegram.exe" $stage
Copy-Item "$PWD\$env:BUILD_DIR\Updater.exe" $stage
$zip = Join-Path $env:RUNNER_TEMP "$name.zip"
if (Test-Path $zip) { Remove-Item $zip }
Compress-Archive -Path "$stage\*" -DestinationPath $zip
echo "ARCHIVE=$zip" >> $env:GITHUB_ENV
Write-Host ("Telegram.exe {0:N0} + Updater.exe {1:N0} bytes -> {2:N0} zipped" -f `
(Get-Item "$PWD\$env:BUILD_DIR\Telegram.exe").Length, `
(Get-Item "$PWD\$env:BUILD_DIR\Updater.exe").Length, (Get-Item $zip).Length)
- uses: actions/upload-artifact@v7
name: Upload the artifact.
with:
name: Telegram XP ${{ matrix.arch }} ${{ env.AppVersionStr }}
path: ${{ env.ARCHIVE }}
# Always: it is small, and without it a stack captured from a hung build
# on XP can only ever be read as module+offset. The PDB would answer the
# same question, but it is 2.5 GB - more to move than the build itself.
- uses: actions/upload-artifact@v7
name: Upload the link map.
with:
name: Telegram map ${{ matrix.arch }} ${{ env.AppVersionStr }}
path: ${{ env.BUILD_DIR }}/Telegram.map
# An update package is Telegram.exe + Updater.exe, compressed and signed
# with the key set up above; the client refuses anything whose signature
# does not match the public key committed in config.h. Packer verifies its
# own output before writing it, so a key mismatch fails here and not on a
# user's machine. The binaries going in are the ones xpsafe just cleared.
- name: Pack the update.
if: inputs.publish_update != 'off' && inputs.publish_update != ''
run: |
$release = "$PWD\$env:BUILD_DIR"
Set-Location $release
# -target winxp names the file txpupd<version>: the XP build is x86 like
# the plain `win` target and would otherwise claim its name in a feed
# shared with the Windows 7+ releases. winxp64 -> txp64upd<version> for
# exactly the same reason against `win64` / tx64upd.
$target = if ($env:XP_ARCH -eq 'x64') { 'winxp64' } else { 'winxp' }
$prefix = if ($env:XP_ARCH -eq 'x64') { 'txp64upd' } else { 'txpupd' }
& .\Packer.exe -version $env:AppVersion -path Telegram.exe -path Updater.exe -target $target
if ($LASTEXITCODE -ne 0) { Write-Error 'packing failed'; exit 1 }
$update = "$prefix$env:AppVersion"
if (-not (Test-Path $update)) { Write-Error "$update was not produced"; exit 1 }
Write-Host ("$update {0:N0} bytes" -f (Get-Item $update).Length)
echo "UPDATE_FILE=$release\$update" >> $env:GITHUB_ENV
- uses: actions/upload-artifact@v7
name: Upload the update package.
if: inputs.publish_update != 'off' && inputs.publish_update != ''
with:
name: XP update ${{ matrix.arch }} ${{ env.AppVersionStr }}
path: ${{ env.UPDATE_FILE }}
- name: Publish the release.
if: inputs.publish_release || startsWith(github.ref, 'refs/tags/xp-v')
shell: bash
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
run: |
set -e
R="$GITHUB_REPOSITORY"
if [[ "$GITHUB_REF" == refs/tags/xp-v* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
TARGET=()
else
TAG="xp-v$AppVersionStr"
TARGET=(--target "$GITHUB_SHA")
fi
if ! gh release view "$TAG" -R "$R" >/dev/null 2>&1; then
# Both architectures reach this at the same time, so losing the race
# is the normal case, not an error: create, and if that failed only
# because the other leg got there first, carry on. A real failure
# still stops the job, because the view below fails too.
gh release create "$TAG" -R "$R" "${TARGET[@]}" --draft \
--title "Telegram Desktop $AppVersionStr for Windows XP" \
--notes "Windows XP builds of Telegram Desktop $AppVersionStr.
_x86 Windows XP SP3, 32-bit.
_x64 Windows XP Professional x64 Edition (NT 5.2). Also runs the x86
build through WOW64 - but the two update through separate feed
keys, so install the one you mean to keep.
A single static executable each. Voice, video and group calls are absent -
they need WebRTC, which cannot run on XP. xp/README.md lists what else the
platform limits and how the builds are produced." \
|| gh release view "$TAG" -R "$R" >/dev/null
fi
gh release upload "$TAG" -R "$R" "$ARCHIVE" --clobber
publish-update:
name: Publish the update to Telegram.
needs: build
runs-on: ubuntu-latest
timeout-minutes: 60
if: always()
&& github.event_name == 'workflow_dispatch'
&& github.event.inputs.publish_update != 'off'
&& github.event.inputs.publish_update != 'package'
&& github.event.inputs.publish_update != ''
&& (needs.build.result == 'success'
|| github.event.inputs.publish_from_run != '')
# The session string reaches only this job, and only after whoever reviews
# the environment approves it.
environment: telegram-publish
steps:
- name: Clone the publisher.
uses: actions/checkout@v7
with:
sparse-checkout: xp/publish_telegram.py
sparse-checkout-cone-mode: false
# Either the package this run just built, or the one an earlier run did -
# a republish then costs a minute instead of a two-hour rebuild.
- name: Download the update package.
env:
GH_TOKEN: ${{ github.token }}
FROM_RUN: ${{ github.event.inputs.publish_from_run }}
run: |
set -e
mkdir -p artifacts
RUN="${FROM_RUN:-$GITHUB_RUN_ID}"
echo "taking the package from run $RUN"
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -D artifacts
find artifacts -type f -printf '%p\t%s bytes\n'
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install Telethon.
run: pip install telethon
# The feed is the same message the Windows 7+ and Linux releases write, so
# the script merges onto it instead of replacing it, and touches only the
# winxp and winxp64 keys. It publishes whichever of the two packages this
# run produced - both, when the matrix built both - in ONE feed message.
# 'rehearsal' sends everything for real but 360 days out: the whole path
# runs, nothing appears in the channels. Delete those afterwards.
- name: Publish.
env:
TG_API_ID: "611335"
TG_API_HASH: "d524b414d21f4d37f08684c1df41ac9c"
TG_SESSION: ${{ secrets.TG_SESSION }}
TG_FEED_CHANNEL: ${{ secrets.TG_FEED_CHANNEL }}
TG_FILES_CHANNEL: ${{ secrets.TG_FILES_CHANNEL }}
ARTIFACTS_DIR: artifacts
TG_SCHEDULE_DAYS: ${{ inputs.publish_update == 'rehearsal' && '360' || '0' }}
TG_ENTRY_KEY: ${{ inputs.publish_update == 'testing' && 'testing' || 'released' }}
run: python xp/publish_telegram.py