release: restore ZUPT and harden source-only 5.2.2

This commit is contained in:
Cristian Cezar Moisés 2026-08-31 14:14:36 -03:00
commit ff99770bd0
205 changed files with 19627 additions and 13215 deletions

View file

@ -1,254 +1,613 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Zupt CI matrix.
#
# Mirrors the project's local-verification protocol from PROMPT.md §6:
# 1. Plain GCC build
# 2. Plain Clang build
# 3. Strict GCC (full warning set)
# 4. Strict Clang (full warning set)
# 5. ASAN + UBSAN
# 6. Full regression suite (12 suites: audit, dedup, path-traversal,
# argument-order, block-swap, F-08, F-09 byte sweep, F-10, F-11,
# F-12, packaging syntax, dist reproducibility)
# 7. License header audit
# 8. `make dist` reproducibility (two runs, sha256 must match)
# 9. aarch64 cross-test via QEMU emulation
# 10. Automatic release on git tag push
name: CI
on:
push:
branches: [master]
tags: ['v*']
branches:
- master
- 'codex/**'
tags:
- 'v*'
pull_request:
branches: [master]
branches:
- master
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
jobs:
# ─── Plain build + test, exactly as a user would do it ───
source-policy:
name: Source-only, license, shell and secret policy
runs-on: ubuntu-24.04
steps:
- name: Check out all refs without LFS or submodules
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install audit tools
run: |
sudo apt-get update
sudo apt-get install -y \
dpkg-dev file git-lfs libarchive-tools libxml2-utils make python3 ruby \
shellcheck unzip
- name: Audit tracked files, worktree and HEAD archive
run: bash scripts/check-source-only.sh
- name: Exercise positive and negative scanner fixtures
run: bash tests/test_source_only.sh
- name: Audit license headers
run: make WITH_SDK=0 WITH_PQBOX=0 audit-licenses
- name: Validate release packaging metadata
run: bash tests/test_packaging_syntax.sh
- name: ShellCheck release and source-policy scripts
run: |
shellcheck \
packaging/build-deb.sh \
packaging/build-rpm.sh \
packaging/build-appimage.sh \
packaging/build-dmg.sh \
packaging/build-gui-appimage.sh \
packaging/build-gui-deb.sh \
packaging/build-gui-rpm.sh \
packaging/opensuse/source-audit.sh \
scripts/check-source-only.sh \
scripts/export-opensuse-package.sh \
scripts/test-installed-zupt.sh \
tests/test_atomic_archive_output.sh \
tests/test_authenticated_dedup_reorder.sh \
tests/test_benchmark_temp_safety.sh \
tests/test_block_type_confusion.sh \
tests/test_disk_device_capacity.sh \
tests/test_f09_preface.sh \
tests/test_legacy_disk_5_2_1.sh \
tests/test_path_traversal.sh \
tests/test_pqbox.sh \
tests/test_sdk.sh \
tests/test_source_only.sh
- name: Credential material audit (paths only)
shell: bash
run: |
set -Eeuo pipefail
findings=$(git grep -Il -E -- \
"-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|https?://[^/@[:space:]]+:[A-Za-z0-9_+=.-]{20,}@|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{20,}|(FORGEJO_TOKEN|SECURITYOPS_TOKEN|GITHUB_TOKEN|CODEBERG_TOKEN)[[:space:]]*[:=][[:space:]]*['\\\"]?[A-Za-z0-9_+=./-]{20,}" \
-- . || true)
if [[ -n $findings ]]; then
printf '%s\n' "$findings" >&2
echo 'credential-like material found in tracked files' >&2
exit 1
fi
echo 'No private-key block, named token assignment, or credential-bearing URL found.'
build-and-test:
name: Build and full tests (${{ matrix.cc }})
needs: source-policy
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
cc: [gcc, clang]
steps:
- uses: actions/checkout@v4
- name: Install build deps
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y build-essential clang dpkg-dev python3
- name: Build (${{ matrix.cc }})
run: make CC=${{ matrix.cc }} -j$(nproc)
- name: zupt version
run: ./zupt version
- name: Full regression suite
run: make test
- name: License header audit
run: make audit-licenses
sudo apt-get install -y build-essential clang file libarchive-tools python3 unzip
- name: Clean source-only build
run: |
make clean
make -j"$(nproc)" CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0
- name: Distribution checks
run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 check
- name: Extended upstream tests
run: make CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 test-all
- name: Functional test of the built CLI
run: bash scripts/test-installed-zupt.sh "$PWD/zupt"
# ─── Strict warning matrix — what the project's §6 protocol uses ───
strict-warnings:
name: Strict warnings (${{ matrix.cc }})
needs: source-policy
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- cc: gcc
cflags: "-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11 -Werror"
flags: >-
-O2 -g -std=c11 -Wall -Wextra -Wpedantic -Wshadow
-Wcast-align -Wstrict-prototypes -Wmissing-prototypes
-Wnull-dereference -Wformat=2 -Werror
- cc: clang
cflags: "-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -O2 -std=c11 -Werror"
flags: >-
-O2 -g -std=c11 -Wall -Wextra -Wpedantic -Wshadow
-Wcast-align -Wstrict-prototypes -Wmissing-prototypes
-Wnull-dereference -Wformat=2 -Werror
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential clang
- name: Strict ${{ matrix.cc }} build (warnings → errors)
run: make CC=${{ matrix.cc }} CFLAGS="${{ matrix.cflags }}" -j$(nproc)
# ─── ASAN + UBSAN — catches memory bugs the warning matrix can't ───
sanitizers:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Build with ASAN + UBSAN
run: make test-asan
- name: Native --pq byte-exact roundtrip under ASAN
env:
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: |
# Native hybrid ML-KEM-768 + X25519 (the source-only default; --pq-sdk
# needs a WITH_SDK=1 build and is unavailable here).
./zupt_asan keygen -o /tmp/k.priv
./zupt_asan keygen --pub -o /tmp/k.pub -k /tmp/k.priv
./zupt_asan compress --pq /tmp/k.pub /tmp/a.zupt include/
mkdir -p /tmp/extracted
./zupt_asan extract --pq /tmp/k.priv -o /tmp/extracted /tmp/a.zupt
diff -qr include /tmp/extracted/include
# ─── PIE hardening build — verifies no runtime breakage from -fPIE ───
pie-hardening:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential
- name: Build with PIE + hardening
run: |
make CFLAGS="-O2 -std=c11 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security" \
LDFLAGS="-pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack" \
-j$(nproc)
- name: Verify binary is PIE
run: |
file ./zupt | grep -E "ELF .*executable.*pie|ELF .*shared object" || \
{ file ./zupt; echo "binary is not PIE"; exit 1; }
- name: Smoke test
run: |
echo "test" > /tmp/in.txt
./zupt c -p secret /tmp/a.zupt /tmp/in.txt
mkdir /tmp/out
(cd /tmp/out && ./../../home/runner/work/zupt/zupt/zupt x -p secret /tmp/a.zupt) || \
{ cd /tmp/out && "$GITHUB_WORKSPACE/zupt" x -p secret /tmp/a.zupt; }
diff -q /tmp/in.txt /tmp/out/in.txt
# ─── aarch64 cross-build via QEMU emulation ───
cross-aarch64:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
platforms: arm64
- name: Build + test inside aarch64 container
run: |
docker run --rm --platform linux/arm64 \
-v "$PWD":/src -w /src \
ubuntu:24.04 \
bash -c '
apt-get update -qq
apt-get install -y -qq build-essential python3
make -j$(nproc)
./zupt version
make test
'
# ─── make dist reproducibility ───
dist-reproducibility:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: First dist build
run: make dist
- name: Capture sha256 (run 1)
id: sha1
run: |
VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
SHA=$(sha256sum /tmp/vaptvupt-$VER.tar.gz | awk '{print $1}')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
echo "Run 1: $SHA"
- name: Second dist build (must produce identical sha256)
run: make dist
- name: Verify reproducibility
run: |
VER="${{ steps.sha1.outputs.ver }}"
SHA2=$(sha256sum /tmp/vaptvupt-$VER.tar.gz | awk '{print $1}')
if [ "$SHA2" != "${{ steps.sha1.outputs.sha }}" ]; then
echo "::error::make dist is NOT reproducible"
echo " run 1: ${{ steps.sha1.outputs.sha }}"
echo " run 2: $SHA2"
exit 1
fi
echo "Reproducible ✓ ($SHA2)"
- name: Upload reproducible source tarball
uses: actions/upload-artifact@v4
with:
name: zupt-source-tarball
path: /tmp/vaptvupt-*.tar.gz
# ─── Packaging-recipe syntax (cross-distro) ───
packaging-syntax:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install validators
persist-credentials: false
- name: Install compilers
run: |
sudo apt-get update
sudo apt-get install -y build-essential dpkg-dev ruby rpm
- name: Build (for include/zupt.h to exist; not strictly needed for syntax test)
run: make -j$(nproc)
- name: Run packaging syntax test
run: bash tests/test_packaging_syntax.sh
sudo apt-get install -y build-essential clang
- name: Compile with warnings as errors
run: |
make clean
make -j"$(nproc)" CC=${{ matrix.cc }} V=1 WITH_SDK=0 WITH_PQBOX=0 \
CFLAGS="${{ matrix.flags }}"
# ─── Automatic GitHub release on git tag push ───
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-and-test, strict-warnings, sanitizers, dist-reproducibility, packaging-syntax]
sanitizers:
name: ASan, LSan and UBSan
needs: source-policy
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Build reproducible source tarball
run: make dist
- name: Get version
id: ver
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install compiler and test tools
run: |
VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Verify tag matches version
sudo apt-get update
sudo apt-get install -y build-essential file python3
- name: Instrumented functional tests
env:
ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1
run: make V=1 WITH_SDK=0 WITH_PQBOX=0 test-asan-run
- name: Mutation smoke under sanitizers
env:
ASAN_OPTIONS: detect_leaks=1:abort_on_error=1
UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1
run: make V=1 WITH_SDK=0 WITH_PQBOX=0 fuzz-format-run
static-analysis:
name: GCC static analyzer
needs: source-policy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install GCC
run: |
TAG="${GITHUB_REF#refs/tags/}"
EXPECTED="v${{ steps.ver.outputs.version }}"
if [ "$TAG" != "$EXPECTED" ]; then
echo "::error::tag $TAG doesn't match include/zupt.h $EXPECTED"
sudo apt-get update
sudo apt-get install -y build-essential
- name: Analyze every source translation unit
run: |
make clean
make -j"$(nproc)" CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 \
CFLAGS="-O1 -g -std=c11 -Wall -Wextra -Werror -fanalyzer"
source-archive:
name: Reproducible audited source archive
needs: source-policy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install archive audit tools
run: |
sudo apt-get update
sudo apt-get install -y file libarchive-tools python3 unzip
- name: Build the source archive twice
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
mkdir -p "$RUNNER_TEMP/dist-one" "$RUNNER_TEMP/dist-two" \
"$RUNNER_TEMP/release-source"
make DIST_TARBALL="$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" dist
make DIST_TARBALL="$RUNNER_TEMP/dist-two/zupt-$version.tar.gz" dist
cmp "$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" \
"$RUNNER_TEMP/dist-two/zupt-$version.tar.gz"
cp "$RUNNER_TEMP/dist-one/zupt-$version.tar.gz" \
"$RUNNER_TEMP/release-source/"
(cd "$RUNNER_TEMP/release-source" && sha256sum "zupt-$version.tar.gz" > \
"zupt-$version.tar.gz.sha256")
bash scripts/check-source-only.sh --archive \
"$RUNNER_TEMP/release-source/zupt-$version.tar.gz"
- name: Match downstream recipe checksums to the tagged source archive
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -Eeuo pipefail
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
source_tar="$RUNNER_TEMP/release-source/zupt-$version.tar.gz"
actual_sha=$(sha256sum "$source_tar" | awk '{print $1}')
aur_sha=$(awk -F"'" '/^sha256sums=/ { print $2; exit }' packaging/aur/PKGBUILD)
homebrew_sha=$(awk -F'"' '/^[[:space:]]*sha256 / { print $2; exit }' packaging/homebrew/zupt.rb)
guix_base32=$(sed -n 's/^[[:space:]]*(base32 "\([^"]*\)").*/\1/p' \
packaging/guix/zupt.scm | head -n 1)
actual_base32=$(python3 - "$source_tar" <<'PY'
import hashlib
import pathlib
import sys
alphabet = "0123456789abcdfghijklmnpqrsvwxyz"
digest = hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).digest()
value = int.from_bytes(digest, "little")
length = (len(digest) * 8 + 4) // 5
print("".join(alphabet[(value >> (5 * index)) & 31]
for index in range(length - 1, -1, -1)))
PY
)
[[ $aur_sha == "$actual_sha" && $homebrew_sha == "$actual_sha" ]] || {
echo 'AUR or Homebrew checksum does not match the source archive' >&2
exit 1
}
[[ $guix_base32 == "$actual_base32" ]] || {
echo 'Guix checksum does not match the source archive' >&2
exit 1
}
- name: Upload source and checksum
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-source
path: ${{ runner.temp }}/release-source/*
if-no-files-found: error
retention-days: 7
debian-package:
name: Debian/Ubuntu source-built package
needs: [source-policy, build-and-test]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Install Debian package tools
run: |
sudo apt-get update
sudo apt-get install -y build-essential binutils dpkg-dev file git libarchive-tools python3 python3-pyqt6 unzip
- name: Build and extract-test the DEB
run: |
mkdir -p "$RUNNER_TEMP/release-deb"
DIST_DIR="$RUNNER_TEMP/release-deb" RUN_CHECKS=1 bash packaging/build-deb.sh
- name: Build and content-test the GUI DEB
run: |
mkdir -p "$RUNNER_TEMP/release-gui-deb"
DIST_DIR="$RUNNER_TEMP/release-gui-deb" bash packaging/build-gui-deb.sh
gui_deb="$RUNNER_TEMP/release-gui-deb/zupt-gui_5.2.2_all.deb"
test -s "$gui_deb"
test "$(dpkg-deb -f "$gui_deb" Package)" = zupt-gui
test "$(dpkg-deb -f "$gui_deb" Version)" = 5.2.2
test "$(dpkg-deb -f "$gui_deb" Architecture)" = all
- name: Install, functionally test and uninstall the DEBs
run: |
deb=$(find "$RUNNER_TEMP/release-deb" -maxdepth 1 -type f -name '*.deb' -print -quit)
gui_deb="$RUNNER_TEMP/release-gui-deb/zupt-gui_5.2.2_all.deb"
test -n "$deb" && test -s "$gui_deb"
sudo apt-get install -y "$deb" "$gui_deb"
bash scripts/test-installed-zupt.sh /usr/bin/zupt
QT_QPA_PLATFORM=offscreen zupt-gui --version | grep -Fx "zupt-gui 5.2.2"
test ! -e /usr/bin/vaptvupt
sudo apt-get purge -y zupt-gui zupt
test ! -e /usr/bin/zupt-gui
test ! -e /usr/bin/zupt
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-deb
path: ${{ runner.temp }}/release-deb/*.deb
if-no-files-found: error
retention-days: 7
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-gui-deb
path: ${{ runner.temp }}/release-gui-deb/*.deb
if-no-files-found: error
retention-days: 7
tumbleweed-rpm:
name: openSUSE Tumbleweed x86_64 RPM gate
needs: [source-policy, build-and-test]
runs-on: ubuntu-24.04
container: opensuse/tumbleweed:latest
defaults:
run:
shell: bash
steps:
- name: Bootstrap Git before checkout
run: |
zypper --non-interactive --gpg-auto-import-keys refresh
zypper --non-interactive install --no-recommends \
bash ca-certificates git-core
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install native openSUSE tooling
run: |
zypper --non-interactive install --no-recommends \
bash binutils cpio coreutils diffutils file findutils gawk gcc git-core grep gzip \
libxml2-tools make osc obs-service-obs_scm obs-service-recompress \
obs-service-tar python3-base rpm-build rpmlint sed \
shadow spec-cleaner tar unzip util-linux
- name: Confirm the Factory architecture gate
run: test "$(uname -m)" = x86_64
- name: Validate OBS service and spec syntax
run: |
xmllint --noout packaging/opensuse/_service
test -x /usr/lib/obs/service/obs_scm
test -x /usr/lib/obs/service/tar
test -x /usr/lib/obs/service/recompress
rpmspec -P packaging/opensuse/zupt.spec >/dev/null
spec-cleaner --no-copyright packaging/opensuse/zupt.spec \
> "$RUNNER_TEMP/zupt.spec.cleaned"
diff -u packaging/opensuse/zupt.spec \
"$RUNNER_TEMP/zupt.spec.cleaned"
- name: Exercise pinned OBS source service chain on release tags
if: startsWith(github.ref, 'refs/tags/v')
run: |
service_dir=$RUNNER_TEMP/obs-service
mkdir -p "$service_dir"
cp packaging/opensuse/_service "$service_dir/"
# `osc service runall` additionally requires OBS working-copy metadata.
# Use osc's installed service executor to validate this standalone,
# repository-owned _service file with the exact same local services.
python3 - "$service_dir" <<'PY'
import sys
from xml.etree import ElementTree
from osc.obs_scm.serviceinfo import Serviceinfo
service_dir = sys.argv[1]
service_info = Serviceinfo()
service_info.read(ElementTree.parse(f"{service_dir}/_service").getroot())
raise SystemExit(service_info.execute(service_dir, "all", verbose=True))
PY
mapfile -t service_archives < <(find "$service_dir" -maxdepth 1 \
-type f -name 'zupt-*.tar.gz' -print)
test "${#service_archives[@]}" -eq 1
bash scripts/check-source-only.sh --archive "${service_archives[0]}"
- name: Build source and binary RPMs with real checks
run: |
mkdir -p "$RUNNER_TEMP/release-rpm"
DIST_DIR="$RUNNER_TEMP/release-rpm" bash packaging/build-rpm.sh
- name: Run rpmlint without suppressions
shell: bash
run: |
set -Eeuo pipefail
rpmlint "$RUNNER_TEMP"/release-rpm/*.rpm 2>&1 \
| tee "$RUNNER_TEMP/rpmlint.log"
if grep -Eq ': E:' "$RUNNER_TEMP/rpmlint.log"; then
echo 'rpmlint reported one or more errors' >&2
exit 1
fi
- name: Compute sha256
id: sha
- name: Install, functionally test and uninstall the RPM
run: |
VER="${{ steps.ver.outputs.version }}"
SHA=$(sha256sum /tmp/vaptvupt-$VER.tar.gz | awk '{print $1}')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "$SHA vaptvupt-$VER.tar.gz" > /tmp/vaptvupt-$VER.tar.gz.sha256
- name: Create GitHub release
uses: softprops/action-gh-release@v2
rpm_file=$(find "$RUNNER_TEMP/release-rpm" -maxdepth 1 -type f \
-name '*.rpm' ! -name '*.src.rpm' -print -quit)
test -n "$rpm_file"
zypper --non-interactive install --allow-unsigned-rpm "$rpm_file"
test_home=/tmp/zupt-ci-user
useradd --create-home --home-dir "$test_home" --shell /bin/bash zupt-ci
runuser -u zupt-ci -- env HOME="$test_home" TMPDIR="$test_home" \
bash "$GITHUB_WORKSPACE/scripts/test-installed-zupt.sh" \
/usr/bin/zupt
test ! -e /usr/bin/vaptvupt
zypper --non-interactive remove zupt
test ! -e /usr/bin/zupt
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
files: |
/tmp/vaptvupt-${{ steps.ver.outputs.version }}.tar.gz
/tmp/vaptvupt-${{ steps.ver.outputs.version }}.tar.gz.sha256
body: |
## Zupt v${{ steps.ver.outputs.version }}
name: release-rpm
path: ${{ runner.temp }}/release-rpm/*.rpm
if-no-files-found: error
retention-days: 7
Reproducible source tarball.
gui-rpm-package:
name: Fedora noarch GUI RPM and SRPM gate
needs: [source-policy, build-and-test]
runs-on: ubuntu-24.04
container: fedora:latest
defaults:
run:
shell: bash
steps:
- name: Bootstrap checkout dependencies
run: dnf install -y ca-certificates git
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install native build, package, audit and GUI runtime tools
run: |
dnf install -y \
binutils cpio file findutils gcc git-core gzip libarchive make \
python3 python3-pyside6 rpm-build rpmdevtools tar unzip
- name: Build and content-test the GUI RPM and source RPM
run: |
mkdir -p "$RUNNER_TEMP/release-gui-rpm"
DIST_DIR="$RUNNER_TEMP/release-gui-rpm" bash packaging/build-gui-rpm.sh
test -s "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm"
test -s "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.src.rpm"
test "$(rpm -qp --qf '%{NAME}' "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm")" = zupt-gui
test "$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm")" = 5.2.2-1
test "$(rpm -qp --qf '%{ARCH}' "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm")" = noarch
rpm -qp --requires "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm" | \
grep -Fx 'zupt >= 5.2.2'
test "$(rpm -qp --qf '%{NAME}' "$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.src.rpm")" = zupt-gui
- name: Build the matching Fedora CLI RPM
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
core_top="$RUNNER_TEMP/core-rpmbuild"
mkdir -p "$core_top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
make DIST_TARBALL="$core_top/SOURCES/zupt-$version.tar.gz" dist
rpmbuild --define "_topdir $core_top" -ba packaging/rpm/zupt.spec
- name: Install and functionally test the GUI with the packaged CLI
run: |
core_rpm=$(find "$RUNNER_TEMP/core-rpmbuild/RPMS" -type f \
-name 'zupt-5.2.2-1.*.rpm' ! -name '*-debuginfo-*' \
! -name '*-debugsource-*' -print -quit)
gui_rpm="$RUNNER_TEMP/release-gui-rpm/zupt-gui-5.2.2-1.noarch.rpm"
test -n "$core_rpm" && test -s "$gui_rpm"
dnf install -y "$core_rpm" "$gui_rpm"
bash scripts/test-installed-zupt.sh /usr/bin/zupt
QT_QPA_PLATFORM=offscreen zupt-gui --version | grep -Fx 'zupt-gui 5.2.2'
test ! -e /usr/bin/vaptvupt
dnf remove -y zupt-gui zupt
test ! -e /usr/bin/zupt-gui
test ! -e /usr/bin/zupt
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-gui-rpm
path: ${{ runner.temp }}/release-gui-rpm/*.rpm
if-no-files-found: error
retention-days: 7
```
sha256: ${{ steps.sha.outputs.sha }}
```
linux-portable:
name: Linux x86_64 notice-bearing CLI tar.xz gate
needs: [source-policy, build-and-test]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install build and archive tools
run: |
sudo apt-get update
sudo apt-get install -y build-essential binutils file python3 xz-utils
- name: Build and audit the native executable
run: |
test "$(uname -m)" = x86_64
make clean
make -j"$(nproc)" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0
make V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check
bash scripts/test-installed-zupt.sh "$PWD/zupt"
if readelf -d zupt | grep -Eq '(RPATH|RUNPATH)'; then
echo 'Linux portable binary contains RPATH/RUNPATH' >&2
exit 1
fi
mapfile -t needed < <(readelf -d zupt | sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p')
((${#needed[@]} > 0))
for library in "${needed[@]}"; do
case $library in
libc.so.6|libm.so.6|libpthread.so.0) ;;
*) echo "unexpected Linux runtime dependency: $library" >&2; exit 1 ;;
esac
done
if ldd zupt | grep -Fq 'not found'; then
echo 'Linux portable binary has an unresolved runtime dependency' >&2
exit 1
fi
- name: Assemble and extracted-package-test the tar.xz
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
epoch=$(<.source-date-epoch)
root="$RUNNER_TEMP/linux-work/zupt-$version-linux-x86_64"
output="$RUNNER_TEMP/release-linux-x86_64/zupt-$version-linux-x86_64.tar.xz"
mkdir -p "$root" "$(dirname "$output")"
install -m 0755 zupt "$root/zupt"
install -m 0644 README.md CHANGELOG.md SECURITY.md THREAT_MODEL.md \
LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-BSD-2-Clause \
LICENSE-BSD-3-Clause LICENSE-CC0-1.0 NOTICE \
THIRD-PARTY-NOTICES.md "$root/"
tar --sort=name --mtime="@$epoch" --owner=0 --group=0 --numeric-owner \
-C "$(dirname "$root")" -cJf "$output" "$(basename "$root")"
extract=$(mktemp -d)
tar -xJf "$output" -C "$extract"
bash scripts/test-installed-zupt.sh \
"$extract/$(basename "$root")/zupt"
test "$(find "$extract/$(basename "$root")" -maxdepth 1 -type f | wc -l)" -eq 13
sha256sum "$output"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-linux-x86_64
path: ${{ runner.temp }}/release-linux-x86_64/*.tar.xz
if-no-files-found: error
retention-days: 7
See CHANGELOG.md for release notes.
gui-portable:
name: Source-only GUI portable ZIP gate
needs: [source-policy, build-and-test]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install GUI smoke-test and archive tools
run: |
sudo apt-get update
sudo apt-get install -y build-essential file python3 python3-pyqt6 unzip zip
- name: Assemble, audit and execute the portable GUI source bundle
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
make -j"$(nproc)" V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0
root="$RUNNER_TEMP/gui-work/zupt-gui-$version-portable"
output="$RUNNER_TEMP/release-gui-portable/zupt-gui-$version-portable.zip"
mkdir -p "$root/assets" "$(dirname "$output")"
install -m 0644 gui/src/zupt_gui.py "$root/zupt_gui.py"
install -m 0755 packaging/portable/zupt-gui.sh \
packaging/portable/zupt-gui.command "$root/"
install -m 0644 packaging/portable/zupt-gui.bat "$root/"
install -m 0644 packaging/portable/README.txt "$root/README.txt"
install -m 0644 gui/assets/zupt-icon.png gui/assets/zupt.ico "$root/assets/"
install -m 0644 LICENSE-AGPL-3.0 gui/LICENSE-GUI CHANGELOG.md "$root/"
install -m 0644 gui/assets/README.md "$root/ASSET-PROVENANCE.md"
bash scripts/check-source-only.sh --tree "$root"
QT_QPA_PLATFORM=offscreen PATH="$PWD:$PATH" \
"$root/zupt-gui.sh" --version | grep -Fx "zupt-gui $version"
epoch=$(<.source-date-epoch)
find "$root" -exec touch -d "@$epoch" {} +
(cd "$(dirname "$root")" && zip -X -9 -r "$output" "$(basename "$root")")
extract=$(mktemp -d)
unzip -q "$output" -d "$extract"
bash scripts/check-source-only.sh --tree "$extract/$(basename "$root")"
QT_QPA_PLATFORM=offscreen PATH="$PWD:$PATH" \
"$extract/$(basename "$root")/zupt-gui.sh" --version | \
grep -Fx "zupt-gui $version"
sha256sum "$output"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-gui-portable
path: ${{ runner.temp }}/release-gui-portable/*.zip
if-no-files-found: error
retention-days: 7
### Verifying the tarball
```sh
sha256sum -c zupt-${{ steps.ver.outputs.version }}.tar.gz.sha256
```
### Building
```sh
tar xzf zupt-${{ steps.ver.outputs.version }}.tar.gz
cd zupt-${{ steps.ver.outputs.version }}
make
make test
sudo make install
```
target-packages:
name: Windows and macOS release gates
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
needs:
- source-policy
- build-and-test
- strict-warnings
- sanitizers
- static-analysis
- source-archive
- debian-package
- tumbleweed-rpm
- gui-rpm-package
- linux-portable
- gui-portable
uses: ./.github/workflows/cross-platform.yml
permissions:
contents: read

View file

@ -1,181 +1,252 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Cross-platform GUI + CLI binaries, built on REAL Windows and macOS runners.
#
# Why a dedicated workflow: the GUI is a PySide6/PyQt6 app and the CLI is
# portable C11, but self-contained native installers (Windows .exe/.msi,
# macOS .app/.dmg) can only be produced on the target OS. This workflow builds
# them on GitHub's windows-latest and macos-latest runners and attaches them to
# the GitHub release on a `v*` tag. Run it manually with "Run workflow"
# (workflow_dispatch) to smoke-test the build before tagging.
#
# Artifacts produced:
# Windows: vaptvupt.exe (CLI, mingw), vaptvupt-gui.exe (PyInstaller onefile),
# VaptVupt-Setup-<ver>.exe (Inno Setup installer)
# macOS: vaptvupt (CLI, universal where possible), VaptVupt-<ver>.dmg
# All: vaptvupt-gui-<ver>-portable.zip (Python GUI + launchers)
name: cross-platform
name: target release packages
on:
push:
tags: ['v*']
workflow_call:
workflow_dispatch:
permissions:
contents: write
contents: read
jobs:
# ─────────────────────────── Windows ───────────────────────────
windows:
windows-x86_64:
name: Windows x86_64 package and smoke test
runs-on: windows-latest
defaults:
run:
shell: 'msys2 {0}'
shell: msys2 {0}
steps:
- uses: actions/checkout@v4
- name: Set up MSYS2 (mingw gcc + make)
uses: msys2/setup-msys2@v2
- name: Check out the audited source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Install the Windows C toolchain
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-binutils
mingw-w64-ucrt-x86_64-gcc
make
bsdtar
coreutils
- name: Build CLI (vaptvupt.exe, source-only, C fallback crypto)
run: |
make CC=gcc WITH_SDK=0 -j2
./vaptvupt.exe version || ./vaptvupt version
cp "$(ls vaptvupt.exe vaptvupt 2>/dev/null | head -1)" vaptvupt.exe 2>/dev/null || true
- name: Set up Python
shell: pwsh
run: |
# Use the runner's native Python (not MSYS) for PyInstaller so the
# produced .exe targets the standard Windows Python ABI.
python -m pip install --upgrade pip
python -m pip install PySide6 pyinstaller
- name: Get version
id: ver
shell: pwsh
run: |
$ver = (Select-String -Path include/zupt.h -Pattern '^#define ZUPT_VERSION_STRING "([^"]+)"').Matches.Groups[1].Value
"version=$ver" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Bundle GUI with PyInstaller (vaptvupt-gui.exe)
shell: pwsh
run: |
# onefile GUI that carries the CLI beside it via --add-binary.
pyinstaller --noconfirm --onefile --windowed `
--name vaptvupt-gui `
--icon gui/assets/zupt-icon.png `
--add-binary "vaptvupt.exe;." `
--add-data "gui/assets/zupt-icon.png;assets" `
gui/src/zupt_gui.py
- name: Build Inno Setup installer
shell: pwsh
run: |
choco install innosetup --no-progress -y
& "$env:ChocolateyInstall\bin\ISCC.exe" `
"/DAppVersion=${{ steps.ver.outputs.version }}" `
packaging/windows/vaptvupt-gui.iss
- name: Collect artifacts
shell: pwsh
run: |
$v = "${{ steps.ver.outputs.version }}"
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item vaptvupt.exe "out/vaptvupt-$v-windows-x86_64.exe"
Copy-Item dist/vaptvupt-gui.exe "out/vaptvupt-gui-$v-windows-x86_64.exe"
if (Test-Path "packaging/windows/Output") {
Copy-Item packaging/windows/Output/*.exe "out/" -ErrorAction SilentlyContinue
}
- uses: actions/upload-artifact@v4
with:
name: windows
path: out/*
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: out/*
diffutils
file
findutils
git
gzip
make
python
tar
unzip
zip
# ─────────────────────────── macOS ───────────────────────────
macos:
- name: Audit source before building
run: bash scripts/check-source-only.sh
- name: Build from source
run: |
test "$(uname -m)" = x86_64
make clean
make -j2 CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0
- name: Run the source-only distribution checks on Windows
run: make CC=gcc V=1 WITH_SDK=0 WITH_PQBOX=0 INSTALL_LEGACY_ALIAS=0 check
- name: Native CLI smoke and round-trip
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
if [[ -x ./zupt.exe ]]; then
exe=$PWD/zupt.exe
elif [[ -x ./zupt ]]; then
exe=$PWD/zupt
else
echo 'ZUPT executable was not produced' >&2
exit 1
fi
version_output=$("$exe" --version)
version_line=${version_output%%$'\n'*}
read -r product reported_version _ <<< "$version_line"
if [[ $product != zupt || $reported_version != "$version" ]]; then
echo 'native Windows executable version does not match include/zupt.h' >&2
exit 1
fi
"$exe" --help >/dev/null
if "$exe" --definitely-invalid-option >/dev/null 2>&1; then
echo 'invalid option returned success' >&2
exit 1
fi
test_root=$(mktemp -d)
trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT
output_dir="$test_root/saída-安全"
archive="$test_root/cópia-安全.zupt"
mkdir -p "$test_root/input/subdir" "$output_dir"
printf 'Windows release smoke test\n' > "$test_root/input/café.txt"
printf 'UTF-8: café-安全\n' > "$test_root/input/subdir/ação-安全.txt"
printf 'emoji filename\n' > "$test_root/input/subdir/emoji-😀.bin"
: > "$test_root/input/empty"
dd if=/dev/urandom of="$test_root/input/subdir/random.bin" bs=4096 count=4 2>/dev/null
(cd "$test_root" && "$exe" compress "$archive" input)
"$exe" test "$archive"
"$exe" list "$archive" > "$test_root/list.txt" 2>&1
grep -F 'café.txt' "$test_root/list.txt"
grep -F 'ação-安全.txt' "$test_root/list.txt"
grep -F 'emoji-😀.bin' "$test_root/list.txt"
"$exe" extract -o "$output_dir" "$archive"
diff -r "$test_root/input" "$output_dir/input"
objdump -p "$exe" > "$test_root/imports.txt"
if grep -Eqi '(vendor[/\\]|libvuptsdk|libpqvaptvupt|libgcc_s|libstdc\+\+|libwinpthread|msys-2[.]0|cygwin1)[^[:space:]]*[.]dll' \
"$test_root/imports.txt"; then
echo 'Windows binary imports a non-system or vendored runtime' >&2
exit 1
fi
version_output=$(env PATH='/c/Windows/System32:/c/Windows' "$exe" --version)
version_line=${version_output%%$'\n'*}
read -r product reported_version _ <<< "$version_line"
if [[ $product != zupt || $reported_version != "$version" ]]; then
echo 'restricted-PATH Windows executable version does not match include/zupt.h' >&2
exit 1
fi
- name: Assemble Windows release files
run: |
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
bundle="out/work/zupt-$version-windows-x86_64"
mkdir -p out "$bundle"
if [[ -x ./zupt.exe ]]; then source_exe=./zupt.exe; else source_exe=./zupt; fi
install -m 0755 "$source_exe" "$bundle/zupt.exe"
install -m 0644 README.md CHANGELOG.md LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 \
LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \
NOTICE THIRD-PARTY-NOTICES.md "$bundle/"
toolchain_prefix=${MINGW_PREFIX:-/ucrt64}
install -m 0644 \
"$toolchain_prefix/share/licenses/crt/COPYING" \
"$bundle/MINGW-CRT-COPYING.txt"
install -m 0644 \
"$toolchain_prefix/share/licenses/crt/COPYING.MinGW-w64-runtime.txt" \
"$bundle/COPYING.MinGW-w64-runtime.txt"
install -m 0644 \
"$toolchain_prefix/share/licenses/crt/COPYING.MinGW-w64.txt" \
"$bundle/COPYING.MinGW-w64.txt"
install -m 0644 \
"$toolchain_prefix/share/licenses/gcc-libs/COPYING3" \
"$bundle/GCC-COPYING3.txt"
install -m 0644 \
"$toolchain_prefix/share/licenses/gcc-libs/COPYING.RUNTIME" \
"$bundle/GCC-RUNTIME-LIBRARY-EXCEPTION.txt"
zip_path=$PWD/out/zupt-$version-windows-x86_64.zip
(cd out/work && zip -9 -r "$zip_path" \
"zupt-$version-windows-x86_64")
- name: Extract and functionally test the Windows ZIP
run: |
set -Eeuo pipefail
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
zip_path=$PWD/out/zupt-$version-windows-x86_64.zip
unzip -t "$zip_path"
extract_root=$(mktemp -d)
cleanup() {
chmod -R u+rwX "$extract_root" 2>/dev/null || true
rm -rf -- "$extract_root"
}
trap cleanup EXIT HUP INT TERM
unzip -q "$zip_path" -d "$extract_root"
for notice in MINGW-CRT-COPYING.txt COPYING.MinGW-w64-runtime.txt \
COPYING.MinGW-w64.txt GCC-COPYING3.txt \
GCC-RUNTIME-LIBRARY-EXCEPTION.txt; do
test -s "$extract_root/zupt-$version-windows-x86_64/$notice"
done
packaged_exe=$extract_root/zupt-$version-windows-x86_64/zupt.exe
test -x "$packaged_exe"
version_output=$(env PATH='/c/Windows/System32:/c/Windows' \
"$packaged_exe" --version)
version_line=${version_output%%$'\n'*}
read -r product reported_version _ <<< "$version_line"
if [[ $product != zupt || $reported_version != "$version" ]]; then
echo 'Windows ZIP executable version does not match include/zupt.h' >&2
exit 1
fi
env PATH='/c/Windows/System32:/c/Windows' "$packaged_exe" --help >/dev/null
mkdir -p "$extract_root/smoke/input" "$extract_root/smoke/saída-安全"
printf 'Windows ZIP package test\n' > "$extract_root/smoke/input/payload-ação-😀.txt"
(
cd "$extract_root/smoke"
env PATH='/c/Windows/System32:/c/Windows' \
"$packaged_exe" compress cópia-安全.zupt input
env PATH='/c/Windows/System32:/c/Windows' \
"$packaged_exe" test cópia-安全.zupt
env PATH='/c/Windows/System32:/c/Windows' \
"$packaged_exe" extract -o saída-安全 cópia-安全.zupt
)
cmp "$extract_root/smoke/input/payload-ação-😀.txt" \
"$extract_root/smoke/saída-安全/input/payload-ação-😀.txt"
- name: Upload tested Windows files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-windows-x86_64
path: out/*.zip
if-no-files-found: error
retention-days: 7
macos-native:
name: macOS native DMG and installed-image test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build CLI (vaptvupt, clang)
run: |
make CC=clang WITH_SDK=0 -j3
./vaptvupt version
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- name: Install GUI build deps
run: |
python -m pip install --upgrade pip
python -m pip install PySide6 pyinstaller
brew install create-dmg || true
- name: Get version
id: ver
run: echo "version=$(awk -F'\"' '/^#define ZUPT_VERSION_STRING/{print $2}' include/zupt.h)" >> "$GITHUB_OUTPUT"
- name: Bundle GUI (.app) with PyInstaller
run: |
pyinstaller --noconfirm --windowed \
--name "VaptVupt" \
--add-binary "vaptvupt:." \
--add-data "gui/assets/zupt-icon.png:assets" \
gui/src/zupt_gui.py
- name: Build .dmg
run: |
V="${{ steps.ver.outputs.version }}"
create-dmg --volname "VaptVupt $V" --window-size 500 300 \
--app-drop-link 350 120 --icon "VaptVupt.app" 150 120 \
"VaptVupt-$V.dmg" "dist/VaptVupt.app" || \
{ mkdir -p dmgroot && cp -R dist/VaptVupt.app dmgroot/ && \
hdiutil create -volname "VaptVupt $V" -srcfolder dmgroot -ov -format UDZO "VaptVupt-$V.dmg"; }
- name: Collect artifacts
run: |
V="${{ steps.ver.outputs.version }}"
mkdir -p out
cp vaptvupt "out/vaptvupt-$V-macos"
cp "VaptVupt-$V.dmg" out/
- uses: actions/upload-artifact@v4
- name: Check out the audited source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
name: macos
path: out/*
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: out/*
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
# ─────────────── Portable GUI (works on every OS) ───────────────
portable:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get version
id: ver
run: echo "version=$(awk -F'\"' '/^#define ZUPT_VERSION_STRING/{print $2}' include/zupt.h)" >> "$GITHUB_OUTPUT"
- name: Assemble portable package
- name: Audit source before building
run: bash scripts/check-source-only.sh
- name: Build and validate the native DMG
run: |
V="${{ steps.ver.outputs.version }}"
D="vaptvupt-gui-$V-portable"
mkdir -p "$D/assets"
cp gui/src/zupt_gui.py "$D/"
cp gui/assets/zupt-icon.png "$D/assets/"
cp packaging/portable/vaptvupt-gui.bat "$D/"
cp packaging/portable/vaptvupt-gui.command "$D/"
cp packaging/portable/vaptvupt-gui.sh "$D/"
cp packaging/portable/README.txt "$D/"
chmod +x "$D/vaptvupt-gui.command" "$D/vaptvupt-gui.sh"
zip -r "$D.zip" "$D"
- uses: actions/upload-artifact@v4
mkdir -p out
DIST_DIR="$PWD/out" RUN_CHECKS=1 bash packaging/build-dmg.sh
- name: Mount and functionally test the packaged binary
run: |
dmg=$(find out -maxdepth 1 -type f -name '*.dmg' -print -quit)
test -n "$dmg"
mount_point=$(mktemp -d)
cleanup() {
hdiutil detach "$mount_point" >/dev/null 2>&1 || true
chmod -R u+rwX "$mount_point" 2>/dev/null || true
rm -rf -- "$mount_point"
}
trap cleanup EXIT HUP INT TERM
hdiutil attach -nobrowse -readonly -mountpoint "$mount_point" "$dmg" >/dev/null
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' \
include/zupt.h)
packaged_binary=$mount_point/ZUPT.app/Contents/MacOS/zupt
version_output=$("$packaged_binary" --version)
version_line=${version_output%%$'\n'*}
read -r product reported_version _ <<< "$version_line"
if [[ $product != zupt || $reported_version != "$version" ]]; then
echo 'mounted macOS executable version does not match include/zupt.h' >&2
exit 1
fi
bash packaging/build-dmg.sh --test-binary \
"$packaged_binary"
hdiutil detach "$mount_point"
trap - EXIT HUP INT TERM
rmdir "$mount_point"
- name: Upload tested macOS DMG
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portable
path: vaptvupt-gui-*-portable.zip
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: vaptvupt-gui-*-portable.zip
name: release-macos-native
path: out/*.dmg
if-no-files-found: error
retention-days: 7

644
.github/workflows/promote-release.yml vendored Normal file
View file

@ -0,0 +1,644 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
name: Promote a tested release
on:
workflow_dispatch:
inputs:
source_run_id:
description: Successful manually dispatched CI run that produced the assets
required: true
type: number
tag:
description: Existing annotated release tag, for example v5.2.2
required: true
type: string
permissions: {}
concurrency:
group: promote-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
promote:
name: Promote tested assets to the canonical GitHub release
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
actions: read
contents: write
steps:
- name: Validate the tag and source CI run through the GitHub API
id: provenance
env:
GH_TOKEN: ${{ github.token }}
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -Eeuo pipefail
set +x
umask 077
[[ $SOURCE_RUN_ID =~ ^[1-9][0-9]*$ ]] || {
echo 'source_run_id must be a positive integer' >&2
exit 1
}
[[ $RELEASE_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo 'tag must have the form vX.Y.Z' >&2
exit 1
}
tag_ref_api="repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG"
tag_object_type=$(gh api "$tag_ref_api" --jq '.object.type')
tag_object_sha=$(gh api "$tag_ref_api" --jq '.object.sha')
[[ $tag_object_type == tag && $tag_object_sha =~ ^[0-9a-f]{40}$ ]] || {
echo 'GitHub release ref is not an annotated tag' >&2
exit 1
}
tag_object_api="repos/$GITHUB_REPOSITORY/git/tags/$tag_object_sha"
target_type=$(gh api "$tag_object_api" --jq '.object.type')
peeled_sha=$(gh api "$tag_object_api" --jq '.object.sha')
[[ $target_type == commit && $peeled_sha =~ ^[0-9a-f]{40}$ ]] || {
echo 'annotated tag does not point directly to a commit' >&2
exit 1
}
run_api="repos/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID"
run_status=$(gh api "$run_api" --jq '.status')
run_conclusion=$(gh api "$run_api" --jq '.conclusion')
run_event=$(gh api "$run_api" --jq '.event')
run_head_branch=$(gh api "$run_api" --jq '.head_branch // ""')
run_workflow_id=$(gh api "$run_api" --jq '.workflow_id')
run_sha=$(gh api "$run_api" --jq '.head_sha')
run_repository=$(gh api "$run_api" --jq '.head_repository.full_name // ""')
workflow_path=$(gh api \
"repos/$GITHUB_REPOSITORY/actions/workflows/$run_workflow_id" \
--jq '.path')
[[ $run_status == completed && $run_conclusion == success ]] || {
echo 'source CI run is not completed successfully' >&2
exit 1
}
[[ $run_event == workflow_dispatch ]] || {
echo 'source CI run must have been started with workflow_dispatch' >&2
exit 1
}
[[ $run_head_branch == "$RELEASE_TAG" ]] || {
echo 'source CI run must have been dispatched from the release tag' >&2
exit 1
}
[[ $workflow_path == .github/workflows/ci.yml ]] || {
echo 'source run did not execute .github/workflows/ci.yml' >&2
exit 1
}
[[ $run_repository == "$GITHUB_REPOSITORY" ]] || {
echo 'source CI run belongs to a different head repository' >&2
exit 1
}
[[ $run_sha =~ ^[0-9a-f]{40}$ && $run_sha == "$peeled_sha" ]] || {
echo 'source CI head SHA does not match the peeled release tag' >&2
exit 1
}
artifact_json=$RUNNER_TEMP/source-run-artifacts.json
gh api "$run_api/artifacts?per_page=100" > "$artifact_json"
python3 - "$artifact_json" <<'PY'
import json
import pathlib
import sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
expected = {
"release-source",
"release-deb",
"release-rpm",
"release-gui-deb",
"release-gui-rpm",
"release-linux-x86_64",
"release-gui-portable",
"release-windows-x86_64",
"release-macos-native",
}
artifacts = payload.get("artifacts", [])
names = [artifact.get("name", "") for artifact in artifacts]
if payload.get("total_count") != len(expected):
raise SystemExit("source CI run artifact count mismatch")
if set(names) != expected or len(names) != len(set(names)):
raise SystemExit("source CI run artifact-name allowlist mismatch")
if any(artifact.get("expired") for artifact in artifacts):
raise SystemExit("one or more source CI artifacts have expired")
PY
{
printf 'head_sha=%s\n' "$peeled_sha"
printf 'tag_object_sha=%s\n' "$tag_object_sha"
printf 'tag=%s\n' "$RELEASE_TAG"
} >> "$GITHUB_OUTPUT"
- name: Check out the exact tested commit without persisted credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.provenance.outputs.head_sha }}
persist-credentials: false
fetch-depth: 0
lfs: false
submodules: false
- name: Confirm the local annotated tag and source version
id: release
env:
RELEASE_TAG: ${{ steps.provenance.outputs.tag }}
EXPECTED_SHA: ${{ steps.provenance.outputs.head_sha }}
EXPECTED_TAG_OBJECT: ${{ steps.provenance.outputs.tag_object_sha }}
run: |
set -Eeuo pipefail
[[ $(git rev-parse HEAD) == "$EXPECTED_SHA" ]] || {
echo 'checked-out commit differs from the validated source run' >&2
exit 1
}
[[ $(git cat-file -t "refs/tags/$RELEASE_TAG") == tag ]] || {
echo 'checked-out release ref is not an annotated tag' >&2
exit 1
}
[[ $(git rev-parse "refs/tags/$RELEASE_TAG") == "$EXPECTED_TAG_OBJECT" ]] || {
echo 'local annotated tag object differs from the validated GitHub tag' >&2
exit 1
}
[[ $(git rev-parse "$RELEASE_TAG^{commit}") == "$EXPECTED_SHA" ]] || {
echo 'local peeled tag does not match the tested commit' >&2
exit 1
}
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' \
include/zupt.h)
[[ -n $version && $RELEASE_TAG == "v$version" ]] || {
echo 'tag does not match include/zupt.h' >&2
exit 1
}
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Install validation tools
run: |
sudo apt-get update
sudo apt-get install -y file libarchive-tools python3 python3-pyqt6 rpm unzip xz-utils
- name: Download the exact source artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-source
path: ${{ runner.temp }}/incoming/release-source
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact DEB artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-deb
path: ${{ runner.temp }}/incoming/release-deb
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact RPM artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-rpm
path: ${{ runner.temp }}/incoming/release-rpm
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact GUI DEB artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-gui-deb
path: ${{ runner.temp }}/incoming/release-gui-deb
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact GUI RPM artifacts from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-gui-rpm
path: ${{ runner.temp }}/incoming/release-gui-rpm
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact Linux tar.xz artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-linux-x86_64
path: ${{ runner.temp }}/incoming/release-linux-x86_64
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact portable GUI source bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-gui-portable
path: ${{ runner.temp }}/incoming/release-gui-portable
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact Windows artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-windows-x86_64
path: ${{ runner.temp }}/incoming/release-windows-x86_64
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Download the exact macOS artifact from the validated run
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-macos-native
path: ${{ runner.temp }}/incoming/release-macos-native
repository: ${{ github.repository }}
run-id: ${{ inputs.source_run_id }}
github-token: ${{ github.token }}
- name: Enforce the allowlist and validate every release format
env:
RELEASE_TAG: ${{ steps.provenance.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
run: |
set -Eeuo pipefail
umask 077
export LC_ALL=C
incoming=$RUNNER_TEMP/incoming
asset_dir=$RUNNER_TEMP/release-assets
mkdir -p "$asset_dir"
artifact_names=(
release-source
release-deb
release-rpm
release-gui-deb
release-gui-rpm
release-linux-x86_64
release-gui-portable
release-windows-x86_64
release-macos-native
)
expected_dirs=$RUNNER_TEMP/artifact-dirs.expected
actual_dirs=$RUNNER_TEMP/artifact-dirs.actual
printf '%s\0' "${artifact_names[@]}" | LC_ALL=C sort -z > "$expected_dirs"
find "$incoming" -mindepth 1 -maxdepth 1 -type d -printf '%f\0' | \
LC_ALL=C sort -z > "$actual_dirs"
cmp "$expected_dirs" "$actual_dirs" || {
echo 'downloaded artifact directory allowlist mismatch' >&2
exit 1
}
if find "$incoming" -mindepth 1 -maxdepth 1 ! -type d -print -quit | \
grep -q .; then
echo 'unexpected non-directory entry in artifact download root' >&2
exit 1
fi
if find "$incoming" -mindepth 2 ! -type f -print -quit | grep -q .; then
echo 'artifact contains a directory, symlink, or special file' >&2
exit 1
fi
source_name="zupt-$VERSION.tar.gz"
source_sidecar="$source_name.sha256"
deb_name="zupt_${VERSION}_amd64.deb"
rpm_name="zupt-$VERSION-0.x86_64.rpm"
srpm_name="zupt-$VERSION-0.src.rpm"
gui_deb_name="zupt-gui_${VERSION}_all.deb"
gui_rpm_name="zupt-gui-$VERSION-1.noarch.rpm"
gui_srpm_name="zupt-gui-$VERSION-1.src.rpm"
linux_tar_name="zupt-$VERSION-linux-x86_64.tar.xz"
gui_portable_name="zupt-gui-$VERSION-portable.zip"
windows_zip_name="zupt-$VERSION-windows-x86_64.zip"
dmg_relative=()
for arch in x86_64 arm64; do
candidate="release-macos-native/ZUPT-$VERSION-macOS-$arch.dmg"
[[ ! -f $incoming/$candidate || -L $incoming/$candidate ]] || \
dmg_relative+=("$candidate")
done
((${#dmg_relative[@]} == 1)) || {
echo 'expected exactly one native macOS DMG' >&2
exit 1
}
expected_relative=(
"release-source/$source_name"
"release-source/$source_sidecar"
"release-deb/$deb_name"
"release-rpm/$rpm_name"
"release-rpm/$srpm_name"
"release-gui-deb/$gui_deb_name"
"release-gui-rpm/$gui_rpm_name"
"release-gui-rpm/$gui_srpm_name"
"release-linux-x86_64/$linux_tar_name"
"release-gui-portable/$gui_portable_name"
"release-windows-x86_64/$windows_zip_name"
"${dmg_relative[0]}"
)
expected_relative_list=$RUNNER_TEMP/artifact-files.expected
actual_relative_list=$RUNNER_TEMP/artifact-files.actual
printf '%s\0' "${expected_relative[@]}" | LC_ALL=C sort -z \
> "$expected_relative_list"
find "$incoming" -mindepth 2 -type f -printf '%P\0' | LC_ALL=C sort -z \
> "$actual_relative_list"
cmp "$expected_relative_list" "$actual_relative_list" || {
echo 'downloaded file allowlist mismatch' >&2
exit 1
}
expected_assets=()
for relative in "${expected_relative[@]}"; do
name=${relative#*/}
cp -- "$incoming/$relative" "$asset_dir/$name"
expected_assets+=("$name")
done
expected_list=$RUNNER_TEMP/release-assets.expected
printf '%s\0' "${expected_assets[@]}" | LC_ALL=C sort -z > "$expected_list"
source_tar=$asset_dir/$source_name
sidecar=$asset_dir/$source_sidecar
actual_source_sha=$(sha256sum "$source_tar" | awk '{print $1}')
[[ $(<"$sidecar") == "$actual_source_sha $source_name" ]] || {
echo 'source archive sidecar is not the exact expected SHA-256 record' >&2
exit 1
}
(cd "$asset_dir" && sha256sum -c -- "$source_sidecar")
file "$source_tar" | grep -Eqi 'gzip compressed data'
tar -tzf "$source_tar" >/dev/null
bash scripts/check-source-only.sh --archive "$source_tar"
archive_version=$(tar -xOf "$source_tar" \
"zupt-$VERSION/include/zupt.h" | sed -n \
's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p')
[[ $archive_version == "$VERSION" && $RELEASE_TAG == "v$archive_version" ]] || {
echo 'source archive version does not match the release tag' >&2
exit 1
}
deb=$asset_dir/$deb_name
dpkg-deb --info "$deb" >/dev/null
[[ $(dpkg-deb -f "$deb" Package) == zupt ]]
[[ $(dpkg-deb -f "$deb" Version) == "$VERSION" ]]
[[ $(dpkg-deb -f "$deb" Architecture) == amd64 ]]
rpm_file=$asset_dir/$rpm_name
[[ $(rpm -qp --qf '%{NAME}' "$rpm_file") == zupt ]]
[[ $(rpm -qp --qf '%{VERSION}' "$rpm_file") == "$VERSION" ]]
[[ $(rpm -qp --qf '%{RELEASE}' "$rpm_file") == 0 ]]
[[ $(rpm -qp --qf '%{ARCH}' "$rpm_file") == x86_64 ]]
srpm=$asset_dir/$srpm_name
[[ $(rpm -qp --qf '%{NAME}' "$srpm") == zupt ]]
[[ $(rpm -qp --qf '%{VERSION}' "$srpm") == "$VERSION" ]]
[[ $(rpm -qp --qf '%{RELEASE}' "$srpm") == 0 ]]
[[ $(rpm -qp --qf '%{ARCH}' "$srpm") == src ]]
gui_deb=$asset_dir/$gui_deb_name
dpkg-deb --info "$gui_deb" >/dev/null
[[ $(dpkg-deb -f "$gui_deb" Package) == zupt-gui ]]
[[ $(dpkg-deb -f "$gui_deb" Version) == "$VERSION" ]]
[[ $(dpkg-deb -f "$gui_deb" Architecture) == all ]]
gui_rpm=$asset_dir/$gui_rpm_name
[[ $(rpm -qp --qf '%{NAME}' "$gui_rpm") == zupt-gui ]]
[[ $(rpm -qp --qf '%{VERSION}' "$gui_rpm") == "$VERSION" ]]
[[ $(rpm -qp --qf '%{RELEASE}' "$gui_rpm") == 1 ]]
[[ $(rpm -qp --qf '%{ARCH}' "$gui_rpm") == noarch ]]
rpm -qp --requires "$gui_rpm" | grep -Fx "zupt >= $VERSION"
gui_srpm=$asset_dir/$gui_srpm_name
[[ $(rpm -qp --qf '%{NAME}' "$gui_srpm") == zupt-gui ]]
[[ $(rpm -qp --qf '%{VERSION}' "$gui_srpm") == "$VERSION" ]]
[[ $(rpm -qp --qf '%{RELEASE}' "$gui_srpm") == 1 ]]
[[ $(rpm -qp --qf '%{ARCH}' "$gui_srpm") == src ]]
linux_tar=$asset_dir/$linux_tar_name
python3 - "$linux_tar" "zupt-$VERSION-linux-x86_64" <<'PY'
import pathlib
import sys
import tarfile
archive = pathlib.Path(sys.argv[1])
root = sys.argv[2]
expected_files = {
"zupt", "README.md", "CHANGELOG.md", "SECURITY.md",
"THREAT_MODEL.md", "LICENSE", "LICENSE-AGPL-3.0",
"LICENSE-GPL-3.0", "LICENSE-BSD-2-Clause",
"LICENSE-BSD-3-Clause", "LICENSE-CC0-1.0", "NOTICE",
"THIRD-PARTY-NOTICES.md",
}
with tarfile.open(archive, "r:xz") as package:
members = package.getmembers()
names = [member.name for member in members]
if len(names) != len(set(names)):
raise SystemExit("duplicate Linux tar member")
actual_files = set()
for member in members:
path = pathlib.PurePosixPath(member.name)
if (path.is_absolute() or ".." in path.parts or not path.parts or
path.parts[0] != root or member.issym() or member.islnk() or
not (member.isdir() or member.isfile())):
raise SystemExit("unsafe Linux tar member")
if member.isfile():
actual_files.add("/".join(path.parts[1:]))
if actual_files != expected_files:
raise SystemExit("Linux tar member allowlist mismatch")
PY
linux_extract=$RUNNER_TEMP/linux-package
mkdir -p "$linux_extract"
tar -xJf "$linux_tar" -C "$linux_extract"
linux_binary="$linux_extract/zupt-$VERSION-linux-x86_64/zupt"
file "$linux_binary" | grep -Eqi 'ELF.*executable'
bash scripts/test-installed-zupt.sh "$linux_binary"
gui_portable=$asset_dir/$gui_portable_name
python3 - "$gui_portable" "zupt-gui-$VERSION-portable" <<'PY'
import pathlib
import sys
import zipfile
archive = pathlib.Path(sys.argv[1])
root = sys.argv[2]
expected = {
f"{root}/", f"{root}/assets/", f"{root}/zupt_gui.py",
f"{root}/zupt-gui.sh", f"{root}/zupt-gui.command",
f"{root}/zupt-gui.bat", f"{root}/README.txt",
f"{root}/assets/zupt-icon.png", f"{root}/assets/zupt.ico",
f"{root}/LICENSE-AGPL-3.0", f"{root}/LICENSE-GUI",
f"{root}/ASSET-PROVENANCE.md", f"{root}/CHANGELOG.md",
}
with zipfile.ZipFile(archive) as package:
names = package.namelist()
if len(names) != len(set(names)) or set(names) != expected:
raise SystemExit("portable GUI ZIP member allowlist mismatch")
for name in names:
path = pathlib.PurePosixPath(name)
if path.is_absolute() or ".." in path.parts or path.parts[0] != root:
raise SystemExit("unsafe portable GUI ZIP member")
PY
bash scripts/check-source-only.sh --archive "$gui_portable"
gui_extract=$RUNNER_TEMP/gui-portable
mkdir -p "$gui_extract"
unzip -q "$gui_portable" -d "$gui_extract"
QT_QPA_PLATFORM=offscreen ZUPT_BIN="$linux_binary" \
"$gui_extract/zupt-gui-$VERSION-portable/zupt-gui.sh" --version | \
grep -Fx "zupt-gui $VERSION"
windows_zip=$asset_dir/$windows_zip_name
unzip -t "$windows_zip" >/dev/null
python3 - "$windows_zip" "zupt-$VERSION-windows-x86_64" <<'PY'
import pathlib
import sys
import zipfile
archive = pathlib.Path(sys.argv[1])
root = sys.argv[2]
with zipfile.ZipFile(archive) as package:
names = package.namelist()
if len(names) != len(set(names)):
raise SystemExit("duplicate Windows ZIP member")
expected = {
f"{root}/",
f"{root}/zupt.exe",
f"{root}/README.md",
f"{root}/CHANGELOG.md",
f"{root}/LICENSE",
f"{root}/LICENSE-AGPL-3.0",
f"{root}/LICENSE-GPL-3.0",
f"{root}/LICENSE-BSD-2-Clause",
f"{root}/LICENSE-BSD-3-Clause",
f"{root}/LICENSE-CC0-1.0",
f"{root}/NOTICE",
f"{root}/THIRD-PARTY-NOTICES.md",
f"{root}/MINGW-CRT-COPYING.txt",
f"{root}/COPYING.MinGW-w64-runtime.txt",
f"{root}/COPYING.MinGW-w64.txt",
f"{root}/GCC-COPYING3.txt",
f"{root}/GCC-RUNTIME-LIBRARY-EXCEPTION.txt",
}
if set(names) != expected:
raise SystemExit("Windows ZIP member allowlist mismatch")
for name in names:
path = pathlib.PurePosixPath(name)
if (path.is_absolute() or "\\" in name or ".." in path.parts or
not path.parts or path.parts[0] != root):
raise SystemExit("unsafe or unexpected Windows ZIP member")
executable = f"{root}/zupt.exe"
if names.count(executable) != 1:
raise SystemExit("Windows ZIP executable is missing or duplicated")
for notice in (
f"{root}/MINGW-CRT-COPYING.txt",
f"{root}/COPYING.MinGW-w64-runtime.txt",
f"{root}/COPYING.MinGW-w64.txt",
f"{root}/GCC-COPYING3.txt",
f"{root}/GCC-RUNTIME-LIBRARY-EXCEPTION.txt",
):
if not package.read(notice):
raise SystemExit("Windows toolchain notice is empty")
PY
unzip -p "$windows_zip" \
"zupt-$VERSION-windows-x86_64/zupt.exe" \
> "$RUNNER_TEMP/windows-zip-zupt.exe"
python3 - "$RUNNER_TEMP/windows-zip-zupt.exe" <<'PY'
import pathlib
import struct
import sys
executable = pathlib.Path(sys.argv[1])
with executable.open("rb") as stream:
header = stream.read(64)
if len(header) != 64 or header[:2] != b"MZ":
raise SystemExit("Windows ZIP executable lacks MZ magic")
pe_offset = struct.unpack_from("<I", header, 0x3C)[0]
stream.seek(pe_offset)
if stream.read(4) != b"PE\0\0":
raise SystemExit("Windows ZIP executable lacks PE signature")
PY
dmg_name=${dmg_relative[0]#*/}
dmg=$asset_dir/$dmg_name
python3 - "$dmg" <<'PY'
import pathlib
import sys
image = pathlib.Path(sys.argv[1])
with image.open("rb") as stream:
stream.seek(-512, 2)
if stream.read(4) != b"koly":
raise SystemExit("DMG lacks the UDIF trailer magic")
PY
checksum_tmp=$RUNNER_TEMP/SHA256SUMS
(cd "$asset_dir" && xargs -0 sha256sum < "$expected_list") \
> "$checksum_tmp"
mv "$checksum_tmp" "$asset_dir/SHA256SUMS"
(cd "$asset_dir" && sha256sum -c SHA256SUMS)
cp "$expected_list" "$RUNNER_TEMP/release-assets.list"
printf 'SHA256SUMS\0' >> "$RUNNER_TEMP/release-assets.list"
echo 'All downloaded release assets match the exact allowlist and formats.'
- name: Refuse to mutate an existing GitHub release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.provenance.outputs.tag }}
run: |
set -Eeuo pipefail
set +x
umask 077
existing_tags=$RUNNER_TEMP/github-release-tags
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" \
--jq '.[].tag_name' > "$existing_tags"
if grep -Fxq -- "$RELEASE_TAG" "$existing_tags"; then
echo 'GitHub release already exists; refusing to replace or add assets' >&2
exit 1
fi
- name: Publish the already-tested byte-identical asset set
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.provenance.outputs.tag }}
RELEASE_COMMIT: ${{ steps.provenance.outputs.head_sha }}
VERSION: ${{ steps.release.outputs.version }}
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
run: |
set -Eeuo pipefail
set +x
umask 077
asset_dir=$RUNNER_TEMP/release-assets
(cd "$asset_dir" && sha256sum -c SHA256SUMS)
mapfile -d '' -t asset_names < "$RUNNER_TEMP/release-assets.list"
release_assets=()
for name in "${asset_names[@]}"; do
path=$asset_dir/$name
[[ -f $path && ! -L $path ]] || {
printf 'validated release asset disappeared or changed type: %q\n' \
"$name" >&2
exit 1
}
release_assets+=("$path")
done
cat > "$RUNNER_TEMP/release-notes.md" <<EOF
ZUPT $VERSION was built and tested by manually dispatched CI run
https://github.com/$GITHUB_REPOSITORY/actions/runs/$SOURCE_RUN_ID
for annotated tag $RELEASE_TAG at commit $RELEASE_COMMIT. The job
definitions and logs in that run record the runner images,
architectures, toolchains, results, and explicit skips.
The attached source archive, CLI DEB/RPM/source RPM, GUI
DEB/RPM/source RPM, notice-bearing Linux x86_64 tar.xz, source-only
portable GUI ZIP, Windows CLI ZIP, and native macOS CLI DMG are the
exact artifacts validated by that run. SHA256SUMS records every
attached payload asset. GitHub is the canonical upstream release.
Binary packages are release-page assets only. The Git tree and source
archive remain source-only, built with WITH_SDK=0 and WITH_PQBOX=0.
AppImage and bare executables are intentionally excluded: the former
lacks an audited runtime source/relink handoff, while the latter does
not carry the required license and notice payload beside the program.
EOF
gh release create "$RELEASE_TAG" "${release_assets[@]}" \
--repo "$GITHUB_REPOSITORY" --draft --verify-tag \
--title "ZUPT $VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md"
gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false