Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/maaend-delta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: 生成 MaaEnd 增量包并上传下载站

on:
workflow_dispatch:
inputs:
base_tag:
description: 起始官方版本(如 v2.31.0)
required: true
type: string
target_tag:
description: 目标官方版本(如 v3.0.0)
required: true
type: string
arch:
description: 安装架构
type: choice
default: x64
options: [x64, arm64]
channel:
description: 下载站发布通道
type: choice
default: stable
options: [stable, beta]

permissions:
contents: read

concurrency:
group: maaend-delta-${{ inputs.arch }}-${{ inputs.channel }}
cancel-in-progress: false

jobs:
publish:
if: github.repository == 'AUTO-MAS-Project/AUTO-MAS-Copilot-Data' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: 下载官方包并生成、校验增量
env:
BASE_TAG: ${{ inputs.base_tag }}
TARGET_TAG: ${{ inputs.target_tag }}
ARCH: ${{ inputs.arch == 'x64' && 'x86_64' || 'aarch64' }}
GITHUB_TOKEN: ${{ github.token }}
PYTHONPATH: src
run: >-
python -m copilot_data.maaend_delta --base-tag "$BASE_TAG"
--target-tag "$TARGET_TAG" --arch "$ARCH" --output .delta-output
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: maaend-${{ inputs.arch }}-${{ inputs.channel }}-delta
path: .delta-output/maaend.delta.zip
if-no-files-found: error
retention-days: 7
- name: 安装发布工具依赖
run: python -m pip install httpx==0.28.1
- name: 上传独立增量文件并回读校验
env:
AUTOMAS_TOKEN: ${{ secrets.AUTOMAS_MAAEND_DELTA_TOKEN }}
AUTOMAS_FILE_ID: ${{ vars[format('MAAEND_DELTA_{0}_{1}_FILE_ID', inputs.arch, inputs.channel)] }}
AUTOMAS_PUBLIC_URL: https://data.auto-mas.top/api/v1/files/auto-mas/Client/maaend-${{ inputs.arch }}-${{ inputs.channel }}-delta/download
PYTHONPATH: src
run: >-
python -c "import asyncio; from pathlib import Path;
from copilot_data.publish import publish;
asyncio.run(publish(Path('.delta-output/maaend.delta.zip')))"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.venv/
.dev/
.delta-output/
__pycache__/
.ruff_cache/
.pytest_cache/
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,37 @@ uv run --no-sync python -m copilot_data build
```

许可证:AGPL-3.0-or-later。作业内容及其权利归原作者所有。

## MaaEnd 增量包

配合 AUTO-MAS 主程序 PR #1949,独立工作流 **生成 MaaEnd 增量包并上传下载站**
在本仓库的 `dev` 手动运行。输入起始和目标官方标签、架构(`x64` / `arm64`)以及通道
(`stable` / `beta`)。它下载两版官方 Windows ZIP,分别核对 GitHub Release 公布的
大小及 SHA256,复用旧 ZIP 中未变的压缩字节,生成增量并在发布前重建验证。
不运行 MaaEnd,不解释其资源、任务或上游私有增量清单。

增量产物先保留为 7 天的 Actions artifact,再复用本仓库 `publish.py` 上传与公开回读校验。
生成失败、增量不小于全量、超过当前发布工具的 32 MiB 上限、凭据缺失或未发布时,
工作流报错,不替换已发布文件;客户端遇到基线或目标不匹配就回退官方全量包。
重新执行同样输入可重试生成与上传。

在下载站 AUTO-MAS → Client 创建独立文件,键为
`maaend-{arch}-{channel}-delta`。例如 x64 稳定版公开地址必须是
`https://data.auto-mas.top/api/v1/files/auto-mas/Client/maaend-x64-stable-delta/download`。
不要使用作业或养成快照的文件 ID 和密钥。

| 类型 | 名称 | 含义 |
| --- | --- | --- |
| Secret | `AUTOMAS_MAAEND_DELTA_TOKEN` | 仅授权 MaaEnd 增量文件上传与自动发布的 Access Key |
| Variable | `MAAEND_DELTA_X64_STABLE_FILE_ID` | x64 稳定版文件数字 ID |
| Variable | `MAAEND_DELTA_ARM64_STABLE_FILE_ID` | arm64 稳定版文件数字 ID |
| Variable | `MAAEND_DELTA_X64_BETA_FILE_ID` | x64 公测版文件数字 ID |
| Variable | `MAAEND_DELTA_ARM64_BETA_FILE_ID` | arm64 公测版文件数字 ID |

