diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eebc15b..ac1479a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,53 @@ name: release on: + workflow_dispatch: + inputs: + tag: + description: "已存在的正式版本标签,例如 v0.3.16" + required: true + type: string push: tags: ["v*"] permissions: - contents: write # 创建 Release 需要 + contents: read + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false jobs: + prepare: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + commit: ${{ steps.commit.outputs.sha }} + steps: + - name: Validate release version + id: version + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "正式版本必须是 v主版本.次版本.修订号,例如 v0.3.16" >&2 + exit 1 + fi + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: refs/tags/${{ steps.version.outputs.tag }} + - name: Pin release commit + id: commit + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # ① 前端静态导出(一次),产物给交叉编译各平台复用 frontend: + needs: prepare runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.commit }} - uses: actions/setup-node@v4 with: node-version: "22" @@ -28,7 +64,7 @@ jobs: # ② 五平台交叉编译(内嵌前端)+ 打 zip binaries: - needs: frontend + needs: [prepare, frontend] runs-on: ubuntu-latest strategy: matrix: @@ -40,9 +76,11 @@ jobs: - { goos: windows, goarch: amd64 } steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.commit }} - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version-file: go.mod cache: true - uses: actions/download-artifact@v4 with: @@ -52,7 +90,7 @@ jobs: env: ARTEX_TARGET_OS: ${{ matrix.goos }} ARTEX_TARGET_ARCH: ${{ matrix.goarch }} - ARTEX_BUILD_VERSION: ${{ github.ref_name }} + ARTEX_BUILD_VERSION: ${{ needs.prepare.outputs.tag }} ARTEX_SKIP_FRONTEND: "1" ARTEX_SKIP_NPM_CI: "1" # UPX 自解压 ELF 在部分 Linux 内核、虚拟化和安全策略下会崩溃。 @@ -78,8 +116,14 @@ jobs: # ③ 汇总所有 zip → 创建 GitHub Release release: - needs: binaries + needs: [prepare, binaries] runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} steps: - uses: actions/download-artifact@v4 with: @@ -88,21 +132,48 @@ jobs: path: dist - name: Generate checksums working-directory: dist - run: sha256sum *.zip > SHA256SUMS + run: | + version="${RELEASE_TAG#v}" + for target in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64; do + test -s "artex-${version}-${target}.zip" + done + sha256sum *.zip > SHA256SUMS + sha256sum --check SHA256SUMS + - name: Refuse to overwrite a published release + run: | + if gh release view "$RELEASE_TAG" --json isDraft > /tmp/artex-release.json 2>/tmp/artex-release.err; then + test "$(jq -r .isDraft /tmp/artex-release.json)" = true || { + echo "该版本已发布,请使用新的版本标签" >&2 + exit 1 + } + elif ! grep -qi 'release not found\|HTTP 404' /tmp/artex-release.err; then + cat /tmp/artex-release.err >&2 + exit 1 + fi - uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.prepare.outputs.tag }} + target_commitish: ${{ needs.prepare.outputs.commit }} + draft: true + fail_on_unmatched_files: true files: | dist/*.zip dist/SHA256SUMS generate_release_notes: true + - name: Publish complete release + run: gh release edit "$RELEASE_TAG" --draft=false --latest # ④ 多架构镜像 → 推 Docker Hub(复用 binaries 编好的 Linux 二进制, # 镜像里只装工具 + 放二进制;arm64 仅需模拟 apt 层,构建快很多) docker: - needs: binaries + # 页面更新只需要 Release;镜像发布按需启用,不依赖原作者的账号。 + if: vars.ARTEX_DOCKER_IMAGE != '' + needs: [prepare, binaries] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # 取 Dockerfile 与 skills/ + with: + ref: ${{ needs.prepare.outputs.commit }} - uses: actions/download-artifact@v4 with: name: bin-linux-amd64 @@ -120,9 +191,9 @@ jobs: - uses: docker/metadata-action@v5 id: meta with: - images: autumn27/artex + images: ${{ vars.ARTEX_DOCKER_IMAGE }} tags: | - type=ref,event=tag + type=raw,value=${{ needs.prepare.outputs.tag }} type=raw,value=latest - uses: docker/build-push-action@v6 with: diff --git a/README.md b/README.md index 3bddb9c..a3917c2 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ docker compose up -d # 拉取 autumn27/artex 镜像 + postgres ### 方式三:下载预编译二进制(Releases) -到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载对应平台的 zip,解压后得到 `artex` + `start.sh`(Windows 为 `start.bat`)+ `skills/` + `config.example.json`: +到本仓库的 [Releases](https://github.com/RuoJi6/ARTEX/releases) 下载对应平台的 zip,解压后得到 `artex` + `start.sh`(Windows 为 `start.bat`)+ `skills/` + `config.example.json`。Linux ARM64(包括 ARM64 Kali)选择 `linux-arm64`,x86_64 选择 `linux-amd64`: ```bash cp config.example.json config.json # 填好 database 连接 @@ -148,6 +148,8 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release 在 **系统配置** 页(侧边栏「系统配置」→ `/system/settings`)的**版本与更新**卡片里,可以直接检查并安装新版本,无需登录服务器。 +本仓库构建的程序固定从 `RuoJi6/ARTEX` 的最新正式 GitHub Release 检查更新,跳过草稿和预发布版本。旧版官方程序仍指向原作者仓库,因此首次切换必须手动安装本仓库构建的程序;以后发布更高的三段式版本即可通过页面升级。仅推送 main 或 Git 标签但未发布 Release,不会产生可在线安装的新版本。 + 点「更新」后:下载当前平台的发布包 → 比对 Release 的 `SHA256SUMS` → 用 `-h` 冒烟测试新二进制 → 暂存为 `artex.new` → 程序退出,由 `start.sh` / `start.bat` 重新拉起并完成换装。页面会自动等到新版本上线后刷新。 - **失败不会留下坏程序**:校验或冒烟不通过就丢弃暂存件、继续跑当前版本;换装后的新版若连续 3 次启动失败,会自动回滚到 `artex.old`(失败的那个留作 `artex.failed` 供排查)。 @@ -157,6 +159,35 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release - **Docker 下只换程序、不换镜像**:镜像里的 playwright / nmap 等工具链不会跟着升级,且 `docker compose up -d` 重建容器后会退回镜像自带的版本。要连镜像一起升级仍请用 `docker compose pull artex && docker compose up -d artex`。 - 访问 GitHub 需要代理时,在同一页面配置**全局代理**即可,更新链路会走它。更新只从 GitHub 域名下载并强制 HTTPS。 +### 维护者:通过 GitHub Actions 发布在线更新 + +发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.16`;后续版本必须高于用户正在运行的版本。 + +```bash +git push origin main +git tag v0.3.16 +git push origin v0.3.16 +``` + +工作流固定标签对应的提交,用 Node.js 22 导出前端,按照 `go.mod` 指定的 Go 版本编译内嵌前端的五个平台程序,并生成 ZIP 与 `SHA256SUMS`。全部发布包准备好后先上传到草稿 Release,上传成功才公开为最新正式版,避免页面检查到尚未上传完整的版本。已公开的同版本不允许覆盖,应发布新标签。 + +页面在线更新所需的发布只使用仓库自带 `GITHUB_TOKEN`,无需 Docker Hub 凭据。Docker 镜像任务默认跳过;如需单独发布自己的镜像,设置仓库 Actions 变量 `ARTEX_DOCKER_IMAGE`(自己的 Docker Hub 镜像名)及 Secrets `DOCKERHUB_USERNAME`、`DOCKERHUB_TOKEN`。现有 Compose 文件仍指向原镜像,使用自有镜像时应自行修改镜像地址。 + +首次迁移已有 Docker 部署时,将已校验的新 ARM64 ZIP 解压到宿主机,备份原程序,然后只替换已有容器中的 `/app/artex`。例如现有容器名为 `artex-artex-1`: + +```bash +# 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。 +sha256sum --ignore-missing --check SHA256SUMS +unzip artex-0.3.16-linux-arm64.zip +sudo docker stop artex-artex-1 +sudo docker cp artex-artex-1:/app/artex ./artex-before-migration +sudo docker cp ./artex-0.3.16-linux-arm64/artex artex-artex-1:/app/artex +sudo docker start artex-artex-1 +curl --fail http://127.0.0.1:8787/api/health +``` + +这会保留现有数据库、data 和 skills 挂载。登录系统配置页检查更新源与版本。容器内替换的程序仍属于容器可写层,删除或重建容器会丢失替换结果;需要重建时应使用包含本仓库程序的镜像。程序回滚不回退数据库迁移,升级前请备份数据库和 data。 + ### 方式二:一键更新脚本 ```bash @@ -182,7 +213,7 @@ docker image prune -f # 清理旧镜像(可选) ### 方式四:预编译二进制(Releases) -到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载新版本 zip,停掉旧进程后覆盖 `artex` 与 `skills/`(保留你的 `config.json` 与 `data/`),重启即可: +到 [Releases](https://github.com/RuoJi6/ARTEX/releases) 下载新版本 zip,停掉旧进程后覆盖 `artex` 与 `skills/`(保留你的 `config.json` 与 `data/`),重启即可: ```bash cp -r <解压目录>/skills ./ && cp <解压目录>/artex ./ diff --git a/selfupdate/github.go b/selfupdate/github.go index e3ea60e..e89fd09 100644 --- a/selfupdate/github.go +++ b/selfupdate/github.go @@ -12,7 +12,7 @@ import ( // Repo 是发布源。写死而不是做成配置项:更新源可配等于给任何能改配置的人一条 // 远程代码执行通道,对一个渗透测试平台来说这个口子开不得。 -const Repo = "Autumn-27/artex" +const Repo = "RuoJi6/ARTEX" // latestURL 是 GitHub 的"最新正式版"接口。它会自动跳过 prerelease 和 draft。 const latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest" @@ -107,7 +107,7 @@ func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) { // 未认证的 GitHub API 是每 IP 每小时 60 次,共用出口 IP 时很容易撞上。 return nil, fmt.Errorf("GitHub 接口限流(每小时 60 次),请稍后再试") case resp.StatusCode == http.StatusNotFound: - return nil, fmt.Errorf("仓库 %s 尚未发布任何正式版本", Repo) + return nil, fmt.Errorf("仓库 %s 不可访问或尚未发布正式版本", Repo) case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("GitHub 返回 %d", resp.StatusCode) } diff --git a/selfupdate/github_test.go b/selfupdate/github_test.go new file mode 100644 index 0000000..a4e1376 --- /dev/null +++ b/selfupdate/github_test.go @@ -0,0 +1,58 @@ +package selfupdate + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type releaseTransport func(*http.Request) (*http.Response, error) + +func (f releaseTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestFetchLatestUsesForkRelease(t *testing.T) { + client := &http.Client{Transport: releaseTransport(func(r *http.Request) (*http.Response, error) { + if got := r.URL.String(); got != "https://api.github.com/repos/RuoJi6/ARTEX/releases/latest" { + t.Fatalf("unexpected update source: %s", got) + } + if r.Method != http.MethodGet || r.Header.Get("Accept") != "application/vnd.github+json" { + t.Fatalf("invalid GitHub request: %s %v", r.Method, r.Header) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{ + "tag_name":"v0.3.16", + "assets":[ + {"name":"artex-0.3.16-linux-arm64.zip","browser_download_url":"https://github.com/RuoJi6/ARTEX/releases/download/v0.3.16/artex-0.3.16-linux-arm64.zip"}, + {"name":"SHA256SUMS","browser_download_url":"https://github.com/RuoJi6/ARTEX/releases/download/v0.3.16/SHA256SUMS"} + ] + }`))}, nil + })} + rel, err := FetchLatest(context.Background(), client) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{AssetName(rel.TagName, "linux", "arm64"), sumsAsset} { + asset, ok := rel.FindAsset(name) + if !ok { + t.Fatalf("missing release asset %s", name) + } + body, err := get(context.Background(), &http.Client{Transport: releaseTransport(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("asset"))}, nil + })}, asset.URL) + if err != nil { + t.Fatalf("fork asset URL rejected: %v", err) + } + body.Close() + } +} + +func TestFetchLatestMissingRelease(t *testing.T) { + client := &http.Client{Transport: releaseTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("{}"))}, nil + })} + _, err := FetchLatest(context.Background(), client) + if err == nil || !strings.Contains(err.Error(), "RuoJi6/ARTEX") || !strings.Contains(err.Error(), "不可访问或尚未发布") { + t.Fatalf("unexpected missing-release error: %v", err) + } +}