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
eezd 67b8e64d6a Merge dev CI migration into main (#1)
Main CI / check-and-package (push) Successful in 4m1s
Main CI / image (push) Successful in 14s
GitHub Git mirror / mirror (push) Successful in 5s
Validate main independently; retain dev for daily development.
2026-10-29 21:33:47 +08:00
root 4607da1a04 fix: preserve note revision ETags through compression proxies
GitHub Git mirror / mirror (push) Skipped
Development CI / check-and-package (push) Successful in 4m3s
Development CI / image (push) Successful in 20s
Development CI / deploy-development (push) Successful in 11s
Main CI / check-and-package (pull_request) Successful in 3m57s
Main CI / image (pull_request) Successful in 11s
2026-10-29 21:18:56 +08:00
root afb959f02a docs: align managed deployment with dev branch workflow
GitHub Git mirror / mirror (push) Skipped
Development CI / check-and-package (push) Successful in 3m51s
Development CI / image (push) Successful in 25s
Development CI / deploy-development (push) Successful in 12s
2026-10-29 21:12:12 +08:00
18 changed files with 360 additions and 164 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()
-3
View File
@@ -63,9 +63,6 @@ jobs:
CI_RESOURCE_LABEL: io.rawmark.ci.run=dev-${{ gitea.run_id }}-${{ gitea.run_attempt }} CI_RESOURCE_LABEL: io.rawmark.ci.run=dev-${{ gitea.run_id }}-${{ gitea.run_attempt }}
TEMP_IMAGE: rawmark-ci:${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }} TEMP_IMAGE: rawmark-ci:${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/rawmark-docker-auth DOCKER_CONFIG: /tmp/rawmark-docker-auth
outputs:
image: ${{ steps.publish.outputs.image }}
version: ${{ steps.publish.outputs.version }}
defaults: defaults:
run: run:
shell: sh shell: sh
+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` 中,重建容器不会丢失。
+11 -9
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'
@@ -160,23 +160,25 @@ The Bearer authentication upgrade requires the frontend and backend to be releas
- Resetting settings retains the account, resources, plugin storage, and local tabs. - Resetting settings retains the account, resources, plugin storage, and local tabs.
- Logs live in `logs/`, with three rotated files retained, each up to 10 MiB. Settings can export logs and a version summary; this does not reconstruct historical logs. - Logs live in `logs/`, with three rotated files retained, each up to 10 MiB. Settings can export logs and a version summary; this does not reconstruct historical logs.
- If the interface breaks after an update, first check that the frontend and backend come from the same build, then inspect browser network requests and application logs. See the [user guide](guide.md#plugins-and-interface-recovery) for plugin and appearance recovery entry points. - If the interface breaks after an update, first check that the frontend and backend come from the same build, then inspect browser network requests and application logs. See the [user guide](guide.md#plugins-and-interface-recovery) for plugin and appearance recovery entry points.
- Note reads send `Cache-Control: no-store, no-transform` so proxies preserve the strong revision ETag used by conditional saves. A proxy rewriting it to a weak ETag can cause otherwise valid edits to return HTTP 412; preserve this response policy through the public ingress.
## Managed development and production ## Managed development and production
The checked-in `deploy/compose.image.yml` is an image-only template. Development lives in `/docker/gitea/dev/RawMark`; production lives in `/docker/main/rawmark`. Copy the template to the instance directory as `compose.yml`, with a private mode-600 `.env` beside it. The container name follows `RAWMARK_PROJECT`, so the development container is `rawmark-dev`. These instances do not depend on a source checkout. Set all required values explicitly: The checked-in `deploy/compose.image.yml` is an image-only template. Development lives in `/docker/gitea/dev/RawMark`; production lives in `/docker/main/rawmark`. Copy the template to the instance directory as `compose.yml`, with a private mode-600 `.env` beside it. Set the project and container names independently. These instances do not depend on a source checkout. Set all required values explicitly:
```dotenv ```dotenv
RAWMARK_CHANNEL=development RAWMARK_CHANNEL=development
RAWMARK_PROJECT=rawmark-dev RAWMARK_PROJECT=rawmark-dev
RAWMARK_CONTAINER_NAME=RawMark-dev
RAWMARK_BIND=127.0.0.1 RAWMARK_BIND=127.0.0.1
RAWMARK_PORT=21001 RAWMARK_PORT=21001
RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data
RAWMARK_IMAGE=git.134333.xyz/eezd/rawmark@sha256:<verified-digest> RAWMARK_IMAGE=git.134333.xyz/eezd/dev/rawmark@sha256:<verified-digest>
``` ```
For production, use channel `production`, project `rawmark`, port 21000, `/docker/main/rawmark/data`, and `ghcr.io/eezd/rawmark@sha256:<verified-digest>`. Preserve the current binding during migration. Create only a new development data directory with owner `65532:65532`; never recursively change existing production ownership or mount the repository example template as live storage. For production, use channel `production`, project `rawmark`, the existing container name, port 21000, `/docker/main/rawmark/data`, and `ghcr.io/eezd/rawmark@sha256:<verified-digest>`. Preserve the current binding during migration. Create only a new development data directory with owner `65532:65532`; never recursively change existing production ownership or mount the repository example template as live storage.
Main-push CI automatically updates development. The **Deploy production manually** Gitea workflow only accepts a version whose Gitea and GitHub releases are formal and contain identical build/release receipts and matching tags. It fixes the GHCR digest, checks current mounts and competing writers, stops only this project's old service, and backs up the whole data directory plus configuration before replacement. Backups are private and include SQLite WAL files; old images and backups are retained. Dev-push CI automatically updates development after checks and isolated smoke tests pass. Main pushes and PRs only validate. Formal release and **Deploy production manually** Gitea workflows remain disabled during this CI migration; enabling or executing them requires a separate release plan and authorization. The prepared production workflow requires matching formal Gitea/GitHub releases and receipts, pins the GHCR digest, and backs up data and configuration before replacement. Backups are private and include SQLite WAL files; old images and backups are retained.
The job records `deployment.pending.json` before replacement and `deployment.json` after health/frontend checks. If the candidate fails after accessing data, it stops the candidate and retains the backup and pending record for diagnosis; it does not automatically restore private data or run an older program against possibly migrated storage. Recovery requires explicit authorization and compatibility review. If backup fails before replacement, the untouched old service can be restarted safely. No global Docker cleanup is performed. The job records `deployment.pending.json` before replacement and `deployment.json` after health/frontend checks. If the candidate fails after accessing data, it stops the candidate and retains the backup and pending record for diagnosis; it does not automatically restore private data or run an older program against possibly migrated storage. Recovery requires explicit authorization and compatibility review. If backup fails before replacement, the untouched old service can be restarted safely. No global Docker cleanup is performed.
+10 -8
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'
@@ -160,23 +160,25 @@ Bearer 认证升级需要前后端一起发布。旧 Cookie 页面不能继续
- 重置设置保留账户、资源、插件存储和本机标签页。 - 重置设置保留账户、资源、插件存储和本机标签页。
- 日志位于 `logs/`,轮转保留三份,每份至多 10 MiB;可在设置中导出日志及版本摘要。该功能不补录历史日志。 - 日志位于 `logs/`,轮转保留三份,每份至多 10 MiB;可在设置中导出日志及版本摘要。该功能不补录历史日志。
- 更新后界面异常先核对前后端是否来自同一构建,再查看浏览器网络请求和应用日志;插件与外观恢复入口见[使用指南](guide.zh-CN.md#插件与界面恢复)。 - 更新后界面异常先核对前后端是否来自同一构建,再查看浏览器网络请求和应用日志;插件与外观恢复入口见[使用指南](guide.zh-CN.md#插件与界面恢复)。
- 笔记读取返回 `Cache-Control: no-store, no-transform`,防止代理改写条件保存使用的强版本 ETag。代理将其改为弱 ETag 可能导致正常编辑返回 HTTP 412;公网入口须保留该响应策略。
## 开发与生产托管 ## 开发与生产托管
仓库 `deploy/compose.image.yml` 是仅拉镜像的模板。开发实例放在 `/docker/gitea/dev/RawMark`,生产实例放在 `/docker/main/rawmark`;将模板复制至对应实例目录并命名为 `compose.yml`,旁边放权限 600 的私有 `.env`。容器名跟随 `RAWMARK_PROJECT`,开发容器为 `rawmark-dev`。实例不依赖源码工作目录,必须明确填写: 仓库 `deploy/compose.image.yml` 是仅拉镜像的模板。开发实例放在 `/docker/gitea/dev/RawMark`,生产实例放在 `/docker/main/rawmark`;将模板复制至对应实例目录并命名为 `compose.yml`,旁边放权限 600 的私有 `.env`。项目名与容器名分别指定。实例不依赖源码工作目录,必须明确填写:
```dotenv ```dotenv
RAWMARK_CHANNEL=development RAWMARK_CHANNEL=development
RAWMARK_PROJECT=rawmark-dev RAWMARK_PROJECT=rawmark-dev
RAWMARK_CONTAINER_NAME=RawMark-dev
RAWMARK_BIND=127.0.0.1 RAWMARK_BIND=127.0.0.1
RAWMARK_PORT=21001 RAWMARK_PORT=21001
RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data RAWMARK_DATA_DIR=/docker/gitea/dev/RawMark/data
RAWMARK_IMAGE=git.134333.xyz/eezd/rawmark@sha256:<已验证摘要> RAWMARK_IMAGE=git.134333.xyz/eezd/dev/rawmark@sha256:<已验证摘要>
``` ```
生产使用 channel `production`、project `rawmark`、端口 21000、`/docker/main/rawmark/data` 和 `ghcr.io/eezd/rawmark@sha256:<已验证摘要>`;迁移时保留当前监听地址。仅为新的开发目录设置 `65532:65532` 所有者,不递归修改现有生产权限,不将仓库示例模板作为运行数据挂载。 生产使用 channel `production`、project `rawmark`、现有容器名、端口 21000、`/docker/main/rawmark/data` 和 `ghcr.io/eezd/rawmark@sha256:<已验证摘要>`;迁移时保留当前监听地址。仅为新的开发目录设置 `65532:65532` 所有者,不递归修改现有生产权限,不将仓库示例模板作为运行数据挂载。
main push CI 自动更新开发。Gitea **Deploy production manually** 工作流只接受两端均正式发布、构建/发布回执相同且标签一致的版本。它固定 GHCR 摘要,检查原挂载与并发写入实例,仅停止本项目旧服务,备份完整数据目录和配置后替换。备份为私有文件并包含 SQLite WAL;旧镜像和备份继续保留。 dev push CI 通过检查和隔离冒烟后自动更新开发;main push 与 PR 仅验证。本次 CI 迁移期间,正式发布及 Gitea **Deploy production manually** 工作流保持禁用,启用或执行须另行规划并授权。已准备的生产工作流要求两端正式发布及回执一致,固定 GHCR 摘要,并在替换前备份数据和配置。备份为私有文件并包含 SQLite WAL;旧镜像和备份继续保留。
替换前记录 `deployment.pending.json`,健康和前端检查通过后记录 `deployment.json`。候选版本访问数据后失败则停止候选,保留备份和 pending 记录,不自动恢复私人数据,也不直接用旧程序打开可能迁移后的数据;恢复须明确授权并核对兼容性。备份在替换前失败时,旧数据未被新程序访问,可安全重新启动旧容器。不做全局 Docker 清理。 替换前记录 `deployment.pending.json`,健康和前端检查通过后记录 `deployment.json`。候选版本访问数据后失败则停止候选,保留备份和 pending 记录,不自动恢复私人数据,也不直接用旧程序打开可能迁移后的数据;恢复须明确授权并核对兼容性。备份在替换前失败时,旧数据未被新程序访问,可安全重新启动旧容器。不做全局 Docker 清理。
+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 保持原配置。
+2 -1
View File
@@ -327,7 +327,8 @@ func (s *Server) readFile(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("ETag", `"`+rev+`"`) w.Header().Set("ETag", `"`+rev+`"`)
w.Header().Set("Cache-Control", "no-store") // Preserve the strong revision ETag through compression proxies for If-Match writes.
w.Header().Set("Cache-Control", "no-store, no-transform")
w.Write(data) w.Write(data)
} }
+22 -1
View File
@@ -37,6 +37,23 @@ type fixture struct {
admin *http.Client admin *http.Client
} }
func TestNoteRevisionSurvivesProxyBoundary(t *testing.T) {
f := newFixture(t)
f.write("proxy.md", "original")
res := f.do(f.admin, "GET", "/api/v1/files/proxy.md", "", nil)
defer res.Body.Close()
etag := res.Header.Get("ETag")
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store, no-transform" || !strings.HasPrefix(etag, `"`) {
t.Fatalf("笔记响应必须保留强版本标识并禁止代理转换: %d %v", res.StatusCode, res.Header)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "updated", map[string]string{"If-Match": etag}); code != http.StatusOK {
t.Fatalf("读取的版本标识应允许条件保存: %d", code)
}
if code := f.status(f.admin, "PUT", "/api/v1/files/proxy.md", "stale", map[string]string{"If-Match": etag}); code != http.StatusPreconditionFailed {
t.Fatalf("过期版本必须仍拒绝保存: %d", code)
}
}
func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) { func TestMoveBroadcastsWhenAssetRewriteReplacesFileIdentity(t *testing.T) {
f := newFixture(t) f := newFixture(t)
f.write("from.md", "![](from.assets/x.png)") f.write("from.md", "![](from.assets/x.png)")
@@ -274,7 +291,11 @@ func TestAPIResponsesAreNotCached(t *testing.T) {
// Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace. // Firefox once cached identity and tree responses offline, incorrectly opening an unloadable workspace.
for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} { for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} {
res := f.do(f.admin, "GET", path, "", nil) res := f.do(f.admin, "GET", path, "", nil)
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" { want := "no-store"
if strings.HasPrefix(path, "/api/v1/files/") {
want = "no-store, no-transform"
}
if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != want {
t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control")) t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control"))
} }
} }
+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
+14 -1
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,6 +159,16 @@ 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')
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) deployment(args.directory, args.channel, image, commit, args.version, args.client_container)
+137 -97
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,19 +672,29 @@ 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':
with release_state() as state:
selected_state()
preflight(args.version, args.commit) preflight(args.version, args.commit)
elif args.command == 'checkpoint': elif args.command == 'checkpoint':
with release_state() as state:
selected_state()
checkpoint() checkpoint()
elif args.command == 'promote-development': elif args.command == 'promote-development':
promote_development(args.image) promote_development(args.image)
else: else:
with release_state() as state:
selected_state()
publish() publish()
+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))