Files
nyaterm/.github/workflows/release.yml
T

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}"