mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-11-06 16:07:55 +00:00
841 lines
34 KiB
YAML
841 lines
34 KiB
YAML
name: Release
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
version:
|
|
required: false
|
|
type: string
|
|
upload_release:
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
verify_release:
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
source_run_id:
|
|
required: false
|
|
type: string
|
|
source_ref:
|
|
required: false
|
|
type: string
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: Optional version; must match the Cargo workspace version
|
|
required: false
|
|
type: string
|
|
upload_release:
|
|
description: Publish GitHub and downstream release channels
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
verify_release:
|
|
description: Sign and verify artifacts without publishing
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
source_run_id:
|
|
description: Optional completed Release run ID with matching package provenance
|
|
required: false
|
|
type: string
|
|
source_ref:
|
|
description: Optional source ref to build; defaults to the triggering ref
|
|
required: false
|
|
type: string
|
|
push:
|
|
tags: ['v*']
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
CARGO_INCREMENTAL: '0'
|
|
DOWNLOAD_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }}
|
|
|
|
jobs:
|
|
preflight:
|
|
name: Preflight
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 60
|
|
outputs:
|
|
version: ${{ steps.release.outputs.version }}
|
|
tag: ${{ steps.release.outputs.tag }}
|
|
publish: ${{ steps.release.outputs.publish }}
|
|
prerelease: ${{ steps.release.outputs.prerelease }}
|
|
source_sha: ${{ steps.source.outputs.sha }}
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
ref: ${{ inputs.source_ref || github.ref }}
|
|
|
|
- name: Install Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.12'
|
|
|
|
- name: Resolve and validate release metadata
|
|
id: release
|
|
shell: bash
|
|
env:
|
|
INPUT_SOURCE_RUN_ID: ${{ inputs.source_run_id }}
|
|
INPUT_VERSION: ${{ inputs.version }}
|
|
INPUT_UPLOAD: ${{ inputs.upload_release }}
|
|
INPUT_VERIFY: ${{ inputs.verify_release }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ -n "${INPUT_SOURCE_RUN_ID:-}" && "${INPUT_UPLOAD:-false}" != 'true' && "${INPUT_VERIFY:-false}" != 'true' && "${GITHUB_REF_TYPE}" != 'tag' ]]; then
|
|
echo 'source_run_id requires publishing or verify_release=true' >&2
|
|
exit 1
|
|
fi
|
|
workspace_version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["package"]["version"])')"
|
|
publish='false'
|
|
if [[ "${GITHUB_REF_TYPE}" == 'tag' ]]; then
|
|
tag="${GITHUB_REF_NAME}"
|
|
version="${tag#v}"
|
|
publish='true'
|
|
else
|
|
version="${INPUT_VERSION#v}"
|
|
version="${version:-${workspace_version}}"
|
|
tag="v${version}"
|
|
if [[ "${INPUT_UPLOAD:-false}" == 'true' ]]; then
|
|
[[ -n "${INPUT_VERSION:-}" ]] || {
|
|
echo 'manual release publishing requires the version input' >&2
|
|
exit 1
|
|
}
|
|
publish='true'
|
|
fi
|
|
fi
|
|
python3 - "${version}" "${workspace_version}" <<'PY'
|
|
import re
|
|
import sys
|
|
version, workspace = sys.argv[1:]
|
|
semver = r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?"
|
|
if not re.fullmatch(semver, version):
|
|
raise SystemExit(f"invalid release version: {version}")
|
|
if version != workspace:
|
|
raise SystemExit(f"release version {version} does not match Cargo workspace version {workspace}")
|
|
PY
|
|
prerelease='false'
|
|
[[ "${version%%+*}" == *-* ]] && prerelease='true'
|
|
{
|
|
echo "version=${version}"
|
|
echo "tag=${tag}"
|
|
echo "publish=${publish}"
|
|
echo "prerelease=${prerelease}"
|
|
} >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: Resolve release source
|
|
id: source
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
|
|
run: |
|
|
set -euo pipefail
|
|
source_sha="$(git rev-parse HEAD)"
|
|
if [[ -z "${SOURCE_RUN_ID:-}" ]]; then
|
|
echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}"
|
|
exit 0
|
|
fi
|
|
[[ "${SOURCE_RUN_ID}" =~ ^[0-9]+$ ]] || {
|
|
echo 'source_run_id must be a numeric GitHub Actions run ID' >&2
|
|
exit 1
|
|
}
|
|
|
|
source_workflow_id="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}" --jq '.workflow_id')"
|
|
current_workflow_id="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/release.yml" --jq '.id')"
|
|
source_status="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}" --jq '.status')"
|
|
source_conclusion="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}" --jq '.conclusion')"
|
|
|
|
[[ "${source_workflow_id}" == "${current_workflow_id}" ]] || {
|
|
echo "run ${SOURCE_RUN_ID} was not produced by release.yml" >&2
|
|
exit 1
|
|
}
|
|
[[ "${source_status}" == 'completed' && "${source_conclusion}" == 'success' ]] || {
|
|
echo "run ${SOURCE_RUN_ID} has not completed successfully" >&2
|
|
exit 1
|
|
}
|
|
[[ "${source_sha}" =~ ^[0-9a-f]{40}$ ]] || {
|
|
echo "run ${SOURCE_RUN_ID} returned an invalid head SHA" >&2
|
|
exit 1
|
|
}
|
|
echo "sha=${source_sha}" >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: Wait for populated draft release
|
|
if: steps.release.outputs.publish == 'true'
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ steps.release.outputs.tag }}
|
|
PRERELEASE: ${{ steps.release.outputs.prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
expected="$(printf 'true\t%s\ttrue' "${PRERELEASE}")"
|
|
for _ in {1..36}; do
|
|
actual="$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" \
|
|
--json isDraft,isPrerelease,body \
|
|
--jq '[.isDraft,.isPrerelease,(.body | length > 0)] | @tsv' 2>/dev/null || true)"
|
|
if [[ "${actual}" == "${expected}" ]]; then
|
|
exit 0
|
|
fi
|
|
sleep 5
|
|
done
|
|
echo "Missing populated matching draft release for ${TAG}" >&2
|
|
exit 1
|
|
- name: Verify build configuration
|
|
if: inputs.source_run_id == ''
|
|
shell: bash
|
|
env:
|
|
NYATERM_GITHUB_GIST_CLIENT_ID: ${{ vars.NYATERM_GITHUB_GIST_CLIENT_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ -z "${NYATERM_GITHUB_GIST_CLIENT_ID:-}" ]]; then
|
|
echo 'NYATERM_GITHUB_GIST_CLIENT_ID repository variable is required' >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Set up Rust
|
|
if: inputs.source_run_id == ''
|
|
uses: ./.github/actions/setup-rust
|
|
with:
|
|
cache-key: release-preflight
|
|
- name: Install Linux system dependencies
|
|
if: inputs.source_run_id == ''
|
|
uses: ./.github/actions/install-linux-deps
|
|
- name: Test workspace
|
|
if: inputs.source_run_id == ''
|
|
run: cargo test --workspace --locked --no-fail-fast
|
|
- name: Test packaging and release helpers
|
|
if: inputs.source_run_id == ''
|
|
run: >-
|
|
python -m unittest
|
|
scripts.tests.test_check_release_assets
|
|
scripts.tests.test_prepare_conpty
|
|
scripts.tests.test_package_native
|
|
scripts.tests.test_verify_native_package
|
|
scripts.tests.test_generate_release_metadata
|
|
scripts.tests.test_bump_version
|
|
scripts.tests.test_release_provenance
|
|
scripts.tests.test_release_workflow
|
|
- name: Check workflow syntax
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
# A recovery dispatch may build a frozen source_ref with an updated workflow.
|
|
# Lint the workflow revision that actually triggered this run, not the frozen source tree.
|
|
git show "${GITHUB_SHA}:.github/workflows/release.yml" > .github/workflows/release.yml
|
|
docker run --rm -v "${PWD}:/repo" -w /repo rhysd/actionlint:1.7.7
|
|
|
|
package:
|
|
name: Package ${{ matrix.label }}
|
|
needs: preflight
|
|
if: inputs.source_run_id == ''
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 90
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- { label: macos-arm64, os: macos-15, target: aarch64-apple-darwin }
|
|
- { label: macos-x64, os: macos-15-intel, target: x86_64-apple-darwin }
|
|
# Keep the x64 glibc floor at 2.35 while ubuntu-22.04 is supported.
|
|
- { label: linux-x64, os: ubuntu-22.04, target: x86_64-unknown-linux-gnu }
|
|
- { label: linux-arm64, os: ubuntu-22.04-arm, target: aarch64-unknown-linux-gnu }
|
|
- { label: windows-x64, os: windows-2025, target: x86_64-pc-windows-msvc }
|
|
- { label: windows-arm64, os: windows-2025, target: aarch64-pc-windows-msvc }
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ needs.preflight.outputs.source_sha }}
|
|
- name: Install Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.12'
|
|
- name: Set up Rust
|
|
uses: ./.github/actions/setup-rust
|
|
with:
|
|
targets: ${{ matrix.target }}
|
|
cache-key: release-${{ matrix.target }}
|
|
cache-on-failure: 'true'
|
|
save-cache: always
|
|
- name: Install Linux system dependencies
|
|
if: runner.os == 'Linux'
|
|
uses: ./.github/actions/install-linux-deps
|
|
with:
|
|
extra_packages: curl file patchelf desktop-file-utils dpkg-dev rpm
|
|
- name: Install AppImage tooling
|
|
if: runner.os == 'Linux'
|
|
shell: bash
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
run: bash scripts/ci/install-appimagetool.sh "${TARGET}"
|
|
- name: Install NSIS
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: ./scripts/ci/install-nsis.ps1
|
|
- name: Install .NET for WiX
|
|
if: runner.os == 'Windows' && needs.preflight.outputs.prerelease == 'false'
|
|
uses: actions/setup-dotnet@v4
|
|
with:
|
|
dotnet-version: '8.0.x'
|
|
- name: Install WiX
|
|
if: runner.os == 'Windows' && needs.preflight.outputs.prerelease == 'false'
|
|
shell: pwsh
|
|
run: ./scripts/ci/install-wix.ps1
|
|
- name: Test MSI packaging contracts
|
|
if: runner.os == 'Windows' && needs.preflight.outputs.prerelease == 'false'
|
|
run: python -m unittest scripts.tests.test_windows_msi -v
|
|
- name: Prepare bundled ConPTY
|
|
if: runner.os == 'Windows'
|
|
run: python scripts/release/prepare_conpty.py
|
|
- name: Package native application
|
|
if: runner.os != 'Windows'
|
|
shell: bash
|
|
env:
|
|
APPIMAGE_EXTRACT_AND_RUN: ${{ runner.os == 'Linux' && '1' || '' }}
|
|
NYATERM_ARTIFACT_VERSION: ${{ needs.preflight.outputs.version }}
|
|
NYATERM_GITHUB_GIST_CLIENT_ID: ${{ vars.NYATERM_GITHUB_GIST_CLIENT_ID }}
|
|
NYATERM_VERSION: ${{ needs.preflight.outputs.version }}
|
|
TARGET: ${{ matrix.target }}
|
|
run: python scripts/release/package_native.py "${TARGET}"
|
|
- name: Package native application (Windows MSVC)
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
env:
|
|
NYATERM_ARTIFACT_VERSION: ${{ needs.preflight.outputs.version }}
|
|
NYATERM_GITHUB_GIST_CLIENT_ID: ${{ vars.NYATERM_GITHUB_GIST_CLIENT_ID }}
|
|
NYATERM_VERSION: ${{ needs.preflight.outputs.version }}
|
|
TARGET: ${{ matrix.target }}
|
|
run: |
|
|
# Git Bash prepends Git's /usr/bin/link.exe, shadowing MSVC's linker.
|
|
# Preserve the PATH configured by ilammy/msvc-dev-cmd for Rust builds.
|
|
$linker = (Get-Command link.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
|
if ($linker -notmatch '\\VC\\Tools\\MSVC\\[^\\]+\\bin\\HostX64\\(x64|arm64)\\link\.exe$') {
|
|
throw "Expected Visual Studio MSVC linker, got: $linker"
|
|
}
|
|
Write-Host "Using MSVC linker: $linker"
|
|
python scripts/release/package_native.py "$env:TARGET"
|
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
- name: Verify native package
|
|
shell: bash
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
run: >-
|
|
python scripts/release/verify_native_package.py
|
|
--target "${TARGET}" --version "${VERSION}"
|
|
--artifact-version "${VERSION}" --dist dist
|
|
# Run host tests even when the package contains ARM64 executables.
|
|
- name: Configure MSVC for host tests
|
|
if: runner.os == 'Windows'
|
|
uses: ilammy/msvc-dev-cmd@v1
|
|
with:
|
|
arch: amd64
|
|
- name: Test portable updater against packaged ZIP
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
run: |
|
|
$arch = if ($env:TARGET -eq 'aarch64-pc-windows-msvc') { 'arm64' } else { 'x64' }
|
|
$env:NYATERM_TEST_PORTABLE_ARCH = if ($arch -eq 'arm64') { 'aarch64' } else { 'x86_64' }
|
|
$env:NYATERM_TEST_PORTABLE_ARCHIVE = (Resolve-Path "dist/NyaTerm_$($env:VERSION)_windows_${arch}_portable.zip").Path
|
|
cargo test -p nyaterm-desktop --lib --locked portable_release_archive_round_trip -- --ignored
|
|
- name: Record package provenance
|
|
shell: bash
|
|
env:
|
|
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
TARGET: ${{ matrix.target }}
|
|
run: >-
|
|
python scripts/release/release_provenance.py record
|
|
--dist dist --records provenance --source-sha "${SOURCE_SHA}"
|
|
--version "${VERSION}" --run-id "${GITHUB_RUN_ID}" --target "${TARGET}"
|
|
- name: Upload package provenance
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: provenance-${{ matrix.label }}
|
|
path: provenance/*.json
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
- name: Upload package artifact
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: NyaTerm-${{ matrix.label }}
|
|
path: dist/*
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
|
|
prepare_release:
|
|
name: Sign and verify release candidate
|
|
needs: [preflight, package]
|
|
if: >-
|
|
always() &&
|
|
needs.preflight.result == 'success' &&
|
|
(needs.preflight.outputs.publish == 'true' || inputs.verify_release == true) &&
|
|
(needs.package.result == 'success' ||
|
|
(inputs.source_run_id != '' &&
|
|
needs.package.result == 'skipped'))
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
outputs:
|
|
version: ${{ needs.preflight.outputs.version }}
|
|
tag: ${{ needs.preflight.outputs.tag }}
|
|
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
ref: ${{ needs.preflight.outputs.source_sha }}
|
|
- name: Install Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.12'
|
|
- name: Install Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
- name: Install signature verification tool
|
|
run: sudo apt-get update && sudo apt-get install -y minisign
|
|
- name: Download package artifacts
|
|
if: inputs.source_run_id == ''
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
pattern: NyaTerm-*
|
|
path: dist-release
|
|
merge-multiple: true
|
|
- name: Download reused package artifacts
|
|
if: inputs.source_run_id != ''
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
pattern: NyaTerm-*
|
|
path: dist-release
|
|
merge-multiple: true
|
|
github-token: ${{ github.token }}
|
|
run-id: ${{ inputs.source_run_id }}
|
|
- name: Download package provenance
|
|
if: inputs.source_run_id == ''
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
pattern: provenance-*
|
|
path: provenance
|
|
merge-multiple: true
|
|
- name: Download reused package provenance
|
|
if: inputs.source_run_id != ''
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
pattern: provenance-*
|
|
path: provenance
|
|
merge-multiple: true
|
|
github-token: ${{ github.token }}
|
|
run-id: ${{ inputs.source_run_id }}
|
|
- name: Verify package provenance
|
|
shell: bash
|
|
env:
|
|
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
SOURCE_RUN_ID: ${{ inputs.source_run_id || github.run_id }}
|
|
run: >-
|
|
python scripts/release/release_provenance.py verify
|
|
--dist dist-release --records provenance --source-sha "${SOURCE_SHA}"
|
|
--version "${VERSION}" --run-id "${SOURCE_RUN_ID}"
|
|
- name: Validate release assets
|
|
env:
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
run: >-
|
|
python3 scripts/ci/check_release_assets.py --dist dist-release
|
|
--version "${VERSION}" --artifact-version "${VERSION}"
|
|
- name: Prepare release notes
|
|
id: metadata
|
|
shell: bash
|
|
env:
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
PUBLISH: ${{ needs.preflight.outputs.publish }}
|
|
TAG: ${{ needs.preflight.outputs.tag }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "${PUBLISH}" == 'true' ]]; then
|
|
gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json body --jq '.body' > release-notes.md
|
|
[[ -s release-notes.md ]] || { echo 'Draft release body is empty' >&2; exit 1; }
|
|
elif [[ -f "scripts/release/notes/${VERSION}.md" ]]; then
|
|
cp "scripts/release/notes/${VERSION}.md" release-notes.md
|
|
else
|
|
printf 'NyaTerm %s\n\n' "${VERSION}" > release-notes.md
|
|
previous_tag="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)"
|
|
if [[ -n "${previous_tag}" ]]; then
|
|
git log --no-merges --format='- %s' "${previous_tag}..HEAD" >> release-notes.md
|
|
else
|
|
git log -20 --no-merges --format='- %s' >> release-notes.md
|
|
fi
|
|
fi
|
|
echo "published_at=$(git show -s --format=%cI HEAD)" >> "${GITHUB_OUTPUT}"
|
|
- name: Sign and verify legacy updater artifacts
|
|
shell: bash
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY_B64: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_B64 }}
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
|
|
TAURI_UPDATER_PUBLIC_KEY_B64: dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyQUYxQTA2NTYyQTNEOTkKUldTWlBTcFdCaHF2Z29pS0pEdE13U3ZUMVZVTlpGVmQ0YlU2cWlORkdNWU1BY005MU01YjFiU2IK
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY_B64:-}" || -z "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" ]]; then
|
|
echo 'Tauri updater signing key and password are required' >&2
|
|
exit 1
|
|
fi
|
|
# Keep the decoded key inside this shell; GITHUB_ENV is printed in later steps.
|
|
TAURI_SIGNING_PRIVATE_KEY="$(printf '%s' "${TAURI_SIGNING_PRIVATE_KEY_B64}" | base64 --decode)"
|
|
export TAURI_SIGNING_PRIVATE_KEY
|
|
printf '%s' "${TAURI_UPDATER_PUBLIC_KEY_B64}" | base64 --decode > "${RUNNER_TEMP}/nyaterm-updater.pub"
|
|
files=(
|
|
"dist-release/NyaTerm_${VERSION}_macos_arm64.app.tar.gz"
|
|
"dist-release/NyaTerm_${VERSION}_macos_x64.app.tar.gz"
|
|
"dist-release/NyaTerm_${VERSION}_linux_arm64.AppImage"
|
|
"dist-release/NyaTerm_${VERSION}_linux_x64.AppImage"
|
|
"dist-release/NyaTerm_${VERSION}_windows_arm64-setup.exe"
|
|
"dist-release/NyaTerm_${VERSION}_windows_x64-setup.exe"
|
|
"dist-release/NyaTerm_${VERSION}_windows_arm64_portable.zip"
|
|
"dist-release/NyaTerm_${VERSION}_windows_x64_portable.zip"
|
|
)
|
|
if [[ "${PRERELEASE}" == 'false' ]]; then
|
|
files+=(
|
|
"dist-release/NyaTerm_${VERSION}_windows_arm64.msi"
|
|
"dist-release/NyaTerm_${VERSION}_windows_x64.msi"
|
|
)
|
|
fi
|
|
for file in "${files[@]}"; do
|
|
npx --yes @tauri-apps/cli@2.10.0 signer sign --password "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD}" "${file}"
|
|
done
|
|
unset TAURI_SIGNING_PRIVATE_KEY
|
|
python3 scripts/release/verify_updater_signatures.py \
|
|
--dist dist-release --version "${VERSION}" \
|
|
--public-key "${RUNNER_TEMP}/nyaterm-updater.pub"
|
|
- name: Test signature rejection
|
|
run: python3 -m unittest scripts.tests.test_verify_updater_signatures -v
|
|
- name: Generate checksums and manifests
|
|
shell: bash
|
|
env:
|
|
BASE_URL: ${{ env.DOWNLOAD_BASE_URL }}
|
|
PUB_DATE: ${{ steps.metadata.outputs.published_at }}
|
|
TAG: ${{ needs.preflight.outputs.tag }}
|
|
VERSION: ${{ needs.preflight.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
[[ -n "${BASE_URL:-}" ]] || { echo 'R2_PUBLIC_BASE_URL repository variable is required' >&2; exit 1; }
|
|
python3 scripts/release/generate_release_metadata.py \
|
|
--dist dist-release --version "${VERSION}" --tag "${TAG}" \
|
|
--base-url "${BASE_URL}" --notes-file release-notes.md --pub-date "${PUB_DATE}"
|
|
python3 scripts/release/verify_release_metadata.py \
|
|
--directory dist-release --version "${VERSION}" --tag "${TAG}" \
|
|
--base-url "${BASE_URL}"
|
|
rm -- dist-release/*.sig
|
|
- name: Save verified release candidate
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: release-candidate
|
|
path: |
|
|
dist-release/*
|
|
provenance/*.json
|
|
release-notes.md
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
|
|
release:
|
|
name: Upload assets to GitHub draft
|
|
needs: [preflight, prepare_release]
|
|
if: >-
|
|
always() &&
|
|
needs.preflight.result == 'success' &&
|
|
needs.prepare_release.result == 'success' &&
|
|
needs.preflight.outputs.publish == 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: write
|
|
outputs:
|
|
version: ${{ needs.preflight.outputs.version }}
|
|
tag: ${{ needs.preflight.outputs.tag }}
|
|
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
|
steps:
|
|
- name: Download verified release candidate
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: release-candidate
|
|
- name: Verify draft and upload assets without publishing
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
|
|
TAG: ${{ needs.preflight.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
status="$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,body \
|
|
--jq '[.isDraft,.isPrerelease,(.body | length > 0)] | @tsv')"
|
|
expected="$(printf 'true\t%s\ttrue' "${PRERELEASE}")"
|
|
[[ "${status}" == "${expected}" ]] || {
|
|
echo 'Expected a populated draft release with matching prerelease status' >&2
|
|
exit 1
|
|
}
|
|
gh release upload "${TAG}" dist-release/* \
|
|
--repo "${GITHUB_REPOSITORY}" --clobber
|
|
|
|
r2:
|
|
name: Publish Cloudflare R2 mirror
|
|
needs: [preflight, release]
|
|
if: >-
|
|
always() &&
|
|
needs.preflight.result == 'success' &&
|
|
needs.release.result == 'success' &&
|
|
needs.preflight.outputs.publish == 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 30
|
|
permissions:
|
|
contents: write
|
|
env:
|
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
AWS_DEFAULT_REGION: us-east-1
|
|
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
|
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
|
BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }}
|
|
R2_BUCKET: nyaterm
|
|
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
|
TAG: ${{ needs.release.outputs.tag }}
|
|
VERSION: ${{ needs.release.outputs.version }}
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ needs.preflight.outputs.source_sha }}
|
|
- name: Validate R2 configuration
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for name in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY BASE_URL R2_ENDPOINT; do
|
|
if [[ -z "${!name:-}" || "${!name}" == 'https://.r2.cloudflarestorage.com' ]]; then
|
|
echo "${name} is required for R2 publishing" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
aws --version
|
|
[[ "${BASE_URL}" == 'https://downloads.nyaterm.app' ]] || {
|
|
echo 'R2_PUBLIC_BASE_URL must be https://downloads.nyaterm.app' >&2
|
|
exit 1
|
|
}
|
|
- name: Download GitHub release assets
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir release-assets
|
|
gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" --dir release-assets --clobber
|
|
(cd release-assets && sha256sum --check SHA256SUMS)
|
|
- name: Upload immutable release assets
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for path in release-assets/*; do
|
|
file="$(basename "${path}")"
|
|
content_type='application/octet-stream'
|
|
case "${file}" in
|
|
*.json) content_type='application/json; charset=utf-8' ;;
|
|
SHA256SUMS) content_type='text/plain; charset=utf-8' ;;
|
|
*.dmg) content_type='application/x-apple-diskimage' ;;
|
|
*.exe) content_type='application/vnd.microsoft.portable-executable' ;;
|
|
*.msi) content_type='application/x-msi' ;;
|
|
*.deb) content_type='application/vnd.debian.binary-package' ;;
|
|
*.rpm) content_type='application/x-rpm' ;;
|
|
*.tar.gz) content_type='application/gzip' ;;
|
|
*.zip) content_type='application/zip' ;;
|
|
esac
|
|
aws s3 cp "${path}" "s3://${R2_BUCKET}/releases/${TAG}/${file}" \
|
|
--endpoint-url "${R2_ENDPOINT}" --content-type "${content_type}" \
|
|
--cache-control 'public, max-age=31536000, immutable' --no-progress
|
|
done
|
|
- name: Verify versioned release assets
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
verified="${RUNNER_TEMP}/verified-release-assets"
|
|
mkdir "${verified}"
|
|
aws s3 sync "s3://${R2_BUCKET}/releases/${TAG}/" "${verified}/" \
|
|
--endpoint-url "${R2_ENDPOINT}" --no-progress
|
|
diff --recursive --brief release-assets "${verified}"
|
|
(cd "${verified}" && sha256sum --check SHA256SUMS)
|
|
for manifest in latest.json downloads.json; do
|
|
python3 - "${verified}/${manifest}" "${VERSION}" <<'PY'
|
|
import json
|
|
import sys
|
|
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
|
|
if manifest.get("version") != sys.argv[2] or not manifest.get("platforms"):
|
|
raise SystemExit(f"invalid release manifest: {sys.argv[1]}")
|
|
PY
|
|
done
|
|
python3 scripts/release/verify_release_metadata.py \
|
|
--directory "${verified}" --version "${VERSION}" --tag "${TAG}" \
|
|
--base-url "${BASE_URL}" --check-assets
|
|
- name: Update stable channel manifests
|
|
if: needs.release.outputs.prerelease == 'false'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for manifest in latest.json downloads.json; do
|
|
aws s3 cp "release-assets/${manifest}" "s3://${R2_BUCKET}/${manifest}" \
|
|
--endpoint-url "${R2_ENDPOINT}" --content-type 'application/json; charset=utf-8' \
|
|
--cache-control 'public, max-age=60, must-revalidate' --no-progress
|
|
done
|
|
- name: Update preview channel manifests
|
|
if: needs.release.outputs.prerelease == 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for manifest in latest.json downloads.json; do
|
|
aws s3 cp "release-assets/${manifest}" "s3://${R2_BUCKET}/channels/preview/${manifest}" \
|
|
--endpoint-url "${R2_ENDPOINT}" --content-type 'application/json; charset=utf-8' \
|
|
--cache-control 'public, max-age=60, must-revalidate' --no-progress
|
|
done
|
|
- name: Verify public stable manifests
|
|
if: needs.release.outputs.prerelease == 'false'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for manifest in latest.json downloads.json; do
|
|
verified='false'
|
|
for attempt in {1..12}; do
|
|
if curl --fail --location --silent --show-error \
|
|
--header 'Cache-Control: no-cache' \
|
|
"${BASE_URL%/}/${manifest}?release=${VERSION}&attempt=${attempt}" \
|
|
-o "${RUNNER_TEMP}/public-${manifest}" && \
|
|
python3 - "${RUNNER_TEMP}/public-${manifest}" "${VERSION}" <<'PY'
|
|
import json
|
|
import sys
|
|
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
|
|
if manifest.get("version") != sys.argv[2] or not manifest.get("platforms"):
|
|
raise SystemExit(f"stale or invalid stable manifest: {sys.argv[1]}")
|
|
PY
|
|
then
|
|
verified='true'
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
[[ "${verified}" == 'true' ]] || {
|
|
echo "public stable manifest did not update: ${manifest}" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
public_dir="${RUNNER_TEMP}/public-stable"
|
|
mkdir "${public_dir}"
|
|
cp "${RUNNER_TEMP}/public-latest.json" "${public_dir}/latest.json"
|
|
cp "${RUNNER_TEMP}/public-downloads.json" "${public_dir}/downloads.json"
|
|
python3 scripts/release/verify_release_metadata.py \
|
|
--directory "${public_dir}" --version "${VERSION}" --tag "${TAG}" \
|
|
--base-url "${BASE_URL}" --check-assets
|
|
- name: Verify public preview manifests
|
|
if: needs.release.outputs.prerelease == 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
for manifest in latest.json downloads.json; do
|
|
verified='false'
|
|
for attempt in {1..12}; do
|
|
if curl --fail --location --silent --show-error \
|
|
--header 'Cache-Control: no-cache' \
|
|
"${BASE_URL%/}/channels/preview/${manifest}?release=${VERSION}&attempt=${attempt}" \
|
|
-o "${RUNNER_TEMP}/public-preview-${manifest}" && \
|
|
python3 - "${RUNNER_TEMP}/public-preview-${manifest}" "${VERSION}" <<'PY'
|
|
import json
|
|
import sys
|
|
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
|
|
if manifest.get("version") != sys.argv[2] or not manifest.get("platforms"):
|
|
raise SystemExit(f"stale or invalid preview manifest: {sys.argv[1]}")
|
|
PY
|
|
then
|
|
verified='true'
|
|
break
|
|
fi
|
|
sleep 5
|
|
done
|
|
[[ "${verified}" == 'true' ]] || {
|
|
echo "public preview manifest did not update: ${manifest}" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
public_dir="${RUNNER_TEMP}/public-preview"
|
|
mkdir "${public_dir}"
|
|
cp "${RUNNER_TEMP}/public-preview-latest.json" "${public_dir}/latest.json"
|
|
cp "${RUNNER_TEMP}/public-preview-downloads.json" "${public_dir}/downloads.json"
|
|
python3 scripts/release/verify_release_metadata.py \
|
|
--directory "${public_dir}" --version "${VERSION}" --tag "${TAG}" \
|
|
--base-url "${BASE_URL}" --check-assets
|
|
|
|
publish:
|
|
name: Publish verified GitHub release
|
|
needs: [preflight, release, r2]
|
|
if: >-
|
|
always() &&
|
|
needs.preflight.result == 'success' &&
|
|
needs.release.result == 'success' &&
|
|
needs.r2.result == 'success' &&
|
|
needs.preflight.outputs.publish == 'true'
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Verify draft assets and prepare downstream version
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.preflight.outputs.tag }}
|
|
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
status="$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,body,assets \
|
|
--jq '[.isDraft,.isPrerelease,(.body | length > 0),([.assets[] | select(.name == "latest.json")] | length == 1),([.assets[] | select(.name == "downloads.json")] | length == 1),([.assets[] | select(.name == "SHA256SUMS")] | length == 1)] | @tsv')"
|
|
expected="$(printf 'true\t%s\ttrue\ttrue\ttrue\ttrue' "${PRERELEASE}")"
|
|
[[ "${status}" == "${expected}" ]] || {
|
|
echo 'Draft release is incomplete or not in the expected state' >&2
|
|
exit 1
|
|
}
|
|
printf '%s\n' "${TAG}" > published-release-tag.txt
|
|
- name: Save downstream release tag
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: published-release-tag
|
|
path: published-release-tag.txt
|
|
if-no-files-found: error
|
|
retention-days: 90
|
|
- name: Publish verified draft
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.preflight.outputs.tag }}
|
|
PRERELEASE: ${{ needs.preflight.outputs.prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --prerelease="${PRERELEASE}"
|