From 9d76009da37cad3540c4cd11a03b216e453736f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Thu, 6 Aug 2026 09:22:55 +0800 Subject: [PATCH 01/11] ci: add audited draft release workflow --- .github/workflows/github-draft-release-v2.yml | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 .github/workflows/github-draft-release-v2.yml diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml new file mode 100644 index 000000000..fa4ad2157 --- /dev/null +++ b/.github/workflows/github-draft-release-v2.yml @@ -0,0 +1,240 @@ +name: GitHub Draft Release v2 + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix (for example, 1.2.3) + required: true + type: string + +permissions: + contents: write + +concurrency: + group: draft-release-v2-${{ inputs.version }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + environment: release + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Resolve and validate release + id: release + env: + MANUAL_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + version="$MANUAL_VERSION" + target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Version must match X.Y.Z without a v prefix" >&2 + exit 1 + fi + if [[ ! "$target_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "Could not resolve the main branch release commit" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" + + - name: Check for an existing tag or release + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Refusing to overwrite existing tag $TAG" >&2 + exit 1 + fi + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Refusing to overwrite existing release $TAG" >&2 + exit 1 + fi + + - name: Check out trusted base history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify target is on main + env: + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + run: | + set -euo pipefail + git fetch --no-tags origin main + git cat-file -e "$TARGET_SHA^{commit}" + git merge-base --is-ancestor "$TARGET_SHA" origin/main + git checkout --detach "$TARGET_SHA" + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + + - name: Resolve previous stable release tag + id: previous + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + latest_existing="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \ + | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ + | sort -V \ + | tail -n 1 \ + || true)" + if [[ -n "$latest_existing" ]] && [[ "$(printf '%s\n%s\n' "$latest_existing" "$TAG" | sort -V | tail -n 1)" != "$TAG" ]]; then + echo "$TAG must be newer than the latest stable tag $latest_existing" >&2 + exit 1 + fi + previous_tag="$({ git tag --list 'v[0-9]*.[0-9]*.[0-9]*'; printf '%s\n' "$TAG"; } \ + | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ + | sort -Vu \ + | awk -v current="$TAG" '$0 == current { print previous; exit } { previous=$0 }')" + if [[ -n "$previous_tag" ]]; then + git cat-file -e "$previous_tag^{commit}" + fi + echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" + + - name: Download and verify OSS artifacts + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + mkdir -p release-assets + base="https://memtensor-cdn.oss-cn-shanghai.aliyuncs.com/memmy/$VERSION" + artifacts=( + "Memmy-$VERSION-win32-x64-cn-signed.exe" + "Memmy-$VERSION-win32-x64-intl-signed.exe" + "Memmy-$VERSION-darwin-arm64-cn-signed.dmg" + "Memmy-$VERSION-darwin-arm64-intl-signed.dmg" + ) + + for artifact in "${artifacts[@]}"; do + url="$base/$artifact" + headers="$(mktemp)" + curl --fail --location --retry 5 --retry-all-errors --head \ + --dump-header "$headers" --output /dev/null "$url" + content_md5="$(awk 'BEGIN { IGNORECASE=1 } /^Content-MD5:/ { gsub("\\r", "", $2); value=$2 } END { print value }' "$headers")" + if [[ -z "$content_md5" ]]; then + echo "OSS did not return Content-MD5 for $artifact" >&2 + exit 1 + fi + + curl --fail --location --retry 5 --retry-all-errors \ + --output "release-assets/$artifact" "$url" + test -s "release-assets/$artifact" + + expected_md5="$(printf '%s' "$content_md5" | base64 --decode | xxd -p -c 256)" + actual_md5="$(md5sum "release-assets/$artifact" | awk '{print $1}')" + if [[ "$actual_md5" != "$expected_md5" ]]; then + echo "Content-MD5 mismatch for $artifact" >&2 + exit 1 + fi + done + + (cd release-assets && md5sum Memmy-* > MD5SUMS.txt) + (cd release-assets && sha256sum Memmy-* > SHA256SUMS.txt) + + - name: Build release notes + env: + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + PREVIOUS_TAG: ${{ steps.previous.outputs.previous_tag }} + run: | + set -euo pipefail + notes="release-assets/RELEASE_NOTES.md" + manual_notes=".github/release-notes/$TAG.md" + manual_object="${TARGET_SHA}:${manual_notes}" + if git cat-file -e "$manual_object" 2>/dev/null; then + git show "$manual_object" > "$notes" + printf '\n\n' >> "$notes" + else + : > "$notes" + fi + + generate_args=( + --method POST + "repos/${GITHUB_REPOSITORY}/releases/generate-notes" + -f tag_name="$TAG" + -f target_commitish="$TARGET_SHA" + ) + if [[ -n "$PREVIOUS_TAG" ]]; then + generate_args+=(-f previous_tag_name="$PREVIOUS_TAG") + fi + gh api "${generate_args[@]}" --jq '.body' >> "$notes" + + cat >> "$notes" < + EOF + + jq -n \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg tag "$TAG" \ + --arg previousTag "$PREVIOUS_TAG" \ + --arg targetSha "$TARGET_SHA" \ + '{ + schemaVersion: 1, + sourceId: ($repository + "@" + $tag), + repository: $repository, + tag: $tag, + previousTag: $previousTag, + targetSha: $targetSha, + artifactManifest: "SHA256SUMS.txt" + }' > release-assets/RELEASE_EVIDENCE.json + + - name: Create draft release and upload every asset + env: + TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + run: | + set -euo pipefail + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$TARGET_SHA" \ + --title "Memmy $TAG" \ + --notes-file release-assets/RELEASE_NOTES.md \ + --draft + gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_EVIDENCE.json \ + --repo "$GITHUB_REPOSITORY" + + - name: Record the manual publish boundary + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + release_url="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '.url')" + { + echo "## Draft Release created" + echo + echo "- Draft: $release_url" + echo "- This workflow intentionally stops before Publish." + echo "- A human must audit the notes, evidence JSON, checksums, and installers before publishing." + } >> "$GITHUB_STEP_SUMMARY" From 60226213862d7aaec9c5cdb6fd1ffe208c19e494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Thu, 6 Aug 2026 23:37:47 +0800 Subject: [PATCH 02/11] fix: harden draft release cleanup --- .github/workflows/github-draft-release-v2.yml | 141 +++++++++++++++++- tests/release-workflow.test.ts | 61 ++++++++ 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index fa4ad2157..d1076b555 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -76,6 +76,14 @@ jobs: git checkout --detach "$TARGET_SHA" test "$(git rev-parse HEAD)" = "$TARGET_SHA" + - name: Verify repository version metadata + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + test "$(node -p "require('./package.json').version")" = "$VERSION" + npm run version:check + - name: Resolve previous stable release tag id: previous env: @@ -186,8 +194,9 @@ jobs: Verify downloads with `MD5SUMS.txt` or `SHA256SUMS.txt` attached to this release. The workflow also verifies every OSS object against its `Content-MD5` header before publishing. + EOF + - name: Build auditable release evidence + env: + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + PREVIOUS_TAG: ${{ steps.previous.outputs.previous_tag }} + run: | + set -euo pipefail + + compare_base="$PREVIOUS_TAG" + if [[ -z "$compare_base" ]]; then + compare_base="$(git rev-list --max-parents=0 "$TARGET_SHA" | head -n 1)" + fi + test -n "$compare_base" + + gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_base}...${TARGET_SHA}" \ + > release-assets/COMPARE.json + + total_commits="$(jq -r '.total_commits // (.commits | length)' release-assets/COMPARE.json)" + received_commits="$(jq -r '.commits | length' release-assets/COMPARE.json)" + changed_file_count="$(jq -r '.files | length' release-assets/COMPARE.json)" + compare_head="$(jq -r '.head_commit.sha // empty' release-assets/COMPARE.json)" + test "$total_commits" = "$received_commits" + test "$changed_file_count" -lt 300 + test "$compare_head" = "$TARGET_SHA" + + : > release-assets/PULL_REQUESTS.jsonl + while IFS= read -r commit_sha; do + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ + --jq '.[] | { + number, + title, + htmlUrl: .html_url, + mergedAt: .merged_at, + baseRef: .base.ref, + headRef: .head.ref + }' >> release-assets/PULL_REQUESTS.jsonl + done < <(jq -r '.commits[].sha' release-assets/COMPARE.json) + jq -s 'unique_by(.number) | sort_by(.number)' \ + release-assets/PULL_REQUESTS.jsonl > release-assets/PULL_REQUESTS.json + + jq -Rn ' + [inputs + | select(length > 0) + | capture("^(?[0-9a-f]{64}) (?.+)$") + ] + ' < release-assets/SHA256SUMS.txt > release-assets/ARTIFACTS.json + + root_version="$(node -p "require('./package.json').version")" + memory_version="$(node -p "require('./Memory/package.json').version")" + memory_cli_version="$(node -p "require('./Memory/src/cli/npm/package.json').version")" + agent_version="$(node -p "require('./App/memmy-agent/package.json').version")" + desktop_version="$(node -p "require('./App/shell/desktop/package.json').version")" + generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + release_notes_sha256="$(sha256sum release-assets/RELEASE_NOTES.md | awk '{print $1}')" + jq -n \ --arg repository "$GITHUB_REPOSITORY" \ + --arg version "$VERSION" \ --arg tag "$TAG" \ --arg previousTag "$PREVIOUS_TAG" \ --arg targetSha "$TARGET_SHA" \ + --arg generatedAt "$generated_at" \ + --arg releaseNotesSha256 "$release_notes_sha256" \ + --arg rootVersion "$root_version" \ + --arg memoryVersion "$memory_version" \ + --arg memoryCliVersion "$memory_cli_version" \ + --arg agentVersion "$agent_version" \ + --arg desktopVersion "$desktop_version" \ + --slurpfile compare release-assets/COMPARE.json \ + --slurpfile pullRequests release-assets/PULL_REQUESTS.json \ + --slurpfile artifacts release-assets/ARTIFACTS.json \ '{ - schemaVersion: 1, + schema: "memmy.release.evidence.v2", + schemaVersion: 2, sourceId: ($repository + "@" + $tag), repository: $repository, + version: $version, tag: $tag, previousTag: $previousTag, targetSha: $targetSha, - artifactManifest: "SHA256SUMS.txt" + generatedAt: $generatedAt, + compare: { + url: $compare[0].html_url, + status: $compare[0].status, + aheadBy: $compare[0].ahead_by, + behindBy: $compare[0].behind_by, + totalCommits: $compare[0].total_commits + }, + commits: ($compare[0].commits | map({ + sha, + shortSha: .sha[0:8], + htmlUrl: .html_url, + message: .commit.message + })), + pullRequests: $pullRequests[0], + changedFiles: ($compare[0].files | map({ + path: .filename, + previousPath: (.previous_filename // null), + status, + additions, + deletions, + changes + })), + versionFiles: [ + {path: "package.json", version: $rootVersion}, + {path: "Memory/package.json", version: $memoryVersion}, + {path: "Memory/src/cli/npm/package.json", version: $memoryCliVersion}, + {path: "App/memmy-agent/package.json", version: $agentVersion}, + {path: "App/shell/desktop/package.json", version: $desktopVersion} + ], + releaseNotesSha256: $releaseNotesSha256, + artifactManifest: "SHA256SUMS.txt", + artifacts: $artifacts[0] }' > release-assets/RELEASE_EVIDENCE.json + { + echo "## Release evidence" + echo + echo "- Compare: $PREVIOUS_TAG...$TARGET_SHA" + echo "- Commits: $received_commits" + echo "- Changed files: $changed_file_count" + echo "- Pull requests: $(jq 'length' release-assets/PULL_REQUESTS.json)" + echo "- Version metadata: $VERSION" + } >> "$GITHUB_STEP_SUMMARY" + - name: Create draft release and upload every asset env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail + draft_created=0 + cleanup_draft_release() { + status="$?" + if [[ "$status" -ne 0 && "$draft_created" == "1" ]]; then + echo "Draft Release asset upload failed; deleting $TAG and its tag so the workflow can be safely re-run." >&2 + gh release delete "$TAG" --cleanup-tag --yes --repo "$GITHUB_REPOSITORY" || true + fi + exit "$status" + } + trap cleanup_draft_release EXIT + gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$TARGET_SHA" \ --title "Memmy $TAG" \ --notes-file release-assets/RELEASE_NOTES.md \ --draft + draft_created=1 gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_EVIDENCE.json \ --repo "$GITHUB_REPOSITORY" + trap - EXIT - name: Record the manual publish boundary env: diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 484d98b66..b3ee6652b 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -5,6 +5,10 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; const workflowPath = resolve(import.meta.dirname, "../.github/workflows/github-release.yml"); +const draftWorkflowPath = resolve( + import.meta.dirname, + "../.github/workflows/github-draft-release-v2.yml", +); const repoRoot = resolve(import.meta.dirname, ".."); const source = readFileSync(workflowPath, "utf8"); const workflow = YAML.parse(source); @@ -169,3 +173,60 @@ describe("GitHub release workflow", () => { } }); }); + +describe("GitHub Draft Release v2 workflow", () => { + const draftSource = readFileSync(draftWorkflowPath, "utf8"); + const draftWorkflow = YAML.parse(draftSource); + const draftJob = draftWorkflow.jobs.release; + const draftSteps = draftJob.steps as Array>; + const draftScript = (name: string) => + String(draftSteps.find((step) => step.name === name)?.run ?? ""); + + it("is manual-only and stops at a Draft Release", () => { + expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); + expect(draftWorkflow.on.pull_request).toBeUndefined(); + expect(draftWorkflow.on.pull_request_target).toBeUndefined(); + expect(draftSource).toContain("gh release create"); + expect(draftSource).toContain("--draft"); + expect(draftSource).not.toContain("--draft=false"); + expect(draftSource).not.toContain("Publish release as latest"); + }); + + it("requires the requested version to match every release manifest", () => { + const verify = draftScript("Verify repository version metadata"); + expect(verify).toContain("require('./package.json').version"); + expect(verify).toContain('= "$VERSION"'); + expect(verify).toContain("npm run version:check"); + }); + + it("records independently auditable commits, PRs, files, versions, and assets", () => { + const evidence = draftScript("Build auditable release evidence"); + expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); + expect(evidence).toContain("commits/${commit_sha}/pulls"); + expect(evidence).toContain("memmy.release.evidence.v2"); + expect(evidence).toContain("changedFiles"); + expect(evidence).toContain("versionFiles"); + expect(evidence).toContain("releaseNotesSha256"); + expect(evidence).toContain("artifacts"); + expect(draftScript("Build release notes")).toContain( + "doc-agent: source-id=memmy-official-changelog-v2", + ); + expect(draftScript("Create draft release and upload every asset")).toContain( + "RELEASE_EVIDENCE.json", + ); + }); + + it("cleans up a half-created Draft Release if asset upload fails", () => { + const create = draftScript("Create draft release and upload every asset"); + expect(create).toContain("cleanup_draft_release()"); + expect(create).toContain('draft_created=1'); + expect(create).toContain('gh release delete "$TAG" --cleanup-tag --yes'); + expect(create.indexOf("gh release create")).toBeLessThan( + create.indexOf("draft_created=1"), + ); + expect(create.indexOf("draft_created=1")).toBeLessThan( + create.indexOf("gh release upload"), + ); + expect(create).toContain("trap - EXIT"); + }); +}); From 894d23ddf01b4e715fd6472e547d1477af715ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Mon, 10 Aug 2026 16:23:19 +0800 Subject: [PATCH 03/11] ci: create audited draft releases from release PRs --- .github/workflows/github-draft-release-v2.yml | 31 ++- .github/workflows/github-release.yml | 203 ---------------- tests/release-workflow.test.ts | 216 +++++++----------- 3 files changed, 116 insertions(+), 334 deletions(-) delete mode 100644 .github/workflows/github-release.yml diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index d1076b555..7d5f1ba3c 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -1,6 +1,11 @@ name: GitHub Draft Release v2 on: + # Use the base repository's trusted workflow so release PRs from forks can + # create an auditable Draft Release without executing untrusted fork code. + pull_request_target: + types: [closed] + branches: [main] workflow_dispatch: inputs: version: @@ -12,11 +17,15 @@ permissions: contents: write concurrency: - group: draft-release-v2-${{ inputs.version }} + group: draft-release-v2-${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.event.pull_request.head.ref }} cancel-in-progress: false jobs: release: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v')) runs-on: ubuntu-latest environment: release env: @@ -25,12 +34,28 @@ jobs: - name: Resolve and validate release id: release env: + EVENT_NAME: ${{ github.event_name }} MANUAL_VERSION: ${{ inputs.version }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | set -euo pipefail - version="$MANUAL_VERSION" - target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then + echo "Release branch must match release/vX.Y.Z" >&2 + exit 1 + fi + version="${BASH_REMATCH[1]}" + target_sha="$PR_MERGE_SHA" + if [[ ! "$target_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "The merged PR did not provide a valid merge_commit_sha" >&2 + exit 1 + fi + else + version="$MANUAL_VERSION" + target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + fi if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "Version must match X.Y.Z without a v prefix" >&2 diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml deleted file mode 100644 index 1982507d2..000000000 --- a/.github/workflows/github-release.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: GitHub Release - -on: - # Use the base repository's trusted workflow so merged PRs from public forks - # can publish without executing workflow code from the PR branch. - pull_request_target: - types: [closed] - branches: [main] - workflow_dispatch: - inputs: - version: - description: Release version without the v prefix (for example, 1.2.3) - required: true - type: string - -permissions: - contents: write - -concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && format('release/v{0}', inputs.version) || github.event.pull_request.head.ref }} - cancel-in-progress: false - -jobs: - release: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/v')) - runs-on: ubuntu-latest - environment: release - env: - GH_TOKEN: ${{ github.token }} - steps: - - name: Resolve and validate release - id: release - env: - EVENT_NAME: ${{ github.event_name }} - MANUAL_VERSION: ${{ inputs.version }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - set -euo pipefail - - if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then - echo "Release branch must match release/vX.Y.Z" >&2 - exit 1 - fi - version="${BASH_REMATCH[1]}" - target_sha="$PR_MERGE_SHA" - if [[ ! "$target_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "The merged PR did not provide a valid merge_commit_sha" >&2 - exit 1 - fi - else - version="$MANUAL_VERSION" - target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" - fi - - if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "Version must match X.Y.Z without a v prefix" >&2 - exit 1 - fi - if [[ ! "$target_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "Could not resolve the main branch release commit" >&2 - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" - - - name: Check for an existing tag or release - env: - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Refusing to overwrite existing tag $TAG" >&2 - exit 1 - fi - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Refusing to overwrite existing release $TAG" >&2 - exit 1 - fi - - - name: Check out trusted base history - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Verify target is on main - env: - TARGET_SHA: ${{ steps.release.outputs.target_sha }} - run: | - set -euo pipefail - git fetch --no-tags origin main - git cat-file -e "$TARGET_SHA^{commit}" - git merge-base --is-ancestor "$TARGET_SHA" origin/main - git checkout --detach "$TARGET_SHA" - test "$(git rev-parse HEAD)" = "$TARGET_SHA" - - - name: Download and verify OSS artifacts - env: - VERSION: ${{ steps.release.outputs.version }} - run: | - set -euo pipefail - mkdir -p release-assets - base="https://memtensor-cdn.oss-cn-shanghai.aliyuncs.com/memmy/$VERSION" - artifacts=( - "Memmy-$VERSION-win32-x64-cn-signed.exe" - "Memmy-$VERSION-win32-x64-intl-signed.exe" - "Memmy-$VERSION-darwin-arm64-cn-signed.dmg" - "Memmy-$VERSION-darwin-arm64-intl-signed.dmg" - ) - - for artifact in "${artifacts[@]}"; do - url="$base/$artifact" - headers="$(mktemp)" - curl --fail --location --retry 5 --retry-all-errors --head \ - --dump-header "$headers" --output /dev/null "$url" - content_md5="$(awk 'BEGIN { IGNORECASE=1 } /^Content-MD5:/ { gsub("\\r", "", $2); value=$2 } END { print value }' "$headers")" - if [[ -z "$content_md5" ]]; then - echo "OSS did not return Content-MD5 for $artifact" >&2 - exit 1 - fi - - curl --fail --location --retry 5 --retry-all-errors \ - --output "release-assets/$artifact" "$url" - test -s "release-assets/$artifact" - - expected_md5="$(printf '%s' "$content_md5" | base64 --decode | xxd -p -c 256)" - actual_md5="$(md5sum "release-assets/$artifact" | awk '{print $1}')" - if [[ "$actual_md5" != "$expected_md5" ]]; then - echo "Content-MD5 mismatch for $artifact" >&2 - exit 1 - fi - done - - (cd release-assets && md5sum Memmy-* > MD5SUMS.txt) - (cd release-assets && sha256sum Memmy-* > SHA256SUMS.txt) - - - name: Build release notes - env: - VERSION: ${{ steps.release.outputs.version }} - TAG: ${{ steps.release.outputs.tag }} - TARGET_SHA: ${{ steps.release.outputs.target_sha }} - run: | - set -euo pipefail - notes="release-assets/RELEASE_NOTES.md" - manual_notes=".github/release-notes/$TAG.md" - manual_object="${TARGET_SHA}:${manual_notes}" - if git cat-file -e "$manual_object" 2>/dev/null; then - git show "$manual_object" > "$notes" - printf '\n\n' >> "$notes" - else - : > "$notes" - fi - - gh api --method POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ - -f tag_name="$TAG" -f target_commitish="$TARGET_SHA" \ - --jq '.body' >> "$notes" - - cat >> "$notes" <>; -const script = (name: string) => String(steps.find((step) => step.name === name)?.run ?? ""); +const legacyWorkflowPath = resolve(repoRoot, ".github/workflows/github-release.yml"); +const draftWorkflowPath = resolve(repoRoot, ".github/workflows/github-draft-release-v2.yml"); +const draftSource = readFileSync(draftWorkflowPath, "utf8"); +const draftWorkflow = YAML.parse(draftSource); +const draftJob = draftWorkflow.jobs.release; +const draftSteps = draftJob.steps as Array>; +const draftScript = (name: string) => + String(draftSteps.find((step) => step.name === name)?.run ?? ""); const packagingConfigs = [ "electron-builder.yml", "electron-builder.unsigned.yml", @@ -35,7 +33,7 @@ function readJson(relativePath: string): { return JSON.parse(readFileSync(resolve(repoRoot, relativePath), "utf8")); } -describe("GitHub release workflow", () => { +describe("Memmy release workflow metadata", () => { it("keeps every release manifest and lockfile aligned to the root version", () => { const version = readJson("package.json").version; @@ -54,93 +52,8 @@ describe("GitHub release workflow", () => { expect(agentLock.packages?.[""].version).toBe(version); }); - it("uses the trusted base workflow for merged release/vX.Y.Z PRs targeting main", () => { - expect(workflow.on.pull_request_target).toEqual({ types: ["closed"], branches: ["main"] }); - expect(workflow.on.pull_request).toBeUndefined(); - expect(releaseJob.if).toContain("github.event.pull_request.merged == true"); - expect(releaseJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); - const resolveScript = script("Resolve and validate release"); - expect(resolveScript).toContain('if [[ "$EVENT_NAME" == "pull_request_target" ]]'); - expect(resolveScript).toContain( - "^release/v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", - ); - }); - - it("supports a strictly validated manual version and resolves main", () => { - expect(workflow.on.workflow_dispatch.inputs.version.required).toBe(true); - const resolveScript = script("Resolve and validate release"); - expect(resolveScript).toContain('version="$MANUAL_VERSION"'); - expect(resolveScript).toContain("git/ref/heads/main"); - expect(resolveScript).toContain( - "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", - ); - }); - - it("binds the tag to the merged commit and refuses existing releases", () => { - const resolveScript = script("Resolve and validate release"); - expect(resolveScript).toContain('target_sha="$PR_MERGE_SHA"'); - expect(script("Create draft release and upload every asset")).toContain( - '--target "$TARGET_SHA"', - ); - const duplicateCheck = script("Check for an existing tag or release"); - expect(duplicateCheck).toContain("git ls-remote --exit-code --tags"); - expect(duplicateCheck).toContain('gh release view "$TAG"'); - expect(duplicateCheck).not.toContain("--force"); - }); - - it("keeps merged fork code out of the trusted release checkout", () => { - const checkout = steps.find((step) => step.name === "Check out trusted base history"); - expect(checkout?.uses).toBe("actions/checkout@v4"); - expect(checkout?.with).toEqual({ - "fetch-depth": 0, - "persist-credentials": false, - }); - expect(checkout?.with).not.toHaveProperty("ref"); - expect(JSON.stringify(checkout)).not.toContain("github.event.pull_request"); - expect(source).not.toContain("refs/pull/"); - expect(source).not.toContain("allow-unsafe-pr-checkout"); - - const verifyScript = script("Verify target is on main"); - expect(verifyScript).toContain("git fetch --no-tags origin main"); - expect(verifyScript).toContain('git cat-file -e "$TARGET_SHA^{commit}"'); - const ancestorCheck = 'git merge-base --is-ancestor "$TARGET_SHA" origin/main'; - const detachTarget = 'git checkout --detach "$TARGET_SHA"'; - const verifyHead = 'test "$(git rev-parse HEAD)" = "$TARGET_SHA"'; - expect(verifyScript).toContain(ancestorCheck); - expect(verifyScript).toContain(detachTarget); - expect(verifyScript).toContain(verifyHead); - expect(verifyScript.indexOf(detachTarget)).toBeGreaterThan(verifyScript.indexOf(ancestorCheck)); - expect(verifyScript.indexOf(verifyHead)).toBeGreaterThan(verifyScript.indexOf(detachTarget)); - - const notesScript = script("Build release notes"); - expect(notesScript).toContain('manual_object="${TARGET_SHA}:${manual_notes}"'); - expect(notesScript).toContain('git show "$manual_object" > "$notes"'); - }); - - it("downloads all four OSS artifacts and verifies Content-MD5", () => { - const download = script("Download and verify OSS artifacts"); - expect(download).toContain("curl --fail --location --retry 5 --retry-all-errors"); - expect(download).toContain("Content-MD5"); - expect(download).toContain('test -s "release-assets/$artifact"'); - expect(download.match(/Memmy-\$VERSION-/g)).toHaveLength(4); - expect(download).toContain("MD5SUMS.txt"); - expect(download).toContain("SHA256SUMS.txt"); - }); - - it("composes notes and only publishes after the draft assets upload", () => { - const notes = script("Build release notes"); - expect(notes).toContain('.github/release-notes/$TAG.md'); - expect(notes).toContain("releases/generate-notes"); - expect(notes).toContain("## Downloads"); - expect(notes).toContain("## Installation"); - expect(notes).toContain("## Checksums"); - - const createIndex = steps.findIndex((step) => step.name === "Create draft release and upload every asset"); - const publishIndex = steps.findIndex((step) => step.name === "Publish release as latest"); - expect(script("Create draft release and upload every asset")).toContain("--draft"); - expect(script("Create draft release and upload every asset")).toContain("gh release upload"); - expect(script("Publish release as latest")).toContain("--draft=false --latest"); - expect(publishIndex).toBeGreaterThan(createIndex); + it("removes the legacy workflow that published releases automatically", () => { + expect(existsSync(legacyWorkflowPath)).toBe(false); }); it("allows versioned manual release notes to be tracked", () => { @@ -154,18 +67,10 @@ describe("GitHub release workflow", () => { expect(result.status).toBe(1); }); - it("uses the release environment, minimal permissions, and per-version concurrency", () => { - expect(workflow.permissions).toEqual({ contents: "write" }); - expect(releaseJob.environment).toBe("release"); - expect(workflow.concurrency["cancel-in-progress"]).toBe(false); - expect(workflow.concurrency.group).toContain("inputs.version"); - expect(workflow.concurrency.group).toContain("pull_request.head.ref"); - }); - it("embeds the repository .env required by packaged desktop runtimes", () => { for (const config of packagingConfigs) { const packagingSource = readFileSync( - resolve(import.meta.dirname, `../App/shell/desktop/${config}`), + resolve(repoRoot, `App/shell/desktop/${config}`), "utf8", ); expect(packagingSource).toMatch(/from:\s+\.\.\/\.\.\/\.\.\/\.env(?:\s|$)/); @@ -175,21 +80,45 @@ describe("GitHub release workflow", () => { }); describe("GitHub Draft Release v2 workflow", () => { - const draftSource = readFileSync(draftWorkflowPath, "utf8"); - const draftWorkflow = YAML.parse(draftSource); - const draftJob = draftWorkflow.jobs.release; - const draftSteps = draftJob.steps as Array>; - const draftScript = (name: string) => - String(draftSteps.find((step) => step.name === name)?.run ?? ""); - - it("is manual-only and stops at a Draft Release", () => { - expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); + it("creates Draft Releases from merged release/vX.Y.Z PRs and keeps manual fallback", () => { + expect(draftWorkflow.on.pull_request_target).toEqual({ + types: ["closed"], + branches: ["main"], + }); expect(draftWorkflow.on.pull_request).toBeUndefined(); - expect(draftWorkflow.on.pull_request_target).toBeUndefined(); - expect(draftSource).toContain("gh release create"); - expect(draftSource).toContain("--draft"); - expect(draftSource).not.toContain("--draft=false"); - expect(draftSource).not.toContain("Publish release as latest"); + expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); + expect(draftJob.if).toContain("github.event.pull_request.merged == true"); + expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); + + const resolve = draftScript("Resolve and validate release"); + expect(resolve).toContain('if [[ "$EVENT_NAME" == "pull_request_target" ]]'); + expect(resolve).toContain( + "^release/v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", + ); + expect(resolve).toContain('version="${BASH_REMATCH[1]}"'); + expect(resolve).toContain('target_sha="$PR_MERGE_SHA"'); + expect(resolve).toContain('version="$MANUAL_VERSION"'); + expect(resolve).toContain("git/ref/heads/main"); + }); + + it("uses trusted base code and checks out the merged main commit", () => { + const checkout = draftSteps.find((step) => step.name === "Check out trusted base history"); + expect(checkout?.uses).toBe("actions/checkout@v4"); + expect(checkout?.with).toEqual({ + "fetch-depth": 0, + "persist-credentials": false, + }); + expect(checkout?.with).not.toHaveProperty("ref"); + expect(JSON.stringify(checkout)).not.toContain("github.event.pull_request"); + expect(draftSource).not.toContain("refs/pull/"); + expect(draftSource).not.toContain("allow-unsafe-pr-checkout"); + + const verify = draftScript("Verify target is on main"); + expect(verify).toContain("git fetch --no-tags origin main"); + expect(verify).toContain('git cat-file -e "$TARGET_SHA^{commit}"'); + expect(verify).toContain('git merge-base --is-ancestor "$TARGET_SHA" origin/main'); + expect(verify).toContain('git checkout --detach "$TARGET_SHA"'); + expect(verify).toContain('test "$(git rev-parse HEAD)" = "$TARGET_SHA"'); }); it("requires the requested version to match every release manifest", () => { @@ -199,6 +128,27 @@ describe("GitHub Draft Release v2 workflow", () => { expect(verify).toContain("npm run version:check"); }); + it("refuses duplicate tags/releases and never forces publication", () => { + const duplicateCheck = draftScript("Check for an existing tag or release"); + expect(duplicateCheck).toContain("git ls-remote --exit-code --tags"); + expect(duplicateCheck).toContain('gh release view "$TAG"'); + expect(duplicateCheck).not.toContain("--force"); + expect(draftSource).toContain("gh release create"); + expect(draftSource).toContain("--draft"); + expect(draftSource).not.toContain("--draft=false"); + expect(draftSource).not.toContain("Publish release as latest"); + }); + + it("downloads all four OSS artifacts and verifies Content-MD5", () => { + const download = draftScript("Download and verify OSS artifacts"); + expect(download).toContain("curl --fail --location --retry 5 --retry-all-errors"); + expect(download).toContain("Content-MD5"); + expect(download).toContain('test -s "release-assets/$artifact"'); + expect(download.match(/Memmy-\$VERSION-/g)).toHaveLength(4); + expect(download).toContain("MD5SUMS.txt"); + expect(download).toContain("SHA256SUMS.txt"); + }); + it("records independently auditable commits, PRs, files, versions, and assets", () => { const evidence = draftScript("Build auditable release evidence"); expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); @@ -221,12 +171,22 @@ describe("GitHub Draft Release v2 workflow", () => { expect(create).toContain("cleanup_draft_release()"); expect(create).toContain('draft_created=1'); expect(create).toContain('gh release delete "$TAG" --cleanup-tag --yes'); - expect(create.indexOf("gh release create")).toBeLessThan( - create.indexOf("draft_created=1"), - ); - expect(create.indexOf("draft_created=1")).toBeLessThan( - create.indexOf("gh release upload"), - ); + expect(create.indexOf("gh release create")).toBeLessThan(create.indexOf("draft_created=1")); + expect(create.indexOf("draft_created=1")).toBeLessThan(create.indexOf("gh release upload")); expect(create).toContain("trap - EXIT"); }); + + it("uses the release environment, minimal permissions, and per-version concurrency", () => { + expect(draftWorkflow.permissions).toEqual({ contents: "write" }); + expect(draftJob.environment).toBe("release"); + expect(draftWorkflow.concurrency["cancel-in-progress"]).toBe(false); + expect(draftWorkflow.concurrency.group).toContain("inputs.version"); + expect(draftWorkflow.concurrency.group).toContain("pull_request.head.ref"); + }); + + it("records the manual Publish boundary in the workflow summary", () => { + const boundary = draftScript("Record the manual publish boundary"); + expect(boundary).toContain("This workflow intentionally stops before Publish."); + expect(boundary).toContain("A human must audit"); + }); }); From 25f16af40ee6fdcc5e89914d85217f54d7a68491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Mon, 10 Aug 2026 16:25:36 +0800 Subject: [PATCH 04/11] ci: add safe draft release dry run --- .github/workflows/github-draft-release-v2.yml | 33 +++++++++++++++++++ tests/release-workflow.test.ts | 27 +++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 7d5f1ba3c..386906c4f 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -12,6 +12,11 @@ on: description: Release version without the v prefix (for example, 1.2.3) required: true type: string + dry_run: + description: Validate the release target without creating a tag or Draft Release + required: false + default: true + type: boolean permissions: contents: write @@ -36,11 +41,13 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} MANUAL_VERSION: ${{ inputs.version }} + DRY_RUN_INPUT: ${{ inputs.dry_run || false }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | set -euo pipefail + dry_run="false" if [[ "$EVENT_NAME" == "pull_request_target" ]]; then if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then echo "Release branch must match release/vX.Y.Z" >&2 @@ -54,6 +61,9 @@ jobs: fi else version="$MANUAL_VERSION" + if [[ "$DRY_RUN_INPUT" == "true" ]]; then + dry_run="true" + fi target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" fi @@ -69,8 +79,10 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" + echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" - name: Check for an existing tag or release + if: ${{ steps.release.outputs.dry_run != 'true' }} env: TAG: ${{ steps.release.outputs.tag }} run: | @@ -134,6 +146,7 @@ jobs: echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - name: Download and verify OSS artifacts + if: ${{ steps.release.outputs.dry_run != 'true' }} env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -174,6 +187,7 @@ jobs: (cd release-assets && sha256sum Memmy-* > SHA256SUMS.txt) - name: Build release notes + if: ${{ steps.release.outputs.dry_run != 'true' }} env: VERSION: ${{ steps.release.outputs.version }} TAG: ${{ steps.release.outputs.tag }} @@ -230,6 +244,7 @@ jobs: EOF - name: Build auditable release evidence + if: ${{ steps.release.outputs.dry_run != 'true' }} env: VERSION: ${{ steps.release.outputs.version }} TAG: ${{ steps.release.outputs.tag }} @@ -358,6 +373,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Create draft release and upload every asset + if: ${{ steps.release.outputs.dry_run != 'true' }} env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} @@ -386,6 +402,7 @@ jobs: trap - EXIT - name: Record the manual publish boundary + if: ${{ steps.release.outputs.dry_run != 'true' }} env: TAG: ${{ steps.release.outputs.tag }} run: | @@ -398,3 +415,19 @@ jobs: echo "- This workflow intentionally stops before Publish." echo "- A human must audit the notes, evidence JSON, checksums, and installers before publishing." } >> "$GITHUB_STEP_SUMMARY" + + - name: Record dry-run result + if: ${{ steps.release.outputs.dry_run == 'true' }} + env: + TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} + run: | + set -euo pipefail + { + echo "## Draft Release dry-run" + echo + echo "- Tag: $TAG" + echo "- Target SHA: $TARGET_SHA" + echo "- No tag, Release, assets, or external publication was created." + echo "- Set dry_run=false only when intentionally creating a Draft Release." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 442937356..e5fcc0353 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -87,6 +87,7 @@ describe("GitHub Draft Release v2 workflow", () => { }); expect(draftWorkflow.on.pull_request).toBeUndefined(); expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); + expect(draftWorkflow.on.workflow_dispatch.inputs.dry_run.default).toBe(true); expect(draftJob.if).toContain("github.event.pull_request.merged == true"); expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); @@ -98,6 +99,8 @@ describe("GitHub Draft Release v2 workflow", () => { expect(resolve).toContain('version="${BASH_REMATCH[1]}"'); expect(resolve).toContain('target_sha="$PR_MERGE_SHA"'); expect(resolve).toContain('version="$MANUAL_VERSION"'); + expect(resolve).toContain('dry_run="true"'); + expect(resolve).toContain("dry_run=$dry_run"); expect(resolve).toContain("git/ref/heads/main"); }); @@ -129,6 +132,9 @@ describe("GitHub Draft Release v2 workflow", () => { }); it("refuses duplicate tags/releases and never forces publication", () => { + expect( + draftSteps.find((step) => step.name === "Check for an existing tag or release")?.if, + ).toBe("${{ steps.release.outputs.dry_run != 'true' }}"); const duplicateCheck = draftScript("Check for an existing tag or release"); expect(duplicateCheck).toContain("git ls-remote --exit-code --tags"); expect(duplicateCheck).toContain('gh release view "$TAG"'); @@ -150,6 +156,18 @@ describe("GitHub Draft Release v2 workflow", () => { }); it("records independently auditable commits, PRs, files, versions, and assets", () => { + for (const stepName of [ + "Download and verify OSS artifacts", + "Build release notes", + "Build auditable release evidence", + "Create draft release and upload every asset", + "Record the manual publish boundary", + ]) { + expect(draftSteps.find((step) => step.name === stepName)?.if).toBe( + "${{ steps.release.outputs.dry_run != 'true' }}", + ); + } + const evidence = draftScript("Build auditable release evidence"); expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); expect(evidence).toContain("commits/${commit_sha}/pulls"); @@ -189,4 +207,13 @@ describe("GitHub Draft Release v2 workflow", () => { expect(boundary).toContain("This workflow intentionally stops before Publish."); expect(boundary).toContain("A human must audit"); }); + + it("keeps fork manual testing side-effect free by default", () => { + const dryRun = draftScript("Record dry-run result"); + expect(draftSteps.find((step) => step.name === "Record dry-run result")?.if).toBe( + "${{ steps.release.outputs.dry_run == 'true' }}", + ); + expect(dryRun).toContain("No tag, Release, assets, or external publication was created."); + expect(dryRun).toContain("Set dry_run=false only when intentionally creating a Draft Release."); + }); }); From 72ef7f61ddf55e0223dc518caddc9a59576e6bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Mon, 10 Aug 2026 17:14:23 +0800 Subject: [PATCH 05/11] ci: harden draft release preflight recovery --- .github/workflows/github-draft-release-v2.yml | 157 +++++++++++++----- tests/release-workflow.test.ts | 72 ++++++-- 2 files changed, 169 insertions(+), 60 deletions(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 386906c4f..02a1d004a 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -12,10 +12,18 @@ on: description: Release version without the v prefix (for example, 1.2.3) required: true type: string - dry_run: - description: Validate the release target without creating a tag or Draft Release + preflight_level: + description: Smoke validates the target only; full also verifies installers, release notes, and evidence required: false - default: true + default: smoke + type: choice + options: + - smoke + - full + create_draft: + description: Create a Draft Release after full preflight + required: false + default: false type: boolean permissions: @@ -41,13 +49,15 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} MANUAL_VERSION: ${{ inputs.version }} - DRY_RUN_INPUT: ${{ inputs.dry_run || false }} + PREFLIGHT_LEVEL_INPUT: ${{ inputs.preflight_level || 'smoke' }} + CREATE_DRAFT_INPUT: ${{ inputs.create_draft || false }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | set -euo pipefail - dry_run="false" + preflight_level="$PREFLIGHT_LEVEL_INPUT" + create_draft="false" if [[ "$EVENT_NAME" == "pull_request_target" ]]; then if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then echo "Release branch must match release/vX.Y.Z" >&2 @@ -55,18 +65,25 @@ jobs: fi version="${BASH_REMATCH[1]}" target_sha="$PR_MERGE_SHA" + preflight_level="full" + create_draft="true" if [[ ! "$target_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "The merged PR did not provide a valid merge_commit_sha" >&2 exit 1 fi else version="$MANUAL_VERSION" - if [[ "$DRY_RUN_INPUT" == "true" ]]; then - dry_run="true" + if [[ "$CREATE_DRAFT_INPUT" == "true" ]]; then + create_draft="true" + preflight_level="full" fi target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" fi + if [[ "$preflight_level" != "smoke" && "$preflight_level" != "full" ]]; then + echo "preflight_level must be smoke or full" >&2 + exit 1 + fi if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "Version must match X.Y.Z without a v prefix" >&2 exit 1 @@ -79,20 +96,21 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" - echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" + echo "preflight_level=$preflight_level" >> "$GITHUB_OUTPUT" + echo "create_draft=$create_draft" >> "$GITHUB_OUTPUT" - name: Check for an existing tag or release - if: ${{ steps.release.outputs.dry_run != 'true' }} + if: ${{ steps.release.outputs.preflight_level == 'full' }} env: TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Refusing to overwrite existing tag $TAG" >&2 + echo "::error title=Release tag already exists::$TAG already exists. Manual recovery: inspect the existing tag and Release before retrying; do not move or overwrite the tag." >&2 exit 1 fi if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Refusing to overwrite existing release $TAG" >&2 + echo "::error title=Release already exists::$TAG already has a GitHub Release. Manual recovery: inspect whether it is the intended Draft/Published Release before retrying." >&2 exit 1 fi @@ -108,8 +126,14 @@ jobs: run: | set -euo pipefail git fetch --no-tags origin main - git cat-file -e "$TARGET_SHA^{commit}" - git merge-base --is-ancestor "$TARGET_SHA" origin/main + if ! git cat-file -e "$TARGET_SHA^{commit}"; then + echo "::error title=Release target missing::Target SHA $TARGET_SHA is not available in this checkout. Manual recovery: re-run after GitHub checkout/network recovers." >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$TARGET_SHA" origin/main; then + echo "::error title=Release target is not on main::Target SHA $TARGET_SHA is not an ancestor of origin/main. Manual recovery: confirm the release PR was merged into main." >&2 + exit 1 + fi git checkout --detach "$TARGET_SHA" test "$(git rev-parse HEAD)" = "$TARGET_SHA" @@ -118,8 +142,15 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail - test "$(node -p "require('./package.json').version")" = "$VERSION" - npm run version:check + root_version="$(node -p "require('./package.json').version")" + if [[ "$root_version" != "$VERSION" ]]; then + echo "::error title=Root version mismatch::release version $VERSION does not match package.json version $root_version. Manual recovery: update the release branch version files, then merge a new release/v$VERSION PR." >&2 + exit 1 + fi + if ! npm run version:check; then + echo "::error title=Release version metadata mismatch::One or more package/lock versions are not aligned with $VERSION. Manual recovery: run the version sync/check locally and commit the corrected files." >&2 + exit 1 + fi - name: Resolve previous stable release tag id: previous @@ -127,13 +158,14 @@ jobs: TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail + git fetch --force origin "refs/tags/v*:refs/tags/v*" latest_existing="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \ | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ | sort -V \ | tail -n 1 \ || true)" if [[ -n "$latest_existing" ]] && [[ "$(printf '%s\n%s\n' "$latest_existing" "$TAG" | sort -V | tail -n 1)" != "$TAG" ]]; then - echo "$TAG must be newer than the latest stable tag $latest_existing" >&2 + echo "::error title=Release version is not newer::$TAG must be newer than the latest stable tag $latest_existing. Manual recovery: choose the next stable SemVer version and update the release branch." >&2 exit 1 fi previous_tag="$({ git tag --list 'v[0-9]*.[0-9]*.[0-9]*'; printf '%s\n' "$TAG"; } \ @@ -146,7 +178,7 @@ jobs: echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - name: Download and verify OSS artifacts - if: ${{ steps.release.outputs.dry_run != 'true' }} + if: ${{ steps.release.outputs.preflight_level == 'full' }} env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -163,22 +195,31 @@ jobs: for artifact in "${artifacts[@]}"; do url="$base/$artifact" headers="$(mktemp)" - curl --fail --location --retry 5 --retry-all-errors --head \ - --dump-header "$headers" --output /dev/null "$url" + if ! curl --fail --location --retry 5 --retry-all-errors --head \ + --dump-header "$headers" --output /dev/null "$url"; then + echo "::error title=Installer asset is missing::$artifact was not found at $url. Manual recovery: confirm the packaging/upload workflow finished for version $VERSION, then re-run this workflow." >&2 + exit 1 + fi content_md5="$(awk 'BEGIN { IGNORECASE=1 } /^Content-MD5:/ { gsub("\\r", "", $2); value=$2 } END { print value }' "$headers")" if [[ -z "$content_md5" ]]; then - echo "OSS did not return Content-MD5 for $artifact" >&2 + echo "::error title=Installer checksum header missing::OSS did not return Content-MD5 for $artifact. Manual recovery: verify the OSS object metadata or re-upload the installer." >&2 exit 1 fi - curl --fail --location --retry 5 --retry-all-errors \ - --output "release-assets/$artifact" "$url" - test -s "release-assets/$artifact" + if ! curl --fail --location --retry 5 --retry-all-errors \ + --output "release-assets/$artifact" "$url"; then + echo "::error title=Installer download failed::Could not download $artifact after retries. Manual recovery: check OSS/CDN availability, then re-run this workflow." >&2 + exit 1 + fi + if [[ ! -s "release-assets/$artifact" ]]; then + echo "::error title=Installer download is empty::$artifact downloaded as an empty file. Manual recovery: re-upload the installer and re-run." >&2 + exit 1 + fi expected_md5="$(printf '%s' "$content_md5" | base64 --decode | xxd -p -c 256)" actual_md5="$(md5sum "release-assets/$artifact" | awk '{print $1}')" if [[ "$actual_md5" != "$expected_md5" ]]; then - echo "Content-MD5 mismatch for $artifact" >&2 + echo "::error title=Installer checksum mismatch::Content-MD5 mismatch for $artifact. Manual recovery: do not publish; rebuild or re-upload the installer, then re-run." >&2 exit 1 fi done @@ -187,7 +228,7 @@ jobs: (cd release-assets && sha256sum Memmy-* > SHA256SUMS.txt) - name: Build release notes - if: ${{ steps.release.outputs.dry_run != 'true' }} + if: ${{ steps.release.outputs.preflight_level == 'full' }} env: VERSION: ${{ steps.release.outputs.version }} TAG: ${{ steps.release.outputs.tag }} @@ -214,7 +255,10 @@ jobs: if [[ -n "$PREVIOUS_TAG" ]]; then generate_args+=(-f previous_tag_name="$PREVIOUS_TAG") fi - gh api "${generate_args[@]}" --jq '.body' >> "$notes" + if ! gh api "${generate_args[@]}" --jq '.body' >> "$notes"; then + echo "::error title=Release notes generation failed::GitHub could not generate release notes for $TAG. Manual recovery: re-run after GitHub API recovers, or add .github/release-notes/$TAG.md and retry." >&2 + exit 1 + fi cat >> "$notes" < release-assets/COMPARE.json + if ! gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_base}...${TARGET_SHA}" \ + > release-assets/COMPARE.json; then + echo "::error title=Release compare failed::Could not compare ${compare_base}...${TARGET_SHA}. Manual recovery: confirm previous tag and target SHA are reachable, then re-run." >&2 + exit 1 + fi total_commits="$(jq -r '.total_commits // (.commits | length)' release-assets/COMPARE.json)" received_commits="$(jq -r '.commits | length' release-assets/COMPARE.json)" changed_file_count="$(jq -r '.files | length' release-assets/COMPARE.json)" compare_head="$(jq -r '.head_commit.sha // empty' release-assets/COMPARE.json)" - test "$total_commits" = "$received_commits" - test "$changed_file_count" -lt 300 - test "$compare_head" = "$TARGET_SHA" + if [[ "$total_commits" != "$received_commits" ]]; then + echo "::error title=Release compare is truncated::GitHub returned $received_commits of $total_commits commits. Manual recovery: reduce release scope or inspect compare evidence manually before retrying." >&2 + exit 1 + fi + if [[ "$changed_file_count" -ge 300 ]]; then + echo "::error title=Release diff is too large::Compare contains $changed_file_count changed files. Manual recovery: split the release or perform manual evidence review." >&2 + exit 1 + fi + if [[ "$compare_head" != "$TARGET_SHA" ]]; then + echo "::error title=Release compare target mismatch::Compare head $compare_head does not match target $TARGET_SHA. Manual recovery: verify the previous tag and release target." >&2 + exit 1 + fi : > release-assets/PULL_REQUESTS.jsonl while IFS= read -r commit_sha; do - gh api \ + if ! gh api \ -H "Accept: application/vnd.github+json" \ "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ --jq '.[] | { @@ -282,7 +338,10 @@ jobs: mergedAt: .merged_at, baseRef: .base.ref, headRef: .head.ref - }' >> release-assets/PULL_REQUESTS.jsonl + }' >> release-assets/PULL_REQUESTS.jsonl; then + echo "::error title=Pull request evidence failed::Could not fetch associated PRs for commit $commit_sha. Manual recovery: re-run after GitHub API recovers or inspect the commit manually." >&2 + exit 1 + fi done < <(jq -r '.commits[].sha' release-assets/COMPARE.json) jq -s 'unique_by(.number) | sort_by(.number)' \ release-assets/PULL_REQUESTS.jsonl > release-assets/PULL_REQUESTS.json @@ -373,7 +432,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Create draft release and upload every asset - if: ${{ steps.release.outputs.dry_run != 'true' }} + if: ${{ steps.release.outputs.create_draft == 'true' }} env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} @@ -390,24 +449,30 @@ jobs: } trap cleanup_draft_release EXIT - gh release create "$TAG" \ + if ! gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$TARGET_SHA" \ --title "Memmy $TAG" \ --notes-file release-assets/RELEASE_NOTES.md \ - --draft + --draft; then + echo "::error title=Draft Release creation failed::Could not create Draft Release $TAG. Manual recovery: confirm GitHub release permissions and that no tag/Release already exists." >&2 + exit 1 + fi draft_created=1 - gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_EVIDENCE.json \ - --repo "$GITHUB_REPOSITORY" + if ! gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_EVIDENCE.json \ + --repo "$GITHUB_REPOSITORY"; then + echo "::error title=Draft asset upload failed::Asset upload failed after Draft Release creation. Automatic recovery: the workflow will delete the half-created Draft Release and tag so it can be safely re-run." >&2 + exit 1 + fi trap - EXIT - name: Record the manual publish boundary - if: ${{ steps.release.outputs.dry_run != 'true' }} + if: ${{ steps.release.outputs.create_draft == 'true' }} env: TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail - release_url="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '.url')" + release_url="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '.url' 2>/dev/null || printf 'https://github.com/%s/releases/tag/%s' "$GITHUB_REPOSITORY" "$TAG")" { echo "## Draft Release created" echo @@ -416,18 +481,20 @@ jobs: echo "- A human must audit the notes, evidence JSON, checksums, and installers before publishing." } >> "$GITHUB_STEP_SUMMARY" - - name: Record dry-run result - if: ${{ steps.release.outputs.dry_run == 'true' }} + - name: Record preflight result + if: ${{ steps.release.outputs.create_draft != 'true' }} env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} + PREFLIGHT_LEVEL: ${{ steps.release.outputs.preflight_level }} run: | set -euo pipefail { - echo "## Draft Release dry-run" + echo "## Draft Release preflight" echo echo "- Tag: $TAG" echo "- Target SHA: $TARGET_SHA" + echo "- Level: $PREFLIGHT_LEVEL" echo "- No tag, Release, assets, or external publication was created." - echo "- Set dry_run=false only when intentionally creating a Draft Release." + echo "- Set create_draft=true only when intentionally creating a Draft Release." } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index e5fcc0353..62cfc183b 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -87,7 +87,12 @@ describe("GitHub Draft Release v2 workflow", () => { }); expect(draftWorkflow.on.pull_request).toBeUndefined(); expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); - expect(draftWorkflow.on.workflow_dispatch.inputs.dry_run.default).toBe(true); + expect(draftWorkflow.on.workflow_dispatch.inputs.preflight_level.default).toBe("smoke"); + expect(draftWorkflow.on.workflow_dispatch.inputs.preflight_level.options).toEqual([ + "smoke", + "full", + ]); + expect(draftWorkflow.on.workflow_dispatch.inputs.create_draft.default).toBe(false); expect(draftJob.if).toContain("github.event.pull_request.merged == true"); expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); @@ -98,9 +103,12 @@ describe("GitHub Draft Release v2 workflow", () => { ); expect(resolve).toContain('version="${BASH_REMATCH[1]}"'); expect(resolve).toContain('target_sha="$PR_MERGE_SHA"'); + expect(resolve).toContain('preflight_level="full"'); + expect(resolve).toContain('create_draft="true"'); expect(resolve).toContain('version="$MANUAL_VERSION"'); - expect(resolve).toContain('dry_run="true"'); - expect(resolve).toContain("dry_run=$dry_run"); + expect(resolve).toContain('if [[ "$CREATE_DRAFT_INPUT" == "true" ]]'); + expect(resolve).toContain("preflight_level=$preflight_level"); + expect(resolve).toContain("create_draft=$create_draft"); expect(resolve).toContain("git/ref/heads/main"); }); @@ -120,6 +128,8 @@ describe("GitHub Draft Release v2 workflow", () => { expect(verify).toContain("git fetch --no-tags origin main"); expect(verify).toContain('git cat-file -e "$TARGET_SHA^{commit}"'); expect(verify).toContain('git merge-base --is-ancestor "$TARGET_SHA" origin/main'); + expect(verify).toContain("Release target missing"); + expect(verify).toContain("Release target is not on main"); expect(verify).toContain('git checkout --detach "$TARGET_SHA"'); expect(verify).toContain('test "$(git rev-parse HEAD)" = "$TARGET_SHA"'); }); @@ -129,15 +139,19 @@ describe("GitHub Draft Release v2 workflow", () => { expect(verify).toContain("require('./package.json').version"); expect(verify).toContain('= "$VERSION"'); expect(verify).toContain("npm run version:check"); + expect(verify).toContain("Root version mismatch"); + expect(verify).toContain("Release version metadata mismatch"); }); it("refuses duplicate tags/releases and never forces publication", () => { expect( draftSteps.find((step) => step.name === "Check for an existing tag or release")?.if, - ).toBe("${{ steps.release.outputs.dry_run != 'true' }}"); + ).toBe("${{ steps.release.outputs.preflight_level == 'full' }}"); const duplicateCheck = draftScript("Check for an existing tag or release"); expect(duplicateCheck).toContain("git ls-remote --exit-code --tags"); expect(duplicateCheck).toContain('gh release view "$TAG"'); + expect(duplicateCheck).toContain("Release tag already exists"); + expect(duplicateCheck).toContain("Release already exists"); expect(duplicateCheck).not.toContain("--force"); expect(draftSource).toContain("gh release create"); expect(draftSource).toContain("--draft"); @@ -146,10 +160,18 @@ describe("GitHub Draft Release v2 workflow", () => { }); it("downloads all four OSS artifacts and verifies Content-MD5", () => { + expect(draftSteps.find((step) => step.name === "Download and verify OSS artifacts")?.if).toBe( + "${{ steps.release.outputs.preflight_level == 'full' }}", + ); const download = draftScript("Download and verify OSS artifacts"); expect(download).toContain("curl --fail --location --retry 5 --retry-all-errors"); expect(download).toContain("Content-MD5"); - expect(download).toContain('test -s "release-assets/$artifact"'); + expect(download).toContain("Installer asset is missing"); + expect(download).toContain("Installer checksum header missing"); + expect(download).toContain("Installer download failed"); + expect(download).toContain("Installer checksum mismatch"); + expect(download).toContain('[[ ! -s "release-assets/$artifact" ]]'); + expect(download).toContain("Installer download is empty"); expect(download.match(/Memmy-\$VERSION-/g)).toHaveLength(4); expect(download).toContain("MD5SUMS.txt"); expect(download).toContain("SHA256SUMS.txt"); @@ -157,20 +179,36 @@ describe("GitHub Draft Release v2 workflow", () => { it("records independently auditable commits, PRs, files, versions, and assets", () => { for (const stepName of [ - "Download and verify OSS artifacts", "Build release notes", "Build auditable release evidence", - "Create draft release and upload every asset", - "Record the manual publish boundary", ]) { expect(draftSteps.find((step) => step.name === stepName)?.if).toBe( - "${{ steps.release.outputs.dry_run != 'true' }}", + "${{ steps.release.outputs.preflight_level == 'full' }}", ); } + expect( + draftSteps.find((step) => step.name === "Create draft release and upload every asset")?.if, + ).toBe("${{ steps.release.outputs.create_draft == 'true' }}"); + expect(draftSteps.find((step) => step.name === "Record the manual publish boundary")?.if).toBe( + "${{ steps.release.outputs.create_draft == 'true' }}", + ); + + expect(draftScript("Resolve previous stable release tag")).toContain( + 'git fetch --force origin "refs/tags/v*:refs/tags/v*"', + ); + expect(draftScript("Resolve previous stable release tag")).toContain( + "Release version is not newer", + ); + expect(draftScript("Build release notes")).toContain("Release notes generation failed"); const evidence = draftScript("Build auditable release evidence"); expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); expect(evidence).toContain("commits/${commit_sha}/pulls"); + expect(evidence).toContain("Release compare failed"); + expect(evidence).toContain("Release compare is truncated"); + expect(evidence).toContain("Release diff is too large"); + expect(evidence).toContain("Release compare target mismatch"); + expect(evidence).toContain("Pull request evidence failed"); expect(evidence).toContain("memmy.release.evidence.v2"); expect(evidence).toContain("changedFiles"); expect(evidence).toContain("versionFiles"); @@ -189,6 +227,9 @@ describe("GitHub Draft Release v2 workflow", () => { expect(create).toContain("cleanup_draft_release()"); expect(create).toContain('draft_created=1'); expect(create).toContain('gh release delete "$TAG" --cleanup-tag --yes'); + expect(create).toContain("Draft Release creation failed"); + expect(create).toContain("Draft asset upload failed"); + expect(create).toContain("Automatic recovery"); expect(create.indexOf("gh release create")).toBeLessThan(create.indexOf("draft_created=1")); expect(create.indexOf("draft_created=1")).toBeLessThan(create.indexOf("gh release upload")); expect(create).toContain("trap - EXIT"); @@ -206,14 +247,15 @@ describe("GitHub Draft Release v2 workflow", () => { const boundary = draftScript("Record the manual publish boundary"); expect(boundary).toContain("This workflow intentionally stops before Publish."); expect(boundary).toContain("A human must audit"); + expect(boundary).toContain("|| printf"); }); - it("keeps fork manual testing side-effect free by default", () => { - const dryRun = draftScript("Record dry-run result"); - expect(draftSteps.find((step) => step.name === "Record dry-run result")?.if).toBe( - "${{ steps.release.outputs.dry_run == 'true' }}", + it("keeps fork manual testing side-effect free unless create_draft is explicit", () => { + const preflight = draftScript("Record preflight result"); + expect(draftSteps.find((step) => step.name === "Record preflight result")?.if).toBe( + "${{ steps.release.outputs.create_draft != 'true' }}", ); - expect(dryRun).toContain("No tag, Release, assets, or external publication was created."); - expect(dryRun).toContain("Set dry_run=false only when intentionally creating a Draft Release."); + expect(preflight).toContain("No tag, Release, assets, or external publication was created."); + expect(preflight).toContain("Set create_draft=true only when intentionally creating a Draft Release."); }); }); From ea72da4135697108baac23f3a53671e2d6b49bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Mon, 10 Aug 2026 21:26:46 +0800 Subject: [PATCH 06/11] feat: generate Memmy draft release notes via Doc Agent --- .github/workflows/github-draft-release-v2.yml | 337 ++++++++++++++++-- tests/release-workflow.test.ts | 54 ++- 2 files changed, 363 insertions(+), 28 deletions(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 02a1d004a..a5be55961 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -38,7 +38,8 @@ jobs: if: >- github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'release/v')) + (startsWith(github.event.pull_request.head.ref, 'v') || + startsWith(github.event.pull_request.head.ref, 'release/v'))) runs-on: ubuntu-latest environment: release env: @@ -59,11 +60,11 @@ jobs: preflight_level="$PREFLIGHT_LEVEL_INPUT" create_draft="false" if [[ "$EVENT_NAME" == "pull_request_target" ]]; then - if [[ ! "$PR_HEAD_REF" =~ ^release/v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then - echo "Release branch must match release/vX.Y.Z" >&2 + if [[ ! "$PR_HEAD_REF" =~ ^(release/)?v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then + echo "Release branch must match vX.Y.Z or release/vX.Y.Z" >&2 exit 1 fi - version="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" target_sha="$PR_MERGE_SHA" preflight_level="full" create_draft="true" @@ -234,30 +235,298 @@ jobs: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} PREVIOUS_TAG: ${{ steps.previous.outputs.previous_tag }} + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ vars.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} run: | set -euo pipefail notes="release-assets/RELEASE_NOTES.md" manual_notes=".github/release-notes/$TAG.md" manual_object="${TARGET_SHA}:${manual_notes}" + notes_source="unknown" + needs_review="false" + : > "$notes" + jq -n '{source: "unknown", needs_review: true, warnings: ["release notes source has not been selected yet"]}' > release-assets/RELEASE_NOTES_SOURCE.json + jq -n '{ok: false, needs_review: true, warnings: ["release notes quality report has not been generated yet"]}' > release-assets/QUALITY_REPORT.json + if git cat-file -e "$manual_object" 2>/dev/null; then git show "$manual_object" > "$notes" - printf '\n\n' >> "$notes" + notes_source="manual" + jq -n \ + --arg source "$notes_source" \ + --arg file "$manual_notes" \ + '{source: $source, manual_file: $file, needs_review: false, warnings: []}' \ + > release-assets/RELEASE_NOTES_SOURCE.json + jq -n \ + --arg source "$notes_source" \ + --arg file "$manual_notes" \ + '{ok: true, needs_review: false, source: $source, manual_file: $file, warnings: []}' \ + > release-assets/QUALITY_REPORT.json else - : > "$notes" - fi + compare_base="$PREVIOUS_TAG" + if [[ -z "$compare_base" ]]; then + compare_base="$(git rev-list --max-parents=0 "$TARGET_SHA" | head -n 1)" + fi + test -n "$compare_base" - generate_args=( - --method POST - "repos/${GITHUB_REPOSITORY}/releases/generate-notes" - -f tag_name="$TAG" - -f target_commitish="$TARGET_SHA" - ) - if [[ -n "$PREVIOUS_TAG" ]]; then - generate_args+=(-f previous_tag_name="$PREVIOUS_TAG") - fi - if ! gh api "${generate_args[@]}" --jq '.body' >> "$notes"; then - echo "::error title=Release notes generation failed::GitHub could not generate release notes for $TAG. Manual recovery: re-run after GitHub API recovers, or add .github/release-notes/$TAG.md and retry." >&2 - exit 1 + if ! gh api "repos/${GITHUB_REPOSITORY}/compare/${compare_base}...${TARGET_SHA}" \ + > release-assets/DRAFT_COMPARE.json; then + echo "::error title=Draft evidence compare failed::Could not compare ${compare_base}...${TARGET_SHA}. Manual recovery: confirm previous tag and target SHA are reachable, then re-run." >&2 + exit 1 + fi + total_commits="$(jq -r '.total_commits // (.commits | length)' release-assets/DRAFT_COMPARE.json)" + received_commits="$(jq -r '.commits | length' release-assets/DRAFT_COMPARE.json)" + compare_head="$(jq -r '.head_commit.sha // empty' release-assets/DRAFT_COMPARE.json)" + if [[ "$total_commits" != "$received_commits" ]]; then + echo "::error title=Draft evidence is truncated::GitHub returned $received_commits of $total_commits commits. Manual recovery: reduce release scope or inspect compare evidence manually before retrying." >&2 + exit 1 + fi + if [[ "$compare_head" != "$TARGET_SHA" ]]; then + echo "::error title=Draft evidence target mismatch::Compare head $compare_head does not match target $TARGET_SHA. Manual recovery: verify the previous tag and release target." >&2 + exit 1 + fi + + : > release-assets/DRAFT_PULL_REQUESTS.jsonl + while IFS= read -r commit_sha; do + if ! gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ + --jq '.[] | { + number, + title, + htmlUrl: .html_url, + mergedAt: .merged_at, + baseRef: .base.ref, + headRef: .head.ref + }' >> release-assets/DRAFT_PULL_REQUESTS.jsonl; then + echo "::error title=Draft PR evidence failed::Could not fetch associated PRs for commit $commit_sha. Manual recovery: re-run after GitHub API recovers or inspect the commit manually." >&2 + exit 1 + fi + done < <(jq -r '.commits[].sha' release-assets/DRAFT_COMPARE.json) + jq -s 'unique_by(.number) | sort_by(.number)' \ + release-assets/DRAFT_PULL_REQUESTS.jsonl > release-assets/DRAFT_PULL_REQUESTS.json + + jq -Rn ' + [inputs + | select(length > 0) + | capture("^(?[0-9a-f]{64}) (?.+)$") + ] + ' < release-assets/SHA256SUMS.txt > release-assets/ARTIFACTS.json + + root_version="$(node -p "require('./package.json').version")" + memory_version="$(node -p "require('./Memory/package.json').version")" + memory_cli_version="$(node -p "require('./Memory/src/cli/npm/package.json').version")" + agent_version="$(node -p "require('./App/memmy-agent/package.json').version")" + desktop_version="$(node -p "require('./App/shell/desktop/package.json').version")" + + node <<'NODE' > release-assets/MEMMY_RELEASE_STYLE_EXAMPLES.json + const { execFileSync } = require("node:child_process"); + const path = require("node:path"); + + const target = process.env.TARGET_SHA; + const currentTag = process.env.TAG; + + function git(args) { + try { + return execFileSync("git", args, { encoding: "utf8" }); + } catch { + return ""; + } + } + + function versionKey(tag) { + const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag); + return match ? match.slice(1).map(Number) : [0, 0, 0]; + } + + const files = git(["ls-tree", "-r", "--name-only", target, ".github/release-notes"]) + .split(/\r?\n/) + .filter(Boolean) + .filter((file) => /^\.github\/release-notes\/v\d+\.\d+\.\d+\.md$/.test(file)) + .filter((file) => path.basename(file, ".md") !== currentTag) + .sort((left, right) => { + const a = versionKey(path.basename(left, ".md")); + const b = versionKey(path.basename(right, ".md")); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + }) + .slice(-3); + + const githubReleaseNotes = files.map((file) => ({ + tag: path.basename(file, ".md"), + path: file, + body: git(["show", `${target}:${file}`]).slice(0, 12000), + })); + + const websiteChangelog = [ + { + tag: "v1.0.5", + title_cn: "记忆支持时间感知、飞书接入更便捷", + contract: "官网短摘要只保留新功能和改进与问题修复,普通版本约 6-8 条。", + }, + { + tag: "v1.0.4", + title_cn: "项目工作区、浏览器自动化与长期记忆", + contract: "跨 Agent/Memory 的同一用户结果只写一条,多 surfaces 复用,All 不重复。", + }, + ]; + + process.stdout.write(JSON.stringify({ + github_release_notes: githubReleaseNotes, + website_changelog: websiteChangelog, + }, null, 2)); + NODE + + jq -n \ + --arg sourceId "memmy-official-changelog-v2" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg version "$VERSION" \ + --arg tag "$TAG" \ + --arg previousTag "$PREVIOUS_TAG" \ + --arg targetSha "$TARGET_SHA" \ + --arg rootVersion "$root_version" \ + --arg memoryVersion "$memory_version" \ + --arg memoryCliVersion "$memory_cli_version" \ + --arg agentVersion "$agent_version" \ + --arg desktopVersion "$desktop_version" \ + --slurpfile compare release-assets/DRAFT_COMPARE.json \ + --slurpfile pullRequests release-assets/DRAFT_PULL_REQUESTS.json \ + --slurpfile artifacts release-assets/ARTIFACTS.json \ + --slurpfile styleExamples release-assets/MEMMY_RELEASE_STYLE_EXAMPLES.json \ + '{ + source_id: $sourceId, + sourceId: $sourceId, + repository: $repository, + repo: $repository, + version: $version, + tag: $tag, + tag_name: $tag, + previous_tag: $previousTag, + previousTag: $previousTag, + target_sha: $targetSha, + targetSha: $targetSha, + commits: ($compare[0].commits | map({ + sha, + short_sha: .sha[0:8], + shortSha: .sha[0:8], + html_url: .html_url, + htmlUrl: .html_url, + subject: (.commit.message | split("\n")[0]), + message: .commit.message + })), + pull_requests: $pullRequests[0], + pullRequests: $pullRequests[0], + changed_files: ($compare[0].files | map({ + path: .filename, + previous_path: (.previous_filename // null), + previousPath: (.previous_filename // null), + status, + additions, + deletions, + changes + })), + version_files: [ + {path: "package.json", version: $rootVersion}, + {path: "Memory/package.json", version: $memoryVersion}, + {path: "Memory/src/cli/npm/package.json", version: $memoryCliVersion}, + {path: "App/memmy-agent/package.json", version: $agentVersion}, + {path: "App/shell/desktop/package.json", version: $desktopVersion} + ], + artifacts: $artifacts[0], + style_examples: $styleExamples[0], + release_note_quality_request: { + candidate_count: 3, + require_source_refs: true, + require_bilingual_output: true, + require_surfaces: true, + max_items: 10, + max_added_items: 5, + max_improved_fixed_items: 5, + dedupe_same_user_result: true, + fail_closed: true, + allowed_surfaces: ["general", "desktop", "agent", "memory", "cli"] + }, + release_context: { + release_kind: "memmy_official_desktop_agent_memory_cli", + public_release_body: "github_draft_release_notes", + docs_product_extraction: "release_published_revalidated_by_106", + manual_release_notes_file: ".github/release-notes/vX.Y.Z.md is optional" + } + }' > release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json + + if [[ -n "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" && -n "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then + if curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + --connect-timeout 10 --max-time 120 \ + --header "Content-Type: application/json" \ + --header "Authorization: Bearer $DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" \ + --data-binary @release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json \ + "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" \ + > release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json; then + if jq -e '((.release_notes_md // .release_notes_markdown // "") | length) > 0' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + jq -r '.release_notes_md // .release_notes_markdown' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > "$notes" + jq '{ + source: (.source // "doc-agent"), + ok: (.ok // false), + needs_review: (.needs_review // false), + confidence: (.confidence // "medium"), + candidate_selection: (.quality_report.candidate_selection // .candidate_selection // {}), + warnings: (.quality_report.warnings // .warnings // []) + }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ + > release-assets/RELEASE_NOTES_SOURCE.json + jq '.quality_report // { + ok: (.ok // false), + needs_review: (.needs_review // false), + confidence: (.confidence // "medium"), + warnings: (.warnings // []), + coverage: (.coverage // {}), + candidate_selection: (.candidate_selection // {}), + attempts: (.attempts // []) + }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ + > release-assets/QUALITY_REPORT.json + notes_source="doc-agent" + needs_review="$(jq -r '.needs_review // .quality_report.needs_review // false' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json)" + else + echo "::warning title=Doc Agent returned no release notes::Falling back to GitHub generated release notes." + fi + else + echo "::warning title=Doc Agent draft generation failed::Falling back to GitHub generated release notes." + fi + else + echo "::notice title=Doc Agent draft generation skipped::DOC_AGENT_RELEASE_NOTES_DRAFT_URL or DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN is not configured." + fi + + if [[ ! -s "$notes" ]]; then + generate_args=( + --method POST + "repos/${GITHUB_REPOSITORY}/releases/generate-notes" + -f tag_name="$TAG" + -f target_commitish="$TARGET_SHA" + ) + if [[ -n "$PREVIOUS_TAG" ]]; then + generate_args+=(-f previous_tag_name="$PREVIOUS_TAG") + fi + if ! gh api "${generate_args[@]}" --jq '.body' > "$notes"; then + echo "::error title=Release notes generation failed::Both Doc Agent and GitHub generated notes failed for $TAG. Manual recovery: re-run after service recovery, or add .github/release-notes/$TAG.md and retry." >&2 + exit 1 + fi + notes_source="github-generated" + needs_review="true" + tmp_notes="$(mktemp)" + { + echo "> **Needs review:** Doc Agent was unavailable, so this Draft uses GitHub generated notes. Review and edit before publishing." + echo + cat "$notes" + } > "$tmp_notes" + mv "$tmp_notes" "$notes" + jq -n \ + --arg source "$notes_source" \ + '{source: $source, needs_review: true, warnings: ["Doc Agent unavailable; used GitHub generated release notes fallback"]}' \ + > release-assets/RELEASE_NOTES_SOURCE.json + jq -n \ + --arg source "$notes_source" \ + '{ok: false, needs_review: true, source: $source, warnings: ["Doc Agent unavailable; used GitHub generated release notes fallback"]}' \ + > release-assets/QUALITY_REPORT.json + fi fi cat >> "$notes" < EOF @@ -360,6 +631,8 @@ jobs: desktop_version="$(node -p "require('./App/shell/desktop/package.json').version")" generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" release_notes_sha256="$(sha256sum release-assets/RELEASE_NOTES.md | awk '{print $1}')" + release_notes_source="$(jq -r '.source // "unknown"' release-assets/RELEASE_NOTES_SOURCE.json)" + release_notes_needs_review="$(jq -r '.needs_review // false' release-assets/RELEASE_NOTES_SOURCE.json)" jq -n \ --arg repository "$GITHUB_REPOSITORY" \ @@ -369,6 +642,8 @@ jobs: --arg targetSha "$TARGET_SHA" \ --arg generatedAt "$generated_at" \ --arg releaseNotesSha256 "$release_notes_sha256" \ + --arg releaseNotesSource "$release_notes_source" \ + --argjson releaseNotesNeedsReview "$release_notes_needs_review" \ --arg rootVersion "$root_version" \ --arg memoryVersion "$memory_version" \ --arg memoryCliVersion "$memory_cli_version" \ @@ -417,6 +692,8 @@ jobs: {path: "App/shell/desktop/package.json", version: $desktopVersion} ], releaseNotesSha256: $releaseNotesSha256, + releaseNotesSource: $releaseNotesSource, + releaseNotesNeedsReview: $releaseNotesNeedsReview, artifactManifest: "SHA256SUMS.txt", artifacts: $artifacts[0] }' > release-assets/RELEASE_EVIDENCE.json @@ -429,8 +706,24 @@ jobs: echo "- Changed files: $changed_file_count" echo "- Pull requests: $(jq 'length' release-assets/PULL_REQUESTS.json)" echo "- Version metadata: $VERSION" + echo "- Release notes source: $release_notes_source" + echo "- Release notes needs review: $release_notes_needs_review" } >> "$GITHUB_STEP_SUMMARY" + - name: Upload release audit artifact + if: ${{ steps.release.outputs.preflight_level == 'full' }} + uses: actions/upload-artifact@v4 + with: + name: memmy-release-audit-${{ steps.release.outputs.tag }} + if-no-files-found: error + path: | + release-assets/RELEASE_NOTES.md + release-assets/RELEASE_NOTES_SOURCE.json + release-assets/QUALITY_REPORT.json + release-assets/RELEASE_EVIDENCE.json + release-assets/MD5SUMS.txt + release-assets/SHA256SUMS.txt + - name: Create draft release and upload every asset if: ${{ steps.release.outputs.create_draft == 'true' }} env: @@ -459,7 +752,7 @@ jobs: exit 1 fi draft_created=1 - if ! gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_EVIDENCE.json \ + if ! gh release upload "$TAG" release-assets/Memmy-* release-assets/MD5SUMS.txt release-assets/SHA256SUMS.txt release-assets/RELEASE_NOTES.md release-assets/RELEASE_NOTES_SOURCE.json release-assets/QUALITY_REPORT.json release-assets/RELEASE_EVIDENCE.json \ --repo "$GITHUB_REPOSITORY"; then echo "::error title=Draft asset upload failed::Asset upload failed after Draft Release creation. Automatic recovery: the workflow will delete the half-created Draft Release and tag so it can be safely re-run." >&2 exit 1 diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 62cfc183b..552b6271b 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; @@ -80,7 +81,25 @@ describe("Memmy release workflow metadata", () => { }); describe("GitHub Draft Release v2 workflow", () => { - it("creates Draft Releases from merged release/vX.Y.Z PRs and keeps manual fallback", () => { + it("keeps every shell block syntactically valid", () => { + const tempDir = mkdtempSync(resolve(tmpdir(), "memmy-release-workflow-")); + + for (const [index, step] of draftSteps.entries()) { + const script = String(step.run ?? ""); + if (!script) continue; + + const scriptPath = resolve(tempDir, `step-${index}.sh`); + writeFileSync(scriptPath, script); + const result = spawnSync("bash", ["-n", scriptPath], { + cwd: repoRoot, + encoding: "utf8", + }); + + expect(result.status, `${String(step.name)}\n${result.stderr}`).toBe(0); + } + }); + + it("creates Draft Releases from merged vX.Y.Z PRs and keeps manual fallback", () => { expect(draftWorkflow.on.pull_request_target).toEqual({ types: ["closed"], branches: ["main"], @@ -94,14 +113,16 @@ describe("GitHub Draft Release v2 workflow", () => { ]); expect(draftWorkflow.on.workflow_dispatch.inputs.create_draft.default).toBe(false); expect(draftJob.if).toContain("github.event.pull_request.merged == true"); + expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'v')"); expect(draftJob.if).toContain("startsWith(github.event.pull_request.head.ref, 'release/v')"); const resolve = draftScript("Resolve and validate release"); expect(resolve).toContain('if [[ "$EVENT_NAME" == "pull_request_target" ]]'); expect(resolve).toContain( - "^release/v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", + "^(release/)?v((0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*))$", ); - expect(resolve).toContain('version="${BASH_REMATCH[1]}"'); + expect(resolve).toContain("vX.Y.Z or release/vX.Y.Z"); + expect(resolve).toContain('version="${BASH_REMATCH[2]}"'); expect(resolve).toContain('target_sha="$PR_MERGE_SHA"'); expect(resolve).toContain('preflight_level="full"'); expect(resolve).toContain('create_draft="true"'); @@ -200,7 +221,18 @@ describe("GitHub Draft Release v2 workflow", () => { expect(draftScript("Resolve previous stable release tag")).toContain( "Release version is not newer", ); - expect(draftScript("Build release notes")).toContain("Release notes generation failed"); + const releaseNotes = draftScript("Build release notes"); + expect(releaseNotes).toContain("DOC_AGENT_RELEASE_NOTES_DRAFT_URL"); + expect(releaseNotes).toContain("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN"); + expect(releaseNotes).toContain("DOC_AGENT_RELEASE_NOTES_REQUEST.json"); + expect(releaseNotes).toContain("MEMMY_RELEASE_STYLE_EXAMPLES.json"); + expect(releaseNotes).toContain("candidate_count: 3"); + expect(releaseNotes).toContain(".release_notes_md // .release_notes_markdown"); + expect(releaseNotes).toContain("Doc Agent draft generation failed"); + expect(releaseNotes).toContain("GitHub generated release notes fallback"); + expect(releaseNotes).toContain("Release notes generation failed"); + expect(releaseNotes).toContain("RELEASE_NOTES_SOURCE.json"); + expect(releaseNotes).toContain("QUALITY_REPORT.json"); const evidence = draftScript("Build auditable release evidence"); expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); expect(evidence).toContain("commits/${commit_sha}/pulls"); @@ -213,13 +245,23 @@ describe("GitHub Draft Release v2 workflow", () => { expect(evidence).toContain("changedFiles"); expect(evidence).toContain("versionFiles"); expect(evidence).toContain("releaseNotesSha256"); + expect(evidence).toContain("releaseNotesSource"); + expect(evidence).toContain("releaseNotesNeedsReview"); expect(evidence).toContain("artifacts"); - expect(draftScript("Build release notes")).toContain( + expect(releaseNotes).toContain( "doc-agent: source-id=memmy-official-changelog-v2", ); + const uploadAudit = draftSteps.find((step) => step.name === "Upload release audit artifact"); + expect(uploadAudit?.uses).toBe("actions/upload-artifact@v4"); + expect(JSON.stringify(uploadAudit)).toContain("RELEASE_NOTES.md"); + expect(JSON.stringify(uploadAudit)).toContain("RELEASE_NOTES_SOURCE.json"); + expect(JSON.stringify(uploadAudit)).toContain("QUALITY_REPORT.json"); expect(draftScript("Create draft release and upload every asset")).toContain( "RELEASE_EVIDENCE.json", ); + expect(draftScript("Create draft release and upload every asset")).toContain( + "RELEASE_NOTES_SOURCE.json", + ); }); it("cleans up a half-created Draft Release if asset upload fails", () => { From 05de2c38c738a039cdd5998f75b376bfc40d1036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Tue, 11 Aug 2026 17:54:43 +0800 Subject: [PATCH 07/11] test: validate draft release embedded scripts --- .github/workflows/github-draft-release-v2.yml | 3 ++ tests/release-workflow.test.ts | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index a5be55961..205029d27 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -789,5 +789,8 @@ jobs: echo "- Target SHA: $TARGET_SHA" echo "- Level: $PREFLIGHT_LEVEL" echo "- No tag, Release, assets, or external publication was created." + if [[ "$PREFLIGHT_LEVEL" == "smoke" ]]; then + echo "- Smoke only validates the target commit and version metadata. It intentionally skips duplicate tag/Release, installer, release notes, and evidence checks; run full preflight before creating a Draft Release." + fi echo "- Set create_draft=true only when intentionally creating a Draft Release." } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 552b6271b..f49080638 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -14,6 +14,27 @@ const draftJob = draftWorkflow.jobs.release; const draftSteps = draftJob.steps as Array>; const draftScript = (name: string) => String(draftSteps.find((step) => step.name === name)?.run ?? ""); +const heredocBodies = (script: string, marker: string) => { + const lines = script.split(/\r?\n/); + const bodies: string[] = []; + + for (let index = 0; index < lines.length; index += 1) { + if (!lines[index].includes(`<<'${marker}'`)) continue; + + const body: string[] = []; + let cursor = index + 1; + for (; cursor < lines.length; cursor += 1) { + if (lines[cursor] === marker) break; + body.push(lines[cursor]); + } + + expect(cursor, `unterminated heredoc ${marker}`).toBeLessThan(lines.length); + bodies.push(body.join("\n")); + index = cursor; + } + + return bodies; +}; const packagingConfigs = [ "electron-builder.yml", "electron-builder.unsigned.yml", @@ -99,6 +120,29 @@ describe("GitHub Draft Release v2 workflow", () => { } }); + it("keeps embedded Node heredocs syntactically valid", () => { + const tempDir = mkdtempSync(resolve(tmpdir(), "memmy-release-workflow-node-")); + const nodeHeredocs = draftSteps.flatMap((step) => + heredocBodies(String(step.run ?? ""), "NODE").map((body, index) => ({ + body, + name: `${String(step.name)} heredoc ${index + 1}`, + })), + ); + + expect(nodeHeredocs.length).toBeGreaterThan(0); + + for (const [index, heredoc] of nodeHeredocs.entries()) { + const scriptPath = resolve(tempDir, `node-heredoc-${index}.cjs`); + writeFileSync(scriptPath, heredoc.body); + const result = spawnSync("node", ["--check", scriptPath], { + cwd: repoRoot, + encoding: "utf8", + }); + + expect(result.status, `${heredoc.name}\n${result.stderr}`).toBe(0); + } + }); + it("creates Draft Releases from merged vX.Y.Z PRs and keeps manual fallback", () => { expect(draftWorkflow.on.pull_request_target).toEqual({ types: ["closed"], @@ -298,6 +342,8 @@ describe("GitHub Draft Release v2 workflow", () => { "${{ steps.release.outputs.create_draft != 'true' }}", ); expect(preflight).toContain("No tag, Release, assets, or external publication was created."); + expect(preflight).toContain("Smoke only validates the target commit and version metadata."); + expect(preflight).toContain("run full preflight before creating a Draft Release."); expect(preflight).toContain("Set create_draft=true only when intentionally creating a Draft Release."); }); }); From 43f4f36c73a397432cdc150b51d67ec00ae7e45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Tue, 11 Aug 2026 18:25:27 +0800 Subject: [PATCH 08/11] ci: grant release workflow PR evidence read --- .github/workflows/github-draft-release-v2.yml | 1 + tests/release-workflow.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 205029d27..8d22a143d 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -28,6 +28,7 @@ on: permissions: contents: write + pull-requests: read concurrency: group: draft-release-v2-${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.event.pull_request.head.ref }} diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index f49080638..bf940c188 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -322,7 +322,10 @@ describe("GitHub Draft Release v2 workflow", () => { }); it("uses the release environment, minimal permissions, and per-version concurrency", () => { - expect(draftWorkflow.permissions).toEqual({ contents: "write" }); + expect(draftWorkflow.permissions).toEqual({ + contents: "write", + "pull-requests": "read", + }); expect(draftJob.environment).toBe("release"); expect(draftWorkflow.concurrency["cancel-in-progress"]).toBe(false); expect(draftWorkflow.concurrency.group).toContain("inputs.version"); From 63a968d0bd0887269749ab566b58c18d90d3521e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Wed, 12 Aug 2026 10:54:24 +0800 Subject: [PATCH 09/11] ci: harden Memmy draft release preflight --- .github/workflows/github-draft-release-v2.yml | 209 ++++++++++++------ tests/release-workflow.test.ts | 43 +++- 2 files changed, 175 insertions(+), 77 deletions(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 8d22a143d..0020c35fd 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -13,7 +13,7 @@ on: required: true type: string preflight_level: - description: Smoke validates the target only; full also verifies installers, release notes, and evidence + description: Smoke validates the target and Doc Agent config; full also verifies installers, release notes, and evidence required: false default: smoke type: choice @@ -139,7 +139,76 @@ jobs: git checkout --detach "$TARGET_SHA" test "$(git rev-parse HEAD)" = "$TARGET_SHA" + - name: Preflight Doc Agent draft endpoint + env: + DOC_AGENT_RELEASE_NOTES_DRAFT_URL: ${{ vars.DOC_AGENT_RELEASE_NOTES_DRAFT_URL || secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_URL }} + DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN: ${{ secrets.DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" ]]; then + echo "::error title=Doc Agent draft URL is missing::Configure DOC_AGENT_RELEASE_NOTES_DRAFT_URL before merging release branches. Manual recovery: add the Actions variable pointing to the 106 /internal/memmy-release-notes/draft endpoint, then re-run smoke." >&2 + exit 1 + fi + if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then + echo "::error title=Doc Agent draft token is missing::Configure DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN before merging release branches. Manual recovery: add the Actions secret matching the 106 RELEASE_NOTES_DRAFT_TOKEN, then re-run smoke." >&2 + exit 1 + fi + if [[ ! "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" =~ ^https?://[^[:space:]]+/internal/(memmy-)?release-notes/draft$ ]]; then + echo "::error title=Doc Agent draft URL is invalid::DOC_AGENT_RELEASE_NOTES_DRAFT_URL must point to /internal/memmy-release-notes/draft or /internal/release-notes/draft. Manual recovery: fix the Actions variable, then re-run smoke." >&2 + exit 1 + fi + + mkdir -p release-assets + response_file="release-assets/DOC_AGENT_SMOKE_RESPONSE.json" + http_status="$(curl --silent --show-error --location --retry 2 --retry-all-errors \ + --connect-timeout 10 --max-time 45 \ + --header "Content-Type: application/json" \ + --header "Authorization: Bearer $DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" \ + --data-binary '[]' \ + --output "$response_file" \ + --write-out '%{http_code}' \ + "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" || true)" + + case "$http_status" in + 400|422) ;; + 200) + echo "::error title=Doc Agent smoke contract mismatch::The 106 draft endpoint accepted an intentionally invalid smoke payload. Manual recovery: verify the endpoint still rejects non-object evidence packets before release." >&2 + exit 1 + ;; + 401|403) + echo "::error title=Doc Agent draft token rejected::The 106 draft endpoint rejected DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN with HTTP $http_status. Manual recovery: make the GitHub secret match the 106 RELEASE_NOTES_DRAFT_TOKEN, then re-run smoke." >&2 + exit 1 + ;; + 404) + echo "::error title=Doc Agent draft endpoint disabled or wrong path::The 106 draft endpoint returned HTTP 404. Manual recovery: enable RELEASE_NOTES_DRAFT_ENABLED=true on 106 and verify the URL path, then re-run smoke." >&2 + exit 1 + ;; + 000|5*) + echo "::error title=Doc Agent draft endpoint unavailable::The 106 draft endpoint was unreachable or returned HTTP $http_status. Manual recovery: verify doc-agent.service health and network access, then re-run smoke." >&2 + exit 1 + ;; + *) + echo "::error title=Unexpected Doc Agent draft endpoint status::The 106 draft endpoint returned HTTP $http_status. Manual recovery: inspect the 106 logs and endpoint configuration, then re-run smoke." >&2 + exit 1 + ;; + esac + + if ! jq -e 'type == "object" and (has("detail") or has("error") or has("message") or has("warnings"))' "$response_file" >/dev/null; then + echo "::error title=Doc Agent smoke response contract mismatch::Validation failures must return a JSON object with detail/error/message/warnings so release failures are actionable." >&2 + exit 1 + fi + + { + echo "## Doc Agent draft endpoint preflight" + echo + echo "- Endpoint: configured" + echo "- Token: accepted" + echo "- Validation response: HTTP $http_status" + echo "- LLM generation: not invoked by smoke" + } >> "$GITHUB_STEP_SUMMARY" + - name: Verify repository version metadata + if: ${{ steps.release.outputs.preflight_level == 'full' }} env: VERSION: ${{ steps.release.outputs.version }} run: | @@ -453,80 +522,72 @@ jobs: } }' > release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json - if [[ -n "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" && -n "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then - if curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ - --connect-timeout 10 --max-time 120 \ - --header "Content-Type: application/json" \ - --header "Authorization: Bearer $DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" \ - --data-binary @release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json \ - "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" \ - > release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json; then - if jq -e '((.release_notes_md // .release_notes_markdown // "") | length) > 0' \ - release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then - jq -r '.release_notes_md // .release_notes_markdown' \ - release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > "$notes" - jq '{ - source: (.source // "doc-agent"), - ok: (.ok // false), - needs_review: (.needs_review // false), - confidence: (.confidence // "medium"), - candidate_selection: (.quality_report.candidate_selection // .candidate_selection // {}), - warnings: (.quality_report.warnings // .warnings // []) - }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ - > release-assets/RELEASE_NOTES_SOURCE.json - jq '.quality_report // { - ok: (.ok // false), - needs_review: (.needs_review // false), - confidence: (.confidence // "medium"), - warnings: (.warnings // []), - coverage: (.coverage // {}), - candidate_selection: (.candidate_selection // {}), - attempts: (.attempts // []) - }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ - > release-assets/QUALITY_REPORT.json - notes_source="doc-agent" - needs_review="$(jq -r '.needs_review // .quality_report.needs_review // false' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json)" - else - echo "::warning title=Doc Agent returned no release notes::Falling back to GitHub generated release notes." - fi - else - echo "::warning title=Doc Agent draft generation failed::Falling back to GitHub generated release notes." - fi - else - echo "::notice title=Doc Agent draft generation skipped::DOC_AGENT_RELEASE_NOTES_DRAFT_URL or DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN is not configured." + if [[ -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" || -z "$DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" ]]; then + echo "::error title=Doc Agent draft configuration missing::Full preflight requires DOC_AGENT_RELEASE_NOTES_DRAFT_URL and DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN. Manual recovery: configure them and run smoke before full preflight." >&2 + exit 1 fi + response_status="$(curl --silent --show-error --location --retry 3 --retry-all-errors \ + --connect-timeout 10 --max-time 180 \ + --header "Content-Type: application/json" \ + --header "Authorization: Bearer $DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN" \ + --data-binary @release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json \ + --output release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ + --write-out '%{http_code}' \ + "$DOC_AGENT_RELEASE_NOTES_DRAFT_URL" || true)" + if [[ "$response_status" != "200" ]]; then + echo "::error title=Doc Agent draft generation failed::The 106 draft endpoint returned HTTP $response_status. Manual recovery: inspect release-assets/DOC_AGENT_RELEASE_NOTES_REQUEST.json, 106 logs, token/URL config, and LLM settings; do not fall back silently for a real Memmy release." >&2 + exit 1 + fi + if ! jq -e 'type == "object" and ((.release_notes_md // .release_notes_markdown // "") | length) > 0' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent returned invalid release notes::The response must include release_notes_md or release_notes_markdown. Manual recovery: fix the 106 draft endpoint contract before retrying." >&2 + exit 1 + fi + if ! jq -e '(.source // "doc-agent") == "doc-agent" and (.quality_report | type == "object")' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent quality report missing::The response must include source=doc-agent and a quality_report object. Manual recovery: deploy the corrected 106 code before retrying." >&2 + exit 1 + fi + if ! jq -e '(.quality_report.candidate_selection.requested_candidate_count // .candidate_selection.requested_candidate_count // 0) >= 3' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent candidate selection missing::The 106 response must prove three-candidate generation/scoring for Memmy. Manual recovery: verify the 106 Memmy draft endpoint is running the v2 renderer." >&2 + exit 1 + fi + if jq -e '(.needs_review // .quality_report.needs_review // false) == true or (.quality_report.ok // .ok // false) != true' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json >/dev/null; then + echo "::error title=Doc Agent release notes need review::The generated release notes did not pass quality gates. Manual recovery: inspect QUALITY_REPORT.json, fix evidence/style/LLM output, or add .github/release-notes/$TAG.md as a reviewed manual override." >&2 + jq '.quality_report // .' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > release-assets/QUALITY_REPORT.json || true + exit 1 + fi + + jq -r '.release_notes_md // .release_notes_markdown' \ + release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json > "$notes" + jq '{ + source: (.source // "doc-agent"), + ok: (.ok // .quality_report.ok // false), + needs_review: (.needs_review // .quality_report.needs_review // false), + confidence: (.confidence // .quality_report.confidence // "medium"), + candidate_selection: (.quality_report.candidate_selection // .candidate_selection // {}), + warnings: (.quality_report.warnings // .warnings // []) + }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ + > release-assets/RELEASE_NOTES_SOURCE.json + jq '.quality_report // { + ok: (.ok // false), + needs_review: (.needs_review // false), + confidence: (.confidence // "medium"), + warnings: (.warnings // []), + coverage: (.coverage // {}), + candidate_selection: (.candidate_selection // {}), + attempts: (.attempts // []) + }' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json \ + > release-assets/QUALITY_REPORT.json + notes_source="doc-agent" + needs_review="$(jq -r '.needs_review // .quality_report.needs_review // false' release-assets/DOC_AGENT_RELEASE_NOTES_RESPONSE.json)" + if [[ ! -s "$notes" ]]; then - generate_args=( - --method POST - "repos/${GITHUB_REPOSITORY}/releases/generate-notes" - -f tag_name="$TAG" - -f target_commitish="$TARGET_SHA" - ) - if [[ -n "$PREVIOUS_TAG" ]]; then - generate_args+=(-f previous_tag_name="$PREVIOUS_TAG") - fi - if ! gh api "${generate_args[@]}" --jq '.body' > "$notes"; then - echo "::error title=Release notes generation failed::Both Doc Agent and GitHub generated notes failed for $TAG. Manual recovery: re-run after service recovery, or add .github/release-notes/$TAG.md and retry." >&2 - exit 1 - fi - notes_source="github-generated" - needs_review="true" - tmp_notes="$(mktemp)" - { - echo "> **Needs review:** Doc Agent was unavailable, so this Draft uses GitHub generated notes. Review and edit before publishing." - echo - cat "$notes" - } > "$tmp_notes" - mv "$tmp_notes" "$notes" - jq -n \ - --arg source "$notes_source" \ - '{source: $source, needs_review: true, warnings: ["Doc Agent unavailable; used GitHub generated release notes fallback"]}' \ - > release-assets/RELEASE_NOTES_SOURCE.json - jq -n \ - --arg source "$notes_source" \ - '{ok: false, needs_review: true, source: $source, warnings: ["Doc Agent unavailable; used GitHub generated release notes fallback"]}' \ - > release-assets/QUALITY_REPORT.json + echo "::error title=Release notes generation produced an empty body::No release notes were produced. Manual recovery: inspect the Doc Agent response or add .github/release-notes/$TAG.md as a reviewed manual override." >&2 + exit 1 fi fi @@ -791,7 +852,9 @@ jobs: echo "- Level: $PREFLIGHT_LEVEL" echo "- No tag, Release, assets, or external publication was created." if [[ "$PREFLIGHT_LEVEL" == "smoke" ]]; then - echo "- Smoke only validates the target commit and version metadata. It intentionally skips duplicate tag/Release, installer, release notes, and evidence checks; run full preflight before creating a Draft Release." + echo "- Smoke validates the target commit and Doc Agent draft endpoint configuration." + echo "- It intentionally skips release version metadata, duplicate tag/Release, installer, release notes, and evidence checks." + echo "- Run full preflight before creating a Draft Release." fi echo "- Set create_draft=true only when intentionally creating a Draft Release." } >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index bf940c188..e4cb24f5b 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -201,6 +201,9 @@ describe("GitHub Draft Release v2 workflow", () => { it("requires the requested version to match every release manifest", () => { const verify = draftScript("Verify repository version metadata"); + expect(draftSteps.find((step) => step.name === "Verify repository version metadata")?.if).toBe( + "${{ steps.release.outputs.preflight_level == 'full' }}", + ); expect(verify).toContain("require('./package.json').version"); expect(verify).toContain('= "$VERSION"'); expect(verify).toContain("npm run version:check"); @@ -208,6 +211,29 @@ describe("GitHub Draft Release v2 workflow", () => { expect(verify).toContain("Release version metadata mismatch"); }); + it("smoke-checks the Doc Agent draft endpoint before release branches are merged", () => { + const preflight = draftSteps.find( + (step) => step.name === "Preflight Doc Agent draft endpoint", + ); + expect(preflight).toBeDefined(); + expect(preflight?.if).toBeUndefined(); + const script = draftScript("Preflight Doc Agent draft endpoint"); + + expect(script).toContain("DOC_AGENT_RELEASE_NOTES_DRAFT_URL"); + expect(script).toContain("DOC_AGENT_RELEASE_NOTES_DRAFT_TOKEN"); + expect(script).toContain("Doc Agent draft URL is missing"); + expect(script).toContain("Doc Agent draft token is missing"); + expect(script).toContain("/internal/(memmy-)?release-notes/draft"); + expect(script).toContain("--data-binary '[]'"); + expect(script).toContain("400|422"); + expect(script).toContain("Doc Agent smoke contract mismatch"); + expect(script).toContain("Doc Agent draft token rejected"); + expect(script).toContain("Doc Agent draft endpoint disabled or wrong path"); + expect(script).toContain("Doc Agent draft endpoint unavailable"); + expect(script).toContain("Doc Agent smoke response contract mismatch"); + expect(script).toContain("LLM generation: not invoked by smoke"); + }); + it("refuses duplicate tags/releases and never forces publication", () => { expect( draftSteps.find((step) => step.name === "Check for an existing tag or release")?.if, @@ -272,11 +298,19 @@ describe("GitHub Draft Release v2 workflow", () => { expect(releaseNotes).toContain("MEMMY_RELEASE_STYLE_EXAMPLES.json"); expect(releaseNotes).toContain("candidate_count: 3"); expect(releaseNotes).toContain(".release_notes_md // .release_notes_markdown"); + expect(releaseNotes).toContain("Doc Agent draft configuration missing"); expect(releaseNotes).toContain("Doc Agent draft generation failed"); - expect(releaseNotes).toContain("GitHub generated release notes fallback"); - expect(releaseNotes).toContain("Release notes generation failed"); + expect(releaseNotes).toContain("do not fall back silently"); + expect(releaseNotes).toContain("Doc Agent returned invalid release notes"); + expect(releaseNotes).toContain("Doc Agent quality report missing"); + expect(releaseNotes).toContain("Doc Agent candidate selection missing"); + expect(releaseNotes).toContain("Doc Agent release notes need review"); + expect(releaseNotes).toContain("requested_candidate_count"); + expect(releaseNotes).toContain("Release notes generation produced an empty body"); expect(releaseNotes).toContain("RELEASE_NOTES_SOURCE.json"); expect(releaseNotes).toContain("QUALITY_REPORT.json"); + expect(releaseNotes).not.toContain("releases/generate-notes"); + expect(releaseNotes).not.toContain("github-generated"); const evidence = draftScript("Build auditable release evidence"); expect(evidence).toContain("compare/${compare_base}...${TARGET_SHA}"); expect(evidence).toContain("commits/${commit_sha}/pulls"); @@ -345,8 +379,9 @@ describe("GitHub Draft Release v2 workflow", () => { "${{ steps.release.outputs.create_draft != 'true' }}", ); expect(preflight).toContain("No tag, Release, assets, or external publication was created."); - expect(preflight).toContain("Smoke only validates the target commit and version metadata."); - expect(preflight).toContain("run full preflight before creating a Draft Release."); + expect(preflight).toContain("Smoke validates the target commit and Doc Agent draft endpoint configuration."); + expect(preflight).toContain("It intentionally skips release version metadata"); + expect(preflight).toContain("Run full preflight before creating a Draft Release."); expect(preflight).toContain("Set create_draft=true only when intentionally creating a Draft Release."); }); }); From b79db99d605dd3d26b8bbd7b554366284f78845e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Wed, 12 Aug 2026 14:32:01 +0800 Subject: [PATCH 10/11] ci: recover draft release when tag already matches --- .github/workflows/github-draft-release-v2.yml | 31 ++++++++++++++++--- tests/release-workflow.test.ts | 13 ++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 0020c35fd..f056bf895 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -102,14 +102,29 @@ jobs: echo "create_draft=$create_draft" >> "$GITHUB_OUTPUT" - name: Check for an existing tag or release + id: existing if: ${{ steps.release.outputs.preflight_level == 'full' }} env: TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail - if git ls-remote --exit-code --tags "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error title=Release tag already exists::$TAG already exists. Manual recovery: inspect the existing tag and Release before retrying; do not move or overwrite the tag." >&2 - exit 1 + repo_url="https://github.com/${GITHUB_REPOSITORY}.git" + tag_object_sha="$(git ls-remote --tags "$repo_url" "refs/tags/$TAG" | awk '{print $1}' | tail -n 1)" + tag_peeled_sha="$(git ls-remote --tags "$repo_url" "refs/tags/$TAG^{}" | awk '{print $1}' | tail -n 1)" + tag_target_sha="${tag_peeled_sha:-$tag_object_sha}" + + if [[ -n "$tag_object_sha" ]]; then + echo "tag_preexists=true" >> "$GITHUB_OUTPUT" + echo "existing_tag_sha=$tag_target_sha" >> "$GITHUB_OUTPUT" + if [[ "$tag_target_sha" != "$TARGET_SHA" ]]; then + echo "::error title=Release tag points to a different commit::$TAG already points to $tag_target_sha, but this release targets $TARGET_SHA. Manual recovery: inspect the existing tag; do not move or overwrite it automatically." >&2 + exit 1 + fi + echo "::notice title=Release tag already exists::$TAG already points to the target commit $TARGET_SHA. The workflow will create the missing Draft Release without moving the tag." + else + echo "tag_preexists=false" >> "$GITHUB_OUTPUT" + echo "existing_tag_sha=" >> "$GITHUB_OUTPUT" fi if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "::error title=Release already exists::$TAG already has a GitHub Release. Manual recovery: inspect whether it is the intended Draft/Published Release before retrying." >&2 @@ -791,14 +806,20 @@ jobs: env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} + TAG_PREEXISTS: ${{ steps.existing.outputs.tag_preexists }} run: | set -euo pipefail draft_created=0 cleanup_draft_release() { status="$?" if [[ "$status" -ne 0 && "$draft_created" == "1" ]]; then - echo "Draft Release asset upload failed; deleting $TAG and its tag so the workflow can be safely re-run." >&2 - gh release delete "$TAG" --cleanup-tag --yes --repo "$GITHUB_REPOSITORY" || true + if [[ "$TAG_PREEXISTS" == "true" ]]; then + echo "Draft Release asset upload failed; deleting the half-created Draft Release $TAG but preserving the pre-existing tag." >&2 + gh release delete "$TAG" --yes --repo "$GITHUB_REPOSITORY" || true + else + echo "Draft Release asset upload failed; deleting $TAG and its tag so the workflow can be safely re-run." >&2 + gh release delete "$TAG" --cleanup-tag --yes --repo "$GITHUB_REPOSITORY" || true + fi fi exit "$status" } diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index e4cb24f5b..89236af88 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -234,14 +234,18 @@ describe("GitHub Draft Release v2 workflow", () => { expect(script).toContain("LLM generation: not invoked by smoke"); }); - it("refuses duplicate tags/releases and never forces publication", () => { + it("reuses a pre-existing tag only when it points at the target commit", () => { expect( draftSteps.find((step) => step.name === "Check for an existing tag or release")?.if, ).toBe("${{ steps.release.outputs.preflight_level == 'full' }}"); const duplicateCheck = draftScript("Check for an existing tag or release"); - expect(duplicateCheck).toContain("git ls-remote --exit-code --tags"); + expect(duplicateCheck).toContain("git ls-remote --tags"); + expect(duplicateCheck).toContain('"refs/tags/$TAG^{}"'); + expect(duplicateCheck).toContain("tag_preexists=true"); + expect(duplicateCheck).toContain("tag_preexists=false"); + expect(duplicateCheck).toContain("Release tag points to a different commit"); + expect(duplicateCheck).toContain("create the missing Draft Release without moving the tag"); expect(duplicateCheck).toContain('gh release view "$TAG"'); - expect(duplicateCheck).toContain("Release tag already exists"); expect(duplicateCheck).toContain("Release already exists"); expect(duplicateCheck).not.toContain("--force"); expect(draftSource).toContain("gh release create"); @@ -345,8 +349,11 @@ describe("GitHub Draft Release v2 workflow", () => { it("cleans up a half-created Draft Release if asset upload fails", () => { const create = draftScript("Create draft release and upload every asset"); expect(create).toContain("cleanup_draft_release()"); + expect(create).toContain("TAG_PREEXISTS"); expect(create).toContain('draft_created=1'); + expect(create).toContain("preserving the pre-existing tag"); expect(create).toContain('gh release delete "$TAG" --cleanup-tag --yes'); + expect(create).toContain('gh release delete "$TAG" --yes'); expect(create).toContain("Draft Release creation failed"); expect(create).toContain("Draft asset upload failed"); expect(create).toContain("Automatic recovery"); From 39f94a205179c40269a6b1e7b63d7860641b9606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=B8=96=E8=80=80?= Date: Wed, 12 Aug 2026 14:47:53 +0800 Subject: [PATCH 11/11] ci: allow safe draft recovery for existing tags --- .github/workflows/github-draft-release-v2.yml | 19 ++++++++++++++++++- tests/release-workflow.test.ts | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index f056bf895..916476b9e 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -12,6 +12,10 @@ on: description: Release version without the v prefix (for example, 1.2.3) required: true type: string + target_sha: + description: Optional recovery commit SHA; only use when the tag already exists but the Draft Release is missing + required: false + type: string preflight_level: description: Smoke validates the target and Doc Agent config; full also verifies installers, release notes, and evidence required: false @@ -51,6 +55,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} MANUAL_VERSION: ${{ inputs.version }} + MANUAL_TARGET_SHA: ${{ inputs.target_sha }} PREFLIGHT_LEVEL_INPUT: ${{ inputs.preflight_level || 'smoke' }} CREATE_DRAFT_INPUT: ${{ inputs.create_draft || false }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} @@ -60,6 +65,7 @@ jobs: preflight_level="$PREFLIGHT_LEVEL_INPUT" create_draft="false" + manual_target_sha_supplied="false" if [[ "$EVENT_NAME" == "pull_request_target" ]]; then if [[ ! "$PR_HEAD_REF" =~ ^(release/)?v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))$ ]]; then echo "Release branch must match vX.Y.Z or release/vX.Y.Z" >&2 @@ -79,7 +85,12 @@ jobs: create_draft="true" preflight_level="full" fi - target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + if [[ -n "$MANUAL_TARGET_SHA" ]]; then + manual_target_sha_supplied="true" + target_sha="$MANUAL_TARGET_SHA" + else + target_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq '.object.sha')" + fi fi if [[ "$preflight_level" != "smoke" && "$preflight_level" != "full" ]]; then @@ -100,6 +111,7 @@ jobs: echo "target_sha=$target_sha" >> "$GITHUB_OUTPUT" echo "preflight_level=$preflight_level" >> "$GITHUB_OUTPUT" echo "create_draft=$create_draft" >> "$GITHUB_OUTPUT" + echo "manual_target_sha_supplied=$manual_target_sha_supplied" >> "$GITHUB_OUTPUT" - name: Check for an existing tag or release id: existing @@ -107,6 +119,7 @@ jobs: env: TAG: ${{ steps.release.outputs.tag }} TARGET_SHA: ${{ steps.release.outputs.target_sha }} + MANUAL_TARGET_SHA_SUPPLIED: ${{ steps.release.outputs.manual_target_sha_supplied }} run: | set -euo pipefail repo_url="https://github.com/${GITHUB_REPOSITORY}.git" @@ -123,6 +136,10 @@ jobs: fi echo "::notice title=Release tag already exists::$TAG already points to the target commit $TARGET_SHA. The workflow will create the missing Draft Release without moving the tag." else + if [[ "$MANUAL_TARGET_SHA_SUPPLIED" == "true" ]]; then + echo "::error title=Manual recovery target requires an existing tag::A target_sha was supplied for $TAG, but the tag does not exist. Manual recovery target_sha is only allowed for tag-exists/release-missing recovery; merge a release branch or re-run without target_sha to create a new tag from current main." >&2 + exit 1 + fi echo "tag_preexists=false" >> "$GITHUB_OUTPUT" echo "existing_tag_sha=" >> "$GITHUB_OUTPUT" fi diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 32ab0c189..6b40d935a 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -160,6 +160,7 @@ describe("GitHub Draft Release v2 workflow", () => { }); expect(draftWorkflow.on.pull_request).toBeUndefined(); expect(draftWorkflow.on.workflow_dispatch.inputs.version.required).toBe(true); + expect(draftWorkflow.on.workflow_dispatch.inputs.target_sha.required).toBe(false); expect(draftWorkflow.on.workflow_dispatch.inputs.preflight_level.default).toBe("smoke"); expect(draftWorkflow.on.workflow_dispatch.inputs.preflight_level.options).toEqual([ "smoke", @@ -182,6 +183,9 @@ describe("GitHub Draft Release v2 workflow", () => { expect(resolve).toContain('create_draft="true"'); expect(resolve).toContain('version="$MANUAL_VERSION"'); expect(resolve).toContain('if [[ "$CREATE_DRAFT_INPUT" == "true" ]]'); + expect(resolve).toContain('if [[ -n "$MANUAL_TARGET_SHA" ]]'); + expect(resolve).toContain('manual_target_sha_supplied="true"'); + expect(resolve).toContain("manual_target_sha_supplied=$manual_target_sha_supplied"); expect(resolve).toContain("preflight_level=$preflight_level"); expect(resolve).toContain("create_draft=$create_draft"); expect(resolve).toContain("git/ref/heads/main"); @@ -255,6 +259,9 @@ describe("GitHub Draft Release v2 workflow", () => { expect(duplicateCheck).toContain("tag_preexists=false"); expect(duplicateCheck).toContain("Release tag points to a different commit"); expect(duplicateCheck).toContain("create the missing Draft Release without moving the tag"); + expect(duplicateCheck).toContain("Manual recovery target requires an existing tag"); + expect(duplicateCheck).toContain("A target_sha was supplied"); + expect(duplicateCheck).toContain("tag-exists/release-missing recovery"); expect(duplicateCheck).toContain('gh release view "$TAG"'); expect(duplicateCheck).toContain("Release already exists"); expect(duplicateCheck).not.toContain("--force");