From eed34500051960d158587416d0d36e5192cb7aa7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 13 Oct 2026 12:42:46 +0000 Subject: [PATCH] ci(release): tag, release and publish from CI Merging a "chore(release): prepare vX.Y.Z" commit (or dispatching Release from main) now creates the tag, the GitHub Release from CHANGELOG, and dispatches an idempotent crates.io publish on the tag. Mirrors bashkit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y2vd61x8kbPtmDxze9Q4Gi --- .github/workflows/publish.yml | 62 ++++++++++++++++---- .github/workflows/release.yml | 107 ++++++++++++++++++++++++++++++++-- CHANGELOG.md | 5 ++ specs/release-process.md | 27 ++++++--- 4 files changed, 176 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 586f8f7..de6ab10 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,18 @@ name: Publish +# Decisions: +# - Runs from a release tag (dispatched by release.yml, or manually from a tag). +# - Idempotent: a crate whose version is already on crates.io is skipped, so +# re-running a release never fails on an already-published crate. +# - Each crate is verified with a dry run before the credentialed upload. on: workflow_dispatch: - inputs: - dry_run: - description: "Dry run (no actual publish)" - required: false - default: "true" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always jobs: publish: @@ -14,12 +20,46 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable - - name: Publish markkit - run: cargo publish -p markkit ${{ inputs.dry_run == 'true' && '--dry-run' || '' }} - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - name: Publish markkit-cli - run: cargo publish -p markkit-cli ${{ inputs.dry_run == 'true' && '--dry-run' || '' }} + + - name: Verify tag and version + id: version + run: | + if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: publish must run from a release tag (vX.Y.Z), got $GITHUB_REF" + exit 1 + fi + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "Error: tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION)" + exit 1 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "Error: publish source is not reachable from origin/main" + exit 1 + fi + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish crates env: + VERSION: ${{ steps.version.outputs.version }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + published() { + curl -sf -A "markkit-release (github.com/everruns/markkit)" \ + "https://crates.io/api/v1/crates/$1/$VERSION" > /dev/null + } + # Dependency order: markkit-cli depends on markkit. + for crate in markkit markkit-cli; do + if published "$crate"; then + echo "$crate $VERSION already on crates.io; skipping" + continue + fi + cargo publish --dry-run -p "$crate" + cargo publish --no-verify -p "$crate" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2ecd8f..a380815 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,21 +1,118 @@ name: Release +# Decisions: +# - Agent-driven release, same shape as everruns/bashkit: merging a +# "chore(release): prepare vX.Y.Z" commit to main (or a manual dispatch from main) +# creates the tag in CI, so nobody has to push tags from a local clone. +# - Releases created with GITHUB_TOKEN do not trigger other workflows, so the +# crates.io publish is dispatched explicitly against the verified tag. on: push: - tags: - - "v*" + branches: + - main + workflow_dispatch: + +permissions: + contents: read jobs: release: - name: Create GitHub Release + name: Create Release runs-on: ubuntu-latest permissions: contents: write + actions: write + if: "${{ github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'chore(release): prepare v') }}" steps: + # Credentials persist so `git push origin "$TAG"` works; this job only runs + # from main and executes no pull-request code. - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Create release + + - name: Extract version + id: version + env: + EVENT_NAME: ${{ github.event_name }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + GITHUB_REF: ${{ github.ref }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Error: manual releases must be dispatched from refs/heads/main" + exit 1 + fi + VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + else + FIRST_LINE=$(echo "$COMMIT_MSG" | head -1) + VERSION=$(echo "$FIRST_LINE" | sed -n 's/.*prepare v\([0-9]*\.[0-9]*\.[0-9]*\).*/\1/p') + fi + if [ -z "$VERSION" ]; then + echo "Error: could not determine version" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Verify version matches Cargo.toml + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') + if [ "$VERSION" != "$CARGO_VERSION" ]; then + echo "Error: release version ($VERSION) does not match Cargo.toml ($CARGO_VERSION)" + exit 1 + fi + + - name: Verify release source is on main + run: | + git fetch --force origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Error: release source $GITHUB_SHA is not reachable from origin/main" + exit 1 + fi + + - name: Extract release notes from CHANGELOG + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + awk -v v="$VERSION" '$0 ~ "^## \\[" v "\\]" {found=1; next} /^## \[/ {if (found) exit} found {print}' CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "Error: no CHANGELOG.md section for [$VERSION]" + exit 1 + fi + + # An existing tag (e.g. a release whose crates were already published) must + # point at this commit; otherwise the tag is created here. + - name: Create or verify release tag + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + REMOTE_TAG_SHA=$(git ls-remote origin "refs/tags/$TAG^{}" | awk '{print $1}') + [ -n "$REMOTE_TAG_SHA" ] || REMOTE_TAG_SHA=$(git ls-remote origin "refs/tags/$TAG" | awk '{print $1}') + if [ -n "$REMOTE_TAG_SHA" ]; then + if [ "$REMOTE_TAG_SHA" != "$GITHUB_SHA" ]; then + echo "Error: existing tag $TAG points to $REMOTE_TAG_SHA, expected $GITHUB_SHA" + exit 1 + fi + echo "Existing tag $TAG verified at $GITHUB_SHA" + else + git tag "$TAG" "$GITHUB_SHA" + git push origin "$TAG" + echo "Created tag $TAG at $GITHUB_SHA" + fi + + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - generate_release_notes: true + tag_name: ${{ steps.version.outputs.tag }} + name: Release ${{ steps.version.outputs.tag }} + body_path: release_notes.md + draft: false + prerelease: false + + - name: Trigger crates.io publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: gh workflow run publish.yml --ref "$TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ad445..40c6068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed + +- Release automation: merging `chore(release): prepare vX.Y.Z` to main tags, creates the + GitHub Release and publishes to crates.io (idempotent); manual dispatch from main also works + ## [0.1.0] - 2026-10-12 ### Added diff --git a/specs/release-process.md b/specs/release-process.md index 10ec233..8564025 100644 --- a/specs/release-process.md +++ b/specs/release-process.md @@ -9,15 +9,24 @@ Follows [Semantic Versioning](https://semver.org/): ## Release Checklist -1. Update version in root `Cargo.toml` -2. Update `CHANGELOG.md` -3. Run `just pre-pr` (fmt, lint, test, deny) -4. Verify nightly CI is green -5. Dry-run publish: `just publish-dry-run` -6. Create git tag: `git tag -a v0.X.Y -m "Release v0.X.Y"` -7. Push tag: `git push origin v0.X.Y` -8. GitHub Actions handles release creation -9. Publish to crates.io, PyPI, npm as needed +Releases are agent-driven; CI creates the tag, GitHub Release and crates.io upload. + +1. Ask the agent: "Create release v0.X.Y". +2. Agent bumps `version` in root `Cargo.toml`, moves `CHANGELOG.md` Unreleased entries + under `## [0.X.Y] - YYYY-MM-DD`, runs `just pre-pr`, and opens a PR titled + `chore(release): prepare v0.X.Y`. +3. Merging that PR to `main` (squash keeps the title as the commit subject) runs + `release.yml`, which: + - verifies the version matches `Cargo.toml` and the commit is on `main`; + - creates tag `v0.X.Y` (or verifies an existing one points at the same commit); + - creates the GitHub Release from the CHANGELOG section; + - dispatches `publish.yml` on the tag. +4. `publish.yml` runs from the tag, dry-runs then uploads `markkit` then `markkit-cli`, + skipping any crate already published at that version. + +Manual fallback: run the **Release** workflow from `main` (workflow_dispatch); it reads the +version from `Cargo.toml`. Python and npm packages are published separately with +`publish-python.yml` / `publish-js.yml`. ## Artifacts