Skip to content
Merged
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
340 changes: 340 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
# SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
#
# SPDX-License-Identifier: MIT

name: Auto Release

on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches:
- main

permissions:
contents: write

jobs:
check-and-release:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
Comment on lines +23 to +25

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check out tested commit before releasing

This workflow is triggered by workflow_run after the CI job succeeds, but actions/checkout@v4 is invoked without a ref and therefore checks out whatever happens to be at main when the job starts. If another commit lands on main before this job begins (for example a version bump whose CI run is still in progress or even fails), the release pipeline will tag and publish that newer code despite it never having completed CI. Fetch the github.event.workflow_run.head_sha so the release is built from the exact commit that actually passed CI.

Useful? React with 👍 / 👎.


- name: Install dependencies
shell: bash
run: |
if ! command -v jq >/dev/null 2>&1; then
sudo apt-get update -y && sudo apt-get install -y jq
fi

- name: Get local version
id: local_version
shell: bash
run: |
CRATE_NAME="cstring-array"
VERSION=$(cargo metadata --no-deps --format-version=1 | jq -r --arg name "$CRATE_NAME" '.packages[] | select(.name == $name and .source == null) | .version' | head -n1)
if [ -z "${VERSION}" ]; then
echo "Cannot determine version for crate: ${CRATE_NAME}"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Local version: ${VERSION}"

- name: Get crates.io version
id: cratesio_version
shell: bash
run: |
set -euo pipefail
CRATE_NAME="cstring-array"
echo "Fetching crates.io version for ${CRATE_NAME}..."

CRATESIO_RESPONSE=$(curl -s -A "cstring-array-ci/1.0" "https://crates.io/api/v1/crates/${CRATE_NAME}" || echo "")

if [ -z "${CRATESIO_RESPONSE}" ]; then
echo "ERROR: Empty response from crates.io API (curl failed)"
CRATESIO_VER="0.0.0"
else
echo "API response received (first 200 chars): ${CRATESIO_RESPONSE:0:200}"
CRATESIO_VER=$(echo "${CRATESIO_RESPONSE}" | jq -r '.crate.max_version // empty')
if [ -z "${CRATESIO_VER}" ]; then
echo "ERROR: No max_version in response"
echo "Full response: ${CRATESIO_RESPONSE}"
CRATESIO_VER="0.0.0"
fi
fi

echo "version=${CRATESIO_VER}" >> "$GITHUB_OUTPUT"
echo "crates.io ${CRATE_NAME} version: ${CRATESIO_VER}"

- name: Compare versions
id: version_check
shell: bash
run: |
LOCAL="${{ steps.local_version.outputs.version }}"
REMOTE="${{ steps.cratesio_version.outputs.version }}"

if [ "${LOCAL}" = "${REMOTE}" ]; then
echo "Versions are equal, skipping release"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi

# Use semver comparison
TMPDIR=$(mktemp -d)
mkdir -p "${TMPDIR}/src"

cat > "${TMPDIR}/src/main.rs" << 'EOF'
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 3 { std::process::exit(1); }
let v1 = semver::Version::parse(&args[1]).unwrap();
let v2 = semver::Version::parse(&args[2]).unwrap();
if v1 > v2 { println!("greater"); }
else if v1 < v2 { println!("less"); }
else { println!("equal"); }
}
EOF

cat > "${TMPDIR}/Cargo.toml" << 'EOF'
[package]
name = "semver-compare"
version = "0.1.0"
edition = "2024"
[dependencies]
semver = "1.0"
EOF

COMPARISON=$(cd "${TMPDIR}" && cargo run --quiet -- "${LOCAL}" "${REMOTE}" 2>/dev/null || echo "unknown")
rm -rf "${TMPDIR}"

if [ "${COMPARISON}" = "greater" ]; then
echo "Local version ${LOCAL} > crates.io ${REMOTE}, creating release"
echo "should_release=true" >> "$GITHUB_OUTPUT"
else
echo "Local version ${LOCAL} not greater than crates.io ${REMOTE}"
echo "should_release=false" >> "$GITHUB_OUTPUT"
fi

