Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:
permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

env:
RELEASE_NOTES_DIR: docs/releases

Expand All @@ -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}')"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve lightweight tags before comparing the remote commit

When a release uses a lightweight tag, git ls-remote origin "refs/tags/${GITHUB_REF_NAME}^{}" returns no row because the ^{} peeled ref is advertised only for annotated tags (the git ls-remote --help contract describes these as peeled tags). Consequently remote_tag_commit is empty and this check aborts every lightweight-tag release before draft creation. Resolve both lightweight and annotated remote tags to their underlying commit before comparing with GITHUB_SHA.

Useful? React with 👍 / 👎.

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 }}
Expand All @@ -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
Expand All @@ -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'
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/releases/v0.5.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 仍是正式发布的治理前置条件。

## 版本组合

Expand Down
16 changes: 11 additions & 5 deletions scripts/release-notes-layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")),
Expand All @@ -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", () => {
Expand Down
Loading