Compare commits

...
Author SHA1 Message Date
eezd 32c1c25414 Merge pull request #3: formal release and MAIN deployment
Main CI / check-and-package (push) Successful in 3m50s
Main CI / image (push) Successful in 15s
GitHub Git mirror / mirror (push) Successful in 5s
2026-10-30 11:15:59 +08:00
eezd be616668fc fix(ci): check exact main admission across pending runs
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / check-and-package (push) Successful in 3m49s
Development CI / image (push) Successful in 19s
Main CI / check-and-package (pull_request) Successful in 3m49s
Development CI / deploy-development (push) Successful in 11s
Main CI / image (pull_request) Successful in 11s
2026-10-30 11:03:59 +08:00
eezd 344ff42823 fix(ci): validate release identities and workflow environment
GitHub Git mirror / mirror (push) Successful in 4s
Development CI / check-and-package (push) Canceled after 0s
Development CI / image (push) Canceled after 0s
Development CI / deploy-development (push) Canceled after 0s
Main CI / check-and-package (pull_request) Successful in 3m52s
Main CI / image (pull_request) Successful in 16s
2026-10-30 10:57:52 +08:00
eezd 525fa51b19 feat(ci): adapt formal releases and MAIN deployment
Development CI / check-and-package (push) Successful in 3m52s
GitHub Git mirror / mirror (push) Successful in 5s
Main CI / check-and-package (pull_request) Successful in 3m51s
Development CI / image (push) Successful in 20s
Main CI / image (pull_request) Successful in 12s
Development CI / deploy-development (push) Successful in 5s
2026-10-30 10:54:07 +08:00
eezd f01d85c133 Merge pull request #2: use the account-level GitHub token
Main CI / check-and-package (push) Successful in 3m50s
GitHub Git mirror / mirror (push) Successful in 6s
Main CI / image (push) Successful in 14s
2026-10-30 09:59:56 +08:00
eezd 78e463b4fa ci: use the account-level GitHub token
Development CI / check-and-package (push) Successful in 3m53s
GitHub Git mirror / mirror (push) Successful in 6s
Main CI / check-and-package (pull_request) Successful in 3m50s
Development CI / image (push) Successful in 19s
Main CI / image (pull_request) Successful in 12s
Development CI / deploy-development (push) Successful in 11s
2026-10-30 09:50:43 +08:00
15 changed files with 328 additions and 155 deletions

No files matched your search