- name: Check if tag and release exist
id: check_existing
if: steps.version_check.outputs.should_release == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.local_version.outputs.version }}"
TAG="v${VERSION}"

# Check if tag exists
if git rev-parse "${TAG}" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists"
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
else
echo "Tag ${TAG} does not exist"
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
fi

# Check if GitHub release exists
if gh release view "${TAG}" >/dev/null 2>&1; then
echo "GitHub release ${TAG} already exists"
echo "release_exists=true" >> "$GITHUB_OUTPUT"
else
echo "GitHub release ${TAG} does not exist"
echo "release_exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Get last release tag
id: last_tag
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.tag_exists == 'false'
shell: bash
run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "${LAST_TAG}" ]; then
# No tags, use first commit
LAST_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "tag=${LAST_TAG}" >> "$GITHUB_OUTPUT"
echo "Last tag: ${LAST_TAG}"

- name: Generate changelog
id: changelog
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.release_exists == 'false'
shell: bash
run: |
VERSION="${{ steps.local_version.outputs.version }}"
LAST_TAG="${{ steps.last_tag.outputs.tag }}"

CHANGELOG_FILE=$(mktemp)

echo "## Changes" >> "${CHANGELOG_FILE}"
echo "" >> "${CHANGELOG_FILE}"

# Get commits since last tag, excluding [skip ci] commits
COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"%H|%s|%an" --no-merges | \
grep -v "\[skip ci\]" || true)

# Group commits by type
declare -A SECTIONS
SECTIONS=(
["feat"]="### Features"
["fix"]="### Bug Fixes"
["perf"]="### Performance"
["refactor"]="### Refactoring"
["docs"]="### Documentation"
["test"]="### Tests"
["ci"]="### CI/CD"
["chore"]="### Chore"
)

# Track which commits were categorized
CATEGORIZED_HASHES=""

# Parse commits by type
for TYPE in feat fix perf refactor docs test ci chore; do
TYPED_COMMITS=$(echo "${COMMITS}" | grep -E "^\w+\|#?[0-9]* ${TYPE}:" || true)

if [ -n "${TYPED_COMMITS}" ]; then
echo "" >> "${CHANGELOG_FILE}"
echo "${SECTIONS[$TYPE]}" >> "${CHANGELOG_FILE}"
echo "" >> "${CHANGELOG_FILE}"

while IFS='|' read -r HASH SUBJECT AUTHOR; do
# Skip empty lines
[ -z "${HASH}" ] && continue

# Mark as categorized
CATEGORIZED_HASHES="${CATEGORIZED_HASHES}${HASH}"$'\n'

# Extract issue number if present
ISSUE=$(echo "${SUBJECT}" | grep -oE '#[0-9]+' | head -1 || echo "")
# Remove issue number and type prefix from subject
CLEAN_SUBJECT=$(echo "${SUBJECT}" | sed -E 's/^#?[0-9]* [a-z]+: //')

SHORT_HASH=$(echo "${HASH}" | cut -c1-7)
COMMIT_LINK="[\`${SHORT_HASH}\`](https://github.com/${{ github.repository }}/commit/${HASH})"

if [ -n "${ISSUE}" ]; then
ISSUE_LINK="([${ISSUE}](https://github.com/${{ github.repository }}/issues/${ISSUE#\#}))"
echo "- ${CLEAN_SUBJECT} ${ISSUE_LINK} ${COMMIT_LINK}" >> "${CHANGELOG_FILE}"
else
echo "- ${CLEAN_SUBJECT} ${COMMIT_LINK}" >> "${CHANGELOG_FILE}"
fi
done <<< "${TYPED_COMMITS}"
fi
done

# Add uncategorized commits as "Other Changes"
UNCATEGORIZED=""
while IFS='|' read -r HASH SUBJECT AUTHOR; do
[ -z "${HASH}" ] && continue
if ! echo "${CATEGORIZED_HASHES}" | grep -q "${HASH}"; then
UNCATEGORIZED="${UNCATEGORIZED}${HASH}|${SUBJECT}|${AUTHOR}"$'\n'
fi
done <<< "${COMMITS}"

