901 lines
30 KiB
YAML
901 lines
30 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:
|
|
build:
|
|
name: Build ${{ matrix.target }}
|
|
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
|
|
|
|
- platform: windows-latest
|
|
target: aarch64-pc-windows-msvc
|
|
args: --target aarch64-pc-windows-msvc --bundles nsis
|
|
|
|
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: Generate release notes
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
id: release_notes
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
VERSION="${GITHUB_REF_NAME#v}"
|
|
NOTES=""
|
|
|
|
if [ -f CHANGELOG.md ]; then
|
|
NOTES=$(awk -v version="$VERSION" '
|
|
BEGIN { found = 0 }
|
|
/^## / {
|
|
if (found == 1) exit
|
|
if ($0 ~ version) found = 1
|
|
}
|
|
found == 1 { print }
|
|
' CHANGELOG.md)
|
|
fi
|
|
|
|
if [ -z "$NOTES" ]; then
|
|
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p' || true)
|
|
|
|
if [ -n "$PREV_TAG" ]; then
|
|
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
|
|
-f tag_name="${GITHUB_REF_NAME}" \
|
|
-f previous_tag_name="$PREV_TAG" \
|
|
--jq '.body' 2>/dev/null || true)
|
|
else
|
|
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
|
|
-f tag_name="${GITHUB_REF_NAME}" \
|
|
--jq '.body' 2>/dev/null || true)
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$NOTES" ]; then
|
|
NOTES="See the assets below to download the installer for your platform."
|
|
fi
|
|
|
|
{
|
|
echo "body<<EOF"
|
|
echo "$NOTES"
|
|
echo "EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- 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 keyB64 = process.env.TAURI_SIGNING_PRIVATE_KEY_B64 || '';
|
|
const runnerTemp = process.env.RUNNER_TEMP || os.tmpdir();
|
|
const keyPath = path.join(runnerTemp, 'tauri-updater.key');
|
|
|
|
fs.writeFileSync(keyPath, Buffer.from(keyB64, 'base64'));
|
|
fs.appendFileSync(process.env.GITHUB_ENV, `TAURI_SIGNING_PRIVATE_KEY=${keyPath}\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 }}
|
|
releaseName: NyaTerm ${{ github.ref_name }}
|
|
releaseBody: ${{ steps.release_notes.outputs.body }}
|
|
releaseDraft: false
|
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
|
includeUpdaterJson: false
|
|
args: ${{ matrix.args }}
|
|
|
|
- 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"
|
|
}
|
|
|
|
$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")
|
|
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
|
|
pnpm tauri signer sign --private-key-path "$env:TAURI_SIGNING_PRIVATE_KEY" "$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
|
|
|
|
rename-assets:
|
|
name: Rename release assets
|
|
needs: build
|
|
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 { data: release } = await github.rest.repos.getReleaseByTag({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag,
|
|
});
|
|
|
|
const assets = release.assets || [];
|
|
|
|
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();
|
|
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`;
|
|
}
|
|
|
|
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 { data: release } = await github.rest.repos.getReleaseByTag({
|
|
owner,
|
|
repo,
|
|
tag,
|
|
});
|
|
|
|
const assets = release.assets || [];
|
|
|
|
function mapSigToPlatforms(name) {
|
|
const baseName = name.replace(/\.sig$/, '');
|
|
const lower = name.toLowerCase();
|
|
const keys = [];
|
|
|
|
if (lower.endsWith('.app.tar.gz.sig')) {
|
|
if (lower.includes('macos_x64') || lower.includes('x64') || lower.includes('x86_64')) {
|
|
keys.push('darwin-x86_64');
|
|
} else if (lower.includes('macos_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
|
keys.push('darwin-aarch64');
|
|
}
|
|
} else if (lower.endsWith('.appimage.sig')) {
|
|
if (lower.includes('linux_x64') || lower.includes('x64') || lower.includes('x86_64') || lower.includes('amd64')) {
|
|
keys.push('linux-x86_64');
|
|
} else if (lower.includes('linux_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
|
keys.push('linux-aarch64');
|
|
}
|
|
} else if (lower.endsWith('_portable.zip.sig')) {
|
|
if (lower.includes('windows_x64') || lower.includes('x64') || lower.includes('x86_64') || lower.includes('amd64')) {
|
|
keys.push('windows-x86_64-portable');
|
|
} else if (lower.includes('windows_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
|
keys.push('windows-aarch64-portable');
|
|
}
|
|
} else if (lower.endsWith('-setup.exe.sig') || lower.endsWith('-setup.nsis.zip.sig')) {
|
|
if (lower.includes('windows_x64') || lower.includes('x64') || lower.includes('x86_64') || lower.includes('amd64')) {
|
|
keys.push('windows-x86_64');
|
|
} else if (lower.includes('windows_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
|
keys.push('windows-aarch64');
|
|
}
|
|
}
|
|
|
|
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 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 { data: release } = await github.rest.repos.getReleaseByTag({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
tag,
|
|
});
|
|
|
|
const sigAssets = release.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).`);
|
|
|
|
publish-beta-updater:
|
|
name: Publish beta updater manifest
|
|
needs: cleanup-sigs
|
|
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}.`);
|
|
|
|
publish-r2:
|
|
name: Publish release assets to Cloudflare R2
|
|
needs: publish-beta-updater
|
|
runs-on: ubuntu-latest
|
|
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
|
|
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: Download GitHub release assets
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
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');
|
|
}
|
|
|
|
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)}`;
|
|
}
|
|
|
|
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"
|
|
;;
|
|
*.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 latest.json to R2
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
echo "Uploading latest.json"
|
|
|
|
aws s3 cp release-assets/r2-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
|
|
|
|
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, max-age=31536000, immutable" \
|
|
--no-progress
|
|
|
|
- name: Verify R2 latest.json
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
curl -fsSL "${DOWNLOAD_BASE_URL}/latest.json" | node -e "
|
|
let input = '';
|
|
process.stdin.on('data', chunk => input += chunk);
|
|
process.stdin.on('end', () => {
|
|
const json = JSON.parse(input);
|
|
const requiredPortablePlatforms = [
|
|
'windows-x86_64-portable',
|
|
'windows-aarch64-portable',
|
|
];
|
|
|
|
for (const platform of requiredPortablePlatforms) {
|
|
const item = json.platforms?.[platform];
|
|
if (!item?.url || !item?.signature) {
|
|
throw new Error(
|
|
'R2 latest.json is missing a signed portable target: ' + platform
|
|
);
|
|
}
|
|
const portableUrlPrefix = process.env.DOWNLOAD_BASE_URL +
|
|
'/releases/' + process.env.GITHUB_REF_NAME + '/';
|
|
if (!item.url.startsWith(portableUrlPrefix)) {
|
|
throw new Error(
|
|
'R2 latest.json portable target does not use the versioned R2 URL: ' + platform
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log('R2 latest.json version:', json.version);
|
|
console.log('Platforms:', Object.keys(json.platforms || {}).join(', '));
|
|
});
|
|
"
|