每个目标只发布最近一次指定的版本对。选择增量更新后,客户端保留上次经 MAS 成功安装、官方 SHA256
校验通过的原始 ZIP;首次无缓存或落后于已发布版本对时使用全量,并建立新缓存。
客户端从 GitHub 独立读取目标整包大小与 SHA256,站点清单不能改写校验依据。
完整 ZIP 重建校验后仍交给 MAS 现有的可恢复安装器,包含原有用户文件保护和安装回执。

字节协议为 v1,`zip_delta.py` 与主程序同名模块共同维护;修改协议时必须同时验证
生成端与客户端,不能只用生成端自己的重建结果证明客户端兼容。
78 changes: 78 additions & 0 deletions src/copilot_data/maaend_delta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""下载官方两版 ZIP、生成字节增量并独立重建验证;不运行 MaaEnd。"""

import argparse
import hashlib
import json
import os
import re
import threading
from pathlib import Path
from urllib.parse import quote
from urllib.request import Request, urlopen


def download_release(tag: str, arch: str, destination: Path) -> tuple[int, str]:
if not re.fullmatch(r"v?\d+\.\d+\.\d+(?:-(?:beta|rc)\.\d+)?", tag):
raise ValueError("只接受正式版或 beta 官方版本标签")
headers = {"Accept": "application/vnd.github+json", "User-Agent": "AUTO-MAS-delta"}
if token := os.environ.get("GITHUB_TOKEN"):
headers["Authorization"] = f"Bearer {token}"
url = f"https://api.github.com/repos/MaaEnd/MaaEnd/releases/tags/{quote(tag, safe='')}"
with urlopen(Request(url, headers=headers), timeout=30) as response:
release = json.load(response)
asset = next(
item
for item in release["assets"]
if item["name"] == f"MaaEnd-win-{arch}-{tag}.zip"
)
size, digest = asset["size"], str(asset.get("digest", ""))
if not 0 < size < 1024**3 or not re.fullmatch(r"sha256:[a-f0-9]{64}", digest):
raise ValueError("官方发行包缺少有效大小或 SHA256")
if not asset["browser_download_url"].startswith(
"https://github.com/MaaEnd/MaaEnd/releases/download/"
):
raise ValueError("发行包下载地址无效")
checksum, copied = hashlib.sha256(), 0
with urlopen(asset["browser_download_url"], timeout=120) as response:
with destination.open("xb") as output:
while chunk := response.read(1024 * 1024):
copied += len(chunk)
if copied > size:
raise ValueError("发行包下载超出官方大小")
checksum.update(chunk)
output.write(chunk)
if copied != size or checksum.hexdigest() != digest[7:]:
raise ValueError("发行包未通过官方 SHA256 校验")
return size, digest[7:]


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-tag", required=True)
parser.add_argument("--target-tag", required=True)
parser.add_argument("--arch", choices=["x86_64", "aarch64"], required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.base_tag == args.target_tag:
raise ValueError("起始和目标版本不能相同")
from .zip_delta import apply_patch, create_patch

args.output.mkdir(parents=True, exist_ok=True)
baseline, target, rebuilt = (
args.output / name for name in ("base.zip", "target.zip", "rebuilt.zip")
)
download_release(args.base_tag, args.arch, baseline)
size, digest = download_release(args.target_tag, args.arch, target)
patch = args.output / "maaend.delta.zip"
create_patch(baseline, target, patch)
apply_patch(baseline, patch, rebuilt, size, digest, threading.Event())
print(
f"{args.base_tag} -> {args.target_tag} ({args.arch}): "
f"增量 {patch.stat().st_size} / 全量 {size} 字节,重建 SHA256 校验通过"
)
for path in (baseline, target, rebuilt):
path.unlink()


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion src/copilot_data/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def upload(
"--config",
"-",
"--form",
f"file=@{source};type=application/gzip",
f"file=@{source};type={'application/zip' if filename.endswith('.zip') else 'application/gzip'}",
"--form-string",
f"change_note=GitHub Actions run {os.environ.get('GITHUB_RUN_ID', 'manual')}",
"--cookie",
Expand Down
156 changes: 156 additions & 0 deletions src/copilot_data/zip_delta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""从旧 ZIP 复用压缩数据,重建远端 ZIP 的原始字节;调用方必须校验整包摘要。"""

import hashlib
import json
import struct
import threading
import zipfile
from pathlib import Path
from typing import BinaryIO

MAX_PATCH_BYTES = 32 * 1024**2


