十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于GitHub Actions与阿里云OSS构建应用自动化更新系统

基于GitHub Actions与阿里云OSS构建应用自动化更新系统 1. 项目缘起从手动部署到自动化更新的痛点做项目开发尤其是涉及到客户端或者需要频繁更新配置、脚本的场景最头疼的事情之一就是版本分发和更新。我最近在折腾一个内部使用的桌面工具它依赖一个本地的规则库文件。每次规则有调整我都得在服务器上打包新版本然后在用户群里发公告让用户手动下载覆盖。这个过程不仅效率低下还经常因为用户忘记更新或者操作失误导致反馈的问题其实是旧版本导致的排查起来非常浪费时间。我理想中的状态是类似一些知名软件比如 VSCode 的插件市场或者一些游戏的资源热更新那样客户端能自动检测并拉取最新版本实现静默更新。这让我想起了 OpenAI 的 Codex 模型虽然它是个AI模型但其背后的服务端能力——能够持续迭代并让客户端无缝使用最新版本——正是我想要的更新机制的精髓。当然我们不可能去复刻 Codex但可以借鉴其“中心发布终端同步”的核心思想。我的技术栈里代码托管自然在 GitHub而静态文件存储和分发国内环境下阿里云 OSS 的稳定性和速度是经过验证的。于是一个清晰的方案浮出水面利用 GitHub Actions 实现构建和发布的自动化流水线将产物推送到阿里云 OSS客户端则定时或启动时检查 OSS 上特定标识文件如版本号、MD5的变化进而决定是否下载更新。这个方案完全基于云服务无需自建复杂的更新服务器成本可控实现起来也相对直接。下面我就把整个设计思路、实现细节以及踩过的坑完整地分享出来。2. 核心架构设计GitHub Actions 与 OSS 的联动整个自动更新系统的核心在于构建发布流程的自动化以及客户端更新逻辑的可靠性。我们将其拆解为两个主要部分服务端的自动发布流水线以及客户端的更新检测与拉取机制。2.1 服务端流水线GitHub Actions 驱动 OSS 发布我们的目标是每当向 GitHub 仓库的主分支或特定标签推送代码时自动完成构建、打包并将最终需要分发给用户的文件上传到阿里云 OSS 的指定目录。这里GitHub Actions 是自动化引擎阿里云 OSS 是存储和分发中心。为什么选择 GitHub Actions首先它和 GitHub 仓库原生集成无需额外配置 Webhook 等复杂设置。其次它提供了免费的额度对于个人项目或小型团队完全够用。最后其社区生态丰富有大量现成的 Action 可供使用比如配置阿里云 OSS 上传就有官方维护的aliyun-oss-website-action。OSS 存储结构设计为了清晰管理版本我在 OSS 上设计了如下目录结构bucket-name/ └── my-app/ ├── latest.json # 存放最新版本信息的清单文件 ├── v1.0.0/ # 按版本号隔离的目录 │ ├── app-v1.0.0.zip # 该版本的完整包 │ └── changelog.txt # 该版本的更新日志 ├── v1.0.1/ │ ├── app-v1.0.1.zip │ └── changelog.txt └── .../关键文件是根目录下的latest.json它是一个轻量的 JSON 文件内容如下{ version: 1.0.1, url: https://bucket-name.oss-cn-hangzhou.aliyuncs.com/my-app/v1.0.1/app-v1.0.1.zip, md5: a1b2c3d4e5f678901234567890123456, release_notes: 修复了某某bug增加了某某功能, publish_date: 2023-10-27T08:00:00Z }客户端只需要定期获取这个latest.json文件与自身当前版本对比就能知道是否需要更新以及更新包的下载地址和完整性校验值。2.2 客户端更新逻辑轻量、健壮与可回退客户端的设计原则是“轻量询问可靠拉取”。我们不在客户端做复杂的版本比较逻辑而是将单一事实源Single Source of Truth放在 OSS 的latest.json中。基本更新流程启动检查客户端启动时在后台线程异步向 OSS 的latest.json文件地址发起一个 GET 请求。版本比对解析获取到的 JSON提取version字段与客户端内嵌的版本号可以编译在代码里或记录在一个本地配置文件中进行比较。决策与下载如果 OSS 上的版本更新则根据url字段下载更新包ZIP 文件。下载过程中可以显示进度条。校验与安装下载完成后计算本地 ZIP 文件的 MD5 值与latest.json中的md5字段比对。校验通过后解压 ZIP 包到临时目录执行预定义的安装脚本如停止服务、覆盖文件、重启服务等。更新本地状态更新成功后更新客户端内记录的版本号。需要考虑的细节网络容错检查更新和下载文件都可能失败。需要设置合理的超时时间、重试机制并且确保更新失败不影响主程序正常运行。原子性操作安装过程特别是文件覆盖最好能做到原子性。一种常见做法是将新版本文件全部释放到一个全新的目录如app_new然后通过一个原子性的重命名操作在大多数操作系统中是原子的将当前运行目录app替换为app_new。这可以避免在覆盖过程中程序处于半新半旧的不一致状态。回滚机制在更新前备份当前版本的文件。如果新版本启动失败例如启动后崩溃可以提供一个安全模式选项自动回滚到上一个版本。这可以通过在备份目录中保留旧版本的可执行文件来实现。注意直接请求 OSS 文件地址获取的是二进制流。在代码中你需要根据编程语言使用相应的 HTTP 库如 Python 的requests Node.js 的axios/fetch Java 的HttpClient来获取这些文件并正确处理响应流将其写入本地文件。3. 实战搭建从零配置 GitHub Actions 到 OSS理论清晰后我们进入实战环节。这里以一个假设的桌面应用为例其构建产物是一个 ZIP 压缩包。3.1 前期准备阿里云 OSS 与 GitHub 配置1. 阿里云 OSS 准备开通 OSS 服务创建一个 Bucket。记住你的Bucket名称和Endpoint例如oss-cn-hangzhou.aliyuncs.com。为了安全不建议使用主账号的 AccessKey。进入RAM 访问控制创建一个专用于此项目的用户例如github-actions-user并为其授予该 Bucket 的PutObject和GetObject权限。保存好生成的AccessKey ID和AccessKey Secret。2. GitHub 仓库配置 Secrets在 GitHub 项目仓库的Settings - Secrets and variables - Actions页面添加以下 SecretsALIYUN_ACCESS_KEY_ID: 填入上一步获取的 AccessKey ID。ALIYUN_ACCESS_KEY_SECRET: 填入上一步获取的 AccessKey Secret。ALIYUN_OSS_ENDPOINT: 填入你的 OSS Endpoint不含https:// 例如oss-cn-hangzhou.aliyuncs.com。ALIYUN_OSS_BUCKET: 填入你的 Bucket 名称。这些 Secrets 会在 GitHub Actions 的工作流文件中以环境变量的形式安全地使用不会暴露在日志中。3.2 编写 GitHub Actions 工作流文件在项目根目录创建.github/workflows/deploy-to-oss.yml文件。name: Build and Deploy to OSS on: push: branches: [ main ] # 仅在推送到 main 分支时触发 # 你也可以配置 tags 触发用于正式版发布 # tags: # - v* jobs: build-and-deploy: runs-on: ubuntu-latest # 使用 GitHub 托管的 Ubuntu 运行器 steps: # 1. 检出代码 - name: Checkout code uses: actions/checkoutv4 # 2. 设置构建环境这里以 Node.js 为例请根据你的项目调整 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 18 # 3. 安装依赖并构建 - name: Install dependencies and build run: | npm ci # 使用 ci 命令确保依赖锁一致 npm run build # 你的构建脚本应产出最终需要分发的文件 # 4. 打包构建产物 - name: Package artifacts run: | # 假设构建产物在 dist 目录 cd dist # 为压缩包生成一个带版本号的名字版本号可以从 package.json 或环境变量获取 APP_VERSION$(node -p require(../package.json).version) ZIP_NAMEmy-app-v${APP_VERSION}.zip # 将 dist 目录下的所有文件打包 zip -r ../${ZIP_NAME} . cd .. # 计算 ZIP 文件的 MD5用于客户端校验 md5sum ${ZIP_NAME} | awk { print $1 } ${ZIP_NAME}.md5 echo APP_VERSION${APP_VERSION} $GITHUB_ENV echo ZIP_NAME${ZIP_NAME} $GITHUB_ENV # 5. 生成 latest.json 清单文件 - name: Generate latest.json manifest run: | # 从环境变量获取版本号和文件名 RELEASE_DATE$(date -u %Y-%m-%dT%H:%M:%SZ) # 这里简化了更新日志实际可以从 CHANGELOG.md 或 commit 信息提取 RELEASE_NOTESAuto-generated release for version ${{ env.APP_VERSION }} MD5_SUM$(cat ${{ env.ZIP_NAME }}.md5) # OSS 上文件的公开访问 URL OSS_BASE_URLhttps://${{ secrets.ALIYUN_OSS_BUCKET }}.${{ secrets.ALIYUN_OSS_ENDPOINT }} DOWNLOAD_URL${OSS_BASE_URL}/my-app/v${{ env.APP_VERSION }}/${{ env.ZIP_NAME }} cat latest.json EOF { version: ${{ env.APP_VERSION }}, url: ${DOWNLOAD_URL}, md5: ${MD5_SUM}, release_notes: ${RELEASE_NOTES}, publish_date: ${RELEASE_DATE} } EOF # 6. 上传到阿里云 OSS - name: Upload to Aliyun OSS uses: manyuanrong/setup-ossutilv2.0 with: endpoint: ${{ secrets.ALIYUN_OSS_ENDPOINT }} access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }} access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }} - name: Upload files run: | # 创建版本目录并上传版本包 ossutil cp ${{ env.ZIP_NAME }} oss://${{ secrets.ALIYUN_OSS_BUCKET }}/my-app/v${{ env.APP_VERSION }}/${{ env.ZIP_NAME }} # 上传该版本的更新日志如果有 # ossutil cp CHANGELOG.md oss://${{ secrets.ALIYUN_OSS_BUCKET }}/my-app/v${{ env.APP_VERSION }}/changelog.txt # 上传 latest.json 到根目录覆盖旧版本 ossutil cp latest.json oss://${{ secrets.ALIYUN_OSS_BUCKET }}/my-app/latest.json -f这个工作流完成了检出代码 - 构建 - 打包 - 生成版本清单 - 上传到 OSS 版本目录并更新根目录的latest.json。提示manyuanrong/setup-ossutil是一个社区 Action用于安装阿里云命令行工具 ossutil。你也可以使用阿里云官方维护的aliyun-oss-website-action它更专注于静态网站部署但用于我们这种文件上传场景也同样合适配置方式略有不同。3.3 客户端更新器实现示例Python客户端需要一个脚本来执行检查、下载和更新的逻辑。这里用 Python 写一个简单的示例其他语言逻辑类似。import json import hashlib import requests import zipfile import os import sys import shutil from pathlib import Path class AppUpdater: def __init__(self, current_version, app_name, oss_base_path): self.current_version current_version self.app_name app_name # OSS 上 latest.json 的完整 URL self.latest_info_url f{oss_base_path}/latest.json self.temp_dir Path(temp_update) self.temp_dir.mkdir(exist_okTrue) def check_for_update(self): 检查是否有更新 try: resp requests.get(self.latest_info_url, timeout10) resp.raise_for_status() latest_info resp.json() latest_version latest_info.get(version) print(f当前版本: {self.current_version}, 最新版本: {latest_version}) if self._version_greater(latest_version, self.current_version): return latest_info else: print(当前已是最新版本。) return None except requests.exceptions.RequestException as e: print(f检查更新失败: {e}) return None except json.JSONDecodeError as e: print(f解析版本信息失败: {e}) return None def _version_greater(self, v1, v2): 简单的版本号比较 (例如 1.2.3 1.2.2) # 这里实现一个简单的版本比较逻辑实际可能需要更复杂的解析 return v1 v2 # 仅适用于纯数字或标准语义化版本建议使用 packaging.version 库 def download_update(self, latest_info): 下载更新包 download_url latest_info[url] expected_md5 latest_info[md5] local_zip_path self.temp_dir / Path(download_url).name print(f开始下载更新: {download_url}) try: with requests.get(download_url, streamTrue, timeout30) as r: r.raise_for_status() total_size int(r.headers.get(content-length, 0)) downloaded 0 with open(local_zip_path, wb) as f: for chunk in r.iter_content(chunk_size8192): downloaded len(chunk) f.write(chunk) # 可以在这里更新进度条 if total_size: percent (downloaded / total_size) * 100 print(f\r下载进度: {percent:.1f}%, end) print() # 换行 except Exception as e: print(f下载失败: {e}) return None # 校验 MD5 actual_md5 self._calculate_md5(local_zip_path) if actual_md5 ! expected_md5: print(f文件校验失败! 期望MD5: {expected_md5}, 实际MD5: {actual_md5}) local_zip_path.unlink(missing_okTrue) return None print(文件下载并校验成功。) return local_zip_path def _calculate_md5(self, file_path): 计算文件的 MD5 哈希值 hash_md5 hashlib.md5() with open(file_path, rb) as f: for chunk in iter(lambda: f.read(4096), b): hash_md5.update(chunk) return hash_md5.hexdigest() def apply_update(self, zip_path, backup_dirbackup): 应用更新示例解压并替换文件 # 1. 备份当前应用目录这里假设应用就在当前目录的 app 文件夹 app_dir Path(app) backup_path Path(backup_dir) if app_dir.exists(): print(正在备份当前版本...) if backup_path.exists(): shutil.rmtree(backup_path) shutil.copytree(app_dir, backup_path) # 2. 解压更新包到临时的新目录 new_app_dir self.temp_dir / new_app if new_app_dir.exists(): shutil.rmtree(new_app_dir) print(f正在解压更新包到 {new_app_dir}...) with zipfile.ZipFile(zip_path, r) as zip_ref: zip_ref.extractall(new_app_dir) # 3. 停止当前应用这里需要你根据实际情况实现可能是发送信号或调用子进程 # self._stop_application() # 4. 原子性替换删除旧目录将新目录移动到位 print(正在替换应用文件...) if app_dir.exists(): shutil.rmtree(app_dir) shutil.move(new_app_dir, app_dir) # 5. 清理临时文件 shutil.rmtree(self.temp_dir, ignore_errorsTrue) print(更新应用完成) # 6. 启动新应用同样需要你根据实际情况实现 # self._start_application() def run(self): 执行完整的更新流程 latest_info self.check_for_update() if latest_info: print(f发现新版本 {latest_info[version]}: {latest_info[release_notes]}) user_input input(是否立即更新 (y/N): ) if user_input.lower() y: zip_file self.download_update(latest_info) if zip_file: self.apply_update(zip_file) else: print(更新包下载或校验失败。) else: print(已取消更新。) else: print(未发现可用更新。) if __name__ __main__: # 这些参数应该从你的应用配置中读取 CURRENT_VERSION 1.0.0 APP_NAME MyDesktopApp # OSS 上你的应用根路径确保 latest.json 在此路径下 OSS_BASE_PATH https://your-bucket.oss-cn-hangzhou.aliyuncs.com/my-app updater AppUpdater(CURRENT_VERSION, APP_NAME, OSS_BASE_PATH) updater.run()这个示例提供了核心框架你需要根据实际应用的结构是单文件可执行程序还是一个包含多个文件的目录来调整apply_update方法中的文件替换逻辑并实现真正的进程停止/启动逻辑。4. 进阶优化与安全考量基础功能跑通后我们需要考虑更多生产环境会遇到的问题让整个系统更健壮、更安全。4.1 性能与成本优化CDN 加速如果你的用户分布较广可以考虑为 OSS Bucket 开启 CDN 加速。这样用户下载更新包时会从离他最近的 CDN 节点获取速度更快体验更好。在阿里云控制台配置即可之后latest.json里的url可以改为 CDN 域名。增量更新对于大型应用每次都下载完整包浪费流量和时间。可以实现增量更新机制。服务端在发布新版本时除了完整包还生成一个与上一个版本之间的差分包可以使用 bsdiff 等工具。latest.json中增加patch_url和patch_from_version字段。客户端如果检测到本地是特定旧版本则下载差分包进行合并更新否则下载完整包。这显著提升了更新效率。OSS 生命周期管理旧版本的压缩包会一直占用存储空间。可以在 OSS 控制台配置生命周期规则自动将超过一定时间如保留最近5个版本的旧版本文件转为低频访问存储或直接删除以节约成本。4.2 安全加固措施直接使用 OSS 的公共读链接虽然方便但也存在一些风险比如被恶意刷流量导致费用激增或者latest.json被篡改虽然概率低。我们可以做一些加固。防盗链在 OSS 控制台设置 Referer 白名单只允许你的应用域名或特定 Referer 访问资源可以有效防止资源被其他网站盗用。使用 STS 临时令牌对于敏感场景可以让客户端先向自己的一个安全的后端服务请求一个临时安全令牌 (STS)。这个后端服务验证客户端身份后向阿里云申请一个具有短期如15分钟且权限受限仅限 GetObject的 STS Token。客户端再用这个 Token 去 OSS 下载更新文件。这样 AccessKey 不会暴露在客户端权限也更小、时间更短安全性大大提升。这需要你有一个简单的后端服务来配合。签名 URL如果不方便部署后端也可以使用 OSS 的签名 URL。在latest.json中不直接放永久有效的文件链接而是放一个由服务端签名的、有时效性比如30分钟的 URL。客户端拿到这个 URL 后需在有效期内下载。这需要你的 GitHub Actions 在生成latest.json时使用 AccessKey 对文件 URL 进行签名。缺点是latest.json本身需要频繁更新因为签名会过期或者你需要一个更复杂的客户端逻辑来动态请求签名。文件完整性校验我们已经使用了 MD5这是基本要求。对于安全性要求更高的场景可以考虑使用 SHA-256 等更强哈希算法并在latest.json中附带一个由服务端私钥签名的哈希值客户端用公钥验证签名确保清单文件本身未被篡改。4.3 客户端更新策略的细化更新时机除了启动时检查还可以增加定时检查如每24小时、手动触发检查、或者在程序空闲时检查。后台静默下载对于非强制性的小更新可以在用户不感知的情况下在后台提前下载好更新包待用户下次启动时提示安装减少等待时间。灰度发布不是所有用户都立刻升级到最新版。可以在latest.json中增加一个release_channel字段如stable,beta。客户端根据自身配置或用户选择检查对应通道的清单文件。GitHub Actions 可以根据 Git 分支如main推送到stabledev推送到beta来更新不同通道的latest.json。更新失败处理在apply_update过程中任何一步出错如文件校验失败、解压错误、替换文件时权限不足都应该有明确的回滚机制自动恢复备份并给出清晰的错误提示引导用户手动处理或报告问题。5. 避坑指南与实战心得在搭建和测试这套系统的过程中我遇到了不少坑这里总结一下希望能帮你绕过去。坑一OSS 文件权限与跨域问题最初上传文件后客户端死活请求不到返回 403 或跨域错误。原因在于新建的 Bucket 默认是私有读写并且没有设置跨域规则。解决在 OSS 控制台找到你的 Bucket在权限管理 - 读写权限中设置为公共读如果文件完全公开或精细配置 ACL。在跨域设置中添加一条规则允许来源Origin为你的应用域名或使用通配符*仅限测试允许方法GET, HEAD。坑二GitHub Actions 中敏感信息泄露一开始我把 OSS 的 Endpoint 和 Bucket 名直接写在了 YAML 文件里虽然问题不大但最好的实践是全部使用 Secrets。解决严格按照前面所述将所有敏感和可配置信息都存入 GitHub Secrets在工作流中通过${{ secrets.XXX }}引用。这样即使仓库公开这些信息也不会泄露。坑三版本号管理与比较手动维护版本号容易出错比较逻辑写起来也麻烦。比如1.10.0和1.9.0字符串比较会认为后者更大。解决生成版本号最好从单一事实源获取比如项目的package.json、pyproject.toml或setup.py。在 GitHub Actions 中通过脚本读取这个文件中的版本号。比较不要自己写字符串分割比较。使用成熟的库如 Python 的packaging.version Node.js 的semver它们能正确处理语义化版本号。坑四客户端更新时的文件占用在 Windows 上如果尝试覆盖正在运行的可执行文件.exe会因文件被占用而失败。解决这是桌面应用更新的经典问题。常用策略有使用独立更新程序主程序只是一个启动器。更新程序负责下载新版本并在主程序退出后由更新程序完成文件替换和重启。移动/重命名策略将新版本文件释放在一个临时目录然后通过批处理脚本或另一个轻量级进程在主程序退出后执行原子性的移动操作。许多成熟的更新框架如 Squirrel for Windows, Sparkle for macOS都采用了类似机制。坑五网络环境与代理问题部分用户环境可能存在网络限制无法直接访问 OSS 或 GitHub。解决在客户端更新逻辑中加入重试机制和更友好的超时设置。对于企业内网环境可以考虑允许用户配置代理服务器。在代码中可以为requests库或相应的 HTTP 客户端设置代理参数。我个人在实际操作中的体会是这套组合拳GitHub Actions OSS的威力在于“全自动化”。一旦配置完成你只需要专注于代码开发提交推送后的一切——构建、测试、打包、发布——都自动完成。看着用户端自动弹出更新提示那种感觉非常美妙。它不仅仅是一个更新功能更是将你的开发-发布流程向 DevOps 迈进了一大步。
返回列表