diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf4617b..6f72000 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,10 @@ on: permissions: contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + env: RELEASE_NOTES_DIR: docs/releases @@ -33,28 +37,33 @@ jobs: - name: Build Core archive and Stable product manifest run: | test "$(git describe --tags --exact-match)" = "${GITHUB_REF_NAME}" + remote_tag_commit="$(git ls-remote origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk '{print $1}')" + test "${remote_tag_commit}" = "${GITHUB_SHA}" cp scripts/evozeus-install-preflight.mjs evozeus-install-preflight.mjs shasum -a 256 evozeus-install-preflight.mjs > evozeus-install-preflight.mjs.sha256 git archive --format=tar.gz --prefix="EvoZeus-${GITHUB_REF_NAME}/" -o "evozeus-${GITHUB_REF_NAME}.tar.gz" "${GITHUB_REF_NAME}" shasum -a 256 "evozeus-${GITHUB_REF_NAME}.tar.gz" > "evozeus-${GITHUB_REF_NAME}.tar.gz.sha256" node scripts/build-stable-product-manifest.mjs --input channels/stable-release-input.json --core-archive "evozeus-${GITHUB_REF_NAME}.tar.gz" --core-commit "${GITHUB_SHA}" --output evozeus-product-stable.json - - name: Verify immutable Releases are enabled - env: - GH_TOKEN: ${{ github.token }} - run: | - immutable_releases_enabled="$(gh api "repos/${GITHUB_REPOSITORY}/immutable-releases" --jq '.enabled')" - test "${immutable_releases_enabled}" = "true" - name: Create or reuse immutable Release draft + id: release_draft env: GH_TOKEN: ${{ github.token }} run: | release_state="$(gh release view "${GITHUB_REF_NAME}" --json isDraft,isImmutable --jq '[.isDraft,.isImmutable] | @tsv' 2>/dev/null || true)" if [ -z "${release_state}" ]; then gh release create "${GITHUB_REF_NAME}" --verify-tag --draft --title "EvoZeus ${GITHUB_REF_NAME}" --notes-file "${RELEASE_NOTES_DIR}/${GITHUB_REF_NAME}.md" - else - test "${release_state}" = $'true\tfalse' + publish_required="true" + elif [ "${release_state}" = $'true\tfalse' ]; then gh release edit "${GITHUB_REF_NAME}" --title "EvoZeus ${GITHUB_REF_NAME}" --notes-file "${RELEASE_NOTES_DIR}/${GITHUB_REF_NAME}.md" + publish_required="true" + elif [ "${release_state}" = $'false\tfalse' ]; then + gh release delete "${GITHUB_REF_NAME}" --yes + exit 1 + else + test "${release_state}" = $'false\ttrue' + publish_required="false" fi + echo "publish_required=${publish_required}" >> "${GITHUB_OUTPUT}" - name: Upload and verify the complete Release asset set env: GH_TOKEN: ${{ github.token }} @@ -66,7 +75,9 @@ jobs: "evozeus-${GITHUB_REF_NAME}.tar.gz.sha256" evozeus-product-stable.json ) - gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" --clobber + if [ "${{ steps.release_draft.outputs.publish_required }}" = "true" ]; then + gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" --clobber + fi asset_count="$(gh release view "${GITHUB_REF_NAME}" --json assets --jq '.assets | length')" test "${asset_count}" -eq "${#assets[@]}" for asset in "${assets[@]}"; do @@ -75,8 +86,15 @@ jobs: test "${actual_digest}" = "${expected_digest}" done - name: Publish and lock the verified immutable Release + if: steps.release_draft.outputs.publish_required == 'true' env: GH_TOKEN: ${{ github.token }} run: | + remote_tag_commit="$(git ls-remote origin "refs/tags/${GITHUB_REF_NAME}^{}" | awk '{print $1}')" + test "${remote_tag_commit}" = "${GITHUB_SHA}" gh release edit "${GITHUB_REF_NAME}" --draft=false --latest - test "$(gh release view "${GITHUB_REF_NAME}" --json isDraft,isImmutable --jq '[.isDraft,.isImmutable] | @tsv')" = $'false\ttrue' + release_state="$(gh release view "${GITHUB_REF_NAME}" --json isDraft,isImmutable --jq '[.isDraft,.isImmutable] | @tsv' 2>/dev/null || true)" + if [ "${release_state}" = $'false\tfalse' ]; then + gh release delete "${GITHUB_REF_NAME}" --yes + fi + test "${release_state}" = $'false\ttrue' diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1d280..8281fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Verification -- Release workflow regressions enforce the repository immutable setting, draft creation, upload-before-publish ordering, the exact five-asset set, per-asset digest verification, and final `isImmutable=true` state. +- Release workflow regressions serialize duplicate tag runs, idempotently re-audit an existing immutable Release, verify remote Tag identity before build and publication, enforce the exact five-asset set and per-asset digests, and require final `isImmutable=true`; a published mutable Release is deleted and the job fails. - The v0.5.1 candidate must repeat the single-UAT install, Doctor, correction/neutral/ambiguous Hook checks, zero-persistence check, and archive-to-tag tree identity audit before Stable completion. ## [v0.5.0] - 2026-08-04 diff --git a/docs/releases/v0.5.1.md b/docs/releases/v0.5.1.md index 7948912..6131a1b 100644 --- a/docs/releases/v0.5.1.md +++ b/docs/releases/v0.5.1.md @@ -6,15 +6,15 @@ EvoZeus v0.5.1 保留 v0.5.0 的 Core Lesson runtime、CoEvolve v0.15.0 与 cano ## 发布修复 -Release workflow 从“先发布、再上传”升级为五段 fail-closed 事务: +Release workflow 从“先发布、再上传”升级为五段发布事务;资产完整性路径 fail-closed,immutability 配置漂移采用 bounded rollback: -1. 在创建正式入口前确认仓库已启用 immutable Releases。 -2. 创建或复用 draft Release,验证 Tag,并同步正式标题与 Release Notes。 +1. 同一 Tag 的重复任务串行执行;后续任务对已发布 immutable Release 做幂等复核。 +2. 创建或复用 draft Release,验证 Tag,并同步正式标题与 Release Notes;恢复任务发现遗留可变 Release 时先删除并失败。 3. 上传 preflight、两份 checksum、Core archive 和 Stable product manifest 共五个资产。 4. 核对精确资产集合,并将每个本地 SHA-256 与 GitHub asset digest 逐项比较。 -5. 全部通过后发布,随后断言 `isDraft=false` 且 `isImmutable=true`。 +5. 构建前和发布前均验证远端 Tag Commit;全部通过后发布并断言 `isDraft=false`、`isImmutable=true`。若仓库配置漂移产生可变 Release,执行补偿删除并使任务失败。 -任何大文件上传失败都会把 Release 保留在 draft,正式入口不会出现缺失资产的版本。 +任何大文件上传失败都会把 Release 保留在 draft,正式入口不会出现缺失资产的版本。GitHub Actions 内置令牌无仓库 Administration 读取权限,因此 immutable 状态在发布后由 Release 自身状态验证,并对可变结果执行补偿删除;这属于 bounded rollback,服务端 immutable setting 仍是正式发布的治理前置条件。 ## 版本组合 diff --git a/scripts/release-notes-layout.test.mjs b/scripts/release-notes-layout.test.mjs index 3cfdb36..62fc56a 100644 --- a/scripts/release-notes-layout.test.mjs +++ b/scripts/release-notes-layout.test.mjs @@ -32,21 +32,23 @@ test("resolves tag Release Notes from docs/releases", () => { test("uploads and verifies every Release asset before immutable publication", () => { const workflow = readFileSync(join(ROOT, ".github", "workflows", "release.yml"), "utf8"); - const verifyImmutableSetting = workflow.indexOf("immutable-releases"); const createDraft = workflow.indexOf("gh release create"); const uploadAssets = workflow.indexOf("gh release upload"); const verifyDigest = workflow.indexOf("actual_digest="); const publishRelease = workflow.indexOf("gh release edit", uploadAssets); const assetBlock = workflow.match(/assets=\(\n([\s\S]*?)\n\s*\)/); - assert.ok(verifyImmutableSetting >= 0); - assert.ok(createDraft > verifyImmutableSetting); + assert.match(workflow, /concurrency:\n\s+group: release-\$\{\{ github\.ref \}\}\n\s+cancel-in-progress: false/); + assert.ok(createDraft >= 0); assert.ok(uploadAssets > createDraft); assert.ok(verifyDigest > uploadAssets); assert.ok(publishRelease > verifyDigest); assert.match(workflow.slice(createDraft, uploadAssets), /--verify-tag --draft/); assert.match(workflow.slice(createDraft, uploadAssets), /gh release edit[^\n]+--notes-file/); - assert.match(workflow.slice(verifyImmutableSetting, createDraft), /test "\$\{immutable_releases_enabled\}" = "true"/); + assert.match(workflow.slice(createDraft, uploadAssets), /elif \[ "\$\{release_state\}" = \$'false\\tfalse' \]; then/); + assert.match(workflow.slice(createDraft, uploadAssets), /gh release delete "\$\{GITHUB_REF_NAME\}" --yes\n\s+exit 1/); + assert.match(workflow.slice(createDraft, uploadAssets), /test "\$\{release_state\}" = \$'false\\ttrue'/); + assert.match(workflow.slice(createDraft, uploadAssets), /publish_required="false"/); assert.ok(assetBlock); assert.deepEqual( assetBlock[1].trim().split("\n").map((line) => line.trim().replace(/^"|"$/g, "")), @@ -59,9 +61,13 @@ test("uploads and verifies every Release asset before immutable publication", () ], ); assert.match(workflow, /test "\$\{asset_count\}" -eq "\$\{#assets\[@\]\}"/); + assert.match(workflow, /steps\.release_draft\.outputs\.publish_required/); + assert.equal([...workflow.matchAll(/git ls-remote origin/g)].length, 2); assert.match(workflow, /isDraft,isImmutable/); assert.match(workflow.slice(publishRelease), /--draft=false --latest/); - assert.match(workflow.slice(publishRelease), /false\\ttrue/); + assert.match(workflow.slice(publishRelease), /if \[ "\$\{release_state\}" = \$'false\\tfalse' \]/); + assert.match(workflow.slice(publishRelease), /gh release delete "\$\{GITHUB_REF_NAME\}" --yes/); + assert.match(workflow.slice(publishRelease), /test "\$\{release_state\}" = \$'false\\ttrue'/); }); test("keeps current product metadata and Release Notes aligned", () => {