def file_digest(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()


def create_patch(baseline: Path, target: Path, patch: Path) -> None:
"""生成字节重建包,不包含上游私有增量清单或安装语义。"""
operations = []
with baseline.open("rb") as old, target.open("rb") as new:
with zipfile.ZipFile(old) as old_zip, zipfile.ZipFile(new) as new_zip:
previous = {item.filename: item for item in old_zip.infolist()}
cursor = 0
literals = []
for item in sorted(new_zip.infolist(), key=lambda item: item.header_offset):
candidate = previous.get(item.filename)
start = _data_offset(new, item)
if start < cursor:
raise ValueError("ZIP 区间冲突")
if start > cursor:
literals.append((cursor, start - cursor))
reusable = False
if (
candidate is not None
and candidate.compress_size == item.compress_size
):
old_start = _data_offset(old, candidate)
old.seek(old_start)
new.seek(start)
remaining = item.compress_size
reusable = True
while remaining:
length = min(remaining, 1024 * 1024)
if old.read(length) != new.read(length):
reusable = False
break
remaining -= length
if reusable and item.compress_size:
for offset, length in literals:
operations.append(["new", offset, length])
literals.clear()
operations.append(["old", old_start, item.compress_size])
elif item.compress_size:
literals.append((start, item.compress_size))
cursor = start + item.compress_size
literals.append((cursor, target.stat().st_size - cursor))
operations.extend(
["new", offset, length] for offset, length in literals if length
)
manifest = {
"version": 1,
"base_sha256": file_digest(baseline),
"target_sha256": file_digest(target),
"target_size": target.stat().st_size,
"operations": [],
}
with zipfile.ZipFile(patch, "w", compression=zipfile.ZIP_DEFLATED) as output:
with output.open("payload", "w", force_zip64=True) as payload:
position = 0
for kind, offset, length in operations:
if kind == "new":
new.seek(offset)
remaining = length
while remaining:
chunk = new.read(min(remaining, 1024 * 1024))
if not chunk:
raise ValueError("ZIP 数据不完整")
payload.write(chunk)
remaining -= len(chunk)
offset = position
position += length
manifest["operations"].append([kind, offset, length])
info = zipfile.ZipInfo("manifest.json")
info.compress_type = zipfile.ZIP_DEFLATED
output.writestr(info, json.dumps(manifest))
if patch.stat().st_size >= min(MAX_PATCH_BYTES, target.stat().st_size):
patch.unlink()
raise ValueError("增量包未节省流量或超过发布限制,使用全量")


def apply_patch(
baseline: Path,
patch: Path,
destination: Path,
size: int,
digest: str,
cancelled: threading.Event,
) -> None:
"""重建归档,独立官方大小与 SHA256 校验通过才允许调用方安装。"""
if patch.stat().st_size > MAX_PATCH_BYTES:
raise ValueError("增量包过大")
with zipfile.ZipFile(patch) as archive:
info = archive.getinfo("manifest.json")
if info.file_size > 16 * 1024**2:
raise ValueError("增量清单过大")
metadata = json.loads(archive.read(info))
if (
metadata.get("version") != 1
or metadata.get("target_size") != size
or metadata.get("target_sha256") != digest
or metadata.get("base_sha256") != file_digest(baseline)
):
raise ValueError("增量包与官方版本或本地基线不一致")
operations = metadata["operations"]
if len(operations) > 300_000 or archive.getinfo("payload").file_size > size:
raise ValueError("增量包展开超限")
with baseline.open("rb") as old, archive.open("payload") as payload:
with destination.open("xb") as output:
written = 0
for kind, offset, length in operations:
if (
kind not in {"old", "new"}
or not isinstance(offset, int)
or not isinstance(length, int)
or offset < 0
or length < 0
or written + length > size
):
raise ValueError("增量重建区间无效")
source = old if kind == "old" else payload
if kind == "new" and offset != payload.tell():
raise ValueError("增量数据顺序无效")
source.seek(offset)
remaining = length
while remaining:
if cancelled.is_set():
raise InterruptedError("增量重建已取消")
chunk = source.read(min(remaining, 1024 * 1024))
if not chunk:
raise ValueError("增量数据不完整")
output.write(chunk)
remaining -= len(chunk)
written += length
if destination.stat().st_size != size or file_digest(destination) != digest:
raise ValueError("重建包未通过官方 SHA256 校验")


def _data_offset(stream: BinaryIO, item: zipfile.ZipInfo) -> int:
stream.seek(item.header_offset)
header = stream.read(30)
if len(header) != 30 or header[:4] != b"PK\x03\x04":
raise ValueError("ZIP 文件头无效")
name_size, extra_size = struct.unpack_from("<HH", header, 26)
return item.header_offset + 30 + name_size + extra_size
Loading
Loading