+7 -4
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: Completed formal release version (X.Y.Z) description: Completed date-hash release version
required: true required: true
type: string type: string
@@ -23,7 +23,8 @@ jobs:
container: container:
image: docker:29-cli image: docker:29-cli
volumes: volumes:
- /docker/main/rawmark:/docker/main/rawmark - /docker/gitea/main/RawMark:/docker/gitea/main/RawMark
- /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
env: env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1 GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_READ_TOKEN: ${{ gitea.token }} GITEA_READ_TOKEN: ${{ gitea.token }}
@@ -34,16 +35,18 @@ jobs:
shell: sh shell: sh
steps: steps:
- name: Install deployment runtimes - 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 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: main
fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Verify completed release, back up and deploy only RawMark - name: Verify completed release, back up and deploy only RawMark
run: | run: |
set -eu set -eu
mkdir -p "$DOCKER_CONFIG" mkdir -p "$DOCKER_CONFIG"
chmod 700 "$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)" --version "$RELEASE_VERSION" --client-container "$(hostname)"
- name: Remove job configuration - name: Remove job configuration
if: always() if: always()
+1 -1
View File
@@ -31,5 +31,5 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Reconcile current source references - name: Reconcile current source references
env: env:
GITHUB_MIRROR_TOKEN: ${{ secrets.RAWMARK_GITHUB_MIRROR_TOKEN }} GITHUB_MIRROR_TOKEN: ${{ secrets.ALL_GITHUB_TOKEN }}
run: python3 scripts/mirror.py --directory /docker/gitea/mirrors/RawMark run: python3 scripts/mirror.py --directory /docker/gitea/mirrors/RawMark
+90 -26
View File
@@ -3,12 +3,12 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: release_id:
description: Version matching VERSION (X.Y.Z) description: Reserved release ID
required: true required: true
type: string type: string
commit: commit:
description: Full main commit SHA with successful development CI description: Full candidate SHA with successful main-push CI
required: true required: true
type: string type: string
@@ -21,6 +21,9 @@ concurrency:
env: env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1 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 }} CI_RUN_ID: ${{ gitea.run_id }}
jobs: jobs:
@@ -28,38 +31,46 @@ jobs:
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 55 timeout-minutes: 55
env: container:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }} image: node:24-bookworm
GITHUB_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITHUB_TOKEN }} volumes:
RELEASE_VERSION: ${{ inputs.version }} - /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
RELEASE_COMMIT: ${{ inputs.commit }}
outputs: outputs:
completed: ${{ steps.preflight.outputs.completed }} completed: ${{ steps.preflight.outputs.completed }}
steps: steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: ${{ inputs.commit }} ref: ${{ gitea.sha }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Validate source, permissions and existing release checkpoint - name: Validate source, permissions and existing release checkpoint
id: preflight 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 - uses: https://github.com/actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
go-version-file: go.mod go-version-file: source/go.mod
cache: false cache: false
- name: Check and build the five platform packages - name: Check and build the six platform packages
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
working-directory: source
run: | 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-check.sh release
sh scripts/ci-package.sh release sh scripts/ci-package.sh release
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: linux-amd64 name: linux-amd64
path: downloads/*_linux_amd64.tar.gz path: source/downloads/*_linux_amd64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -67,7 +78,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: linux-arm64 name: linux-arm64
path: downloads/*_linux_arm64.tar.gz path: source/downloads/*_linux_arm64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -75,7 +86,15 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: windows-amd64 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 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -83,7 +102,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: darwin-amd64 name: darwin-amd64
path: downloads/*_darwin_amd64.tar.gz path: source/downloads/*_darwin_amd64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -91,7 +110,7 @@ jobs:
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: darwin-arm64 name: darwin-arm64
path: downloads/*_darwin_arm64.tar.gz path: source/downloads/*_darwin_arm64.tar.gz
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -100,21 +119,25 @@ jobs:
with: with:
name: checksums name: checksums
path: | path: |
downloads/SHA256SUMS source/downloads/SHA256SUMS
downloads/build.json source/downloads/build.json
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.preflight.outputs.reuse == 'false' if: steps.preflight.outputs.reuse == 'false'
with: with:
name: demo name: demo
path: downloads/demo.zip path: source/downloads/demo.zip
compression-level: 0 compression-level: 0
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Save immutable build checkpoint after artifact uploads - name: Save immutable build checkpoint after artifact uploads
if: steps.preflight.outputs.reuse == 'false' 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: publish:
needs: build needs: build
@@ -123,9 +146,9 @@ jobs:
timeout-minutes: 55 timeout-minutes: 55
container: container:
image: docker:29-cli image: docker:29-cli
volumes:
- /docker/gitea/mirrors/RawMark:/docker/gitea/mirrors/RawMark
env: env:
GITEA_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITEA_TOKEN }}
GITHUB_RELEASE_TOKEN: ${{ secrets.RAWMARK_GITHUB_TOKEN }}
CI_RESOURCE_LABEL: io.rawmark.ci.run=release-${{ gitea.run_id }}-${{ gitea.run_attempt }} CI_RESOURCE_LABEL: io.rawmark.ci.run=release-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/rawmark-docker-auth DOCKER_CONFIG: /tmp/rawmark-docker-auth
defaults: defaults:
@@ -136,17 +159,58 @@ jobs:
run: apk add --no-cache nodejs python3 git skopeo run: apk add --no-cache nodejs python3 git skopeo
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
ref: ${{ inputs.commit }} ref: ${{ gitea.sha }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false 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 - 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: | run: |
set -eu set -eu
mkdir -p "$DOCKER_CONFIG" mkdir -p "$DOCKER_CONFIG"
chmod 700 "$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 - name: Clean only this attempt's test resources and credentials
if: always() if: always()
run: | run: |
python3 scripts/ci-cleanup.py "$CI_RESOURCE_LABEL" --client-container "$(hostname)" python3 scripts/ci-cleanup.py "$CI_RESOURCE_LABEL" --client-container "$(hostname)"
rm -rf "$DOCKER_CONFIG" 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"
+2 -2
View File
@@ -134,7 +134,7 @@ Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 mai
- dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按摘要部署 DEV。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。 - dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按摘要部署 DEV。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。
- main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。 - main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。
- DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw-dev.134333.xyz`。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。 - DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw-dev.134333.xyz`。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。
- Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据为 `RAWMARK_GITHUB_MIRROR_TOKEN`。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。 - 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。 - CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。
- 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。 - 开发构建通过 `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,旧版本不得覆盖新版;生产数据保留并先备份,不随开发推送更新。
+2 -2
View File
@@ -45,12 +45,12 @@ Choose an available version from [Releases](https://github.com/eezd/RawMark/rele
docker run -d --name rawmark --restart unless-stopped \ docker run -d --name rawmark --restart unless-stopped \
-p 127.0.0.1:21000:8080 \ -p 127.0.0.1:21000:8080 \
-v rawmark-data:/data \ -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. 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. [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.
+1 -1
View File
@@ -45,7 +45,7 @@ RawMark 面向**单用户、自托管**场景。目前不提供多人协作或
docker run -d --name rawmark --restart unless-stopped \ docker run -d --name rawmark --restart unless-stopped \
-p 127.0.0.1:21000:8080 \ -p 127.0.0.1:21000:8080 \
-v rawmark-data:/data \ -v rawmark-data:/data \
ghcr.io/eezd/rawmark:0.1.1 ghcr.io/eezd/rawmark:latest
``` ```
打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。 打开 **http://localhost:21000**,首次访问设置登录密码。全新空实例会提供 7 篇英文示例笔记,可以自由编辑或删除,然后开始记录自己的内容。数据保存在 Docker 卷 `rawmark-data` 中,重建容器不会丢失。
+5 -5
View File
@@ -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:<version>` 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. Formal releases publish `ghcr.io/eezd/rawmark:<version>` 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 ```sh
sha256sum --ignore-missing --check SHA256SUMS 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 --version
RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark 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: On Windows, compare the output of `Get-FileHash` with the archive's entry in `SHA256SUMS`, then run in PowerShell:
```powershell ```powershell
Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256
Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark
Set-Location .\rawmark Set-Location .\rawmark
.\rawmark.exe --version .\rawmark.exe --version
$env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark'
+4 -4
View File
@@ -60,18 +60,18 @@ RAWMARK_DATA_DIR=/your/storage/rawmark/data \
```sh ```sh
sha256sum --ignore-missing --check SHA256SUMS 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 --version
RAWMARK_DATA="$HOME/rawmark-data" RAWMARK_ADDR=127.0.0.1:21000 ./rawmark 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 中启动: Windows 先将 `Get-FileHash` 的结果与 `SHA256SUMS` 中对应记录比较,再在 PowerShell 中启动:
```powershell ```powershell
Get-FileHash .\rawmark_0.1.1_windows_amd64.zip -Algorithm SHA256 Get-FileHash .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -Algorithm SHA256
Expand-Archive .\rawmark_0.1.1_windows_amd64.zip -DestinationPath .\rawmark Expand-Archive .\RawMark_YYYYMMDD-xxxxxxxx_windows_amd64.zip -DestinationPath .\rawmark
Set-Location .\rawmark Set-Location .\rawmark
.\rawmark.exe --version .\rawmark.exe --version
$env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark' $env:RAWMARK_DATA = Join-Path $env:LOCALAPPDATA 'RawMark'
+2 -2
View File
@@ -115,7 +115,7 @@ Gitea is the public source repository; GitHub is a strict one-way Git mirror. Bo
- A dev push checks, packages and smoke-tests Linux amd64 in CI containers, publishes only `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8-char-SHA>`, then deploys its verified digest. The date comes from the commit timestamp in UTC. Receipts retain the full SHA; conflicting versions fail and verified images are reused on retry. - A dev push checks, packages and smoke-tests Linux amd64 in CI containers, publishes only `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8-char-SHA>`, then deploys its verified digest. The date comes from the commit timestamp in UTC. Receipts retain the full SHA; conflicting versions fail and verified images are reused on retry.
- A main push independently checks and smoke-tests the actual merged commit without publishing images or deploying. Trusted same-repository PRs only check; external fork PRs do not automatically enter the shared Docker runner or receive publication credentials. - A main push independently checks and smoke-tests the actual merged commit without publishing images or deploying. Trusted same-repository PRs only check; external fork PRs do not automatically enter the shared Docker runner or receive publication credentials.
- DEV lives at `/docker/gitea/dev/RawMark`, container `RawMark-dev`, explicit Compose project `rawmark-dev`, loopback port 21001 and domain `raw-dev.134333.xyz`. All deployment entrypoints share the instance `.deploy.lock`; stale dev commits are skipped and unresolved pending deployments block updates. Existing data is backed up even when no old container exists. - DEV lives at `/docker/gitea/dev/RawMark`, container `RawMark-dev`, explicit Compose project `rawmark-dev`, loopback port 21001 and domain `raw-dev.134333.xyz`. All deployment entrypoints share the instance `.deploy.lock`; stale dev commits are skipped and unresolved pending deployments block updates. Existing data is backed up even when no old container exists.
- 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 `RAWMARK_GITHUB_MIRROR_TOKEN` with repository/workflow write access. DEV images never go to GHCR. - 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_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_<version>_linux_amd64.tar.gz`; historical release artifacts retain their names. Keep the existing 90,000,000-byte transfer budget. - `RAWMARK_BUILD_CHANNEL=development` derives a consistent frontend/binary version without editing the formal VERSION file. Development archives use `RawMark_<version>_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.
+2 -2
View File
@@ -115,7 +115,7 @@ Gitea 为公开主仓库,GitHub 为单向公开镜像;默认分支均为 mai
- dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按摘要部署 DEV。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。 - dev push:容器内检查、Linux amd64 打包与隔离冒烟,通过后发布 `git.134333.xyz/eezd/dev/rawmark:YYYYMMDD-<8位SHA>`,按摘要部署 DEV。日期取提交时间 UTC,完整 SHA 保留在回执;同名异提交拒绝,已验证镜像重试复用。
- main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。 - main push:独立检查实际合并提交、Linux amd64 构建及容器冒烟,不发布镜像、不部署。仓库内受信 PR 只检查;外部 fork PR 不自动进入共享 Docker runner,不注入发布凭据。
- DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw-dev.134333.xyz`。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。 - DEV 为 `/docker/gitea/dev/RawMark`,容器 `RawMark-dev`,Compose 项目 `rawmark-dev`,监听 `127.0.0.1:21001`,域名 `raw-dev.134333.xyz`。数据独立;所有部署共用宿主实例 `.deploy.lock`,锁内核对 dev 最新 SHA,pending 未处理时拒绝覆盖。已有数据即使无旧容器也必须备份。
- Git 镜像独立于 CI 成败:push/create/delete、每小时对账和手动重试,共用 `/docker/gitea/mirrors/RawMark/.mirror.lock`,仅同步业务分支和 TAG 及完整历史,跟随源端删除。同步代码从 main 读取;`RAWMARK_MIRROR_ENABLED=true` 后启用,凭据为 `RAWMARK_GITHUB_MIRROR_TOKEN`。DEV 镜像不上传 GHCR。main 切换前保持该变量关闭。 - 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。 - CI 凭据 `RAWMARK_GITEA_TOKEN` 需要仓库和 Packages 写权限。runner 保持容量一、`sweep: false`,只运行受信代码;工具链安装在任务容器。资源按任务标签清理,禁止全局 prune。
- 开发构建通过 `RAWMARK_BUILD_CHANNEL=development` 派生版本,前端与二进制一致;正式 `VERSION` 不因开发构建改变。开发包为 `RawMark_<版本>_linux_amd64.tar.gz`;历史正式产物不改名。安装包及单次传输保持既有 90,000,000 字节预算。 - 开发构建通过 `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 保持原配置。
+1 -1
View File
@@ -4,7 +4,7 @@ set -eu
mode="${1:?Expected development or release}" mode="${1:?Expected development or release}"
case "$mode" in development|release) ;; *) exit 2 ;; esac 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 RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)"
export CGO_ENABLED=0 export CGO_ENABLED=0
corepack enable corepack enable
+2 -2
View File
@@ -4,10 +4,10 @@ set -eu
mode="${1:?Expected development or release}" mode="${1:?Expected development or release}"
case "$mode" in case "$mode" in
development) targets='linux/amd64' ;; 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 ;; *) exit 2 ;;
esac 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 RAWMARK_BUILD_VERSION="$(python3 scripts/release.py version)"
export CGO_ENABLED=0 export CGO_ENABLED=0
mkdir -p package/licenses downloads mkdir -p package/licenses downloads
+15 -2
View File
@@ -14,7 +14,7 @@ import tempfile
import time import time
import urllib.request 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): 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. # Serializes actual deployments, including manually started jobs and CLI use.
with (directory / '.deploy.lock').open('a') as lock: with (directory / '.deploy.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX) 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(), require(not (directory / 'deployment.pending.json').exists(),
'Resolve the previous pending deployment before attempting another update') 'Resolve the previous pending deployment before attempting another update')
run('docker', 'pull', image) run('docker', 'pull', image)
@@ -156,7 +159,17 @@ def main():
else: else:
image, commit = args.image, args.commit image, commit = args.image, args.commit
require(image and commit, 'Development requires an image digest and 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__': if __name__ == '__main__':
+140 -100
View File
@@ -4,6 +4,8 @@
import argparse import argparse
import base64 import base64
import datetime import datetime
import contextlib
import fcntl
import hashlib import hashlib
import io import io
import json import json
@@ -25,7 +27,7 @@ REPOSITORY = 'eezd/RawMark'
GITEA_IMAGE = 'git.134333.xyz/eezd/rawmark' GITEA_IMAGE = 'git.134333.xyz/eezd/rawmark'
GITEA_DEV_IMAGE = 'git.134333.xyz/eezd/dev/rawmark' GITEA_DEV_IMAGE = 'git.134333.xyz/eezd/dev/rawmark'
GITHUB_IMAGE = 'ghcr.io/eezd/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 UPLOAD_LIMIT = 90_000_000
DOWNLOADS = Path('downloads') DOWNLOADS = Path('downloads')
@@ -55,7 +57,7 @@ def output(name, value):
def build_version(): 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() return Path('VERSION').read_text().strip()
commit = run('git', 'rev-parse', 'HEAD') commit = run('git', 'rev-parse', 'HEAD')
timestamp = int(run('git', 'show', '-s', '--format=%ct', commit)) timestamp = int(run('git', 'show', '-s', '--format=%ct', commit))
@@ -188,7 +190,7 @@ class API:
return self.request('/repos/' + REPOSITORY + path, **kwargs) return self.request('/repos/' + REPOSITORY + path, **kwargs)
def release(self, version): 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): def assets(self, release):
return self.repo(f'/releases/{release["id"]}/assets') return self.repo(f'/releases/{release["id"]}/assets')
@@ -242,16 +244,16 @@ class API:
def clients(): def clients():
require(os.environ.get('GITHUB_RELEASE_TOKEN'), 'Configure the RAWMARK_GITHUB_TOKEN Gitea secret before releasing') require(os.environ.get('GITHUB_RELEASE_TOKEN'), 'Configure the ALL_GITHUB_TOKEN Gitea secret before releasing')
return (API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']), return (API(os.environ['GITEA_API_URL'], os.environ['GITEA_RELEASE_TOKEN']),
API('https://api.github.com', os.environ['GITHUB_RELEASE_TOKEN'], github=True)) API('https://api.github.com', os.environ['GITHUB_RELEASE_TOKEN'], github=True))
def validate_identity(version, commit): 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(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(version == version_for(commit), 'Version does not match the selected commit')
require(commit == run('git', 'rev-parse', 'HEAD'), 'Checkout does not match the requested commit') run('git', 'merge-base', '--is-ancestor', commit, 'HEAD')
def git_env(api): def git_env(api):
@@ -270,10 +272,11 @@ def fetch_main(api):
def release_receipt(gitea, github, version): 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) 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)), 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') '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') a, b = gitea.asset_bytes(left, 'release.json'), github.asset_bytes(right, 'release.json')
require(a == b, 'Published release receipts differ') require(a == b, 'Published release receipts differ')
receipt = json.loads(a) 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, require(re.fullmatch(r'[0-9a-f]{64}', info['sha256']) and 0 < info['size'] < UPLOAD_LIMIT,
'Invalid published asset metadata') 'Invalid published asset metadata')
for api in (gitea, github): 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)} assets = {asset['name']: asset for asset in api.assets(left if api is gitea else right)}
for name, info in build['files'].items(): 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') require(name in assets and assets[name]['size'] == info['size'], 'Published asset is missing or has a different size')
@@ -296,9 +299,32 @@ def release_receipt(gitea, github, version):
return build, receipt return build, receipt
def successful_main_ci(gitea, commit):
page = 1
passed = False
while not passed:
runs = gitea.repo(f'/actions/runs?limit=100&page={page}')['workflow_runs']
if not runs:
break
passed = any(r['head_sha'] == commit and r['event'] == 'push' and r.get('conclusion') == 'success'
and r['path'] == 'main.yml@refs/heads/main' for r in runs)
page += 1
return passed
def preflight(version, commit): 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) validate_identity(version, commit)
gitea, github = clients() gitea, github = clients()
require(gitea.request('/user')['login'] == 'eezd', 'Unexpected Gitea release identity')
require(github.request('/user')['login'] == 'eezd', 'Unexpected GitHub release identity')
repository = github.repo() repository = github.repo()
require(repository['permissions']['push'], 'GitHub credential cannot publish this repository') require(repository['permissions']['push'], 'GitHub credential cannot publish this repository')
scopes = {s.strip() for s in github.headers.get('X-OAuth-Scopes', '').split(',')} scopes = {s.strip() for s in github.headers.get('X-OAuth-Scopes', '').split(',')}
@@ -313,47 +339,106 @@ def preflight(version, commit):
return return
main = fetch_main(gitea) main = fetch_main(gitea)
run('git', 'merge-base', '--is-ancestor', commit, main) run('git', 'merge-base', '--is-ancestor', commit, main)
page = 1 require(successful_main_ci(gitea, commit), 'The selected commit has no successful main-push CI')
passed = False
while not passed:
runs = gitea.repo(f'/actions/runs?limit=100&page={page}')['workflow_runs']
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)
page += 1
require(passed, 'The selected commit has no successful main-push development CI')
remote = fetch_main(github) remote = fetch_main(github)
run('git', 'merge-base', '--is-ancestor', remote, commit) 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', '[email protected]',
'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: if current:
restore(gitea, current) restore(gitea, current)
output('completed', 'false') output('completed', 'false')
output('reuse', 'true' if current else '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): 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' 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. ' 'Validation: Linux amd64 native lifecycle and container tests; six targets cross-built and vetted. '
'Windows, macOS and Linux arm64 have not passed native runtime acceptance for this release.\n') 'Other targets have not passed native runtime acceptance. Downloads are unsigned.\n\n'
'验证:Linux amd64 已完成原生生命周期与容器测试;六平台交叉构建及 vet。其他平台未完成原生运行验收,程序未签名。\n')
def ensure_draft(api, build): def ensure_draft(api, build):
tag = 'v' + build['version'] tag = build['version']
existing = api.repo('/commits/' + tag, missing=True) existing = api.repo('/commits/' + tag, missing=True)
if existing: require(existing and existing['sha'] == build['commit'], 'Expected the verified mirrored annotated tag')
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']})
release = api.release(build['version']) release = api.release(build['version'])
if not release: if not release:
release = api.repo('/releases', method='POST', data={ 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, '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') require(release['body'] == notes(build), 'Existing release notes conflict')
return release return release
@@ -362,7 +447,8 @@ def checkpoint():
build = verify_bundle() build = verify_bundle()
gitea, _ = clients() gitea, _ = clients()
release = ensure_draft(gitea, build) 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): def restore(gitea, release):
@@ -491,69 +577,9 @@ def verify_anonymous(image):
run('docker', 'pull', image, env=env) 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', '[email protected]', '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(): def publish():
gitea, github = clients() 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') require(release and release['draft'], 'Expected the build checkpoint draft')
restore(gitea, release) restore(gitea, release)
build = verify_bundle() build = verify_bundle()
@@ -602,15 +628,15 @@ def publish():
str(Path(os.environ['DOCKER_CONFIG']) / 'config.json'), str(Path(os.environ['DOCKER_CONFIG']) / 'config.json'),
'docker://' + GITEA_IMAGE + '@' + value, 'docker://' + target) 'docker://' + GITEA_IMAGE + '@' + value, 'docker://' + target)
require(image_digest(target) == value, 'GHCR image differs from the tested candidate') 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) verify_anonymous(GITHUB_IMAGE + '@' + value)
sync_source(github, build)
public_release = ensure_draft(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 name in [*(archive_name(tag, p) for p in PLATFORMS), 'SHA256SUMS', 'build.json', 'demo.zip']:
for api, destination in ((gitea, release), (github, public_release)): for api, destination in ((gitea, release), (github, public_release)):
api.upload(destination, DOWNLOADS / name) api.upload(destination, DOWNLOADS / name)
pages_commit = sync_pages(github, build)
write_json(DOWNLOADS / 'release.json', {'schema': 1, 'build_sha256': digest(DOWNLOADS / 'build.json'), 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)): for api, destination in ((gitea, release), (github, public_release)):
api.upload(destination, DOWNLOADS / 'release.json') api.upload(destination, DOWNLOADS / 'release.json')
# Match the internal checkpoint on GitHub too; both releases expose the same files. # Match the internal checkpoint on GitHub too; both releases expose the same files.
@@ -632,6 +658,10 @@ def publish():
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True) 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('bundle').add_argument('mode', choices=('development', 'release'))
sub.add_parser('prepare-image') sub.add_parser('prepare-image')
sub.add_parser('version') sub.add_parser('version')
@@ -642,20 +672,30 @@ def main():
sub.add_parser('publish') sub.add_parser('publish')
sub.add_parser('promote-development').add_argument('image') sub.add_parser('promote-development').add_argument('image')
args = parser.parse_args() 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()) print(build_version())
elif args.command == 'bundle': elif args.command == 'bundle':
bundle(args.mode) bundle(args.mode)
elif args.command == 'prepare-image': elif args.command == 'prepare-image':
prepare_image() prepare_image()
elif args.command == 'preflight': 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': elif args.command == 'checkpoint':
checkpoint() with release_state() as state:
selected_state()
checkpoint()
elif args.command == 'promote-development': elif args.command == 'promote-development':
promote_development(args.image) promote_development(args.image)
else: else:
publish() with release_state() as state:
selected_state()
publish()
if __name__ == '__main__': if __name__ == '__main__':
+54 -1
View File
@@ -117,7 +117,7 @@ class PublicationTests(unittest.TestCase):
gitea.release.return_value = {'draft': True, 'prerelease': False} gitea.release.return_value = {'draft': True, 'prerelease': False}
github.release.return_value = {'draft': False, 'prerelease': False} github.release.return_value = {'draft': False, 'prerelease': False}
with self.assertRaisesRegex(ValueError, 'not completely published'): 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): def test_registry_permission_error_is_not_treated_as_missing_image(self):
result = Mock(returncode=1, stderr='403 Forbidden', stdout='') result = Mock(returncode=1, stderr='403 Forbidden', stdout='')
@@ -327,3 +327,56 @@ class CleanupTests(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.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')
class MainAdmissionTests(unittest.TestCase):
def test_pending_rows_do_not_hide_later_success(self):
api = Mock()
api.repo.side_effect = [
{'workflow_runs': [{'head_sha': SHA, 'event': 'push', 'path': 'mirror.yml@refs/heads/main'}]},
{'workflow_runs': [{'head_sha': SHA, 'event': 'push', 'path': 'main.yml@refs/heads/main', 'conclusion': 'success'}]},
]
self.assertTrue(release.successful_main_ci(api, SHA))
def test_other_sha_pr_or_dev_success_cannot_admit_main(self):
api = Mock()
api.repo.side_effect = [{'workflow_runs': [
{'head_sha': 'b' * 40, 'event': 'push', 'path': 'main.yml@refs/heads/main', 'conclusion': 'success'},
{'head_sha': SHA, 'event': 'pull_request', 'path': 'main.yml@refs/pull/3/head', 'conclusion': 'success'},
{'head_sha': SHA, 'event': 'push', 'path': 'dev.yml@refs/heads/dev', 'conclusion': 'success'},
]}, {'workflow_runs': []}]
self.assertFalse(release.successful_main_ci(api, SHA))