Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,65 @@
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:
name: Publish to crates.io
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
107 changes: 102 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions specs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading