Files

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