# 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.8 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(" "$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" <