diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml index 129491c..3cec9c5 100644 --- a/.gitea/workflows/deploy-production.yml +++ b/.gitea/workflows/deploy-production.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Completed formal release version (X.Y.Z) + description: Completed date-hash release version required: true type: string @@ -23,7 +23,8 @@ jobs: container: image: docker:29-cli volumes: - - /docker/main/rawmark:/docker/main/rawmark + - /docker/gitea/main/RawMark:/docker/gitea/main/RawMark + - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark env: GITEA_API_URL: ${{ gitea.server_url }}/api/v1 GITEA_READ_TOKEN: ${{ gitea.token }} @@ -34,16 +35,18 @@ jobs: shell: sh steps: - name: Install deployment runtimes - run: apk add --no-cache nodejs python3 docker-cli-compose + run: apk add --no-cache nodejs python3 git docker-cli-compose - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: + ref: main + fetch-depth: 0 persist-credentials: false - name: Verify completed release, back up and deploy only RawMark run: | set -eu mkdir -p "$DOCKER_CONFIG" chmod 700 "$DOCKER_CONFIG" - python3 scripts/deploy.py production --directory /docker/main/rawmark \ + python3 scripts/deploy.py production --directory /docker/gitea/main/RawMark \ --version "$RELEASE_VERSION" --client-container "$(hostname)" - name: Remove job configuration if: always() diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5c6f300..9465f18 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -3,12 +3,12 @@ name: Release on: workflow_dispatch: inputs: - version: - description: Version matching VERSION (X.Y.Z) + release_id: + description: Reserved release ID required: true type: string commit: - description: Full main commit SHA with successful development CI + description: Full candidate SHA with successful main-push CI required: true type: string @@ -21,6 +21,9 @@ concurrency: env: GITEA_API_URL: ${{ gitea.server_url }}/api/v1 + RAWMARK_BUILD_CHANNEL: release + RELEASE_ID: ${{ inputs.release_id }} + RELEASE_COMMIT: ${{ inputs.commit }} CI_RUN_ID: ${{ gitea.run_id }} jobs: @@ -29,37 +32,46 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 55 env: - GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} - GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} - RELEASE_VERSION: ${{ inputs.version }} - RELEASE_COMMIT: ${{ inputs.commit }} + container: + image: node:24-bookworm + volumes: + - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark outputs: completed: ${{ steps.preflight.outputs.completed }} steps: - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ inputs.commit }} + ref: ${{ gitea.sha }} fetch-depth: 0 persist-credentials: false - name: Validate source, permissions and existing release checkpoint id: preflight - run: python3 scripts/release.py preflight "$RELEASE_VERSION" "$RELEASE_COMMIT" + env: + GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} + GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} + run: python3 scripts/release.py preflight "$(python3 -c 'import sys; sys.path.insert(0,"scripts"); import release; import os; print(release.version_for(os.environ["RELEASE_COMMIT"]))')" "$RELEASE_COMMIT" + - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.commit }} + path: source + persist-credentials: false - uses: https://github.com/actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 if: steps.preflight.outputs.reuse == 'false' with: - go-version-file: go.mod + go-version-file: source/go.mod cache: false - - name: Check and build the five platform packages + - name: Check and build the six platform packages if: steps.preflight.outputs.reuse == 'false' + working-directory: source run: | - python3 -m unittest discover -s scripts/tests -v + env -u RAWMARK_BUILD_CHANNEL python3 -m unittest discover -s scripts/tests -v sh scripts/ci-check.sh release sh scripts/ci-package.sh release - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.preflight.outputs.reuse == 'false' with: name: linux-amd64 - path: downloads/*_linux_amd64.tar.gz + path: source/downloads/*_linux_amd64.tar.gz compression-level: 0 if-no-files-found: error retention-days: 30 @@ -67,7 +79,7 @@ jobs: if: steps.preflight.outputs.reuse == 'false' with: name: linux-arm64 - path: downloads/*_linux_arm64.tar.gz + path: source/downloads/*_linux_arm64.tar.gz compression-level: 0 if-no-files-found: error retention-days: 30 @@ -75,7 +87,15 @@ jobs: if: steps.preflight.outputs.reuse == 'false' with: name: windows-amd64 - path: downloads/*_windows_amd64.zip + path: source/downloads/*_windows_amd64.zip + compression-level: 0 + if-no-files-found: error + retention-days: 30 + - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + if: steps.preflight.outputs.reuse == 'false' + with: + name: windows-arm64 + path: source/downloads/*_windows_arm64.zip compression-level: 0 if-no-files-found: error retention-days: 30 @@ -83,7 +103,7 @@ jobs: if: steps.preflight.outputs.reuse == 'false' with: name: darwin-amd64 - path: downloads/*_darwin_amd64.tar.gz + path: source/downloads/*_darwin_amd64.tar.gz compression-level: 0 if-no-files-found: error retention-days: 30 @@ -91,7 +111,7 @@ jobs: if: steps.preflight.outputs.reuse == 'false' with: name: darwin-arm64 - path: downloads/*_darwin_arm64.tar.gz + path: source/downloads/*_darwin_arm64.tar.gz compression-level: 0 if-no-files-found: error retention-days: 30 @@ -100,21 +120,25 @@ jobs: with: name: checksums path: | - downloads/SHA256SUMS - downloads/build.json + source/downloads/SHA256SUMS + source/downloads/build.json if-no-files-found: error retention-days: 30 - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.preflight.outputs.reuse == 'false' with: name: demo - path: downloads/demo.zip + path: source/downloads/demo.zip compression-level: 0 if-no-files-found: error retention-days: 30 - name: Save immutable build checkpoint after artifact uploads if: steps.preflight.outputs.reuse == 'false' - run: python3 scripts/release.py checkpoint + working-directory: source + env: + GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} + GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} + run: python3 ../scripts/release.py checkpoint publish: needs: build @@ -123,9 +147,9 @@ jobs: timeout-minutes: 55 container: image: docker:29-cli + volumes: + - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark env: - GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} - GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} CI_RESOURCE_LABEL: io.rawmark.ci.run=release-${{ gitea.run_id }}-${{ gitea.run_attempt }} DOCKER_CONFIG: /tmp/rawmark-docker-auth defaults: @@ -136,17 +160,58 @@ jobs: run: apk add --no-cache nodejs python3 git skopeo - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: - ref: ${{ inputs.commit }} + ref: ${{ gitea.sha }} fetch-depth: 0 persist-credentials: false + - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.commit }} + path: source + persist-credentials: false - name: Publish the original checked artifacts to both services + working-directory: source + env: + GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} + GITHUB_RELEASE_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }} run: | set -eu mkdir -p "$DOCKER_CONFIG" chmod 700 "$DOCKER_CONFIG" - python3 scripts/release.py publish + python3 ../scripts/release.py publish - name: Clean only this attempt's test resources and credentials if: always() run: | python3 scripts/ci-cleanup.py "$CI_RESOURCE_LABEL" --client-container "$(hostname)" rm -rf "$DOCKER_CONFIG" + + deploy: + needs: [build, publish] + if: always() && needs.build.result == 'success' && (needs.publish.result == 'success' || needs.publish.result == 'skipped') + runs-on: docker + timeout-minutes: 20 + container: + image: docker:29-cli + volumes: + - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark + - /docker/gitea/main/RawMark:/docker/gitea/main/RawMark + env: + GITEA_READ_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} + DOCKER_CONFIG: /tmp/rawmark-anonymous-docker + steps: + - name: Install deployment runtimes + run: apk add --no-cache nodejs python3 git docker-cli-compose + - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ gitea.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Deploy the completed release and finish its reservation + run: | + set -eu + mkdir -p "$DOCKER_CONFIG" + python3 scripts/deploy.py production --directory /docker/gitea/main/RawMark \ + --version "$(python3 -c 'import sys;sys.path.insert(0,"scripts");import release;print(release.selected_state()["version"])')" \ + --client-container "$(hostname)" + - name: Remove temporary Docker configuration + if: always() + run: rm -rf "$DOCKER_CONFIG" diff --git a/AGENTS.md b/AGENTS.md index 8bbabe9..1cab319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,4 +137,4 @@ Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 mai - Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据统一使用 Gitea 账号级 Secret `ALL_GITHUB_TOKEN`(头像 → 设置 → Actions → 密钥),接入前配置并验证实际权限,仓库级同名项会覆盖账号级值。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。 - CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。 - 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。 -- 正式发布与生产入口暂时禁用,后续由 gitea-release 适配,不能绕过 Git 镜像锁直接写 GitHub 分支。生产保留现有目录和数据,不随开发推送更新;GitHub 旧 Release workflow 保持禁用,Pages Demo 不切换。 +- 正式发布从受信 main 手动触发,使用日期哈希注解 TAG、六平台程序包与 Linux 双架构镜像;发布 ID 和候选 SHA 固定,发布记录与 .release.lock 位于持久镜像目录。GitHub 引用同步复用镜像锁,Pages Demo 不切换。双端发布和 GHCR 公开拉取验收后自动部署 MAIN:/docker/gitea/main/RawMark、RawMark-main、rawmark-main,127.0.0.1:21000,raw.134333.xyz。自动/手动/批量启停共用实例 .deploy.lock,旧版本不得覆盖新版;生产数据保留并先备份,不随开发推送更新。 diff --git a/README.md b/README.md index c0ddac3..43c9eb0 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,12 @@ Choose an available version from [Releases](https://github.com/eezd/RawMark/rele docker run -d --name rawmark --restart unless-stopped \ -p 127.0.0.1:21000:8080 \ -v rawmark-data:/data \ - ghcr.io/eezd/rawmark:0.1.1 + ghcr.io/eezd/rawmark:latest ``` Open **http://localhost:21000** and set your password on first use. A new, empty instance starts with seven English example notes; edit or delete them and begin writing. Data lives in the Docker volume `rawmark-data` and persists when you recreate the container with the same volume. -Docker supports Linux amd64 and arm64. Native programs are available for Linux amd64/arm64, Windows amd64, and macOS Intel/Apple Silicon. Download them from [Releases](https://github.com/eezd/RawMark/releases), or build from source using the deployment guide. +Docker supports Linux amd64 and arm64. Native programs are available for Linux amd64/arm64, Windows amd64/arm64, and macOS Intel/Apple Silicon. Download them from [Releases](https://github.com/eezd/RawMark/releases), or build from source using the deployment guide. [Try the online editor](https://eezd.github.io/RawMark/) without installing anything. Demo edits are temporary and reset on refresh; the demo does not provide a server-backed workspace. diff --git a/README.zh-CN.md b/README.zh-CN.md index d761f69..8cb9a27 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ RawMark 面向**单用户、自托管**场景。目前不提供多人协作或 docker run -d --name rawmark --restart unless-stopped \ -p 127.0.0.1:21000:8080 \ -v rawmark-data:/data \ - ghcr.io/eezd/rawmark:0.1.1 + ghcr.io/eezd/rawmark:latest ``` 打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 diff --git a/docs/deployment.md b/docs/deployment.md index 46f11be..9c339fd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -56,22 +56,22 @@ Do not point the quick-start container and a Compose instance at the same databa Formal releases publish `ghcr.io/eezd/rawmark:` for Linux amd64 and arm64. The earlier releases were withdrawn; `VERSION` alone does not mean a version is available. Check the Releases page and package visibility before using the example version below. Replace `rawmark` in the Docker commands above with that image name to skip source builds. `latest` follows the latest stable release; use a version tag for controlled upgrades. The repository Compose file continues to support source builds. -Native archives for Linux amd64/arm64, Windows amd64 and macOS Intel/Apple Silicon, plus `SHA256SUMS`, are available on the [Releases page](https://github.com/eezd/RawMark/releases). Download the archive matching your operating system and architecture and verify its checksum before extracting it: +Native archives for Linux amd64/arm64, Windows amd64/arm64 and macOS Intel/Apple Silicon, plus `SHA256SUMS`, are available on the [Releases page](https://github.com/eezd/RawMark/releases). Download the archive matching your operating system and architecture and verify its checksum before extracting it: ```sh sha256sum --ignore-missing --check SHA256SUMS -tar -xzf rawmark_0.1.1_linux_amd64.tar.gz +tar -xzf RawMark_YYYYMMDD-xxxxxxxx_linux_amd64.tar.gz ./rawmark --version RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark ``` -For macOS, use `darwin_arm64` on Apple Silicon or `darwin_amd64` on Intel. Compare `shasum -a 256 rawmark_0.1.1_darwin_arm64.tar.gz` with its entry in `SHA256SUMS`, extract it with `tar -xzf`, then use the same environment variables as above. The program is unsigned; review and approve it through macOS security settings if prompted. +For macOS, use `darwin_arm64` on Apple Silicon or `darwin_amd64` on Intel. Compare `shasum -a 256 RawMark_YYYYMMDD-xxxxxxxx_darwin_arm64.tar.gz` with its entry in `SHA256SUMS`, extract it with `tar -xzf`, then use the same environment variables as above. The program is unsigned; review and approve it through macOS security settings if prompted. On Windows, compare the output of `Get-FileHash` with the archive's entry in `SHA256SUMS`, then run in PowerShell: ```powershell -Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 -Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark +Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256 +Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark Set-Location .\rawmark .\rawmark.exe --version $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' diff --git a/docs/deployment.zh-CN.md b/docs/deployment.zh-CN.md index d14f3bb..3a03b81 100644 --- a/docs/deployment.zh-CN.md +++ b/docs/deployment.zh-CN.md @@ -60,18 +60,18 @@ RAWMARK_DATA_DIR=/your/storage/rawmark/data \ ```sh sha256sum --ignore-missing --check SHA256SUMS -tar -xzf rawmark_0.1.1_linux_amd64.tar.gz +tar -xzf RawMark_YYYYMMDD-xxxxxxxx_linux_amd64.tar.gz ./rawmark --version RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark ``` -macOS 的 Apple Silicon 使用 `darwin_arm64`,Intel 使用 `darwin_amd64`。先将 `shasum -a 256 rawmark_0.1.1_darwin_arm64.tar.gz` 的结果与 `SHA256SUMS` 中对应记录比较,再用 `tar -xzf` 解压并沿用上述环境变量启动。程序未签名,若系统提示拦截,请核对来源后通过 macOS 安全设置手动允许。 +macOS 的 Apple Silicon 使用 `darwin_arm64`,Intel 使用 `darwin_amd64`。先将 `shasum -a 256 RawMark_YYYYMMDD-xxxxxxxx_darwin_arm64.tar.gz` 的结果与 `SHA256SUMS` 中对应记录比较,再用 `tar -xzf` 解压并沿用上述环境变量启动。程序未签名,若系统提示拦截,请核对来源后通过 macOS 安全设置手动允许。 Windows 先将 `Get-FileHash` 的结果与 `SHA256SUMS` 中对应记录比较,再在 PowerShell 中启动: ```powershell -Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 -Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark +Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256 +Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark Set-Location .\rawmark .\rawmark.exe --version $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' diff --git a/docs/development.md b/docs/development.md index 49b6eee..c1a6f98 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,4 +118,4 @@ Gitea is the public source repository; GitHub is a strict one-way Git mirror. Bo - Git mirroring runs independently on push/create/delete, hourly reconciliation and manual retry, sharing `/docker/gitea/mirrors/RawMark/.mirror.lock`. Only business branches, tags and complete history are mirrored, including deletions. Control code comes from main. Enable `RAWMARK_MIRROR_ENABLED=true` after the main cutover and configure the account-level `ALL_GITHUB_TOKEN` in Gitea user Settings → Actions → Secrets with repository/workflow write access. DEV images never go to GHCR. - `RAWMARK_GITEA_TOKEN` requires repository and Packages write access. Keep runner capacity one and sweep disabled; only trusted code runs on this Docker-socket runner. Install toolchains inside CI containers and clean only resources labeled for the current attempt. - `RAWMARK_BUILD_CHANNEL=development` derives a consistent frontend/binary version without editing the formal VERSION file. Development archives use `RawMark__linux_amd64.tar.gz`; historical release artifacts retain their names. Keep the existing 90,000,000-byte transfer budget. -- Formal release and production workflows are temporarily disabled pending gitea-release adaptation; future publication must share Git mirror serialization. Production data and paths remain unchanged. Keep the old GitHub Release workflow disabled and preserve the current Pages Demo. +- Formal releases run manually from trusted main, require successful main-push CI, and publish six native packages plus Linux amd64/arm64 images with date/hash versions. Fixed release IDs retain resumable checkpoints under the persistent release lock. After both releases and public GHCR digest pulls pass, MAIN deploys to `/docker/gitea/main/RawMark` as `RawMark-main` (project `rawmark-main`). Keep Pages unchanged. diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 93ee97b..114e2a1 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -118,4 +118,4 @@ Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 mai - Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据统一使用 Gitea 账号级 Secret `ALL_GITHUB_TOKEN`(头像 → 设置 → Actions → 密钥),接入前配置并验证实际权限,仓库级同名项会覆盖账号级值。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。 - CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。 - 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。 -- 正式发布与生产入口暂时禁用,后续由 gitea-release 适配,不能绕过 Git 镜像锁直接写 GitHub 分支。生产保留现有目录和数据,不随开发推送更新;GitHub 旧 Release workflow 保持禁用,Pages Demo 不切换。 +- 正式发布从受信 main 手动触发,要求候选完整 SHA 的 main push CI 成功;日期哈希版本用于六平台包和 Linux 双架构镜像。固定发布 ID 与持久锁保证可续跑;双端发布和公开 GHCR 摘要拉取验收后自动部署 MAIN:`/docker/gitea/main/RawMark`、`RawMark-main`、`rawmark-main`。Pages 保持原配置。 diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 93efaf6..de2fb91 100644 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -4,7 +4,7 @@ set -eu mode="${1:?Expected development or release}" case "$mode" in development|release) ;; *) exit 2 ;; esac -if [ "$mode" = development ]; then export RAWMARK_BUILD_CHANNEL=development; fi +export RAWMARK_BUILD_CHANNEL="$mode" export RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)" export CGO_ENABLED=0 corepack enable diff --git a/scripts/ci-package.sh b/scripts/ci-package.sh index c28c72e..ee7c9b4 100644 --- a/scripts/ci-package.sh +++ b/scripts/ci-package.sh @@ -4,10 +4,10 @@ set -eu mode="${1:?Expected development or release}" case "$mode" in development) targets='linux/amd64' ;; - release) targets='linux/amd64 linux/arm64 windows/amd64 darwin/amd64 darwin/arm64' ;; + release) targets='linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64' ;; *) exit 2 ;; esac -if [ "$mode" = development ]; then export RAWMARK_BUILD_CHANNEL=development; fi +export RAWMARK_BUILD_CHANNEL="$mode" export RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)" export CGO_ENABLED=0 mkdir -p package/licenses downloads diff --git a/scripts/deploy.py b/scripts/deploy.py index 9b29e87..efe78ef 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -14,7 +14,7 @@ import tempfile import time import urllib.request -from release import API, GITEA_DEV_IMAGE, GITHUB_IMAGE, release_receipt, require, run +from release import API, GITEA_DEV_IMAGE, GITHUB_IMAGE, release_receipt, require, run, release_state, save_state def settings(directory, channel): @@ -55,6 +55,9 @@ def deployment(directory, channel, image, commit, version, client_container=None # Serializes actual deployments, including manually started jobs and CLI use. with (directory / '.deploy.lock').open('a') as lock: fcntl.flock(lock, fcntl.LOCK_EX) + if channel == 'production' and (directory / 'deployment.json').exists(): + deployed = json.loads((directory / 'deployment.json').read_text()) + run('git', 'merge-base', '--is-ancestor', deployed['commit'], commit) require(not (directory / 'deployment.pending.json').exists(), 'Resolve the previous pending deployment before attempting another update') run('docker', 'pull', image) @@ -156,7 +159,17 @@ def main(): else: image, commit = args.image, args.commit require(image and commit, 'Development requires an image digest and commit') - deployment(args.directory, args.channel, image, commit, args.version, args.client_container) + if args.channel == 'production': + with release_state() as state: + if state and state.get('status') != 'complete': + require(state.get('commit') == commit and state.get('version') == args.version, + 'Another release is pending') + deployment(args.directory, args.channel, image, commit, args.version, args.client_container) + if state and state.get('commit') == commit: + state['status'] = 'complete' + save_state(state) + else: + deployment(args.directory, args.channel, image, commit, args.version, args.client_container) if __name__ == '__main__': diff --git a/scripts/release.py b/scripts/release.py index 78e3a54..ed5cea3 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -4,6 +4,8 @@ import argparse import base64 import datetime +import contextlib +import fcntl import hashlib import io import json @@ -25,7 +27,7 @@ REPOSITORY = 'eezd/RawMark' GITEA_IMAGE = 'git.134333.xyz/eezd/rawmark' GITEA_DEV_IMAGE = 'git.134333.xyz/eezd/dev/rawmark' GITHUB_IMAGE = 'ghcr.io/eezd/rawmark' -PLATFORMS = ('linux/amd64', 'linux/arm64', 'windows/amd64', 'darwin/amd64', 'darwin/arm64') +PLATFORMS = ('linux/amd64', 'linux/arm64', 'windows/amd64', 'windows/arm64', 'darwin/amd64', 'darwin/arm64') UPLOAD_LIMIT = 90_000_000 DOWNLOADS = Path('downloads') @@ -55,7 +57,7 @@ def output(name, value): def build_version(): - if os.environ.get('RAWMARK_BUILD_CHANNEL') != 'development': + if os.environ.get('RAWMARK_BUILD_CHANNEL') not in ('development', 'release'): return Path('VERSION').read_text().strip() commit = run('git', 'rev-parse', 'HEAD') timestamp = int(run('git', 'show', '-s', '--format=%ct', commit)) @@ -188,7 +190,7 @@ class API: return self.request('/repos/' + REPOSITORY + path, **kwargs) def release(self, version): - return self.repo('/releases/tags/v' + version, missing=True) + return self.repo('/releases/tags/' + version, missing=True) def assets(self, release): return self.repo(f'/releases/{release["id"]}/assets') @@ -248,10 +250,10 @@ def clients(): def validate_identity(version, commit): - require(re.fullmatch(r'\d+\.\d+\.\d+', version), 'Version must use X.Y.Z') + require(re.fullmatch(r'[0-9]{8}-[0-9a-f]{8}', version), 'Expected a date and eight-character commit hash') require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Commit must be a full SHA') - require(version == Path('VERSION').read_text().strip(), 'VERSION does not match the requested version') - require(commit == run('git', 'rev-parse', 'HEAD'), 'Checkout does not match the requested commit') + require(version == version_for(commit), 'Version does not match the selected commit') + run('git', 'merge-base', '--is-ancestor', commit, 'HEAD') def git_env(api): @@ -270,10 +272,11 @@ def fetch_main(api): def release_receipt(gitea, github, version): - require(re.fullmatch(r'\d+\.\d+\.\d+', version), 'Version must use X.Y.Z') + require(re.fullmatch(r'[0-9]{8}-[0-9a-f]{8}', version), 'Expected a date and eight-character commit hash') left, right = gitea.release(version), github.release(version) require(left and right and not any(r['draft'] or r['prerelease'] for r in (left, right)), 'Version is not completely published on both services') + require(left['name'] == right['name'] and left['body'] == right['body'], 'Published release metadata differs') a, b = gitea.asset_bytes(left, 'release.json'), github.asset_bytes(right, 'release.json') require(a == b, 'Published release receipts differ') receipt = json.loads(a) @@ -288,7 +291,7 @@ def release_receipt(gitea, github, version): require(re.fullmatch(r'[0-9a-f]{64}', info['sha256']) and 0 < info['size'] < UPLOAD_LIMIT, 'Invalid published asset metadata') for api in (gitea, github): - require(api.repo('/commits/v' + version)['sha'] == build['commit'], 'Published tag points to another commit') + require(api.repo('/commits/' + version)['sha'] == build['commit'], 'Published tag points to another commit') assets = {asset['name']: asset for asset in api.assets(left if api is gitea else right)} for name, info in build['files'].items(): require(name in assets and assets[name]['size'] == info['size'], 'Published asset is missing or has a different size') @@ -297,6 +300,14 @@ def release_receipt(gitea, github, version): def preflight(version, commit): + state = selected_state() + gitea, _ = clients() + active = state.get('active_run_id') + if active and str(active) != os.environ['CI_RUN_ID']: + require(gitea.repo('/actions/runs/' + str(active))['status'] == 'completed', 'Another release run is active') + state['active_run_id'] = int(os.environ['CI_RUN_ID']) + save_state(state) + require(state['version'] == version, 'Reserved version mismatch') validate_identity(version, commit) gitea, github = clients() repository = github.repo() @@ -320,40 +331,108 @@ def preflight(version, commit): if not runs: break passed = any(r['head_sha'] == commit and r['event'] == 'push' and r['conclusion'] == 'success' - and r['path'] == 'ci.yml@refs/heads/main' for r in runs) + and r['path'] == 'main.yml@refs/heads/main' for r in runs) page += 1 - require(passed, 'The selected commit has no successful main-push development CI') + require(passed, 'The selected commit has no successful main-push CI') remote = fetch_main(github) run('git', 'merge-base', '--is-ancestor', remote, commit) + tag = version + existing = gitea.repo('/commits/' + tag, missing=True) + if existing: + require(existing['sha'] == commit, 'Existing tag belongs to another commit') + else: + run('git', '-c', 'user.name=eezd', '-c', 'user.email=eezd@outlook.com', + 'tag', '-a', tag, commit, '-m', 'RawMark ' + version) + require(run('git', 'for-each-ref', '--format=%(taggername)', 'refs/tags/' + tag) == 'eezd', 'Unexpected tagger') + run('git', 'push', gitea.root.removesuffix('/api/v1') + '/' + REPOSITORY + '.git', + 'refs/tags/' + tag, env=git_env(gitea)) + from mirror import synchronize + synchronize(STATE_DIRECTORY, gitea.root.removesuffix('/api/v1') + '/' + REPOSITORY + '.git', + 'https://github.com/' + REPOSITORY + '.git', git_env(github)) + require(github.repo('/commits/' + tag)['sha'] == commit, 'GitHub tag has not mirrored') if current: restore(gitea, current) output('completed', 'false') output('reuse', 'true' if current else 'false') +def version_for(commit): + timestamp = int(run('git', 'show', '-s', '--format=%ct', commit)) + return datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc).strftime('%Y%m%d') + '-' + commit[:8] + + +STATE_DIRECTORY = Path('/docker/gitea/mirrors/RawMark') + + +@contextlib.contextmanager +def release_state(): + with (STATE_DIRECTORY / '.release.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + path = STATE_DIRECTORY / 'release.pending.json' + state = json.loads(path.read_text()) if path.exists() else None + yield state + + +def save_state(state): + path = STATE_DIRECTORY / 'release.pending.json' + temporary = path.with_suffix('.tmp') + write_json(temporary, state) + temporary.replace(path) + + +def reserve(dev): + require(re.fullmatch(r'[0-9a-f]{40}', dev), 'Expected the accepted dev SHA') + with release_state() as current: + if current and current.get('status') != 'complete': + require(current['dev'] == dev, 'Another release is pending; resolve it before starting another') + print(current['id']) + return + state = {'id': dev, 'dev': dev, 'status': 'preparing'} + save_state(state) + print(state['id']) + + +def select_release(commit, notes_file): + with release_state() as state: + require(state and state['status'] == 'preparing', 'Expected a release preparation reservation') + require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected a full main SHA') + content = Path(notes_file).read_text() + require(content.strip(), 'Release notes are required') + state.update(commit=commit, version=version_for(commit), notes=content, + notes_sha256=hashlib.sha256(content.encode()).hexdigest(), status='selected') + save_state(state) + print(state['version']) + + +def selected_state(): + state = json.loads((STATE_DIRECTORY / 'release.pending.json').read_text()) + require(state['id'] == os.environ['RELEASE_ID'] and state['commit'] == os.environ['RELEASE_COMMIT'], + 'Release inputs differ from the reserved candidate') + require(hashlib.sha256(state['notes'].encode()).hexdigest() == state['notes_sha256'], 'Release notes changed') + return state + + def notes(build): - return (Path('.github/release-notes.md').read_text().rstrip() + '\n\n' + state = selected_state() + require(state['commit'] == build['commit'] and state['version'] == build['version'], 'Release notes candidate mismatch') + return (state['notes'].rstrip() + '\n\n' f'Source commit: `{build["commit"]}`. Build run: `{build["run_id"]}`.\n\n' - 'Validation: Linux amd64 runtime tests; all five targets cross-built and vetted on Gitea. ' - 'Windows, macOS and Linux arm64 have not passed native runtime acceptance for this release.\n') + 'Validation: Linux amd64 native lifecycle and container tests; six targets cross-built and vetted. ' + 'Other targets have not passed native runtime acceptance. Downloads are unsigned.\n\n' + '验证:Linux amd64 已完成原生生命周期与容器测试;六平台交叉构建及 vet。其他平台未完成原生运行验收,程序未签名。\n') def ensure_draft(api, build): - tag = 'v' + build['version'] + tag = build['version'] existing = api.repo('/commits/' + tag, missing=True) - if existing: - require(existing['sha'] == build['commit'], 'Existing tag conflicts') - elif api.github: - api.repo('/git/refs', method='POST', data={'ref': 'refs/tags/' + tag, 'sha': build['commit']}) - else: - api.repo('/tags', method='POST', data={'tag_name': tag, 'target': build['commit']}) + require(existing and existing['sha'] == build['commit'], 'Expected the verified mirrored annotated tag') release = api.release(build['version']) if not release: release = api.repo('/releases', method='POST', data={ - 'tag_name': 'v' + build['version'], 'target_commitish': build['commit'], + 'tag_name': build['version'], 'target_commitish': build['commit'], 'name': 'RawMark ' + build['version'], 'body': notes(build), 'draft': True, }) - require(api.repo('/commits/v' + build['version'])['sha'] == build['commit'], 'Existing tag conflicts') + require(api.repo('/commits/' + build['version'])['sha'] == build['commit'], 'Existing tag conflicts') require(release['body'] == notes(build), 'Existing release notes conflict') return release @@ -362,7 +441,8 @@ def checkpoint(): build = verify_bundle() gitea, _ = clients() release = ensure_draft(gitea, build) - gitea.upload(release, DOWNLOADS / 'build.json') + for name in [*build['files'], 'SHA256SUMS', 'build.json']: + gitea.upload(release, DOWNLOADS / name) def restore(gitea, release): @@ -491,69 +571,9 @@ def verify_anonymous(image): run('docker', 'pull', image, env=env) -def sync_source(github, build): - remote = fetch_main(github) - run('git', 'merge-base', '--is-ancestor', remote, build['commit']) - tag = 'v' + build['version'] - existing = github.repo('/commits/' + tag, missing=True) - if existing: - require(existing['sha'] == build['commit'], 'GitHub tag already points to another commit') - # Bound the actual pack rather than checking individual tracked file sizes. - revision_input = f'{build["commit"]}\n^{remote}\n' - pack = subprocess.run(['git', 'pack-objects', '--revs', '--stdout'], input=revision_input.encode(), - stdout=subprocess.PIPE, check=True).stdout - require(len(pack) < UPLOAD_LIMIT, 'GitHub source push exceeds CDN budget') - run('git', 'push', 'https://github.com/' + REPOSITORY + '.git', - build['commit'] + ':refs/heads/main', env=git_env(github)) - - -def sync_pages(github, build): - env = git_env(github) - with tempfile.TemporaryDirectory(prefix='rawmark-pages-') as directory: - target = Path(directory) - url = 'https://github.com/' + REPOSITORY + '.git' - run('git', 'init', '--initial-branch=gh-pages', directory) - existing = github.repo('/branches/gh-pages', missing=True) - if existing: - run('git', '-C', directory, 'fetch', '--depth=1', url, 'gh-pages', env=env) - run('git', '-C', directory, 'reset', '--hard', 'FETCH_HEAD') - run('git', '-C', directory, 'rm', '-r', '--ignore-unmatch', '.') - with zipfile.ZipFile(DOWNLOADS / 'demo.zip') as archive: - for info in archive.infolist(): - path = Path(info.filename) - require(not path.is_absolute() and '..' not in path.parts and '.git' not in path.parts, - 'Unsafe demo archive entry') - archive.extract(info, target) - run('git', '-C', directory, 'add', '.') - changed = run('git', '-C', directory, 'status', '--porcelain') - if changed: - run('git', '-C', directory, '-c', 'user.name=RawMark CI', - '-c', 'user.email=rawmark-ci@users.noreply.github.com', 'commit', - '-m', 'docs(demo): publish RawMark ' + build['version']) - run('git', '-C', directory, 'push', url, 'HEAD:refs/heads/gh-pages', env=env) - pages_commit = run('git', '-C', directory, 'rev-parse', 'HEAD') - pages = github.repo('/pages') - if pages.get('build_type') != 'legacy' or pages.get('source') != {'branch': 'gh-pages', 'path': '/'}: - github.repo('/pages', method='PUT', data={'build_type': 'legacy', 'source': {'branch': 'gh-pages', 'path': '/'}}) - deadline = time.monotonic() + 900 - while time.monotonic() < deadline: - url = 'https://eezd.github.io/RawMark/rawmark-release.json?commit=' + build['commit'] - try: - with urllib.request.urlopen(url, timeout=15) as response: - published = json.load(response) - except urllib.error.HTTPError as error: - if error.code not in (404, 503): - raise - else: - if published == {'version': build['version'], 'commit': build['commit']}: - return pages_commit - time.sleep(5) - raise TimeoutError('GitHub Pages did not serve the selected release; Gitea release remains a draft') - - def publish(): gitea, github = clients() - release = gitea.release(Path('VERSION').read_text().strip()) + release = gitea.release(selected_state()['version']) require(release and release['draft'], 'Expected the build checkpoint draft') restore(gitea, release) build = verify_bundle() @@ -602,15 +622,15 @@ def publish(): str(Path(os.environ['DOCKER_CONFIG']) / 'config.json'), 'docker://' + GITEA_IMAGE + '@' + value, 'docker://' + target) require(image_digest(target) == value, 'GHCR image differs from the tested candidate') + package = github.request('/user/packages/container/rawmark') + require(package['visibility'] == 'public', 'GHCR package is private. Set Public at https://github.com/users/eezd/packages/container/rawmark/settings then retry this release ID; original artifacts are saved.') verify_anonymous(GITHUB_IMAGE + '@' + value) - sync_source(github, build) public_release = ensure_draft(github, build) for name in [*(archive_name(tag, p) for p in PLATFORMS), 'SHA256SUMS', 'build.json', 'demo.zip']: for api, destination in ((gitea, release), (github, public_release)): api.upload(destination, DOWNLOADS / name) - pages_commit = sync_pages(github, build) write_json(DOWNLOADS / 'release.json', {'schema': 1, 'build_sha256': digest(DOWNLOADS / 'build.json'), - 'image_digest': value, 'pages_commit': pages_commit}) + 'image_digest': value}) for api, destination in ((gitea, release), (github, public_release)): api.upload(destination, DOWNLOADS / 'release.json') # Match the internal checkpoint on GitHub too; both releases expose the same files. @@ -632,6 +652,10 @@ def publish(): def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='command', required=True) + sub.add_parser('reserve').add_argument('dev') + selection = sub.add_parser('select') + selection.add_argument('commit') + selection.add_argument('notes_file') sub.add_parser('bundle').add_argument('mode', choices=('development', 'release')) sub.add_parser('prepare-image') sub.add_parser('version') @@ -642,20 +666,30 @@ def main(): sub.add_parser('publish') sub.add_parser('promote-development').add_argument('image') args = parser.parse_args() - if args.command == 'version': + if args.command == 'reserve': + reserve(args.dev) + elif args.command == 'select': + select_release(args.commit, args.notes_file) + elif args.command == 'version': print(build_version()) elif args.command == 'bundle': bundle(args.mode) elif args.command == 'prepare-image': prepare_image() elif args.command == 'preflight': - preflight(args.version, args.commit) + with release_state() as state: + selected_state() + preflight(args.version, args.commit) elif args.command == 'checkpoint': - checkpoint() + with release_state() as state: + selected_state() + checkpoint() elif args.command == 'promote-development': promote_development(args.image) else: - publish() + with release_state() as state: + selected_state() + publish() if __name__ == '__main__': diff --git a/scripts/tests/test_cicd.py b/scripts/tests/test_cicd.py index 38042b5..174e75f 100644 --- a/scripts/tests/test_cicd.py +++ b/scripts/tests/test_cicd.py @@ -117,7 +117,7 @@ class PublicationTests(unittest.TestCase): gitea.release.return_value = {'draft': True, 'prerelease': False} github.release.return_value = {'draft': False, 'prerelease': False} with self.assertRaisesRegex(ValueError, 'not completely published'): - release.release_receipt(gitea, github, '0.1.1') + release.release_receipt(gitea, github, '20261030-aaaaaaaa') def test_registry_permission_error_is_not_treated_as_missing_image(self): result = Mock(returncode=1, stderr='403 Forbidden', stdout='') @@ -327,3 +327,37 @@ class CleanupTests(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class ReleaseReservationTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.patch = patch.object(release, 'STATE_DIRECTORY', Path(self.directory.name)) + self.patch.start() + self.addCleanup(self.patch.stop) + + def test_other_candidate_cannot_replace_pending_release(self): + release.reserve(SHA) + with self.assertRaisesRegex(ValueError, 'Another release'): + release.reserve('c' * 40) + + def test_fixed_notes_and_candidate_are_required_on_retry(self): + release.reserve(SHA) + notes = Path(self.directory.name) / 'notes.md' + notes.write_text('English\n\n中文') + with patch.object(release, 'version_for', return_value='20261030-aaaaaaaa'): + release.select_release(SHA, notes) + with patch.dict(os.environ, {'RELEASE_ID': SHA, 'RELEASE_COMMIT': SHA}): + self.assertEqual(release.selected_state()['notes'], notes.read_text()) + with patch.dict(os.environ, {'RELEASE_COMMIT': 'b' * 40}): + with self.assertRaisesRegex(ValueError, 'reserved candidate'): + release.selected_state() + + def test_release_matrix_includes_windows_arm64(self): + self.assertEqual(set(release.PLATFORMS), {'linux/amd64', 'linux/arm64', + 'darwin/amd64', 'darwin/arm64', 'windows/amd64', 'windows/arm64'}) + + def test_release_version_uses_utc_commit_date_and_fixed_sha(self): + with patch.object(release, 'run', return_value='0'): + self.assertEqual(release.version_for(SHA), '19700101-aaaaaaaa')