name: Publish
on:
workflow_run:
workflows: ["CI"]
types:
- completed
concurrency:
group: publish-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
publish:
name: Publish to crates.io
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-tags: true
- name: Check that commit has a version tag
id: tag
run: |
COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"
# || true: under `set -o pipefail`, grep exits 1 when the commit has
# no version tag; without it the empty-result case would abort the
# step before the skip branch below.
GITHUB_REF_NAME=$(git tag --points-at "$COMMIT_SHA" | grep -E '^v[0-9]' | head -1 || true)
if [ -z "$GITHUB_REF_NAME" ]; then
echo "ℹ️ Commit $COMMIT_SHA has no version tag — skipping publish"
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "Found tag: $GITHUB_REF_NAME"
echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
- name: Verify Security Check passed (non-blocking)
if: steps.tag.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"
SEC_RUN=$(gh run list --workflow="Forge Guard Security Check" --commit "$COMMIT_SHA" --json conclusion,status --jq '.[0] // empty' 2>&1)
if [ -n "$SEC_RUN" ] && [ "$SEC_RUN" != "null" ]; then
SEC_CONCLUSION=$(echo "$SEC_RUN" | jq -r '.conclusion // empty')
SEC_STATUS=$(echo "$SEC_RUN" | jq -r '.status // empty')
if [ "$SEC_CONCLUSION" = "success" ] && [ "$SEC_STATUS" = "completed" ]; then
echo "✅ Forge Guard Security Check passed"
else
echo "⚠️ Forge Guard Security Check: $SEC_CONCLUSION (status: $SEC_STATUS)"
echo " Non-blocking — CI already passed all core checks."
fi
else
echo "ℹ️ Forge Guard Security Check did not run for this commit"
fi
- name: Install Rust toolchain
if: steps.tag.outputs.skip != 'true'
uses: dtolnay/rust-toolchain@stable
- name: Generate lockfile for cache
if: steps.tag.outputs.skip != 'true'
run: cargo generate-lockfile
- name: Verify version consistency (Cargo.toml ↔ tag ↔ CHANGELOG ↔ docs)
if: steps.tag.outputs.skip != 'true'
run: |
TAG_NAME="${{ steps.tag.outputs.tag }}"
VERSION="${TAG_NAME#v}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*//')
# 1. Check Cargo.toml matches tag
CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml)
if [ "$CARGO_VERSION" != "$BASE_VERSION" ]; then
echo "❌ Cargo.toml version ($CARGO_VERSION) does not match tag version ($BASE_VERSION)"
exit 1
fi
echo "✅ Cargo.toml v$CARGO_VERSION matches tag $TAG_NAME"
# 2. Check CHANGELOG has a section for this version
if grep -q "^## \[$BASE_VERSION\]" CHANGELOG.md; then
echo "✅ CHANGELOG.md has section for v$BASE_VERSION"
else
echo "❌ CHANGELOG.md is missing section for v$BASE_VERSION"
echo " Add: ## [$BASE_VERSION] - $(date +%Y-%m-%d)"
exit 1
fi
# 3. Check README version badge uses dynamic shield.io (not hardcoded)
if grep -q 'crates\.io/v1/crates/forge-guard' README.md 2>/dev/null || \
grep -q 'crates\.io/v/forge-guard' README.md 2>/dev/null || \
grep -q 'shields\.io/crates/v/forge-guard' README.md 2>/dev/null; then
echo "✅ README.md uses dynamic crates.io version badge"
else
echo "⚠️ README.md may have a hardcoded version badge — verify it's dynamic"
fi
# 4. Check Cargo.lock matches Cargo.toml
LOCK_VERSION=$(grep -A2 'name = "forge-guard"' Cargo.lock | grep '^version = ' | sed 's/version = "\(.*\)"/\1/')
if [ -n "$LOCK_VERSION" ] && [ "$LOCK_VERSION" != "$CARGO_VERSION" ]; then
echo "⚠️ Cargo.lock version ($LOCK_VERSION) differs from Cargo.toml ($CARGO_VERSION)"
echo " Run: cargo generate-lockfile"
else
echo "✅ Cargo.lock version is consistent"
fi
echo ""
echo "📋 Version Consistency Summary:"
echo " Tag: $TAG_NAME"
echo " Cargo.toml: v$CARGO_VERSION"
echo " CHANGELOG: v$BASE_VERSION"
echo ""
echo "✅ All version checks passed"
- name: Check if version already published on crates.io
if: steps.tag.outputs.skip != 'true'
id: crates_check
run: |
TAG_NAME="${{ steps.tag.outputs.tag }}"
VERSION="${TAG_NAME#v}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*//')
echo "Checking if forge-guard $BASE_VERSION exists on crates.io..."
# crates.io requires a User-Agent; without it the API returns 403
# even for published versions, which would break this check.
HTTP_STATUS=$(curl -s -H "User-Agent: forge-guard-publish-check" -o /dev/null -w "%{http_code}" \
"https://crates.io/api/v1/crates/forge-guard/$BASE_VERSION")
if [ "$HTTP_STATUS" = "200" ]; then
echo "✅ forge-guard $BASE_VERSION already published — skipping cargo publish"
echo "skip_publish=true" >> "$GITHUB_OUTPUT"
else
echo "forge-guard $BASE_VERSION not found on crates.io — proceeding to publish"
echo "skip_publish=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to crates.io
if: steps.tag.outputs.skip != 'true' && steps.crates_check.outputs.skip_publish != 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
# GitHub Actions runs bash with `set -e`, which would abort on the
# failing command substitution before we can inspect the status.
# Wrap the publish in an `if` condition instead (condition failures
# do not trigger set -e) so a duplicate-publish error can be treated
# as success.
if OUTPUT=$(cargo publish --allow-dirty 2>&1); then
echo "$OUTPUT"
echo "✅ Published to crates.io"
else
STATUS=$?
echo "$OUTPUT"
if echo "$OUTPUT" | grep -qiE 'already uploaded|already exists'; then
echo "✅ Version already published (duplicate run) — continuing"
else
exit $STATUS
fi
fi
- name: Determine if prerelease
if: steps.tag.outputs.skip != 'true'
id: prerelease
run: |
TAG_NAME="${{ steps.tag.outputs.tag }}"
TAG_VERSION="${TAG_NAME#v}"
if echo "$TAG_VERSION" | grep -qE '-(alpha|beta|rc|pre|dev)'; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Release type: PRERELEASE ($TAG_VERSION)"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Release type: STABLE ($TAG_VERSION)"
fi
- name: Extract changelog section for release notes
id: changelog
if: steps.tag.outputs.skip != 'true'
run: |
TAG_NAME="${{ steps.tag.outputs.tag }}"
VERSION="${TAG_NAME#v}"
REPO="${{ github.repository }}"
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
# Extract the section between "## [VERSION]" and the next "---" separator
SECTION=$(sed -n "/^## \[$VERSION\]/,/^---/p" CHANGELOG.md | tail -n +2 | sed \$d)
if [ -z "$(echo "$SECTION" | tr -d ' \n\r')" ]; then
echo "⚠️ Could not extract CHANGELOG section for v$VERSION — using fallback"
echo "See [CHANGELOG.md](https://github.com/$REPO/blob/main/CHANGELOG.md) for the complete changelog." > "$NOTES_FILE"
else
echo "✅ Extracted CHANGELOG section for v$VERSION"
# Find the previous version tag for the compare link
PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | grep -A1 "$TAG_NAME" | tail -1)
if [ "$PREV_TAG" = "$TAG_NAME" ] || [ -z "$PREV_TAG" ]; then
COMPARE_LINK="https://github.com/$REPO/releases/tag/$TAG_NAME"
else
COMPARE_LINK="https://github.com/$REPO/compare/$PREV_TAG...$TAG_NAME"
fi
cat > "$NOTES_FILE" <<- NOTE
## What's Changed in v$VERSION
$SECTION
---
**Full Changelog**: $COMPARE_LINK
NOTE
fi
echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"
- name: Create or update GitHub Release
if: steps.tag.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
TAG_NAME="${{ steps.tag.outputs.tag }}"
VERSION="${TAG_NAME#v}"
IS_PRERELEASE="${{ steps.prerelease.outputs.is_prerelease }}"
NOTES_FILE="${{ steps.changelog.outputs.notes_file }}"
# Build the prerelease flag explicitly — the step output is the
# STRING "true"/"false", so ${VAR:+--flag} would wrongly expand
# --prerelease for "false" (non-empty). Only set it when "true".
LATEST_FLAG="--latest"
PRERELEASE_FLAG=""
if [ "${IS_PRERELEASE}" = "true" ]; then
PRERELEASE_FLAG="--prerelease"
echo "ℹ️ Marking release as prerelease"
fi
# Check if release already exists
if gh release view "$TAG_NAME" --json tagName --jq '.tagName' &>/dev/null; then
echo "ℹ️ Release $TAG_NAME already exists — updating notes and title"
gh release edit "$TAG_NAME" \
--title "Forge Guard v${VERSION}" \
--notes-file "${NOTES_FILE}" \
${LATEST_FLAG} \
${PRERELEASE_FLAG}
echo "✅ Release $TAG_NAME updated"
else
echo "Creating new release $TAG_NAME..."
gh release create "$TAG_NAME" \
--title "Forge Guard v${VERSION}" \
--notes-file "${NOTES_FILE}" \
${LATEST_FLAG} \
${PRERELEASE_FLAG} \
--verify-tag
echo "✅ Release $TAG_NAME created"
fi