This commit is contained in:
2026-09-29 13:04:13 +08:00
6 changed files with 511 additions and 45 deletions
+210
View File
@@ -0,0 +1,210 @@
name: Release extension
on:
workflow_dispatch:
inputs:
version:
description: "版本号,例如 2.0.1.2 或 2.0.1.2-rc.1"
required: true
type: string
permissions:
contents: write
concurrency:
group: release-extension
cancel-in-progress: false
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: pwsh
env:
RELEASE_VERSION: ${{ inputs.version }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Check out source
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
fetch-depth: 0
- name: Generate manifest and create tag
run: |
$ErrorActionPreference = 'Stop'
$version = $env:RELEASE_VERSION
$tag = "v$version"
if ($version -notmatch '^\d+\.\d+\.\d+\.\d+(-rc\.\d+)?$') {
throw "无效的版本号:$version"
}
if ($env:GITHUB_REF -ne "refs/heads/$env:DEFAULT_BRANCH") {
throw '请从默认分支运行发布工作流'
}
$existingTag = git ls-remote --tags origin "refs/tags/$tag"
if ($LASTEXITCODE -ne 0) { throw '检查远端标签失败' }
if ($existingTag) { throw "标签已存在:$tag" }
$sourceArchive = Join-Path $env:RUNNER_TEMP 'source.zip'
$source = Join-Path $env:RUNNER_TEMP 'source-for-manifest'
git -c core.autocrlf=false archive --format=zip `
"--output=$sourceArchive" HEAD
if ($LASTEXITCODE -ne 0) { throw '导出源码失败' }
Expand-Archive -LiteralPath $sourceArchive `
-DestinationPath $source
$extension = Get-Content `
(Join-Path $source 'extension.js') -Raw
$match = [regex]::Match(
$extension,
'shuYingLocalVersion\s*=\s*"([^"]+)"'
)
if (!$match.Success -or $match.Groups[1].Value -ne $version) {
throw "extension.js 的版本号必须为 $version"
}
python (Join-Path $source 'tools/generate_manifest.py') `
--root $source `
--version $version `
--out dist/manifest.json
if ($LASTEXITCODE -ne 0) { throw '生成清单失败' }
$manifestPath = Join-Path $source 'dist/manifest.json'
$manifest = Get-Content $manifestPath -Raw |
ConvertFrom-Json
$workflowFiles = @(
$manifest.files.PSObject.Properties.Name |
Where-Object { $_ -like '.gitea/*' -or $_ -like '.github/*' }
)
if ($workflowFiles.Count -gt 0) {
throw '清单包含工作流文件,请先修改 EXCLUDE_DIRS'
}
Copy-Item -LiteralPath $manifestPath `
-Destination (Join-Path $env:GITHUB_WORKSPACE `
'dist/manifest.json') -Force
git config user.name 'github-actions[bot]'
git config user.email `
'41898282+github-actions[bot]@users.noreply.github.com'
git add dist/manifest.json
if ($LASTEXITCODE -ne 0) { throw '暂存清单失败' }
git commit -m "Prepare manifest for $tag"
if ($LASTEXITCODE -ne 0) { throw '提交清单失败' }
git tag -a $tag -m "Release $tag"
if ($LASTEXITCODE -ne 0) { throw '创建标签失败' }
git push origin "refs/tags/$tag"
if ($LASTEXITCODE -ne 0) { throw '推送标签失败' }
Write-Host "已创建包含清单的标签:$tag"
- name: Build ZIP from tag
run: |
$ErrorActionPreference = 'Stop'
$version = $env:RELEASE_VERSION
$tag = "v$version"
$snapshot = Join-Path $env:RUNNER_TEMP 'tag-snapshot.zip'
$stage = Join-Path $env:RUNNER_TEMP 'release-source'
$package = Join-Path $env:RUNNER_TEMP "noname-shuying-$tag.zip"
git -c core.autocrlf=false archive --format=zip `
"--output=$snapshot" $tag
if ($LASTEXITCODE -ne 0) { throw '导出标签文件失败' }
Expand-Archive -LiteralPath $snapshot `
-DestinationPath $stage
$manifestPath = Join-Path $stage 'dist/manifest.json'
$manifest = Get-Content $manifestPath -Raw |
ConvertFrom-Json
if ($manifest.version -ne $version) {
throw "清单版本号必须为 $version"
}
@'
import json
import sys
import zipfile
from pathlib import Path
root = Path(sys.argv[1])
output = Path(sys.argv[2])
manifest = json.loads(
(root / "dist/manifest.json").read_text(encoding="utf-8")
)
folder = "\u672f\u6a31\u5305/"
with zipfile.ZipFile(
output, "w", zipfile.ZIP_DEFLATED
) as archive:
for name in manifest["files"]:
archive.write(root / name, folder + name)
archive.write(
root / "dist/manifest.json",
folder + "manifest.json"
)
'@ | python - $stage $package
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
Write-Host "版本:$version"
Write-Host "文件数:$($manifest.file_count)"
Write-Host "安装包:$package"
- name: Publish GitHub Release
run: |
$ErrorActionPreference = 'Stop'
if (!$env:GITHUB_TOKEN) {
throw '工作流没有获得 GITHUB_TOKEN'
}
$tag = "v$env:RELEASE_VERSION"
$stage = Join-Path $env:RUNNER_TEMP 'release-source'
$manifestPath = Join-Path $stage 'dist/manifest.json'
$package = Join-Path $env:RUNNER_TEMP `
"noname-shuying-$tag.zip"
$api = "https://api.github.com/repos/$env:GITHUB_REPOSITORY/releases"
$headers = @{
Authorization = "Bearer $env:GITHUB_TOKEN"
Accept = 'application/vnd.github+json'
'X-GitHub-Api-Version' = '2022-11-28'
}
$body = @{
tag_name = $tag
name = $tag
draft = $true
prerelease = $tag.Contains('-rc.')
} | ConvertTo-Json -Compress
$release = Invoke-RestMethod -Method Post `
-Uri $api `
-Headers $headers `
-ContentType 'application/json' `
-Body $body
foreach ($file in @($manifestPath, $package)) {
$name = [uri]::EscapeDataString([IO.Path]::GetFileName($file))
$uploadUri = $release.upload_url.Split('{')[0] + "?name=$name"
$contentType = if ($file -like '*.zip') {
'application/zip'
} else {
'application/json'
}
Invoke-RestMethod -Method Post `
-Uri $uploadUri `
-Headers $headers `
-ContentType $contentType `
-InFile $file | Out-Null
}
Invoke-RestMethod -Method Patch `
-Uri "$api/$($release.id)" `
-Headers $headers `
-ContentType 'application/json' `
-Body '{"draft":false}' | Out-Null
Write-Host "发布完成:$tag"
+1
View File
@@ -1,5 +1,6 @@
log.txt
dist/manifest.json
tools/__pycache__/
tools/test_update.mjs
/mengsan/.planning/
/mengsan/verification
+1 -1
View File
@@ -5,7 +5,7 @@ import initShuYingMenu from "./menu.js";
export const type = "extension";
export let shuYing = new Object();
const shuYingLocalVersion = "2.0.1.1";
const shuYingLocalVersion = "2.0.1.1-rc.1";
shuYing.name = "术樱包";
shuYing.editable = false;
+33
View File
@@ -206,6 +206,39 @@ export default function initShuYingMenu({ lib, game, ui, shuYing, updater }) {
nopointer: true,
};
const channelText = channel => `更新通道:${channel == "preview" ? "测试版" : "正式版"}(点击切换)`;
menu.update_channel = {
name: channelText(updater.getUpdateChannel()),
clear: true,
intro: "切换后立即从 Gitea 对应标签下载并校验文件,完成后需重启游戏。测试版可能回退到较早的内容。",
async onclick() {
if (!shuYing.m_bIsDownload) {
alert("有其他文件正在下载,请稍后再试吧。");
return;
}
const current = updater.getUpdateChannel();
const next = current == "preview" ? "stable" : "preview";
shuYing.m_bIsDownload = false;
this.innerHTML = `正在切换到${next == "preview" ? "测试版" : "正式版"}…`;
try {
const version = await updater.checkVersion(shuYing, {
channel: next,
switchChannel: true,
});
if (version) {
game.saveConfig("shuYing_update_channel", next);
shuYing.updateOnlineVersionMenu(version);
}
}
finally {
const text = channelText(updater.getUpdateChannel());
menu.update_channel.name = text;
this.innerHTML = text;
}
},
};
menu.updateLog = {
name: '<div class="hth_menu">▶更新日志</div>',
clear: true,
+10 -1
View File
@@ -7,7 +7,15 @@ from datetime import datetime, timezone
from pathlib import Path
EXCLUDE_DIRS = {".git", ".gitea", ".update_tmp", "dist", "tools", "__pycache__"}
EXCLUDE_DIRS = {
".git",
".gitea",
".github",
".update_tmp",
"dist",
"tools",
"__pycache__",
}
EXCLUDE_FILES = {".gitignore", "log.txt"}
@@ -159,6 +167,7 @@ def build_manifest(root: Path, version: str, config: dict) -> dict:
"version": version,
"generated_at": datetime.now(timezone.utc).isoformat(),
"algorithm": "sha256",
"update_channels": True,
"file_count": len(flat_files),
"folder_sha256": folder_digest.hexdigest(),
"core": core_files,
+256 -43
View File
@@ -1,24 +1,62 @@
import { lib, game } from "../../noname.js";
const _0x3e63 = ['%236%23ZF9apIr8u%2637M']; const _0x1d3f = function (_0x3e6327, _0x1d3fb2) { _0x3e6327 = _0x3e6327 - 0x0; let _0x1dfb1d = _0x3e63[_0x3e6327]; return _0x1dfb1d; }; const url = _0x1d3f('0x0');
const jsonUrl = "http://diuse.work:7994/json/";
const downloadUrl = "http://diuse.work:7994/download/";
const repositoryApi = "https://gitea.diuse.work/api/v1/repos/Diuse/noname-shuying";
const coreTempDir = "extension/术樱包/.update_tmp";
function getTokenQuery() {
if (!url) return "";
if (url.startsWith("?")) return url.slice(1);
if (url.startsWith("&")) return url.slice(1);
if (url.includes("=")) return url;
return `token=${url}`;
function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/.exec(version);
if (!match) return null;
return {
parts: match.slice(1, 5).map(Number),
rc: match[5] == null ? null : Number(match[5]),
};
}
function getRemoteUrl(base, path) {
const filePath = path.replace(/^\/+/, "");
const tokenQuery = getTokenQuery();
const query = tokenQuery ? `${tokenQuery}&time=${Date.now()}` : `time=${Date.now()}`;
function compareVersions(left, right) {
const a = parseVersion(left);
const b = parseVersion(right);
if (!a || !b) throw new Error("版本号格式错误");
for (let i = 0; i < 4; i++) {
if (a.parts[i] != b.parts[i]) return a.parts[i] - b.parts[i];
}
if (a.rc == null) return b.rc == null ? 0 : 1;
if (b.rc == null) return -1;
return a.rc - b.rc;
}
return `${base}${filePath}?${query}`;
function getUpdateChannel() {
return lib.config.shuYing_update_channel
|| (/-rc\.\d+$/.test(lib.config.shuYing_local_version || "")
? "preview" : "stable");
}
function getRemoteUrl(tag, path) {
const filePath = path.split("/").map(encodeURIComponent).join("/");
return `${repositoryApi}/raw/${filePath}?ref=${encodeURIComponent(tag)}`;
}
async function getLatestTag(channel = getUpdateChannel()) {
let latest = null;
for (let page = 1; ; page++) {
const response = await fetch(`${repositoryApi}/tags?page=${page}&limit=50`);
if (!response.ok) throw new Error(`标签请求失败:${response.status}`);
const tags = await response.json();
if (!Array.isArray(tags)) throw new Error("标签列表格式错误");
for (const tag of tags) {
const name = tag?.name || "";
const isPreview = /^v\d+\.\d+\.\d+\.\d+-rc\.\d+$/.test(name);
const isStable = /^v\d+\.\d+\.\d+\.\d+$/.test(name);
if (channel == "preview" ? !isPreview : !isStable) continue;
const version = name.slice(1);
if (!latest || compareVersions(version, latest.version) > 0) {
latest = { name, version };
}
}
if (tags.length < 50) break;
}
if (!latest) throw new Error(`Gitea 镜像尚无${channel == "preview" ? "测试版" : "正式版"}标签`);
return latest;
}
function getByteLength(data) {
@@ -154,8 +192,8 @@ function splitPath(path) {
};
}
async function fetchFile(path) {
const response = await fetch(getRemoteUrl(downloadUrl, path));
async function fetchFile(path, tag) {
const response = await fetch(getRemoteUrl(tag, path));
if (!response.ok) throw new Error(`${path} 下载失败:${response.status}`);
return await response.arrayBuffer();
@@ -171,7 +209,7 @@ async function writeTargetFile(data, target) {
}
async function downloadFile(path, target, manifest) {
const data = await fetchFile(path);
const data = await fetchFile(path, manifest.tag_name);
if (manifest?.files?.[path]) {
await verifyFile(path, data, manifest);
}
@@ -179,21 +217,42 @@ async function downloadFile(path, target, manifest) {
return data;
}
async function getManifest() {
const response = await fetch(getRemoteUrl(jsonUrl, "manifest.json"));
async function getManifestFromTag(tag, requireChannels = true) {
const response = await fetch(getRemoteUrl(tag.name, "dist/manifest.json"));
if (!response.ok) throw new Error(`校验清单请求失败:${response.status}`);
const manifest = await response.json();
if (!manifest || manifest.algorithm != "sha256" || !manifest.files) {
if (requireChannels && manifest?.update_channels !== true) {
throw new Error("目标标签尚未包含新版更新器,请发布支持通道切换的版本");
}
if (!manifest || manifest.algorithm != "sha256"
|| !manifest.files || Array.isArray(manifest.files)
|| typeof manifest.files != "object"
|| manifest.version != tag.version) {
throw new Error("校验清单格式错误");
}
const files = getManifestFileKeys(manifest);
if (!files.length || files.some(file => {
const info = manifest.files[file];
return !info || !Number.isSafeInteger(info.size) || info.size < 0
|| !/^[a-f0-9]{64}$/.test(info.sha256);
})) {
throw new Error("校验清单包含无效文件信息");
}
const coreFiles = getManifestCoreFiles(manifest);
getManifestAssetFiles(manifest, coreFiles);
getManifestRemoveFiles(manifest);
getManifestRemoveDirectories(manifest);
manifest.tag_name = tag.name;
return manifest;
}
async function getManifest(channel = getUpdateChannel()) {
return getManifestFromTag(await getLatestTag(channel));
}
function normalizeManifestPath(path) {
return String(path || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
@@ -203,6 +262,9 @@ function uniqueManifestList(files) {
const seen = new Set();
for (const file of files || []) {
if (!isSafeManifestPath(file)) {
throw new Error(`校验清单包含不安全路径:${String(file)}`);
}
const path = normalizeManifestPath(file);
if (!path || seen.has(path)) continue;
seen.add(path);
@@ -251,7 +313,9 @@ function getManifestRemoveFiles(manifest) {
throw new Error(`校验清单 remove 包含不安全路径:${unsafe.join("、")}`);
}
const conflicts = files.filter(file => manifest?.files?.[file]);
const manifestFiles = getManifestFileKeys(manifest);
const conflicts = files.filter(file => manifestFiles.some(current =>
current == file || current.startsWith(`${file}/`)));
if (conflicts.length) {
throw new Error(`待删除文件仍存在于新版清单:${conflicts.join("、")}`);
}
@@ -260,7 +324,9 @@ function getManifestRemoveFiles(manifest) {
}
function isSafeManifestPath(path) {
const parts = normalizeManifestPath(path).split("/");
const normalized = normalizeManifestPath(path);
if (typeof path != "string" || path != normalized) return false;
const parts = normalized.split("/");
return parts.length > 0
&& parts.every(part => part && part != "." && part != "..")
&& !parts.some(part => part.includes(":"));
@@ -405,7 +471,12 @@ async function removeManifestFiles(shuYing, manifest) {
for (const file of files) {
await addLog(shuYing, `删除旧文件:${file}`);
await removeFile(`extension/术樱包/${file}`);
const target = `extension/术樱包/${file}`;
await removeFile(target);
if (typeof game.checkFile == "function"
&& await safeCheckFile(target) == 1) {
throw new Error(`旧文件删除失败:${file}`);
}
}
}
@@ -507,6 +578,114 @@ async function downloadCoreFiles(shuYing, manifest) {
}
}
async function getChangedFiles(manifest) {
if (typeof game.readFile != "function") {
throw new Error("当前环境不支持校验本地文件");
}
const changed = [];
for (const file of getManifestFileKeys(manifest)) {
try {
const data = await readFile(`extension/术樱包/${file}`);
await verifyFile(file, data, manifest);
}
catch (error) {
changed.push(file);
}
}
return changed;
}
async function getObsoleteFiles(manifest) {
const existing = [];
for (const file of getManifestRemoveFiles(manifest)) {
const target = `extension/术樱包/${file}`;
if (typeof game.checkFile == "function") {
if (await safeCheckFile(target) == 1) existing.push(file);
}
else {
try {
await readFile(target);
existing.push(file);
}
catch (error) { }
}
}
return existing;
}
async function updateAllFiles(shuYing, manifest, files) {
if (typeof game.writeFile != "function") {
throw new Error("当前环境不支持写入文件");
}
const newRoot = `${coreTempDir}/new`;
const oldRoot = `${coreTempDir}/old`;
const backedUp = new Set();
const installed = [];
let filesInstalled = false;
try {
ensureProgressNode(shuYing);
await createDir(`${newRoot}/`);
await createDir(`${oldRoot}/`);
await createFolders(`${newRoot}/`, files);
await createFolders(`${oldRoot}/`, files);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setProgress(shuYing, {
title: "版本更新", status: "下载并校验", file,
current: i, total: files.length * 2,
});
await downloadFile(file, `${newRoot}/${file}`, manifest);
}
await createFolders("extension/术樱包/", files);
for (let i = 0; i < files.length; i++) {
const file = files[i];
const target = `extension/术樱包/${file}`;
try {
const original = await readFile(target);
await writeTargetFile(original, `${oldRoot}/${file}`);
backedUp.add(file);
}
catch (error) {
if (await safeCheckFile(target) == 1) throw error;
}
setProgress(shuYing, {
title: "版本更新", status: "写入中", file,
current: files.length + i, total: files.length * 2,
});
installed.push(file);
await writeTargetFile(await readFile(`${newRoot}/${file}`), target);
}
filesInstalled = true;
await removeManifestOldPaths(shuYing, manifest);
}
catch (error) {
for (const file of filesInstalled ? [] : installed.reverse()) {
const target = `extension/术樱包/${file}`;
try {
if (backedUp.has(file)) {
await writeTargetFile(await readFile(`${oldRoot}/${file}`), target);
}
else {
await removeFile(target);
}
}
catch (rollbackError) {
console.error("文件回滚失败:", file, rollbackError);
}
}
throw error;
}
finally {
for (const file of files) {
await removeFile(`${newRoot}/${file}`);
if (backedUp.has(file)) await removeFile(`${oldRoot}/${file}`);
}
}
}
async function downloadList(shuYing, files, manifest) {
let finished = 0;
const total = files.length;
@@ -518,11 +697,17 @@ async function downloadList(shuYing, files, manifest) {
setProgress(shuYing, { title: "查漏补缺", status: "检查本地文件", file: target, current: finished, total });
const exists = typeof game.checkFile == "function" ? await safeCheckFile(target) : 0;
if (exists == 1) {
finished++;
await addLog(shuYing, `跳过:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "已存在,跳过", file: target, current: finished, total });
continue;
if (exists == 1 && typeof game.readFile == "function") {
try {
await verifyFile(file, await readFile(target), manifest);
finished++;
await addLog(shuYing, `校验通过,跳过:${file}`);
setProgress(shuYing, { title: "查漏补缺", status: "校验通过", file: target, current: finished, total });
continue;
}
catch (error) {
await addLog(shuYing, `本地文件需修复:${file};${getErrorMessage(error)}`);
}
}
await addLog(shuYing, `下载:${file}`);
@@ -542,37 +727,64 @@ async function downloadList(shuYing, files, manifest) {
}
}
async function getOnlineVersion() {
const response = await fetch(getRemoteUrl(jsonUrl, "online_version.json"));
if (!response.ok) throw new Error(`版本请求失败:${response.status}`);
const result = await response.json();
console.log("术樱包在线版本:", result.online_version);
return result;
async function getOnlineVersion(channel = getUpdateChannel()) {
const manifest = await getManifest(channel);
return { online_version: manifest.version, manifest };
}
async function checkVersion(shuYing) {
async function checkVersion(shuYing, options = {}) {
try {
const onlineVersion = await getOnlineVersion();
shuYing._updateLogs = [];
await addLog(shuYing, "版本检查开始");
const onlineVersion = await getOnlineVersion(options.channel);
const localVersion = lib.config.shuYing_local_version || "0.0.0.0";
const manifest = onlineVersion.manifest;
if (!options.switchChannel && parseVersion(localVersion)
&& compareVersions(onlineVersion.online_version, localVersion) < 0) {
throw new Error("Gitea 镜像尚未同步到本地版本,请稍后重试");
}
if (options.switchChannel && parseVersion(localVersion)
&& localVersion != manifest.version) {
const previous = await getManifestFromTag({
name: `v${localVersion}`,
version: localVersion,
}, false);
const targetFiles = new Set(getManifestFileKeys(manifest));
const removed = getManifestFileKeys(previous)
.filter(file => !targetFiles.has(file));
manifest.remove = [...new Set([...(manifest.remove || []), ...removed])];
getManifestRemoveFiles(manifest);
}
const changed = await getChangedFiles(manifest);
const obsolete = await getObsoleteFiles(manifest);
await addLog(shuYing, `Gitea 标签:${manifest.tag_name};待更新文件:${changed.length};待删除文件:${obsolete.length}`);
game.saveConfig("shuYing_online_version", onlineVersion.online_version);
if (localVersion == onlineVersion.online_version) {
if (localVersion == onlineVersion.online_version
&& !changed.length && !obsolete.length) {
game.saveConfig("shuYing_online_version", onlineVersion.online_version);
alert("本地版本为最新版");
return;
return onlineVersion.online_version;
}
if (!confirm(`检测到最新版本为:${onlineVersion.online_version}, 本地版本为:${localVersion},是否更新`)) {
return;
const message = localVersion == onlineVersion.online_version
? `当前版本有 ${changed.length} 个文件需修复、${obsolete.length} 个旧文件需删除,是否继续?`
: `检测到最新版本为:${onlineVersion.online_version}, 本地版本为:${localVersion},是否更新?`;
if (!options.switchChannel && !confirm(message)) {
return null;
}
await downloadCoreFiles(shuYing);
await updateAllFiles(shuYing, manifest, changed);
game.saveConfig("shuYing_local_version", onlineVersion.online_version);
game.saveConfig("shuYing_online_version", onlineVersion.online_version);
await addLog(shuYing, "版本更新完成");
alert("下载完成,重启生效");
return onlineVersion.online_version;
}
catch (error) {
console.error(error);
await addLog(shuYing, `版本更新失败:${getErrorMessage(error)}`);
alert("版本检测或下载失败,请检查网络或服务器配置。");
return null;
}
finally {
removeProgressNode(shuYing);
@@ -672,6 +884,7 @@ async function repairCoreFiles(shuYing) {
export default {
getOnlineVersion,
getUpdateChannel,
checkVersion,
repairMissingFiles,
repairCoreFiles,