1409 lines
51 KiB
YAML
1409 lines
51 KiB
YAML
name: Build and Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- "v*"
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: build-release-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
APP_NAME: NyaTerm
|
|
|
|
jobs:
|
|
release-ready:
|
|
name: Verify draft release is ready
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Require a version tag
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
|
echo "::error::Release requires refs/tags/v*; got $GITHUB_REF. Dispatch against a version tag."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Verify release versions and main ancestry
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import tomllib
|
|
|
|
version = os.environ['GITHUB_REF_NAME'][1:]
|
|
def check(source, actual):
|
|
if actual != version:
|
|
raise ValueError(f'{source}: version {actual!r} does not match tag {version!r}')
|
|
|
|
for filename in ['package.json', 'src-tauri/tauri.conf.json']:
|
|
check(filename, json.loads(pathlib.Path(filename).read_text())['version'])
|
|
for filename in ['src-tauri/Cargo.toml', 'src-tauri/crates/nyaterm-mcp/Cargo.toml']:
|
|
check(filename, tomllib.loads(pathlib.Path(filename).read_text())['package']['version'])
|
|
for filename, name in [
|
|
('src-tauri/Cargo.lock', 'nyaterm'),
|
|
('src-tauri/crates/nyaterm-mcp/Cargo.lock', 'nyaterm-mcp'),
|
|
]:
|
|
packages = tomllib.loads(pathlib.Path(filename).read_text())['package']
|
|
check(filename, next(pkg['version'] for pkg in packages if pkg['name'] == name))
|
|
print(f'Release versions match {version}')
|
|
PY
|
|
|
|
git fetch origin main:refs/remotes/origin/main
|
|
tag_commit=$(git rev-parse "${GITHUB_REF}^{commit}")
|
|
if [ "$tag_commit" != "$(git rev-parse HEAD)" ] || ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
|
echo "::error::The checked-out tag commit must belong to origin/main."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Wait for the Agent draft release
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
expected_prerelease=false
|
|
[[ "$GITHUB_REF_NAME" != *-* ]] || expected_prerelease=true
|
|
for attempt in $(seq 1 13); do
|
|
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
|
|
--json tagName,isDraft,isPrerelease,body > release.json; then
|
|
if ! jq -e --arg tag "$GITHUB_REF_NAME" --argjson prerelease "$expected_prerelease" \
|
|
'.tagName == $tag and .isDraft == true and .isPrerelease == $prerelease' release.json > /dev/null; then
|
|
echo "::error::Release must match the tag, remain Draft, and have isPrerelease=$expected_prerelease."
|
|
exit 1
|
|
fi
|
|
if jq -e '.body // "" | test("\\S")' release.json > /dev/null; then
|
|
echo "Agent draft release is ready."
|
|
exit 0
|
|
fi
|
|
fi
|
|
if [ "$attempt" -lt 13 ]; then
|
|
echo "Waiting for a Draft Release with a non-empty body ($attempt/13)..."
|
|
sleep 10
|
|
fi
|
|
done
|
|
echo "::error::Timed out waiting for the Agent Draft Release with a non-empty body."
|
|
exit 1
|
|
|
|
build:
|
|
name: Build ${{ matrix.target }}
|
|
needs: release-ready
|
|
runs-on: ${{ matrix.platform }}
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- platform: macos-latest
|
|
target: aarch64-apple-darwin
|
|
args: --target aarch64-apple-darwin
|
|
|
|
- platform: macos-latest
|
|
target: x86_64-apple-darwin
|
|
args: --target x86_64-apple-darwin
|
|
|
|
- platform: ubuntu-22.04
|
|
target: x86_64-unknown-linux-gnu
|
|
args: ""
|
|
|
|
- platform: ubuntu-22.04-arm
|
|
target: aarch64-unknown-linux-gnu
|
|
args: ""
|
|
|
|
- platform: windows-latest
|
|
target: x86_64-pc-windows-msvc
|
|
args: --bundles nsis,msi
|
|
|
|
- platform: windows-latest
|
|
target: aarch64-pc-windows-msvc
|
|
args: --target aarch64-pc-windows-msvc --bundles nsis,msi
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Install Rust
|
|
uses: dtolnay/rust-toolchain@stable
|
|
|
|
- name: Add Rust target
|
|
if: matrix.target != 'x86_64-unknown-linux-gnu' && matrix.target != 'x86_64-pc-windows-msvc'
|
|
run: rustup target add ${{ matrix.target }}
|
|
|
|
- name: Rust cache
|
|
uses: swatinem/rust-cache@v2
|
|
with:
|
|
workspaces: |
|
|
./src-tauri -> target
|
|
key: ${{ matrix.platform }}-${{ matrix.target }}
|
|
cache-on-failure: false
|
|
env:
|
|
RUST_CACHE_COMPRESS: true
|
|
|
|
- name: Install pnpm
|
|
uses: pnpm/action-setup@v5
|
|
with:
|
|
version: 9
|
|
run_install: false
|
|
|
|
- name: Install Node.js
|
|
uses: actions/setup-node@v6
|
|
with:
|
|
node-version: "20"
|
|
cache: pnpm
|
|
cache-dependency-path: pnpm-lock.yaml
|
|
|
|
- name: Install Linux dependencies
|
|
if: contains(matrix.platform, 'ubuntu')
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y \
|
|
libgtk-3-dev \
|
|
libwebkit2gtk-4.1-dev \
|
|
libjavascriptcoregtk-4.1-dev \
|
|
libsoup-3.0-dev \
|
|
libayatana-appindicator3-dev \
|
|
librsvg2-dev \
|
|
patchelf \
|
|
libssl-dev \
|
|
libudev-dev \
|
|
pkg-config \
|
|
build-essential \
|
|
curl \
|
|
wget \
|
|
file \
|
|
xdg-utils
|
|
|
|
if [ "${{ matrix.platform }}" = "ubuntu-22.04-arm" ]; then
|
|
sudo apt-get install -y libfuse2
|
|
fi
|
|
|
|
- name: Install frontend dependencies
|
|
run: pnpm install --frozen-lockfile
|
|
|
|
- name: Prepare Tauri updater signing key
|
|
shell: bash
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY_B64: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_B64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY_B64:-}" ]; then
|
|
echo "TAURI_SIGNING_PRIVATE_KEY_B64 is empty"
|
|
exit 1
|
|
fi
|
|
|
|
node <<'NODE'
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const keyB64 = process.env.TAURI_SIGNING_PRIVATE_KEY_B64 || '';
|
|
const runnerTemp = process.env.RUNNER_TEMP || os.tmpdir();
|
|
const keyPath = path.join(runnerTemp, 'tauri-updater.key');
|
|
const keyText = Buffer.from(keyB64, 'base64').toString('utf8').trim();
|
|
const envDelimiter = `TAURI_KEY_${crypto.randomUUID().replace(/-/g, '')}`;
|
|
|
|
fs.writeFileSync(keyPath, keyText);
|
|
fs.appendFileSync(
|
|
process.env.GITHUB_ENV,
|
|
`TAURI_SIGNING_PRIVATE_KEY<<${envDelimiter}\n${keyText}\n${envDelimiter}\n`
|
|
);
|
|
|
|
console.log(`Tauri updater signing key prepared at: ${keyPath}`);
|
|
NODE
|
|
|
|
- name: Verify GitHub Gist OAuth Client 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 for release builds."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Build Tauri app
|
|
uses: tauri-apps/tauri-action@v0
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
APPIMAGE_EXTRACT_AND_RUN: ${{ matrix.target == 'aarch64-unknown-linux-gnu' && '1' || '' }}
|
|
NYATERM_GITHUB_GIST_CLIENT_ID: ${{ vars.NYATERM_GITHUB_GIST_CLIENT_ID }}
|
|
with:
|
|
tagName: ${{ github.ref_name }}
|
|
releaseDraft: true
|
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
|
includeUpdaterJson: false
|
|
args: ${{ matrix.args }}
|
|
|
|
- name: Verify Windows installers contain ConPTY
|
|
if: matrix.platform == 'windows-latest'
|
|
shell: pwsh
|
|
run: |
|
|
if (!(Get-Command 7z.exe -ErrorAction SilentlyContinue) -and
|
|
!(Test-Path (Join-Path $env:ProgramFiles "7-Zip/7z.exe"))) {
|
|
choco install 7zip -y --no-progress
|
|
}
|
|
$releaseDir = if ("${{ matrix.target }}" -eq "aarch64-pc-windows-msvc") {
|
|
"src-tauri/target/aarch64-pc-windows-msvc/release"
|
|
} else {
|
|
"src-tauri/target/release"
|
|
}
|
|
foreach ($kind in @("nsis", "msi")) {
|
|
$installer = Get-ChildItem (Join-Path $releaseDir "bundle/$kind") -File |
|
|
Where-Object { $_.Extension -in @(".exe", ".msi") } |
|
|
Select-Object -First 1
|
|
if ($null -eq $installer) { throw "Missing $kind installer" }
|
|
./.github/scripts/assert-conpty-package.ps1 -InstallerPath $installer.FullName
|
|
}
|
|
|
|
- name: Package Windows portable zip
|
|
if: startsWith(github.ref, 'refs/tags/v') && matrix.platform == 'windows-latest'
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$version = $env:GITHUB_REF_NAME -replace '^v', ''
|
|
$target = "${{ matrix.target }}"
|
|
$arch = if ($target -eq "aarch64-pc-windows-msvc") { "arm64" } else { "x64" }
|
|
$releaseDir = if ($target -eq "aarch64-pc-windows-msvc") {
|
|
Join-Path "src-tauri/target" "$target/release"
|
|
} else {
|
|
"src-tauri/target/release"
|
|
}
|
|
|
|
$exe = Join-Path $releaseDir "nyaterm.exe"
|
|
if (-not (Test-Path $exe)) {
|
|
$exe = Get-ChildItem -Path $releaseDir -Filter "*.exe" |
|
|
Where-Object { $_.Name -notmatch 'build[-_]script' } |
|
|
Select-Object -First 1 -ExpandProperty FullName
|
|
}
|
|
|
|
if (-not $exe -or -not (Test-Path $exe)) {
|
|
throw "Release executable not found in $releaseDir"
|
|
}
|
|
|
|
$sidecar = Join-Path "src-tauri/binaries" "nyaterm-mcp-$target.exe"
|
|
if (-not (Test-Path $sidecar)) {
|
|
throw "MCP sidecar not found at $sidecar"
|
|
}
|
|
|
|
$assetName = "NyaTerm_${version}_windows_${arch}_portable.zip"
|
|
$portableRoot = Join-Path $env:RUNNER_TEMP "NyaTerm-portable-package"
|
|
$portableDir = Join-Path $portableRoot "NyaTerm-portable"
|
|
$dataDir = Join-Path $portableDir "data"
|
|
$assetPath = Join-Path (Get-Location) $assetName
|
|
|
|
Remove-Item -LiteralPath $portableRoot -Recurse -Force -ErrorAction SilentlyContinue
|
|
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
|
|
Copy-Item -LiteralPath $exe -Destination (Join-Path $portableDir "NyaTerm.exe")
|
|
Copy-Item -LiteralPath $sidecar -Destination (Join-Path $portableDir "nyaterm-mcp.exe")
|
|
Copy-Item -LiteralPath "src-tauri/resources/windows/conpty" -Destination $portableDir -Recurse
|
|
Copy-Item -LiteralPath "src-tauri/conpty-license/LICENSE.txt" -Destination (Join-Path $portableDir "conpty/LICENSE.txt")
|
|
New-Item -ItemType File -Path (Join-Path $portableDir "portable.flag") -Force | Out-Null
|
|
New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null
|
|
|
|
Compress-Archive -Path $portableDir -DestinationPath $assetPath -Force
|
|
./.github/scripts/assert-conpty-package.ps1 -PortableZipPath $assetPath
|
|
Remove-Item Env:TAURI_SIGNING_PRIVATE_KEY_PATH -ErrorAction SilentlyContinue
|
|
pnpm tauri signer sign --password "$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$assetPath"
|
|
|
|
$signaturePath = "$assetPath.sig"
|
|
if (-not (Test-Path $signaturePath)) {
|
|
throw "Portable updater signature was not generated: $signaturePath"
|
|
}
|
|
|
|
gh release upload "$env:GITHUB_REF_NAME" "$assetPath" "$signaturePath" --repo "$env:GITHUB_REPOSITORY" --clobber
|
|
|
|
build-win7:
|
|
name: Build Windows 7 x64
|
|
needs: release-ready
|
|
runs-on: windows-2022
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v6
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve release version
|
|
id: version
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$version = $env:GITHUB_REF_NAME -replace '^v', ''
|
|
"version=$version" >> $env:GITHUB_OUTPUT
|
|
Write-Host "Release version: $version"
|
|
|
|
- name: Build Win7 offline installer
|
|
id: win7
|
|
uses: ./.github/actions/build-win7
|
|
with:
|
|
artifact-version: ${{ steps.version.outputs.version }}
|
|
|
|
- name: Upload Win7 build artifact
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: release-assets-windows-x64-win7
|
|
path: ${{ steps.win7.outputs.installer-path }}
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
upload-win7-release:
|
|
name: Upload Windows 7 release asset
|
|
needs:
|
|
- build
|
|
- build-win7
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Download Win7 build artifact
|
|
uses: actions/download-artifact@v5
|
|
with:
|
|
name: release-assets-windows-x64-win7
|
|
path: release-assets
|
|
|
|
- name: Upload Win7 installer to GitHub release
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
gh release upload "$GITHUB_REF_NAME" release-assets/* \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--clobber
|
|
|
|
rename-assets:
|
|
name: Rename release assets
|
|
needs:
|
|
- build
|
|
- upload-win7-release
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Rename release assets
|
|
uses: actions/github-script@v8
|
|
env:
|
|
APP_NAME: ${{ env.APP_NAME }}
|
|
with:
|
|
script: |
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
const version = tag.replace(/^v/, '');
|
|
const appName = process.env.APP_NAME || 'NyaTerm';
|
|
|
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
|
owner: context.repo.owner, repo: context.repo.repo, per_page: 100,
|
|
});
|
|
const release = releases.find(item => item.tag_name === tag);
|
|
if (!release?.draft) throw new Error(`Expected an existing Draft Release for ${tag}`);
|
|
|
|
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
|
owner: context.repo.owner, repo: context.repo.repo, release_id: release.id, per_page: 100,
|
|
});
|
|
|
|
function normalizeArch(name) {
|
|
const lower = name.toLowerCase();
|
|
|
|
if (
|
|
lower.includes('aarch64') ||
|
|
lower.includes('arm64') ||
|
|
lower.includes('arm_64')
|
|
) {
|
|
return 'arm64';
|
|
}
|
|
|
|
if (
|
|
lower.includes('x86_64') ||
|
|
lower.includes('x64') ||
|
|
lower.includes('amd64')
|
|
) {
|
|
return 'x64';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getNewAssetName(assetName) {
|
|
if (assetName === 'latest.json') return null;
|
|
|
|
const isSig = assetName.endsWith('.sig');
|
|
const baseName = isSig ? assetName.slice(0, -4) : assetName;
|
|
const lower = baseName.toLowerCase();
|
|
|
|
if (lower.includes('win7') || lower.includes('windows-7') || lower.includes('windows_7')) {
|
|
return null;
|
|
}
|
|
|
|
const arch = normalizeArch(baseName);
|
|
|
|
if (!arch) return null;
|
|
|
|
let newName = null;
|
|
|
|
if (lower.endsWith('.dmg')) {
|
|
newName = `${appName}_${version}_macos_${arch}.dmg`;
|
|
} else if (lower.endsWith('.app.tar.gz')) {
|
|
newName = `${appName}_${version}_macos_${arch}.app.tar.gz`;
|
|
} else if (lower.endsWith('.appimage')) {
|
|
newName = `${appName}_${version}_linux_${arch}.AppImage`;
|
|
} else if (lower.endsWith('.deb')) {
|
|
newName = `${appName}_${version}_linux_${arch}.deb`;
|
|
} else if (lower.endsWith('.rpm')) {
|
|
newName = `${appName}_${version}_linux_${arch}.rpm`;
|
|
} else if (lower.endsWith('-setup.exe')) {
|
|
newName = `${appName}_${version}_windows_${arch}-setup.exe`;
|
|
} else if (lower.endsWith('-setup.nsis.zip')) {
|
|
newName = `${appName}_${version}_windows_${arch}-setup.nsis.zip`;
|
|
} else if (lower.endsWith('.msi.zip')) {
|
|
newName = `${appName}_${version}_windows_${arch}.msi.zip`;
|
|
} else if (lower.endsWith('.msi')) {
|
|
newName = `${appName}_${version}_windows_${arch}.msi`;
|
|
}
|
|
|
|
if (!newName) return null;
|
|
|
|
return isSig ? `${newName}.sig` : newName;
|
|
}
|
|
|
|
const existingNames = new Set(assets.map(asset => asset.name));
|
|
const renames = [];
|
|
|
|
for (const asset of assets) {
|
|
const newName = getNewAssetName(asset.name);
|
|
|
|
if (!newName) {
|
|
console.log(`Skip unmatched asset: ${asset.name}`);
|
|
continue;
|
|
}
|
|
|
|
if (newName === asset.name) {
|
|
console.log(`Already normalized: ${asset.name}`);
|
|
continue;
|
|
}
|
|
|
|
if (existingNames.has(newName)) {
|
|
console.log(`Skip because target already exists: ${asset.name} -> ${newName}`);
|
|
continue;
|
|
}
|
|
|
|
renames.push({
|
|
id: asset.id,
|
|
oldName: asset.name,
|
|
newName,
|
|
});
|
|
|
|
existingNames.add(newName);
|
|
}
|
|
|
|
for (const item of renames) {
|
|
console.log(`Renaming: ${item.oldName} -> ${item.newName}`);
|
|
|
|
await github.rest.repos.updateReleaseAsset({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
asset_id: item.id,
|
|
name: item.newName,
|
|
});
|
|
}
|
|
|
|
console.log(`Renamed ${renames.length} asset(s).`);
|
|
|
|
publish-updater:
|
|
name: Generate updater manifest
|
|
needs: rename-assets
|
|
runs-on: ubuntu-latest
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Build and upload latest.json
|
|
uses: actions/github-script@v8
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
const version = tag.replace(/^v/, '');
|
|
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
|
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
|
owner, repo, per_page: 100,
|
|
});
|
|
const release = releases.find(item => item.tag_name === tag);
|
|
if (!release?.draft) throw new Error(`Expected an existing Draft Release for ${tag}`);
|
|
|
|
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
|
owner, repo, release_id: release.id, per_page: 100,
|
|
});
|
|
|
|
function normalizeArch(name) {
|
|
const lower = name.toLowerCase();
|
|
|
|
if (
|
|
lower.includes('aarch64') ||
|
|
lower.includes('arm64') ||
|
|
lower.includes('arm_64')
|
|
) {
|
|
return 'aarch64';
|
|
}
|
|
|
|
if (
|
|
lower.includes('x86_64') ||
|
|
lower.includes('x64') ||
|
|
lower.includes('amd64')
|
|
) {
|
|
return 'x86_64';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function mapSigToPlatforms(name) {
|
|
const baseName = name.replace(/\.sig$/, '');
|
|
const lower = name.toLowerCase();
|
|
const keys = [];
|
|
const arch = normalizeArch(name);
|
|
|
|
if (lower.endsWith('.app.tar.gz.sig')) {
|
|
if (arch === 'x86_64') {
|
|
keys.push('darwin-x86_64');
|
|
} else if (arch === 'aarch64') {
|
|
keys.push('darwin-aarch64');
|
|
}
|
|
} else if (lower.endsWith('.appimage.sig')) {
|
|
if (arch === 'x86_64') {
|
|
keys.push('linux-x86_64');
|
|
} else if (arch === 'aarch64') {
|
|
keys.push('linux-aarch64');
|
|
}
|
|
} else if (lower.endsWith('.deb.sig')) {
|
|
if (arch === 'x86_64') {
|
|
keys.push('linux-x86_64-deb');
|
|
} else if (arch === 'aarch64') {
|
|
keys.push('linux-aarch64-deb');
|
|
}
|
|
} else if (lower.endsWith('_portable.zip.sig')) {
|
|
if (arch === 'x86_64') {
|
|
keys.push('windows-x86_64-portable');
|
|
} else if (arch === 'aarch64') {
|
|
keys.push('windows-aarch64-portable');
|
|
}
|
|
} else if (lower.endsWith('-setup.exe.sig') || lower.endsWith('-setup.nsis.zip.sig')) {
|
|
if (arch === 'x86_64') {
|
|
// New Tauri updater clients use the NSIS-specific key; old clients fall back here.
|
|
keys.push('windows-x86_64-nsis');
|
|
keys.push('windows-x86_64');
|
|
} else if (arch === 'aarch64') {
|
|
// Keep the generic Windows key tied to NSIS for historical client compatibility.
|
|
keys.push('windows-aarch64-nsis');
|
|
keys.push('windows-aarch64');
|
|
}
|
|
} else if (lower.endsWith('.msi.sig') || lower.endsWith('.msi.zip.sig')) {
|
|
if (arch === 'x86_64') {
|
|
keys.push('windows-x86_64-msi');
|
|
} else if (arch === 'aarch64') {
|
|
keys.push('windows-aarch64-msi');
|
|
}
|
|
}
|
|
|
|
return { keys, baseName };
|
|
}
|
|
|
|
async function downloadAssetText(asset) {
|
|
const response = await fetch(asset.url, {
|
|
headers: {
|
|
Accept: 'application/octet-stream',
|
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'actions/github-script',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(
|
|
`Failed to download ${asset.name}: HTTP ${response.status} ${response.statusText} ${body.slice(0, 300)}`
|
|
);
|
|
}
|
|
|
|
return (await response.text()).trim();
|
|
}
|
|
|
|
const platforms = {};
|
|
const sigAssets = assets.filter(asset => asset.name.endsWith('.sig'));
|
|
|
|
for (const sigAsset of sigAssets) {
|
|
const { keys, baseName } = mapSigToPlatforms(sigAsset.name);
|
|
|
|
if (keys.length === 0) {
|
|
console.log(`Skip non-updater signature asset: ${sigAsset.name}`);
|
|
continue;
|
|
}
|
|
|
|
const binaryAsset = assets.find(asset => asset.name === baseName);
|
|
|
|
if (!binaryAsset) {
|
|
console.log(`Warning: no binary asset found for ${baseName}`);
|
|
continue;
|
|
}
|
|
|
|
let signature;
|
|
try {
|
|
signature = await downloadAssetText(sigAsset);
|
|
} catch (error) {
|
|
console.log(`Warning: ${error.message}`);
|
|
continue;
|
|
}
|
|
|
|
for (const key of keys) {
|
|
platforms[key] = {
|
|
signature,
|
|
url: binaryAsset.browser_download_url,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (Object.keys(platforms).length === 0) {
|
|
core.setFailed('No updater platforms generated. Check release asset names and .sig download permissions.');
|
|
return;
|
|
}
|
|
|
|
const requiredSignedPlatforms = [
|
|
'linux-x86_64-deb',
|
|
'linux-aarch64-deb',
|
|
'windows-x86_64',
|
|
'windows-aarch64',
|
|
'windows-x86_64-nsis',
|
|
'windows-aarch64-nsis',
|
|
'windows-x86_64-msi',
|
|
'windows-aarch64-msi',
|
|
'windows-x86_64-portable',
|
|
'windows-aarch64-portable',
|
|
];
|
|
|
|
for (const platform of requiredSignedPlatforms) {
|
|
const item = platforms[platform];
|
|
|
|
if (!item?.url || !item?.signature) {
|
|
throw new Error(`latest.json is missing signed updater target: ${platform}`);
|
|
}
|
|
}
|
|
|
|
const latestJson = {
|
|
version,
|
|
notes: release.body || '',
|
|
pub_date: release.published_at || new Date().toISOString(),
|
|
platforms,
|
|
};
|
|
|
|
const existing = assets.find(asset => asset.name === 'latest.json');
|
|
|
|
if (existing) {
|
|
console.log('Deleting existing latest.json');
|
|
|
|
await github.rest.repos.deleteReleaseAsset({
|
|
owner,
|
|
repo,
|
|
asset_id: existing.id,
|
|
});
|
|
}
|
|
|
|
const content = JSON.stringify(latestJson, null, 2);
|
|
|
|
await github.rest.repos.uploadReleaseAsset({
|
|
owner,
|
|
repo,
|
|
release_id: release.id,
|
|
name: 'latest.json',
|
|
data: content,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'content-length': Buffer.byteLength(content),
|
|
},
|
|
});
|
|
|
|
console.log(`Uploaded latest.json with ${Object.keys(platforms).length} platform key(s).`);
|
|
|
|
cleanup-sigs:
|
|
name: Remove signature assets
|
|
needs: publish-updater
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Delete .sig assets from release
|
|
uses: actions/github-script@v8
|
|
with:
|
|
script: |
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
|
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
|
owner: context.repo.owner, repo: context.repo.repo, per_page: 100,
|
|
});
|
|
const release = releases.find(item => item.tag_name === tag);
|
|
if (!release?.draft) throw new Error(`Expected an existing Draft Release for ${tag}`);
|
|
|
|
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
|
owner: context.repo.owner, repo: context.repo.repo, release_id: release.id, per_page: 100,
|
|
});
|
|
const sigAssets = assets.filter(asset => asset.name.endsWith('.sig'));
|
|
|
|
for (const asset of sigAssets) {
|
|
console.log(`Deleting signature asset: ${asset.name}`);
|
|
|
|
await github.rest.repos.deleteReleaseAsset({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
asset_id: asset.id,
|
|
});
|
|
}
|
|
|
|
console.log(`Deleted ${sigAssets.length} .sig file(s).`);
|
|
|
|
validate-release-assets:
|
|
name: Validate final draft release assets
|
|
needs: cleanup-sigs
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Verify draft assets and signed updater targets
|
|
uses: actions/github-script@v8
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
const version = tag.replace(/^v/, '');
|
|
const appName = process.env.APP_NAME;
|
|
const { owner, repo } = context.repo;
|
|
const releases = await github.paginate(github.rest.repos.listReleases, {
|
|
owner, repo, per_page: 100,
|
|
});
|
|
const release = releases.find(item => item.tag_name === tag);
|
|
if (!release) throw new Error(`No Release found for ${tag}`);
|
|
const expectedPrerelease = tag.includes('-');
|
|
if (release.prerelease !== expectedPrerelease) {
|
|
throw new Error(`Release ${tag} must have prerelease=${expectedPrerelease}.`);
|
|
}
|
|
if (release.tag_name !== tag || !release.draft || !release.body?.trim()) {
|
|
throw new Error('Release must match the tag and remain Draft with a non-empty body.');
|
|
}
|
|
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
|
|
owner, repo, release_id: release.id, per_page: 100,
|
|
});
|
|
const byName = new Map(assets.map(asset => [asset.name, asset]));
|
|
// Names come from rename-assets, the portable step, and build-win7/action.yml.
|
|
const requiredAssets = [
|
|
'latest.json',
|
|
`${appName}_${version}_windows_x64-win7-webview2-109-offline-setup.exe`,
|
|
];
|
|
const requiredPlatforms = {};
|
|
for (const [arch, updaterArch] of [['x64', 'x86_64'], ['arm64', 'aarch64']]) {
|
|
const mac = `${appName}_${version}_macos_${arch}`;
|
|
const linux = `${appName}_${version}_linux_${arch}`;
|
|
const win = `${appName}_${version}_windows_${arch}`;
|
|
requiredAssets.push(
|
|
`${mac}.dmg`, `${mac}.app.tar.gz`,
|
|
`${linux}.AppImage`, `${linux}.deb`, `${linux}.rpm`,
|
|
`${win}-setup.exe`, `${win}.msi`, `${win}_portable.zip`,
|
|
);
|
|
requiredPlatforms[`darwin-${updaterArch}`] = [`${mac}.app.tar.gz`];
|
|
requiredPlatforms[`linux-${updaterArch}`] = [`${linux}.AppImage`];
|
|
requiredPlatforms[`linux-${updaterArch}-deb`] = [`${linux}.deb`];
|
|
// Retain the existing zipped installer compatibility and generic Windows aliases.
|
|
requiredPlatforms[`windows-${updaterArch}`] = [`${win}-setup.exe`, `${win}-setup.nsis.zip`];
|
|
requiredPlatforms[`windows-${updaterArch}-nsis`] = requiredPlatforms[`windows-${updaterArch}`];
|
|
requiredPlatforms[`windows-${updaterArch}-msi`] = [`${win}.msi`, `${win}.msi.zip`];
|
|
requiredPlatforms[`windows-${updaterArch}-portable`] = [`${win}_portable.zip`];
|
|
}
|
|
for (const name of requiredAssets) {
|
|
if (!byName.get(name)?.size) throw new Error(`Missing or empty required asset: ${name}`);
|
|
}
|
|
if (assets.some(asset => asset.name.endsWith('.sig'))) {
|
|
throw new Error('Standalone .sig assets remain after cleanup-sigs.');
|
|
}
|
|
const response = await fetch(byName.get('latest.json').url, {
|
|
headers: {
|
|
Accept: 'application/octet-stream',
|
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'actions/github-script',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
if (!response.ok) throw new Error(`Failed to download latest.json: HTTP ${response.status}`);
|
|
const manifest = await response.json();
|
|
if (manifest.version !== version || manifest.notes !== release.body) {
|
|
throw new Error('latest.json version/notes must match the tag and Agent release body.');
|
|
}
|
|
function installerFor(platform, item) {
|
|
if (typeof item?.signature !== 'string' || !item.signature.trim() || typeof item.url !== 'string') {
|
|
throw new Error(`Missing URL or embedded signature for updater target: ${platform}`);
|
|
}
|
|
const url = new URL(item.url);
|
|
const name = decodeURIComponent(url.pathname.split('/').pop());
|
|
const asset = byName.get(name);
|
|
if (!asset?.size || name.endsWith('.sig') || url.href !== new URL(asset.browser_download_url).href) {
|
|
throw new Error(`Updater target ${platform} does not reference a real release installer: ${item.url}`);
|
|
}
|
|
return name;
|
|
}
|
|
for (const [platform, names] of Object.entries(requiredPlatforms)) {
|
|
const name = installerFor(platform, manifest.platforms?.[platform]);
|
|
if (!names.includes(name)) throw new Error(`Wrong installer for updater target ${platform}: ${name}`);
|
|
}
|
|
for (const [platform, item] of Object.entries(manifest.platforms || {})) {
|
|
installerFor(platform, item);
|
|
}
|
|
console.log(`Validated ${requiredAssets.length} required assets and all signed updater targets on Draft ${tag}.`);
|
|
|
|
publish-release:
|
|
name: Publish validated draft release
|
|
needs:
|
|
- validate-release-assets
|
|
- stage-r2-release
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Publish the Agent draft without changing its metadata
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
expected_prerelease=false
|
|
[[ "$GITHUB_REF_NAME" != *-* ]] || expected_prerelease=true
|
|
gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
|
|
--json tagName,isDraft,isPrerelease,body \
|
|
| jq -e --arg tag "$GITHUB_REF_NAME" --argjson prerelease "$expected_prerelease" \
|
|
'.tagName == $tag and .isDraft == true and .isPrerelease == $prerelease and (.body // "" | test("\\S"))' > /dev/null
|
|
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
|
|
|
publish-beta-updater:
|
|
name: Publish beta updater manifest
|
|
needs: publish-release
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
steps:
|
|
- name: Upload latest.json to updater-beta release
|
|
uses: actions/github-script@v8
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
const tag = context.ref.replace('refs/tags/', '');
|
|
|
|
const { data: release } = await github.rest.repos.getReleaseByTag({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag,
|
|
});
|
|
|
|
const latestJsonAsset = release.assets.find(asset => asset.name === 'latest.json');
|
|
|
|
if (!latestJsonAsset) {
|
|
core.setFailed('No latest.json found on current release.');
|
|
return;
|
|
}
|
|
|
|
async function downloadAssetText(asset) {
|
|
const response = await fetch(asset.url, {
|
|
headers: {
|
|
Accept: 'application/octet-stream',
|
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'actions/github-script',
|
|
},
|
|
redirect: 'follow',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(
|
|
`Failed to download ${asset.name}: HTTP ${response.status} ${response.statusText} ${body.slice(0, 300)}`
|
|
);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
|
|
const content = await downloadAssetText(latestJsonAsset);
|
|
|
|
let betaRelease;
|
|
|
|
try {
|
|
const { data } = await github.rest.repos.getReleaseByTag({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag: 'updater-beta',
|
|
});
|
|
|
|
betaRelease = data;
|
|
} catch (error) {
|
|
if (error.status !== 404) {
|
|
throw error;
|
|
}
|
|
|
|
const { data } = await github.rest.repos.createRelease({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag_name: 'updater-beta',
|
|
name: 'Beta Updater Manifest',
|
|
body: 'This release hosts latest.json for the beta update channel. Do not delete.',
|
|
prerelease: true,
|
|
draft: false,
|
|
});
|
|
|
|
betaRelease = data;
|
|
}
|
|
|
|
const existing = betaRelease.assets.find(asset => asset.name === 'latest.json');
|
|
|
|
if (existing) {
|
|
console.log('Deleting existing updater-beta latest.json');
|
|
|
|
await github.rest.repos.deleteReleaseAsset({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
asset_id: existing.id,
|
|
});
|
|
}
|
|
|
|
await github.rest.repos.uploadReleaseAsset({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
release_id: betaRelease.id,
|
|
name: 'latest.json',
|
|
data: content,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'content-length': Buffer.byteLength(content),
|
|
},
|
|
});
|
|
|
|
console.log(`Uploaded latest.json to updater-beta release for ${tag}.`);
|
|
|
|
stage-r2-release:
|
|
name: Stage versioned release assets to Cloudflare R2
|
|
outputs:
|
|
manifest-sha256: ${{ steps.verify.outputs.manifest-sha256 }}
|
|
needs: validate-release-assets
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
env:
|
|
TAG: ${{ github.ref_name }}
|
|
R2_BUCKET: nyaterm
|
|
DOWNLOAD_BASE_URL: https://downloads.nyaterm.app
|
|
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
|
|
|
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
|
|
|
|
steps:
|
|
- name: Download GitHub release assets
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--json tagName,isDraft,body \
|
|
| jq -e --arg tag "$TAG" '.tagName == $tag and .isDraft == true and (.body // "" | test("\\S"))' > /dev/null
|
|
|
|
rm -rf release-assets
|
|
mkdir -p release-assets
|
|
|
|
gh release download "${TAG}" \
|
|
--repo "${GITHUB_REPOSITORY}" \
|
|
--dir release-assets \
|
|
--clobber
|
|
|
|
echo "Downloaded release assets:"
|
|
find release-assets -maxdepth 1 -type f -print
|
|
|
|
- name: Build R2 latest.json
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
node <<'NODE'
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const tag = process.env.GITHUB_REF_NAME;
|
|
const baseUrl = (process.env.DOWNLOAD_BASE_URL || '').replace(/\/$/, '');
|
|
|
|
const inputPath = 'release-assets/latest.json';
|
|
const outputPath = 'release-assets/r2-latest.json';
|
|
|
|
if (!fs.existsSync(inputPath)) {
|
|
throw new Error(`Missing ${inputPath}`);
|
|
}
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
|
|
|
|
if (!manifest.platforms || typeof manifest.platforms !== 'object') {
|
|
throw new Error('latest.json does not contain platforms');
|
|
}
|
|
|
|
function verifySignedUpdaterTargets(source) {
|
|
const requiredSignedPlatforms = [
|
|
'linux-x86_64-deb',
|
|
'linux-aarch64-deb',
|
|
'windows-x86_64',
|
|
'windows-aarch64',
|
|
'windows-x86_64-nsis',
|
|
'windows-aarch64-nsis',
|
|
'windows-x86_64-msi',
|
|
'windows-aarch64-msi',
|
|
'windows-x86_64-portable',
|
|
'windows-aarch64-portable',
|
|
];
|
|
|
|
for (const platform of requiredSignedPlatforms) {
|
|
const item = manifest.platforms?.[platform];
|
|
|
|
if (!item?.url || !item?.signature) {
|
|
throw new Error(`${source} is missing signed updater target: ${platform}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
verifySignedUpdaterTargets('latest.json');
|
|
|
|
for (const [platform, item] of Object.entries(manifest.platforms)) {
|
|
if (!item.url) {
|
|
throw new Error(`Missing url for platforms.${platform}`);
|
|
}
|
|
|
|
const oldUrl = new URL(item.url);
|
|
const filename = decodeURIComponent(oldUrl.pathname.split('/').pop());
|
|
|
|
const localFile = path.join('release-assets', filename);
|
|
|
|
if (!fs.existsSync(localFile)) {
|
|
throw new Error(`Installer referenced by latest.json not found: ${filename}`);
|
|
}
|
|
|
|
item.url = `${baseUrl}/releases/${tag}/${encodeURIComponent(filename)}`;
|
|
}
|
|
|
|
verifySignedUpdaterTargets('r2-latest.json');
|
|
|
|
fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
|
|
console.log(`Wrote ${outputPath}`);
|
|
console.log(`Version: ${manifest.version}`);
|
|
console.log(`Platforms: ${Object.keys(manifest.platforms).join(', ')}`);
|
|
NODE
|
|
|
|
- name: Upload installers to R2
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
|
|
for file_path in release-assets/*; do
|
|
file="$(basename "$file_path")"
|
|
|
|
case "$file" in
|
|
latest.json|r2-latest.json|*.sig)
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
content_type="application/octet-stream"
|
|
|
|
case "$file" in
|
|
*.dmg)
|
|
content_type="application/x-apple-diskimage"
|
|
;;
|
|
*.exe)
|
|
content_type="application/vnd.microsoft.portable-executable"
|
|
;;
|
|
*.msi)
|
|
content_type="application/x-msi"
|
|
;;
|
|
*.AppImage)
|
|
content_type="application/octet-stream"
|
|
;;
|
|
*.deb)
|
|
content_type="application/vnd.debian.binary-package"
|
|
;;
|
|
*.rpm)
|
|
content_type="application/x-rpm"
|
|
;;
|
|
*.tar.gz|*.gz)
|
|
content_type="application/gzip"
|
|
;;
|
|
*.zip)
|
|
content_type="application/zip"
|
|
;;
|
|
esac
|
|
|
|
echo "Uploading installer: $file"
|
|
|
|
aws s3 cp "$file_path" "s3://${R2_BUCKET}/releases/${GITHUB_REF_NAME}/${file}" \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--content-type "$content_type" \
|
|
--cache-control "public, max-age=31536000, immutable" \
|
|
--content-disposition "attachment; filename=\"${file}\"" \
|
|
--no-progress
|
|
done
|
|
|
|
- name: Upload versioned latest.json to R2
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
echo "Uploading versioned latest.json"
|
|
|
|
aws s3 cp release-assets/r2-latest.json "s3://${R2_BUCKET}/releases/${GITHUB_REF_NAME}/latest.json" \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--content-type "application/json; charset=utf-8" \
|
|
--cache-control "public, no-cache, must-revalidate" \
|
|
--no-progress
|
|
|
|
- name: Verify versioned R2 latest.json
|
|
id: verify
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
verify_manifest() {
|
|
local source="$1"
|
|
|
|
node -e "
|
|
const source = process.argv[1];
|
|
let input = '';
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
const json = JSON.parse(input);
|
|
const requiredSignedPlatforms = [
|
|
'darwin-x86_64',
|
|
'darwin-aarch64',
|
|
'linux-x86_64',
|
|
'linux-aarch64',
|
|
'linux-x86_64-deb',
|
|
'linux-aarch64-deb',
|
|
'windows-x86_64',
|
|
'windows-aarch64',
|
|
'windows-x86_64-nsis',
|
|
'windows-aarch64-nsis',
|
|
'windows-x86_64-msi',
|
|
'windows-aarch64-msi',
|
|
'windows-x86_64-portable',
|
|
'windows-aarch64-portable',
|
|
];
|
|
const updaterUrlPrefix = process.env.DOWNLOAD_BASE_URL +
|
|
'/releases/' + process.env.GITHUB_REF_NAME + '/';
|
|
|
|
if (json.version !== process.env.GITHUB_REF_NAME.replace(/^v/, '')) {
|
|
throw new Error(source + ' version does not match the tag');
|
|
}
|
|
for (const platform of requiredSignedPlatforms) {
|
|
const item = json.platforms?.[platform];
|
|
if (!item?.url || !item?.signature) {
|
|
throw new Error(
|
|
source + ' is missing a signed updater target: ' + platform
|
|
);
|
|
}
|
|
if (!item.url.startsWith(updaterUrlPrefix)) {
|
|
throw new Error(
|
|
source + ' updater target does not use the versioned R2 URL: ' + platform
|
|
);
|
|
}
|
|
}
|
|
for (const [platform, item] of Object.entries(json.platforms || {})) {
|
|
if (!item?.signature || !item?.url?.startsWith(updaterUrlPrefix) || new URL(item.url).pathname.endsWith('.sig')) {
|
|
throw new Error(source + ' has an invalid versioned updater target: ' + platform);
|
|
}
|
|
}
|
|
|
|
console.log(source + ' version:', json.version);
|
|
console.log('Platforms:', Object.keys(json.platforms || {}).join(', '));
|
|
});
|
|
" "$source"
|
|
}
|
|
|
|
aws s3 cp "s3://${R2_BUCKET}/releases/${GITHUB_REF_NAME}/latest.json" staged-latest.json \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--no-progress
|
|
verify_manifest "Versioned R2 latest.json" < staged-latest.json
|
|
cmp release-assets/r2-latest.json staged-latest.json
|
|
echo "manifest-sha256=$(sha256sum staged-latest.json | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
|
|
|
|
promote-r2:
|
|
name: Promote verified R2 updater manifest
|
|
needs:
|
|
- publish-release
|
|
- stage-r2-release
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
TAG: ${{ github.ref_name }}
|
|
R2_BUCKET: nyaterm
|
|
DOWNLOAD_BASE_URL: https://downloads.nyaterm.app
|
|
R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
|
|
|
|
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
|
|
|
|
steps:
|
|
- name: Promote the staged versioned manifest to the global pointer
|
|
shell: bash
|
|
env:
|
|
EXPECTED_SHA256: ${{ needs.stage-r2-release.outputs.manifest-sha256 }}
|
|
run: |
|
|
set -euo pipefail
|
|
aws s3 cp "s3://${R2_BUCKET}/releases/${TAG}/latest.json" promoted-latest.json \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--no-progress
|
|
echo "${EXPECTED_SHA256} promoted-latest.json" | sha256sum --check --status
|
|
aws s3 cp promoted-latest.json "s3://${R2_BUCKET}/latest.json" \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--content-type "application/json; charset=utf-8" \
|
|
--cache-control "public, max-age=60, must-revalidate" \
|
|
--no-progress
|
|
|
|
- name: Verify global R2 latest.json
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
verify_manifest() {
|
|
local source="$1"
|
|
|
|
node -e "
|
|
const source = process.argv[1];
|
|
let input = '';
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
const json = JSON.parse(input);
|
|
const requiredSignedPlatforms = [
|
|
'darwin-x86_64',
|
|
'darwin-aarch64',
|
|
'linux-x86_64',
|
|
'linux-aarch64',
|
|
'linux-x86_64-deb',
|
|
'linux-aarch64-deb',
|
|
'windows-x86_64',
|
|
'windows-aarch64',
|
|
'windows-x86_64-nsis',
|
|
'windows-aarch64-nsis',
|
|
'windows-x86_64-msi',
|
|
'windows-aarch64-msi',
|
|
'windows-x86_64-portable',
|
|
'windows-aarch64-portable',
|
|
];
|
|
const updaterUrlPrefix = process.env.DOWNLOAD_BASE_URL +
|
|
'/releases/' + process.env.GITHUB_REF_NAME + '/';
|
|
|
|
if (json.version !== process.env.GITHUB_REF_NAME.replace(/^v/, '')) {
|
|
throw new Error(source + ' version does not match the tag');
|
|
}
|
|
for (const platform of requiredSignedPlatforms) {
|
|
const item = json.platforms?.[platform];
|
|
if (!item?.url || !item?.signature) {
|
|
throw new Error(
|
|
source + ' is missing a signed updater target: ' + platform
|
|
);
|
|
}
|
|
if (!item.url.startsWith(updaterUrlPrefix)) {
|
|
throw new Error(
|
|
source + ' updater target does not use the versioned R2 URL: ' + platform
|
|
);
|
|
}
|
|
}
|
|
for (const [platform, item] of Object.entries(json.platforms || {})) {
|
|
if (!item?.signature || !item?.url?.startsWith(updaterUrlPrefix) || new URL(item.url).pathname.endsWith('.sig')) {
|
|
throw new Error(source + ' has an invalid versioned updater target: ' + platform);
|
|
}
|
|
}
|
|
|
|
console.log(source + ' version:', json.version);
|
|
console.log('Platforms:', Object.keys(json.platforms || {}).join(', '));
|
|
});
|
|
" "$source"
|
|
}
|
|
|
|
aws s3 cp "s3://${R2_BUCKET}/latest.json" verified-global-latest.json \
|
|
--endpoint-url "$R2_ENDPOINT" \
|
|
--no-progress
|
|
verify_manifest "R2 latest.json" < verified-global-latest.json
|
|
cmp promoted-latest.json verified-global-latest.json
|