diff --git a/.github/actions/checkout-release-candidate/action.yml b/.github/actions/checkout-release-candidate/action.yml new file mode 100644 index 0000000..82f131e --- /dev/null +++ b/.github/actions/checkout-release-candidate/action.yml @@ -0,0 +1,26 @@ +name: Check out release candidate +description: Materialize the unpushed release commit prepared by the release workflow +inputs: + release-sha: + description: Expected release candidate commit + required: true +runs: + using: composite + steps: + - uses: actions/download-artifact@v4 + with: + name: release-candidate + path: ${{ runner.temp }}/release-candidate + - shell: bash + env: + RELEASE_SHA: ${{ inputs.release-sha }} + run: | + git fetch \ + "$RUNNER_TEMP/release-candidate/release-candidate.bundle" \ + refs/heads/release-candidate:refs/remotes/release-candidate + candidate=$(git rev-parse refs/remotes/release-candidate) + if [[ $candidate != "$RELEASE_SHA" ]]; then + echo "Release candidate $candidate does not match expected commit $RELEASE_SHA." >&2 + exit 1 + fi + git checkout --detach "$RELEASE_SHA" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d254f0d..0c259ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,16 +5,30 @@ on: branches: - main pull_request: + types: [opened, edited, synchronize, reopened] jobs: test: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: + - name: Validate Conventional Commit title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + shell: bash + run: | + pattern='^[a-z][a-z0-9-]*(\([^()]+\))?(!)?: .+' + if [[ ! $PR_TITLE =~ $pattern ]]; then + echo "Pull request titles must use Conventional Commit syntax." >&2 + echo "Examples: feat: add a feature, fix(api): correct behavior, feat!: break compatibility" >&2 + exit 1 + fi - uses: useblacksmith/checkout@v1 - uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 components: rustfmt, clippy + - run: scripts/tests/test_next_release_version.sh - run: cargo fmt --check - run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo test --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34b03e9..63ac8d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,64 +20,156 @@ jobs: version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} previous_tag: ${{ steps.release.outputs.previous_tag }} + source_sha: ${{ steps.release.outputs.source_sha }} + release_sha: ${{ steps.release.outputs.release_sha }} prerelease: ${{ steps.release.outputs.prerelease }} env: GH_TOKEN: ${{ github.token }} steps: - uses: useblacksmith/checkout@v1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false - uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 components: rustfmt, clippy - - name: Resolve and verify release version + - name: Prepare release candidate id: release shell: bash run: | - version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) - if [[ -z $version ]]; then - echo "::error::Could not read the package version from Cargo.toml." + if [[ $GITHUB_REF != refs/heads/main ]]; then + echo "::error::Releases must run from main, not $GITHUB_REF." exit 1 fi - tag="v$version" - previous_tag=$( + + published_tags=() + while IFS= read -r published_tag; do + published_tags+=("$published_tag") + done < <( gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ --json tagName,isDraft \ - --jq 'map(select(.isDraft | not))[0].tagName // ""' + --jq '.[] | select(.isDraft | not) | .tagName' ) - if [[ -z $previous_tag ]]; then + if [[ ${#published_tags[@]} -eq 0 ]]; then echo "::error::Could not find a previous published release." exit 1 fi - if [[ ${previous_tag#v} == "$version" ]]; then - echo "::error::Cargo.toml version $version is already the latest release." + + cargo_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1) + if [[ ! $cargo_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Cargo.toml contains invalid release version $cargo_version." + exit 1 + fi + + current_tag="v$cargo_version" + latest_published=${published_tags[0]} + current_tag_sha=$(git rev-parse -q --verify "$current_tag^{commit}" 2>/dev/null || true) + workflow_sha=$(git rev-parse HEAD) + resume=false + + if [[ $latest_published == "$current_tag" && $current_tag_sha != "$workflow_sha" ]]; then + previous_tag=$current_tag + source_sha=$workflow_sha + elif [[ -n $current_tag_sha && $current_tag_sha == "$workflow_sha" ]]; then + resume=true + release_sha=$current_tag_sha + source_sha=$(git rev-parse "$release_sha^") + if [[ $latest_published == "$current_tag" ]]; then + if [[ ${#published_tags[@]} -lt 2 ]]; then + echo "::error::Could not find the release before $current_tag." + exit 1 + fi + previous_tag=${published_tags[1]} + else + previous_tag=$latest_published + fi + elif [[ -n $current_tag_sha && $latest_published != "$current_tag" ]] && + git merge-base --is-ancestor "$current_tag_sha" "$workflow_sha"; then + resume=true + release_sha=$current_tag_sha + source_sha=$(git rev-parse "$release_sha^") + previous_tag=$latest_published + else + echo "::error::Cargo.toml version $cargo_version does not match latest release $latest_published." exit 1 fi - set +e - git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 - tag_status=$? - set -e - case $tag_status in - 0) - echo "::error::Tag or release $tag already exists." + + if [[ $resume == true ]]; then + subject=$(git show -s --format=%s "$release_sha") + if [[ $subject != "chore(release): $current_tag" ]]; then + echo "::error::$current_tag does not point to a Kit release commit." exit 1 - ;; - 2) ;; - *) - echo "::error::Could not check whether tag $tag already exists." + fi + expected=$(scripts/next-release-version.sh \ + "${previous_tag#v}" "$previous_tag" "$source_sha") + if [[ $expected != "$cargo_version" ]]; then + echo "::error::Release commit version $cargo_version should be $expected." + exit 1 + fi + version=$cargo_version + tag=$current_tag + else + if ! git merge-base --is-ancestor "$previous_tag" "$source_sha"; then + echo "::error::Previous release $previous_tag is not an ancestor of $source_sha." exit 1 - ;; - esac - prerelease=false - if [[ $version == *-* ]]; then - prerelease=true + fi + version=$(scripts/next-release-version.sh \ + "$cargo_version" "$previous_tag" "$source_sha") + tag="v$version" + if git rev-parse -q --verify "$tag^{commit}" >/dev/null; then + echo "::error::Tag $tag already exists." + exit 1 + fi + + CURRENT_VERSION=$cargo_version NEXT_VERSION=$version python3 <<'PY' + import os + import re + from pathlib import Path + + current = re.escape(os.environ["CURRENT_VERSION"]) + replacement = os.environ["NEXT_VERSION"] + + def update(path, pattern): + source = Path(path).read_text() + changed, count = re.subn(pattern, lambda match: match.group(1) + replacement + match.group(2), source) + if count != 1: + raise SystemExit(f"expected one version entry in {path}, found {count}") + Path(path).write_text(changed) + + update("Cargo.toml", rf'(?ms)(^\[package\].*?^version = "){current}(")') + update("Cargo.lock", rf'(?m)(^\[\[package\]\]\nname = "kit"\nversion = "){current}(")') + PY + cargo metadata --locked --no-deps --format-version 1 >/dev/null + git diff --check + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add Cargo.toml Cargo.lock + git commit -m "chore(release): $tag" + release_sha=$(git rev-parse HEAD) fi + + git branch -f release-candidate "$release_sha" + git bundle create "$RUNNER_TEMP/release-candidate.bundle" \ + release-candidate "^$source_sha" + git bundle verify "$RUNNER_TEMP/release-candidate.bundle" + echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + echo "release_sha=$release_sha" >> "$GITHUB_OUTPUT" + echo "prerelease=false" >> "$GITHUB_OUTPUT" - run: cargo fmt --check - run: cargo clippy --locked --all-targets --all-features -- -D warnings - run: cargo test --locked + - uses: actions/upload-artifact@v4 + with: + name: release-candidate + path: ${{ runner.temp }}/release-candidate.bundle + if-no-files-found: error + retention-days: 1 linux-x64: needs: verify @@ -86,6 +178,12 @@ jobs: RELEASE_VERSION: ${{ needs.verify.outputs.version }} steps: - uses: useblacksmith/checkout@v1 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} - uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 @@ -119,6 +217,12 @@ jobs: APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} steps: - uses: useblacksmith/checkout@v1 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} - uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 @@ -217,6 +321,12 @@ jobs: APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} steps: - uses: useblacksmith/checkout@v1 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} - uses: dtolnay/rust-toolchain@master with: toolchain: 1.94.0 @@ -303,8 +413,12 @@ jobs: steps: - uses: useblacksmith/checkout@v1 with: + ref: ${{ needs.verify.outputs.source_sha }} fetch-depth: 0 persist-credentials: false + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} - uses: actions/download-artifact@v4 with: name: kit-linux-x64 @@ -323,7 +437,8 @@ jobs: --root "$GITHUB_WORKSPACE" \ --provider openrouter \ --model z-ai/glm-5.3 \ - "use release-notes skill respond only with release notes" > kit-output.txt + "use release-notes skill for ${{ needs.verify.outputs.previous_tag }}..${{ needs.verify.outputs.release_sha }}; respond only with release notes" \ + > kit-output.txt sed '$ { /^session_id: /d; }' kit-output.txt > release-notes.md if ! grep -q '[^[:space:]]' release-notes.md; then echo "::error::Kit returned an empty release note." @@ -344,6 +459,45 @@ jobs: RELEASE_VERSION: ${{ needs.verify.outputs.version }} RELEASE_TAG: ${{ needs.verify.outputs.tag }} steps: + - uses: useblacksmith/checkout@v1 + with: + ref: ${{ needs.verify.outputs.source_sha }} + fetch-depth: 0 + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} + - name: Advance main and create release tag + env: + SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + RELEASE_SHA: ${{ needs.verify.outputs.release_sha }} + shell: bash + run: | + remote_main=$( + git ls-remote --heads origin refs/heads/main | cut -f1 + ) + remote_tag=$( + git ls-remote --tags --refs origin "refs/tags/$RELEASE_TAG" | cut -f1 + ) + + if [[ $remote_main == "$SOURCE_SHA" && -z $remote_tag ]]; then + git push --atomic \ + --force-with-lease="refs/heads/main:$SOURCE_SHA" \ + --force-with-lease="refs/tags/$RELEASE_TAG:" \ + origin \ + "$RELEASE_SHA:refs/heads/main" \ + "$RELEASE_SHA:refs/tags/$RELEASE_TAG" + elif [[ $remote_tag == "$RELEASE_SHA" ]]; then + git fetch --no-tags origin "refs/heads/main:refs/remotes/origin/release-main" + if ! git merge-base --is-ancestor \ + "$RELEASE_SHA" refs/remotes/origin/release-main; then + echo "::error::Tag $RELEASE_TAG is not contained in main." + exit 1 + fi + echo "Release refs already exist; resuming publication." + else + echo "::error::main or $RELEASE_TAG changed while the release was building." + exit 1 + fi - uses: actions/download-artifact@v4 with: pattern: kit-* @@ -362,21 +516,13 @@ jobs: prerelease=${{ needs.verify.outputs.prerelease }} title="Kit $RELEASE_VERSION" if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - release_target=$( - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --json targetCommitish --jq .targetCommitish - ) - if [[ $release_target != "$GITHUB_SHA" ]]; then - echo "::error::Existing release $RELEASE_TAG targets $release_target, not $GITHUB_SHA." - exit 1 - fi gh release upload "$RELEASE_TAG" kit-*.tar.gz Kit-*.zip SHA256SUMS \ --clobber --repo "$GITHUB_REPOSITORY" else create_args=( "$RELEASE_TAG" kit-*.tar.gz Kit-*.zip SHA256SUMS --repo "$GITHUB_REPOSITORY" - --target "$GITHUB_SHA" + --verify-tag --title "$title" --notes-file ../release-notes.md ) @@ -394,13 +540,11 @@ jobs: --prerelease="$prerelease" ) if [[ $prerelease == false ]]; then - newest_stable=$( - gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ - --json tagName,isDraft,isPrerelease \ - --jq '.[] | select((.isDraft | not) and (.isPrerelease | not)) | .tagName' \ - | sort -V | tail -1 + latest_tag=$( + gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name ) - if [[ $RELEASE_TAG == "$newest_stable" ]]; then + if git merge-base --is-ancestor "$latest_tag" \ + "${{ needs.verify.outputs.release_sha }}"; then edit_args+=(--latest) fi fi @@ -441,6 +585,12 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.verify.outputs.source_sha }} + persist-credentials: false + - uses: ./.github/actions/checkout-release-candidate + with: + release-sha: ${{ needs.verify.outputs.release_sha }} - name: Read container version id: version shell: bash @@ -463,7 +613,7 @@ jobs: platforms: ${{ matrix.platform }} build-args: | VERSION=${{ steps.version.outputs.version }} - REVISION=${{ github.sha }} + REVISION=${{ needs.verify.outputs.release_sha }} cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ matrix.arch }}-${{ matrix.family }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - name: Build and push Bookworm image by digest @@ -476,7 +626,7 @@ jobs: platforms: ${{ matrix.platform }} build-args: | VERSION=${{ steps.version.outputs.version }} - REVISION=${{ github.sha }} + REVISION=${{ needs.verify.outputs.release_sha }} cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ matrix.arch }}-${{ matrix.family }} cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ matrix.arch }}-${{ matrix.family }},mode=max,ignore-error=true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true @@ -490,7 +640,7 @@ jobs: platforms: ${{ matrix.platform }} build-args: | VERSION=${{ steps.version.outputs.version }} - REVISION=${{ github.sha }} + REVISION=${{ needs.verify.outputs.release_sha }} cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ matrix.arch }}-${{ matrix.family }} cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache-${{ matrix.arch }}-${{ matrix.family }},mode=max,ignore-error=true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true @@ -548,10 +698,7 @@ jobs: exit 0 fi newest_stable=$( - gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ - --json tagName,isDraft,isPrerelease \ - --jq '.[] | select((.isDraft | not) and (.isPrerelease | not)) | .tagName' \ - | sort -V | tail -1 + gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name ) if [[ $RELEASE_TAG == "$newest_stable" ]]; then echo "floating=true" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 7928fa9..84f8c13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,4 @@ Only when you are using Kit as your agent harness: report issues with Kit's harness at https://github.com/speakeasy-api/kit/issues. Do not report issues with other harnesses there. Do not open an issue on the user's behalf unless the user explicitly requests it; ask the user first when they have not already made that request. Follow [Reporting Kit Issues](docs/user/reporting-kit-issues.md). -Changes that alter Kit's shipped artifacts or runtime behavior must be accompanied by a version bump. This includes source code, user documentation under `docs/user/` that is embedded in the binary, and any other content compiled, embedded, or bundled into the CLI, desktop app, or container images. Changes confined to release automation, CI workflows, repository management, documentation that is not embedded in shipped artifacts, or agent skills used only by automation do not require a Kit version bump. Classify changes by whether they affect distributed Kit artifacts, not only by file type or directory. Before 1.0, features and fixes receive a patch version bump; larger changes and refactors receive a minor version bump, but confirm the minor bump with a human first. +Do not change release versions in ordinary pull requests. Use a Conventional Commit title for every pull request. Mark a breaking change with `!` after the commit type or scope, or with a `BREAKING CHANGE:` line in the commit body. The release workflow derives the next version from commits since the latest release, advances the version files in a release commit, and applies a minor bump when any commit is breaking or a patch bump otherwise. diff --git a/docs/releasing.md b/docs/releasing.md index 6abee44..7c9932e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -6,16 +6,22 @@ GitHub Actions builds and publishes release artifacts on demand. ## Publish a release -Update `Cargo.toml` and `Cargo.lock` to the new version and commit the release. In -GitHub, open **Actions > release**, select **Run workflow**, choose the commit or -branch to release, and run it. Do not create or push a release tag. - -The workflow reads the version from `Cargo.toml` and stops if it matches the most -recent published release or if its tag or release already exists. It runs formatting, -Clippy, and tests, builds the Linux x86-64 and macOS arm64 CLI archives, the ARM64 -Kit.app ZIP, and all container images, then creates the `v` tag and GitHub -release. Versions with a SemVer prerelease suffix, such as `0.1.120-pre.1`, produce -a prerelease. +Ordinary pull requests do not update release versions. In GitHub, open +**Actions > release**, select **Run workflow** on `main`, and run it. Do not create +or push the release tag first. + +The workflow verifies that the version in `Cargo.toml` matches the latest release +tag, then examines commits since that tag. Any breaking commit advances the minor +version; other commits advance the patch version. It prepares a release commit that +updates `Cargo.toml` and `Cargo.lock`, then runs +formatting, Clippy, tests, artifact builds, and release-note generation against that +exact commit. + +After every required build succeeds, the workflow atomically advances `main` to the +release commit and creates the release tag. If `main` advances while the workflow is +running, the release stops without updating either ref and must be run again. A +rerun of the failed jobs resumes publication when the release commit and tag were +pushed but a later publishing step failed. The newly built Linux Kit CLI reviews every change since the previous release and writes the GitHub release notes before the workflow attaches the archives and diff --git a/scripts/next-release-version.sh b/scripts/next-release-version.sh new file mode 100755 index 0000000..947fda4 --- /dev/null +++ b/scripts/next-release-version.sh @@ -0,0 +1,72 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: $0 CURRENT_VERSION FROM_REF TO_REF" >&2 + exit 2 +} + +die() { + echo "next-release-version: $*" >&2 + exit 1 +} + +[ "$#" -eq 3 ] || usage + +current_version=$1 +from_ref=$2 +to_ref=$3 + +case $current_version in + 0 | *[!0-9.]* | .* | *. | *..*) die "invalid current version: $current_version" ;; +esac + +old_ifs=$IFS +IFS=. +set -- $current_version +IFS=$old_ifs +[ "$#" -eq 3 ] || die "invalid current version: $current_version" +major=$1 +minor=$2 +patch=$3 + +for component in "$major" "$minor" "$patch"; do + case $component in + 0 | [1-9] | [1-9][0-9]*) ;; + *) die "invalid current version: $current_version" ;; + esac +done + +git rev-parse --verify "$from_ref^{commit}" >/dev/null 2>&1 || + die "unknown release ref: $from_ref" +git rev-parse --verify "$to_ref^{commit}" >/dev/null 2>&1 || + die "unknown target ref: $to_ref" +git merge-base --is-ancestor "$from_ref" "$to_ref" || + die "release ref $from_ref is not an ancestor of $to_ref" + +commit_count=$(git rev-list --count "$from_ref..$to_ref") +[ "$commit_count" -gt 0 ] || die "no commits to release" + +invalid_commits=$( + git log --format='%h %s' "$from_ref..$to_ref" | + grep -Ev '^[0-9a-f]+ [a-z][a-z0-9-]*(\([^()]+\))?!?: .+' || true +) +if [ -n "$invalid_commits" ]; then + die "non-Conventional Commit subjects: +$invalid_commits" +fi + +bump=patch +if git log --format='%s' "$from_ref..$to_ref" | + grep -Eq '^[a-z][a-z0-9-]*(\([^()]+\))?!: .+'; then + bump=minor +elif git log --format='%b' "$from_ref..$to_ref" | + grep -Eq '^BREAKING CHANGE:($|[[:space:]])'; then + bump=minor +fi + +if [ "$bump" = minor ]; then + printf '%s.%s.0\n' "$major" "$((minor + 1))" +else + printf '%s.%s.%s\n' "$major" "$minor" "$((patch + 1))" +fi diff --git a/scripts/tests/test_next_release_version.sh b/scripts/tests/test_next_release_version.sh new file mode 100755 index 0000000..f5f951f --- /dev/null +++ b/scripts/tests/test_next_release_version.sh @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +calculator=$root/scripts/next-release-version.sh +temporary=$(mktemp -d "${TMPDIR:-/tmp}/kit-release-version.XXXXXX") +trap 'rm -rf "$temporary"' EXIT +case_number=0 + +new_repo() { + case_number=$((case_number + 1)) + repo=$temporary/repo-$case_number + mkdir "$repo" + git -C "$repo" init -q + git -C "$repo" config user.name test + git -C "$repo" config user.email test@example.com + printf 'base\n' > "$repo/changes" + git -C "$repo" add changes + git -C "$repo" commit -qm 'chore: release 1.2.3' + git -C "$repo" tag v1.2.3 +} + +commit_change() { + subject=$1 + body=${2-} + printf '%s\n' "$subject" >> "$repo/changes" + git -C "$repo" add changes + if [ -n "$body" ]; then + git -C "$repo" commit -qm "$subject" -m "$body" + else + git -C "$repo" commit -qm "$subject" + fi +} + +expect_version() { + expected=$1 + actual=$(cd "$repo" && "$calculator" 1.2.3 v1.2.3 HEAD) + if [ "$actual" != "$expected" ]; then + echo "expected $expected, got $actual" >&2 + exit 1 + fi +} + +expect_failure() { + if (cd "$repo" && "$calculator" "$@") >/dev/null 2>&1; then + echo "expected failure: $*" >&2 + exit 1 + fi +} + +new_repo +commit_change 'docs: explain releases' +commit_change 'chore: update automation' +expect_version 1.2.4 + +new_repo +commit_change 'feat!: replace the protocol' +expect_version 1.3.0 + +new_repo +commit_change 'fix(api)!: reject the old protocol' +expect_version 1.3.0 + +new_repo +commit_change 'feat: add the protocol' 'BREAKING CHANGE: replaces the old protocol' +commit_change 'fix: follow up' +expect_version 1.3.0 + +new_repo +commit_change 'docs: mention BREAKING CHANGE: as prose' +expect_version 1.2.4 + +new_repo +expect_failure 1.2.3 v1.2.3 HEAD +commit_change 'fix: correct behavior' +expect_failure 1.02.3 v1.2.3 HEAD +expect_failure 1.2.3 HEAD v1.2.3 + +new_repo +commit_change 'update documentation' +expect_failure 1.2.3 v1.2.3 HEAD + +printf 'next release version tests passed\n'