diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 2110da2f..f0c4fc7f 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -35,11 +35,12 @@ jobs: pnpm lint pnpm fmt:check pnpm architecture:check --changed - - name: Test desktop update and release manifest policy + - name: Test desktop update, release manifest, and main release allocation policy run: >- pnpm exec tsx --test packages/desktop/test/ccbuddy-update-policy.test.mjs packages/desktop/test/finalize-macos-release.test.mjs + packages/desktop/test/prepare-main-release.test.mjs macos-package: name: Build unsigned macOS arm64 package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f8904ef..3df26953 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,13 @@ name: CCbuddy macOS Release on: push: + branches: + - main tags: - "v*.*.*" concurrency: + # Keep every queued main push and serialize version allocation and publication. group: ccbuddy-macos-release cancel-in-progress: false queue: max @@ -15,44 +18,46 @@ permissions: jobs: verify: - name: Verify annotated tag, main ancestry, and version + name: Allocate or verify the release tag, main ancestry, and version runs-on: ubuntu-24.04 + permissions: + # Pushes only the immutable annotated version tag; main never moves. + contents: write outputs: + should_release: ${{ steps.existing.outputs.published != 'true' }} version: ${{ steps.version.outputs.version }} commit: ${{ steps.version.outputs.commit }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: + ref: ${{ github.sha }} fetch-depth: 0 - persist-credentials: false + # This isolated job pushes only the version tag. Checkout removes the token afterwards. + persist-credentials: true - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: node-version: 24.14.0 - - name: Verify release source + - name: Allocate a version for the main push or verify the supplied tag id: version + run: node packages/desktop/scripts/prepare-main-release.mjs + - name: Do not rebuild an already public release + id: existing + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ steps.version.outputs.version }} shell: bash run: | set -euo pipefail - tag="${GITHUB_REF#refs/tags/}" - [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - test "$(git cat-file -t "refs/tags/$tag")" = tag - commit="$(git rev-parse "refs/tags/$tag^{commit}")" - git fetch --force --no-tags origin '+refs/heads/main:refs/remotes/origin/main' - git merge-base --is-ancestor "$commit" refs/remotes/origin/main - version="${tag#v}" - RELEASE_VERSION="$version" node --input-type=module -e ' - import { readFileSync } from "node:fs"; - for (const path of ["package.json", "apps/ccbuddy-cli/package.json", "apps/ccbuddy-cli/packages/cli/package.json"]) { - const actual = JSON.parse(readFileSync(path, "utf8")).version; - if (actual !== process.env.RELEASE_VERSION) throw new Error(`${path}: expected ${process.env.RELEASE_VERSION}, found ${actual}`); - } - ' - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "commit=$commit" >> "$GITHUB_OUTPUT" + if draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq .isDraft 2>/dev/null)" && [ "$draft" = false ]; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "$TAG is already public; keeping its immutable assets." + fi build: name: Sign, notarize, and verify arm64 release needs: verify + if: needs.verify.outputs.should_release == 'true' runs-on: macos-26 timeout-minutes: 180 environment: release @@ -193,6 +198,7 @@ jobs: publish: name: Publish one complete GitHub Release needs: [verify, build] + if: needs.verify.outputs.should_release == 'true' runs-on: ubuntu-24.04 environment: release permissions: @@ -218,7 +224,8 @@ jobs: git fetch --force --no-tags origin '+refs/heads/main:refs/remotes/origin/main' git fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG" test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "${{ needs.verify.outputs.commit }}" - git merge-base --is-ancestor "${{ needs.verify.outputs.commit }}" refs/remotes/origin/main + node packages/desktop/scripts/prepare-main-release.mjs --verify \ + "${{ needs.verify.outputs.commit }}" "$VERSION" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft \ --jq .isDraft > "$RUNNER_TEMP/existing-release" 2>/dev/null; then test "$(cat "$RUNNER_TEMP/existing-release")" = true diff --git a/docs/specs/macos-release-workflow.md b/docs/specs/macos-release-workflow.md index a790ee0f..a57d7f17 100644 --- a/docs/specs/macos-release-workflow.md +++ b/docs/specs/macos-release-workflow.md @@ -2,9 +2,24 @@ ## Product rule and owner -The root `package.json` owns the app version. A reviewed, annotated `vX.Y.Z` tag -on the merged `main` history starts a release; merging a pull request alone does -not publish or change the update channel. GitHub Release owns the published +The root `package.json` owns the app version. Every push to `main` publishes a +release, so installed apps keep receiving updates without a manual tag step. The +`verify` job owns version allocation and runs serialized in the +`ccbuddy-macos-release` concurrency group: + +- If the root package version is newer than every `vX.Y.Z` tag, the main commit + itself gets that annotated tag. +- Otherwise the job creates a release snapshot commit whose single parent is the + main commit. It changes only the `version` field of the root, CLI workspace, and + CLI package files to the highest tag's patch + 1, and its message carries + `Release-Source-SHA:
`. `main` never moves; the tag keeps the snapshot + reachable. +- A rerun for the same main commit reuses the tag already allocated to it, and an + already public release skips the build. + +An annotated `vX.Y.Z` tag pushed by a maintainer still starts a release, provided +it points at a commit on `main` or at a valid release snapshot. Tags pushed by the +workflow's `GITHUB_TOKEN` do not start a second run. GitHub Release owns the published assets. The production Electron build embeds `https://github.com//releases/latest/download/latest-mac.yml` as its immutable update source. Preview and unsigned PR packages have no update source. @@ -27,7 +42,8 @@ backwards to an older version. ```text PR → pnpm checks + unsigned arm64 package → review and merge -annotated tag on main → version/ancestry check → signed arm64 build +main push → allocate annotated tag (main commit or version snapshot) +annotated tag → version/ancestry check → signed arm64 build → notarize ZIP and DMG → staple DMG → finalize YAML and verify assets → draft GitHub Release → upload and compare inventory → publish as latest ``` @@ -51,8 +67,10 @@ and no release credentials. 1. A PR targeting `main` installs the locked pnpm workspace, runs typecheck, lint, formatting, architecture checks, targeted updater tests, and builds an unsigned arm64 ZIP and DMG without Apple credentials. -2. A plain push to `main`, a lightweight tag, a tag outside merged `main`, or a - tag whose version differs from the root and CLI packages cannot publish. +2. A push to `main` allocates exactly one annotated version tag for that commit + and publishes it. A lightweight tag, a tag outside merged `main` that is not a + version-only snapshot of a `main` commit, or a tag whose version differs from + the root and CLI packages cannot publish. 3. A valid tagged build embeds the CCbuddy latest manifest URL and emits signed, notarized artifacts whose ZIP hash matches the published YAML. The manifest contains no DMG or foreign update source. diff --git a/packages/desktop/scripts/prepare-main-release.mjs b/packages/desktop/scripts/prepare-main-release.mjs new file mode 100644 index 00000000..184d45e3 --- /dev/null +++ b/packages/desktop/scripts/prepare-main-release.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +// 修复原因:#53 重建 release.yml 时只保留了 tag 触发,main 推送不再自动打包发布, +// 旧版用户因此收不到新版本。这里恢复“每次 main 推送都分配一个版本并打 tag”的流程: +// - 根 package.json 版本高于已有最高 tag 时,直接给 main 提交打该版本 tag; +// - 否则在 main 提交之上生成只改版本字段的快照提交(不推送 main),版本为最高 tag 的 patch + 1; +// - 同一 main 提交重跑时复用已分配的 tag,保证幂等。 +import { execFile } from "node:child_process"; +import { appendFile, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const workspaceDir = resolve(import.meta.dirname, "../../.."); + +export const VERSION_FILES = Object.freeze([ + "package.json", + "apps/ccbuddy-cli/package.json", + "apps/ccbuddy-cli/packages/cli/package.json", +]); +const MAIN_REF = "refs/remotes/origin/main"; +const SOURCE_TRAILER = "Release-Source-SHA"; +const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SHA_RE = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const VERSION_FIELD_RE = /^( "version": ")([^"]+)(",)$/gm; +const BOT_CONFIG = [ + "-c", + "user.name=github-actions[bot]", + "-c", + "user.email=41898282+github-actions[bot]@users.noreply.github.com", +]; + +function parts(version) { + const match = VERSION_RE.exec(version ?? ""); + if (!match) throw new Error(`Expected an exact x.y.z version, found ${version}`); + return match.slice(1).map(BigInt); +} + +function compareVersions(left, right) { + const a = parts(left); + const b = parts(right); + for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1; + return 0; +} + +const isReleaseTag = (tag) => + typeof tag === "string" && tag.startsWith("v") && VERSION_RE.test(tag.slice(1)); + +export function selectVersion(packageVersion, tags) { + const released = tags.filter(isReleaseTag).map((tag) => tag.slice(1)); + if (released.length === 0) return packageVersion; + const highest = released.reduce((max, v) => (compareVersions(v, max) > 0 ? v : max)); + if (compareVersions(packageVersion, highest) > 0) return packageVersion; + const [major, minor, patch] = parts(highest); + return `${major}.${minor}.${patch + 1n}`; +} + +export function bumpVersionText(text, previous, next) { + parts(previous); + parts(next); + const matches = [...text.matchAll(VERSION_FIELD_RE)]; + if (matches.length !== 1 || matches[0][2] !== previous) { + throw new Error(`Expected exactly one top-level "version": "${previous}" field`); + } + return text.replace(VERSION_FIELD_RE, (_, prefix, _value, suffix) => `${prefix}${next}${suffix}`); +} + +const snapshotMessage = (version, source) => `release: v${version}\n\n${SOURCE_TRAILER}: ${source}`; + +function gitClient(cwd) { + return async (args, { allowFailure = false } = {}) => { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return allowFailure ? 0 : stdout.replace(/\n$/, ""); + } catch (error) { + // 不回显 git stderr,避免把带凭据的远端地址写进日志。 + if (allowFailure && typeof error.code === "number") return error.code; + throw new Error(`git ${args[0]} failed (status ${error.code ?? "unavailable"})`); + } + }; +} + +async function readVersionAt(git, commit, file) { + return JSON.parse(await git(["show", `${commit}:${file}`])).version; +} + +/** + * Accepts a release commit that is on origin/main, or a snapshot whose single parent is on + * origin/main and whose only change is the version field of VERSION_FILES. Returns the main source. + */ +export async function resolveReleaseSource(commit, version, { cwd = workspaceDir } = {}) { + if (!SHA_RE.test(commit ?? "")) throw new Error("Expected a complete release commit SHA"); + parts(version); + const git = gitClient(cwd); + for (const file of VERSION_FILES) { + const actual = await readVersionAt(git, commit, file); + if (actual !== version) throw new Error(`${file}: expected ${version}, found ${actual}`); + } + if ( + (await git(["merge-base", "--is-ancestor", commit, MAIN_REF], { allowFailure: true })) === 0 + ) { + return commit; + } + const parents = (await git(["rev-list", "--parents", "-n", "1", commit])).split(" "); + if (parents.length !== 2) throw new Error("Release snapshot must have exactly one parent"); + const source = parents[1]; + if ( + (await git(["merge-base", "--is-ancestor", source, MAIN_REF], { allowFailure: true })) !== 0 + ) { + throw new Error("Release source is not on origin/main"); + } + if ( + (await git(["log", "-1", "--format=%B", commit])).trim() !== snapshotMessage(version, source) + ) { + throw new Error("Release snapshot has an invalid source trailer"); + } + const previous = await readVersionAt(git, source, "package.json"); + if (compareVersions(version, previous) <= 0) + throw new Error("Release snapshot must increase the version"); + const raw = await git(["diff", "--raw", "--no-abbrev", "--no-renames", source, commit]); + for (const line of raw.split("\n").filter(Boolean)) { + const match = /^:(\d+) (\d+) [a-f0-9]+ [a-f0-9]+ M\t(.+)$/.exec(line); + if (!match || match[1] !== match[2] || !VERSION_FILES.includes(match[3])) { + throw new Error("Release snapshot contains an unexpected change"); + } + } + for (const file of VERSION_FILES) { + const expected = bumpVersionText(await git(["show", `${source}:${file}`]), previous, version); + if ((await git(["show", `${commit}:${file}`])) !== expected.replace(/\n$/, "")) { + throw new Error(`Noncanonical release snapshot change: ${file}`); + } + } + return source; +} + +async function writeOutputs(outputFile, result) { + if (!outputFile) return; + await appendFile( + outputFile, + Object.entries(result) + .map(([key, value]) => `${key}=${value}\n`) + .join(""), + ); +} + +export async function prepare({ + cwd = workspaceDir, + ref = process.env.GITHUB_REF, + sha = process.env.GITHUB_SHA, + outputFile = process.env.GITHUB_OUTPUT, +} = {}) { + const git = gitClient(cwd); + if (!SHA_RE.test(sha ?? "") || (await git(["rev-parse", "--verify", "HEAD"])) !== sha) { + throw new Error("Event SHA must equal the checked-out HEAD"); + } + if (await git(["status", "--porcelain=v1", "--untracked-files=all"])) + throw new Error("Working tree must be clean"); + await git([ + "fetch", + "--force", + "--no-recurse-submodules", + "--tags", + "origin", + `+refs/heads/main:${MAIN_REF}`, + ]); + + const emit = async (tag, commit, source) => { + const result = { version: tag.slice(1), tag, commit, source_sha: source }; + await writeOutputs(outputFile, result); + return result; + }; + + if (ref?.startsWith("refs/tags/")) { + const tag = ref.slice("refs/tags/".length); + if (!isReleaseTag(tag)) throw new Error("Expected an exact vX.Y.Z tag"); + if ((await git(["cat-file", "-t", `refs/tags/${tag}`])) !== "tag") + throw new Error("Release tags must be annotated"); + const commit = await git(["rev-parse", `refs/tags/${tag}^{commit}`]); + return emit(tag, commit, await resolveReleaseSource(commit, tag.slice(1), { cwd })); + } + if (ref !== "refs/heads/main") throw new Error("Automatic releases require refs/heads/main"); + if ((await git(["merge-base", "--is-ancestor", sha, MAIN_REF], { allowFailure: true })) !== 0) { + throw new Error("Event SHA is not on origin/main"); + } + + const tags = (await git(["tag", "--list", "v*"])).split("\n").filter(isReleaseTag); + // 重跑同一 main 提交时复用已分配的版本,不重复占号。 + for (const tag of tags) { + if ((await git(["cat-file", "-t", `refs/tags/${tag}`])) !== "tag") continue; + const commit = await git(["rev-parse", `refs/tags/${tag}^{commit}`]); + const isSnapshotOfSha = + commit !== sha && + (await git(["log", "-1", "--format=%B", commit])).trim() === + snapshotMessage(tag.slice(1), sha); + if (commit === sha || isSnapshotOfSha) { + const source = await resolveReleaseSource(commit, tag.slice(1), { cwd }); + if (source !== sha) throw new Error(`${tag} does not belong to ${sha}`); + return emit(tag, commit, sha); + } + } + + const packageVersion = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")).version; + const version = selectVersion(packageVersion, tags); + const tag = `v${version}`; + let commit = sha; + if (version !== packageVersion) { + await git(["checkout", "--detach", sha]); + for (const file of VERSION_FILES) { + const path = join(cwd, file); + await writeFile(path, bumpVersionText(await readFile(path, "utf8"), packageVersion, version)); + } + await git(["add", "--", ...VERSION_FILES]); + await git([ + ...BOT_CONFIG, + "-c", + "commit.gpgSign=false", + "commit", + "-m", + `release: ${tag}`, + "-m", + `${SOURCE_TRAILER}: ${sha}`, + ]); + commit = await git(["rev-parse", "HEAD"]); + } + if ((await resolveReleaseSource(commit, version, { cwd })) !== sha) + throw new Error("Release source mismatch"); + await git([ + ...BOT_CONFIG, + "-c", + "tag.gpgSign=false", + "tag", + "-a", + tag, + commit, + "-m", + `CCbuddy ${tag}`, + "-m", + `${SOURCE_TRAILER}: ${sha}`, + ]); + // GITHUB_TOKEN 推送的 tag 不会再次触发工作流,发布在本次运行内完成。 + await git(["push", "origin", `refs/tags/${tag}:refs/tags/${tag}`]); + return emit(tag, commit, sha); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [mode, commit, version] = process.argv.slice(2); + const run = + mode === "--verify" && commit && version + ? resolveReleaseSource(commit, version).then((source) => console.log(source)) + : mode === undefined + ? prepare() + : Promise.reject( + new Error("Usage: prepare-main-release.mjs [--verify ]"), + ); + run.catch((error) => { + console.error(`Release preparation failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/packages/desktop/test/prepare-main-release.test.mjs b/packages/desktop/test/prepare-main-release.test.mjs new file mode 100644 index 00000000..95d803c5 --- /dev/null +++ b/packages/desktop/test/prepare-main-release.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { + VERSION_FILES, + bumpVersionText, + prepare, + resolveReleaseSource, + selectVersion, +} from "../scripts/prepare-main-release.mjs"; + +const execFileAsync = promisify(execFile); +// 隔离开发者的全局 git 配置(签名、钩子等),脚本内的 git 调用继承该环境。 +process.env.GIT_CONFIG_GLOBAL = "/dev/null"; +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.invalid", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.invalid", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", +}; + +async function git(cwd, ...args) { + const { stdout } = await execFileAsync("git", args, { cwd, env: GIT_ENV }); + return stdout.trim(); +} + +const packageText = (version) => + `{\n "name": "fixture",\n "version": "${version}",\n "private": true\n}\n`; + +async function withRepo(packageVersion, tags, run) { + const root = await mkdtemp(join(tmpdir(), "ccbuddy-main-release-")); + const origin = join(root, "origin.git"); + const work = join(root, "work"); + try { + await git(root, "init", "--bare", "--initial-branch=main", origin); + await git(root, "clone", origin, work); + await writeFile(join(work, "README.md"), "fixture\n"); + await git(work, "add", "."); + await git(work, "commit", "-m", "base"); + for (const tag of tags) await git(work, "tag", "-a", tag, "-m", tag); + for (const file of VERSION_FILES) { + await mkdir(dirname(join(work, file)), { recursive: true }); + await writeFile(join(work, file), packageText(packageVersion)); + } + await git(work, "add", "."); + await git(work, "commit", "-m", "feature"); + await git(work, "push", "--follow-tags", "origin", "main"); + const sha = await git(work, "rev-parse", "HEAD"); + await run({ work, origin, sha, outputFile: join(root, "output") }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("selectVersion keeps a newer package version and otherwise bumps the highest tag patch", () => { + assert.equal(selectVersion("2.0.12", ["v2.0.9", "v2.0.10", "latest"]), "2.0.12"); + assert.equal(selectVersion("2.0.9", ["v2.0.9", "v2.0.10"]), "2.0.11"); + assert.equal(selectVersion("1.0.0", []), "1.0.0"); +}); + +test("bumpVersionText changes only the single top-level version field", () => { + assert.equal(bumpVersionText(packageText("2.0.9"), "2.0.9", "2.0.10"), packageText("2.0.10")); + assert.throws(() => bumpVersionText(packageText("2.0.8"), "2.0.9", "2.0.10")); +}); + +test("a main push with an unreleased package version tags the main commit itself", async () => { + await withRepo("2.0.12", ["v2.0.9"], async ({ work, origin, sha, outputFile }) => { + const result = await prepare({ cwd: work, ref: "refs/heads/main", sha, outputFile }); + assert.deepEqual(result, { version: "2.0.12", tag: "v2.0.12", commit: sha, source_sha: sha }); + assert.equal(await git(origin, "rev-parse", "v2.0.12^{commit}"), sha); + assert.equal(await git(origin, "cat-file", "-t", "v2.0.12"), "tag"); + assert.match(await readFile(outputFile, "utf8"), /^version=2\.0\.12$/m); + + // 重跑同一提交复用已有 tag,不再分配新版本。 + const rerun = await prepare({ cwd: work, ref: "refs/heads/main", sha }); + assert.equal(rerun.tag, "v2.0.12"); + }); +}); + +test("a main push with an already released version publishes a bumped snapshot", async () => { + await withRepo("2.0.9", ["v2.0.9"], async ({ work, origin, sha }) => { + const result = await prepare({ cwd: work, ref: "refs/heads/main", sha }); + assert.equal(result.tag, "v2.0.10"); + assert.equal(result.source_sha, sha); + assert.notEqual(result.commit, sha); + assert.equal(await git(origin, "rev-parse", "v2.0.10^{commit}"), result.commit); + assert.equal(await git(origin, "rev-parse", "main"), sha, "main must not move"); + for (const file of VERSION_FILES) { + assert.equal( + await git(work, "show", `${result.commit}:${file}`), + packageText("2.0.10").trim(), + ); + } + assert.equal(await resolveReleaseSource(result.commit, "2.0.10", { cwd: work }), sha); + + await git(work, "checkout", "--detach", sha); + const rerun = await prepare({ cwd: work, ref: "refs/heads/main", sha }); + assert.deepEqual(rerun, result); + }); +}); + +test("a snapshot with changes beyond the version fields is rejected", async () => { + await withRepo("2.0.9", ["v2.0.9"], async ({ work, sha }) => { + await git(work, "checkout", "--detach", sha); + for (const file of VERSION_FILES) await writeFile(join(work, file), packageText("2.0.10")); + await writeFile(join(work, "README.md"), "tampered\n"); + await git(work, "add", "."); + await git(work, "commit", "-m", "release: v2.0.10", "-m", `Release-Source-SHA: ${sha}`); + const commit = await git(work, "rev-parse", "HEAD"); + await git(work, "fetch", "origin", "+refs/heads/main:refs/remotes/origin/main"); + await assert.rejects( + resolveReleaseSource(commit, "2.0.10", { cwd: work }), + /unexpected change/, + ); + }); +}); + +test("a tag push must be annotated and match the package versions", async () => { + await withRepo("2.0.12", ["v2.0.9"], async ({ work, sha }) => { + await git(work, "tag", "-a", "v2.0.13", "-m", "wrong version"); + await assert.rejects( + prepare({ cwd: work, ref: "refs/tags/v2.0.13", sha }), + /expected 2\.0\.13, found 2\.0\.12/, + ); + await git(work, "tag", "v2.0.12"); + await assert.rejects(prepare({ cwd: work, ref: "refs/tags/v2.0.12", sha }), /annotated/); + }); +});