diff --git a/.github/scripts/cargo-package-field.sh b/.github/scripts/cargo-package-field.sh new file mode 100755 index 0000000..af0ea94 --- /dev/null +++ b/.github/scripts/cargo-package-field.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +manifest_path="$1" +field="$2" + +if [ ! -f "$manifest_path" ]; then + echo "Cargo manifest not found: $manifest_path" >&2 + exit 1 +fi + +value="$( + awk -v requested_field="$field" ' + /^\[package\][[:space:]]*$/ { in_package = 1; next } + /^\[[^]]+\][[:space:]]*$/ { + if (in_package) exit + } + in_package && $0 ~ ("^[[:space:]]*" requested_field "[[:space:]]*=") { + if (match($0, /"[^"]+"/)) { + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } + } + ' "$manifest_path" +)" + +if [ -z "$value" ]; then + echo "Could not read package.$field from $manifest_path" >&2 + exit 1 +fi + +printf '%s\n' "$value" diff --git a/.github/scripts/changelog-section.sh b/.github/scripts/changelog-section.sh new file mode 100755 index 0000000..7d3e327 --- /dev/null +++ b/.github/scripts/changelog-section.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +changelog_path="$1" +version="$2" + +if [ ! -f "$changelog_path" ]; then + echo "Changelog not found: $changelog_path" >&2 + exit 1 +fi + +awk -v requested_version="$version" ' + $0 ~ ("^## \\[" requested_version "\\]") { in_section = 1; next } + in_section && /^## \[/ { exit } + in_section { print } +' "$changelog_path" diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml new file mode 100644 index 0000000..fc778ef --- /dev/null +++ b/.github/workflows/publish-crate.yml @@ -0,0 +1,98 @@ +name: Publish Crate + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: publish-crate + cancel-in-progress: false + +jobs: + publish: + name: Publish to crates.io and create release + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Read crate metadata + id: meta + run: | + echo "crate_name=$(.github/scripts/cargo-package-field.sh Cargo.toml name)" >> "$GITHUB_OUTPUT" + echo "version=$(.github/scripts/cargo-package-field.sh Cargo.toml version)" >> "$GITHUB_OUTPUT" + echo "tag=v$(.github/scripts/cargo-package-field.sh Cargo.toml version)" >> "$GITHUB_OUTPUT" + + - name: Check crates.io for existing version + id: crates + env: + CRATE_NAME: ${{ steps.meta.outputs.crate_name }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + STATUS="$(curl -sS -o /tmp/crates-version.json -w '%{http_code}' "https://crates.io/api/v1/crates/${CRATE_NAME}/${VERSION}")" + if [ "$STATUS" = "200" ]; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Version ${VERSION} is already published on crates.io." + elif [ "$STATUS" = "404" ]; then + echo "published=false" >> "$GITHUB_OUTPUT" + echo "Version ${VERSION} is not published yet." + else + echo "Unexpected crates.io response code: $STATUS" >&2 + cat /tmp/crates-version.json >&2 || true + exit 1 + fi + + - name: Publish crate + if: steps.crates.outputs.published != 'true' + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked + + - name: Ensure git tag exists + id: tag + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists on origin." + else + git tag "${TAG}" "${GITHUB_SHA}" + git push origin "${TAG}" + echo "Created and pushed ${TAG}." + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release ${TAG} already exists." + exit 0 + fi + + NOTES_FILE="$(mktemp)" + { + echo "## micro-moka ${VERSION}" + echo + .github/scripts/changelog-section.sh CHANGELOG.md "$VERSION" + } > "$NOTES_FILE" + + gh release create "$TAG" \ + --title "micro-moka ${VERSION}" \ + --notes-file "$NOTES_FILE" diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..a88bf30 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,72 @@ +name: Release Check + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + release-readiness: + name: Validate release readiness + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Read crate metadata (head) + id: head + run: | + echo "crate_name=$(.github/scripts/cargo-package-field.sh Cargo.toml name)" >> "$GITHUB_OUTPUT" + echo "version=$(.github/scripts/cargo-package-field.sh Cargo.toml version)" >> "$GITHUB_OUTPUT" + + - name: Read base version + id: base + run: | + BASE_REF="${{ github.base_ref || 'main' }}" + git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" + git show "origin/${BASE_REF}:Cargo.toml" > /tmp/base-Cargo.toml + echo "version=$(.github/scripts/cargo-package-field.sh /tmp/base-Cargo.toml version)" >> "$GITHUB_OUTPUT" + + - name: Validate semantic version bump + env: + BASE_VERSION: ${{ steps.base.outputs.version }} + HEAD_VERSION: ${{ steps.head.outputs.version }} + run: | + echo "Base version: $BASE_VERSION" + echo "Head version: $HEAD_VERSION" + + if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then + echo "Version must be bumped in Cargo.toml for merges into main." >&2 + exit 1 + fi + + HIGHEST="$(printf '%s\n%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -n1)" + if [ "$HIGHEST" != "$HEAD_VERSION" ]; then + echo "Version must increase. Got $HEAD_VERSION <= $BASE_VERSION." >&2 + exit 1 + fi + + - name: Validate CHANGELOG entry for target version + env: + TARGET_VERSION: ${{ steps.head.outputs.version }} + run: | + if ! grep -Eq "^## \\[${TARGET_VERSION}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md; then + echo "CHANGELOG.md must contain a section header like:" >&2 + echo "## [${TARGET_VERSION}] - YYYY-MM-DD" >&2 + exit 1 + fi + + - name: Cargo publish dry-run + run: cargo publish --dry-run --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cdd82..3c0016a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.5] - 2026-02-27 + +### Added + +- Added automated release and publish workflows: + - PR-time release readiness checks (version bump, changelog entry, `cargo publish --dry-run`). + - Post-merge publish to crates.io, version tag creation, and GitHub release creation. + ## [0.1.4] - 2026-02-27 ### Changed diff --git a/Cargo.toml b/Cargo.toml index 85b73b1..1e8afcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "micro-moka" -version = "0.1.4" +version = "0.1.5" edition = "2018" rust-version = "1.76" diff --git a/README.md b/README.md index ecf42d5..ee48628 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ to take a look at the [Quick Cache][quick-cache] crate. - [Example: Basic Usage](#example-basic-usage) - [Minimum Supported Rust Versions](#minimum-supported-rust-versions) - [Developing Micro Moka](#developing-micro-moka) + - [Releasing](#releasing) - [Credits](#credits) - [Caffeine](#caffeine) - [License](#license) @@ -130,6 +131,14 @@ $ cargo +nightly -Z unstable-options --config 'build.rustdocflags="--cfg docsrs" doc --no-deps ``` +## Releasing + +Releases are automated from merges into `main`. + +- See [RELEASING.md](./RELEASING.md) for one-time setup. +- Every PR to `main` must bump `Cargo.toml` version and add the matching changelog section. +- On merge, GitHub Actions publishes to crates.io, creates `v` tag, and creates a GitHub release. + ## Credits ### Caffeine diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..1afd324 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,35 @@ +# Releasing micro-moka + +This repository is configured for release-on-merge to `main`. + +## One-time setup + +1. Add repository secret `CARGO_REGISTRY_TOKEN`. +2. In branch protection for `main`, require this check: + - `Validate release readiness` +3. Keep merge policy as pull-request merge into `main`. + +## Per-PR requirements + +Every PR targeting `main` must: + +1. Bump `[package].version` in `Cargo.toml`. +2. Add the matching changelog header in `CHANGELOG.md`: + +```md +## [x.y.z] - YYYY-MM-DD +``` + +3. Pass `Release Check` workflow (runs `cargo publish --dry-run --locked`). + +## What happens on merge to `main` + +`Publish Crate` workflow runs automatically: + +1. Reads crate `name` and `version` from `Cargo.toml`. +2. Checks whether that version already exists on crates.io. +3. Publishes with `cargo publish --locked` if it is new. +4. Ensures git tag `v` exists. +5. Creates a GitHub release from the matching changelog section. + +The publish workflow is idempotent: re-runs will skip publishing if crates.io already has that version.