if [ -n "${UNCATEGORIZED}" ]; then
echo "" >> "${CHANGELOG_FILE}"
echo "### Other Changes" >> "${CHANGELOG_FILE}"
echo "" >> "${CHANGELOG_FILE}"

while IFS='|' read -r HASH SUBJECT AUTHOR; do
[ -z "${HASH}" ] && continue
SHORT_HASH=$(echo "${HASH}" | cut -c1-7)
COMMIT_LINK="[\`${SHORT_HASH}\`](https://github.com/${{ github.repository }}/commit/${HASH})"
echo "- ${SUBJECT} ${COMMIT_LINK}" >> "${CHANGELOG_FILE}"
done <<< "${UNCATEGORIZED}"
fi

# Add comparison link
echo "" >> "${CHANGELOG_FILE}"
echo "---" >> "${CHANGELOG_FILE}"
echo "" >> "${CHANGELOG_FILE}"
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...v${VERSION}" >> "${CHANGELOG_FILE}"

# Output changelog
CHANGELOG_CONTENT=$(cat "${CHANGELOG_FILE}")
echo "changelog<<EOF" >> "$GITHUB_OUTPUT"
echo "${CHANGELOG_CONTENT}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"

rm -f "${CHANGELOG_FILE}"

- name: Skip tag creation (already exists)
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.tag_exists == 'true'
run: echo "Tag v${{ steps.local_version.outputs.version }} already exists, skipping creation"

- name: Create and push tag
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.tag_exists == 'false'
shell: bash
run: |
VERSION="${{ steps.local_version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git tag -a "v${VERSION}" -m "Release v${VERSION}"
git push origin "v${VERSION}"

- name: Skip GitHub release creation (already exists)
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.release_exists == 'true'
run: echo "GitHub release v${{ steps.local_version.outputs.version }} already exists, skipping creation"

- name: Create GitHub release
if: steps.version_check.outputs.should_release == 'true' && steps.check_existing.outputs.release_exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.local_version.outputs.version }}
name: v${{ steps.local_version.outputs.version }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false

- name: Install Rust toolchain for publish
if: steps.version_check.outputs.should_release == 'true'
uses: dtolnay/rust-toolchain@stable

- name: Publish cstring-array to crates.io
if: steps.version_check.outputs.should_release == 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
shell: bash
run: |
set -euo pipefail
VERSION="${{ steps.local_version.outputs.version }}"

# Check if version already exists on crates.io
echo "Checking crates.io for cstring-array..."
CRATESIO_RESPONSE=$(curl -s -A "cstring-array-ci/1.0" "https://crates.io/api/v1/crates/cstring-array")

if [ -z "${CRATESIO_RESPONSE}" ]; then
echo "ERROR: Empty response from crates.io API"
echo "Publishing anyway..."
else
echo "API response received (first 200 chars): ${CRATESIO_RESPONSE:0:200}"
CRATESIO_VER=$(echo "${CRATESIO_RESPONSE}" | jq -r '.crate.max_version // empty')
echo "crates.io cstring-array version: '${CRATESIO_VER}'"

if [ -n "${CRATESIO_VER}" ] && [ "${VERSION}" = "${CRATESIO_VER}" ]; then
echo "cstring-array@${VERSION} already published on crates.io, skipping"
exit 0
fi
fi

echo "Publishing cstring-array v${VERSION}..."
n=0
until [ $n -ge 5 ]; do
if cargo publish --locked --token "$CARGO_REGISTRY_TOKEN"; then
echo "Successfully published cstring-array v${VERSION} to crates.io"
echo "https://crates.io/crates/cstring-array/${VERSION}"
exit 0
fi
n=$((n+1))
echo "Retry $n/5 for cstring-array..."
sleep $((10*n))
done
echo "Failed to publish cstring-array after 5 retries"
exit 1