chore: update CHANGELOG for v0.5.8 #125
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ============================================================================ | |
| # Release Workflow Template (release.yml) | |
| # ============================================================================ | |
| # | |
| # ORIGIN: https://github.com/XAOSTECH/dev-control | |
| # This workflow template is part of the dev-control toolkit. | |
| # See: https://github.com/XAOSTECH/dev-control/blob/main/workflows-templates/ | |
| # | |
| # ============================================================================ | |
| # | |
| # Automates GitHub releases for any project using packaging.sh. | |
| # Builds tarball, creates GitHub release, manages version and latest tags. | |
| # | |
| # TRIGGERS: | |
| # - Manual dispatch (recommended for controlled releases) | |
| # - Push to tags matching v* (requires careful tagging) | |
| # | |
| # ARTIFACTS: | |
| # - PROJECT-X.Y.Z.tar.gz - Main release tarball | |
| # - Optional: Additional tarballs (configure in 'Build additional packages' step) | |
| # | |
| # SAFEGUARDS: | |
| # ✓ Version format validation (semantic versioning) | |
| # ✓ Commit validation (minimum changes before release) | |
| # ✓ Manual approval workflow support | |
| # ✓ Tag existence checks to prevent overwrites | |
| # ✓ CHANGELOG validation | |
| # | |
| # WHAT IT DOES: | |
| # 1. Validates version format and commit count | |
| # 2. Builds main tarball using packaging.sh | |
| # 3. Optionally builds additional packages (customise as needed) | |
| # 4. Creates GitHub release with tarballs attached | |
| # 5. Updates 'latest' tag to point to new release | |
| # 6. Generates release notes from commits | |
| # | |
| # CUSTOMISATION: | |
| # - Edit the 'Build additional packages' step to add project-specific tarballs | |
| # - Modify release notes template in 'Generate release notes' step | |
| # - Adjust validation rules as needed for your project | |
| # | |
| # RECOMMENDED WORKFLOW: | |
| # | |
| # 1. Create feature branch: git checkout -b feat/my-feature | |
| # 2. Make changes and test locally | |
| # 3. Create PR and merge to main (use conventional commits: feat/fix/chore/docs) | |
| # 4. Trigger via Actions → Release → Run workflow | |
| # CHANGELOG.md is auto-generated from commits and committed by the bot | |
| # OR create a signed tag locally and push to trigger the push path: | |
| # git tag -s v1.0.0 -m "Release v1.0.0" && git push origin v1.0.0 | |
| # | |
| # TO SIGN AN EXISTING TAG RETROACTIVELY: | |
| # git tag -s -f v1.0.0 -m "Release v1.0.0" # Force recreate with signature | |
| # git push --force origin v1.0.0 # Force push to replace | |
| # | |
| # OR use manual dispatch for testing/special releases: | |
| # - Actions → Release → Run workflow → Enter version | |
| # | |
| # BEST PRACTICES: | |
| # - Always update CHANGELOG.md before releasing | |
| # - Use semantic versioning (v1.0.0, not v1.0 or v01.00.00) | |
| # - Create signed tags: git tag -s v1.0.0 -m "message" (or -a for unsigned) | |
| # - Wait for at least 3 commits since last release | |
| # - Run full test suite locally before tagging | |
| # - Review commits to ensure quality with: git log v0.9.0..main --oneline | |
| # | |
| # TAG SIGNING (RECOMMENDED): | |
| # Create signed tags locally for better security and authenticity: | |
| # | |
| # New tags: | |
| # git tag -s v1.0.0 -m "Release v1.0.0" | |
| # git push origin v1.0.0 | |
| # | |
| # Retroactively sign existing tag: | |
| # git tag -s -f v1.0.0 -m "Release v1.0.0" # Force recreate with signature | |
| # git push --force origin v1.0.0 # Force push to replace unsigned tag | |
| # | |
| # Workflow automation (optional): | |
| # If you want GitHub Actions to sign tags during manual dispatch, add these secrets: | |
| # 1. Generate GPG key: gpg --full-generate-key | |
| # 2. Export private key: gpg --armor --export-secret-keys YOUR_EMAIL > private.key | |
| # 3. Add to GitHub Secrets: | |
| # - GPG_PRIVATE_KEY: contents of private.key | |
| # - GPG_PASSPHRASE: your GPG passphrase | |
| # 4. The workflow will automatically sign tags when these secrets exist | |
| # | |
| # ============================================================================ | |
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - 'v*' | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version (leave blank to autoincrement, use current to rerelease)' | |
| required: false | |
| type: string | |
| prerelease: | |
| description: 'Mark as pre-release' | |
| required: false | |
| type: boolean | |
| default: false | |
| draft: | |
| description: 'Create as draft release' | |
| required: false | |
| type: boolean | |
| default: false | |
| skip_lib: | |
| description: 'Skip building lib-only tarball' | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| # One release job per tag at a time; if a second run starts (e.g. re-trigger from tag push) | |
| # it waits rather than racing. Both paths are idempotent so the second run is a no-op. | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| env: | |
| GUM_VERSION: '0.17.0' | |
| jobs: | |
| release: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate App Token | |
| id: app_token | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ secrets['XB_AI'] }} | |
| private-key: ${{ secrets['XB_PK'] }} | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 # Full history for changelog generation | |
| # Use the app token so actions/checkout installs its credential helper | |
| # with the app token — this carries the 'workflows' installation permission | |
| # for all subsequent git pushes, including workflow file references. | |
| token: ${{ steps.app_token.outputs.token }} | |
| - name: Setup bot identity | |
| uses: XAOSTECH/dev-control/.github/actions/identity@b067f72ca7f849b734a45634f23581a739d5146f # v2.0.0 | |
| with: | |
| gpg-private-key: ${{ secrets['XB_GK'] }} | |
| gpg-passphrase: ${{ secrets['XB_GP'] }} | |
| user-token: ${{ secrets['XB_UT'] }} | |
| bot-name: ${{ vars.BOT_NAME || 'xaos-bot' }} | |
| - name: Determine version | |
| id: version | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| if [[ "${{ github.event_name }}" == "push" ]]; then | |
| # Extract version from tag (remove 'v' prefix) | |
| VERSION="${GITHUB_REF#refs/tags/v}" | |
| TAG_NAME="${GITHUB_REF#refs/tags/}" | |
| else | |
| # Manual dispatch - use input or auto-detect next patch | |
| VERSION="${{ github.event.inputs.version }}" | |
| VERSION="${VERSION#v}" # strip leading 'v' if present | |
| if [[ -z "$VERSION" ]]; then | |
| LATEST=$(gh api repos/${{ github.repository }}/releases/latest --jq '.tag_name' 2>/dev/null || \ | |
| git tag --list 'v*' --sort=-version:refname | grep -vE 'latest|alpha|beta|rc' | head -1) | |
| LATEST=${LATEST#v} | |
| if [[ "$LATEST" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then | |
| MAJOR=${BASH_REMATCH[1]} | |
| MINOR=${BASH_REMATCH[2]} | |
| PATCH=${BASH_REMATCH[3]} | |
| VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" | |
| echo "🔢 Auto-detected next version: ${VERSION} (from latest: v${LATEST})" | |
| else | |
| echo "❌ Could not determine latest version to auto-increment" | |
| exit 1 | |
| fi | |
| fi | |
| TAG_NAME="v${VERSION}" | |
| fi | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT | |
| echo "📦 Release version: ${VERSION}" | |
| echo "🏷️ Tag: ${TAG_NAME}" | |
| - name: Validate version format | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then | |
| echo "❌ Invalid version format: $VERSION" | |
| echo "Expected: X.Y.Z or X.Y.Z-suffix (e.g., 1.0.0, 2.1.0-beta)" | |
| exit 1 | |
| fi | |
| echo "✅ Version format valid: $VERSION" | |
| - name: Check if release already published | |
| id: check_release | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| RERELEASE="false" | |
| if gh release view "$TAG_NAME" &>/dev/null; then | |
| if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | |
| # Only allow re-release if this is the latest published release | |
| LATEST_TAG=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name' 2>/dev/null || echo "") | |
| if [[ "$LATEST_TAG" == "$TAG_NAME" ]]; then | |
| echo "🔄 Re-release: $TAG_NAME is latest — cleaning up existing release, version tag, and latest tag" | |
| gh release delete "$TAG_NAME" --yes 2>/dev/null || true | |
| git push origin ":refs/tags/${TAG_NAME}" 2>/dev/null || true | |
| git tag -d "$TAG_NAME" 2>/dev/null || true | |
| git push origin :refs/tags/latest 2>/dev/null || true | |
| git tag -d latest 2>/dev/null || true | |
| echo "✅ Cleaned up — will rebuild and republish" | |
| RERELEASE="true" | |
| echo "exists=false" >> $GITHUB_OUTPUT | |
| else | |
| echo "⚠️ Release $TAG_NAME exists but is not the latest (latest: $LATEST_TAG)" | |
| echo "ℹ️ Only the latest release can be re-released via manual dispatch" | |
| echo "exists=true" >> $GITHUB_OUTPUT | |
| fi | |
| else | |
| echo "⏭️ Release $TAG_NAME already exists — skipping build (push trigger)" | |
| echo "exists=true" >> $GITHUB_OUTPUT | |
| fi | |
| else | |
| echo "🆕 Release $TAG_NAME not yet published — proceeding" | |
| echo "exists=false" >> $GITHUB_OUTPUT | |
| fi | |
| echo "rerelease=${RERELEASE}" >> $GITHUB_OUTPUT | |
| - name: Gather commit history | |
| id: commits | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| # Previous release tag — excludes the version being released (doesn't exist yet) | |
| PREV_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^v${VERSION}$" | head -1 || echo "") | |
| if [[ -n "$PREV_TAG" ]]; then | |
| COMMIT_COUNT=$(git log "${PREV_TAG}..HEAD" --oneline | wc -l) | |
| echo "📊 Commits since ${PREV_TAG}: ${COMMIT_COUNT}" | |
| # Minimum 2 commits for release (encourages meaningful batching of changes) | |
| if [[ $COMMIT_COUNT -lt 2 ]]; then | |
| echo "⚠️ Warning: Only ${COMMIT_COUNT} commit(s) since last release" | |
| echo "ℹ️ Consider waiting for more changes before releasing" | |
| echo "ℹ️ Current practice: aim for 3+ commits per release" | |
| fi | |
| git log "${PREV_TAG}..HEAD" --oneline | |
| git log "${PREV_TAG}..HEAD" --pretty=format:"%s" > .commits-subjects.txt | |
| else | |
| echo "📊 Initial release" | |
| COMMIT_COUNT=$(git log --oneline | wc -l) | |
| echo "📊 Total commits: ${COMMIT_COUNT}" | |
| git log --pretty=format:"%s" -20 > .commits-subjects.txt | |
| fi | |
| echo "prev_tag=${PREV_TAG}" >> $GITHUB_OUTPUT | |
| - name: Update CHANGELOG.md | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| RERELEASE="${{ steps.check_release.outputs.rerelease }}" | |
| TODAY=$(date +%Y-%m-%d) | |
| # Build grouped commit categories from .commits-subjects.txt | |
| ADDED="" FIXED="" CHANGED="" DOCS="" | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| msg="${line#*: }" | |
| case "$line" in | |
| feat:*|feat\(*) ADDED="${ADDED}\n- ${msg}" ;; | |
| fix:*|fix\(*) FIXED="${FIXED}\n- ${msg}" ;; | |
| docs:*|doc\(*) DOCS="${DOCS}\n- ${msg}" ;; | |
| *) CHANGED="${CHANGED}\n- ${line}" ;; | |
| esac | |
| done < .commits-subjects.txt | |
| # Determine heading and insertion mode | |
| if [[ "$RERELEASE" == "true" ]]; then | |
| ENTRY_LINE=$(grep -n "^## \[${VERSION}\]\|^## ${VERSION}" CHANGELOG.md 2>/dev/null | head -1 | cut -d: -f1) | |
| ENTRY_DATE=$(sed -n "${ENTRY_LINE}p" CHANGELOG.md 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1) | |
| if [[ -n "$ENTRY_LINE" && "$ENTRY_DATE" == "$TODAY" ]]; then | |
| HEADING="## [${VERSION}] - ${TODAY} (re-release)" | |
| MODE="overwrite" | |
| echo "♻️ Same-day re-release: overwriting CHANGELOG section for v${VERSION}" | |
| else | |
| HEADING="## [${VERSION}] - ${TODAY} (re-release)" | |
| MODE="prepend" | |
| echo "🔄 Re-release on new day: prepending CHANGELOG section for v${VERSION}" | |
| fi | |
| else | |
| # Normal release: skip if version already documented | |
| if grep -q "^## \[${VERSION}\]" CHANGELOG.md 2>/dev/null || \ | |
| grep -q "^## ${VERSION}" CHANGELOG.md 2>/dev/null; then | |
| echo "✅ CHANGELOG.md already contains v${VERSION} — skipping" | |
| exit 0 | |
| fi | |
| HEADING="## [${VERSION}] - ${TODAY}" | |
| MODE="prepend" | |
| fi | |
| # Build the new entry | |
| { | |
| echo "$HEADING" | |
| [[ -n "$ADDED" ]] && printf "\n### Added%b\n" "$ADDED" | |
| [[ -n "$FIXED" ]] && printf "\n### Fixed%b\n" "$FIXED" | |
| [[ -n "$CHANGED" ]] && printf "\n### Changed%b\n" "$CHANGED" | |
| [[ -n "$DOCS" ]] && printf "\n### Documentation%b\n" "$DOCS" | |
| } > /tmp/new_entry.md | |
| if [[ "$MODE" == "overwrite" ]]; then | |
| # Replace the existing section: everything before it + new entry + everything from next ## on | |
| NEXT_SECTION=$(tail -n +$((ENTRY_LINE + 1)) CHANGELOG.md | grep -n "^## " | head -1 | cut -d: -f1) | |
| head -n $((ENTRY_LINE - 1)) CHANGELOG.md > /tmp/cl_head.txt | |
| if [[ -n "$NEXT_SECTION" ]]; then | |
| tail -n +$((ENTRY_LINE + NEXT_SECTION - 1)) CHANGELOG.md > /tmp/cl_tail.txt | |
| else | |
| echo "" > /tmp/cl_tail.txt | |
| fi | |
| { cat /tmp/cl_head.txt; echo ""; cat /tmp/new_entry.md; echo ""; cat /tmp/cl_tail.txt; } > CHANGELOG.md | |
| else | |
| # Insert before first existing ## entry (or append if file is new) | |
| FIRST_ENTRY=$(grep -n "^## " CHANGELOG.md 2>/dev/null | head -1 | cut -d: -f1) | |
| if [[ -n "$FIRST_ENTRY" ]]; then | |
| head -n $((FIRST_ENTRY - 1)) CHANGELOG.md > /tmp/cl_head.txt | |
| tail -n +${FIRST_ENTRY} CHANGELOG.md > /tmp/cl_tail.txt | |
| { cat /tmp/cl_head.txt; echo ""; cat /tmp/new_entry.md; echo ""; cat /tmp/cl_tail.txt; } > CHANGELOG.md | |
| else | |
| { echo ""; cat /tmp/new_entry.md; echo ""; } >> CHANGELOG.md | |
| fi | |
| fi | |
| COMMIT_MSG="chore: update CHANGELOG for v${VERSION}" | |
| [[ "$RERELEASE" == "true" ]] && COMMIT_MSG="chore: update CHANGELOG for v${VERSION} (re-release)" | |
| # Collapse any consecutive blank lines down to one | |
| cat -s CHANGELOG.md > /tmp/cl_normalized.md && mv /tmp/cl_normalized.md CHANGELOG.md | |
| git add CHANGELOG.md | |
| git commit -m "$COMMIT_MSG" | |
| git push | |
| echo "✅ CHANGELOG.md updated for v${VERSION}" | |
| - name: Setup bot identity (for signing) | |
| id: import_gpg | |
| if: github.event_name == 'workflow_dispatch' | |
| uses: XAOSTECH/dev-control/.github/actions/identity@b067f72ca7f849b734a45634f23581a739d5146f # v2.0.0 | |
| with: | |
| gpg-private-key: ${{ secrets['XB_GK'] }} | |
| gpg-passphrase: ${{ secrets['XB_GP'] }} | |
| user-token: ${{ secrets['XB_UT'] }} | |
| bot-name: ${{ vars.BOT_NAME || 'xaos-bot' }} | |
| - name: Create tag (manual dispatch only) | |
| if: github.event_name == 'workflow_dispatch' | |
| run: | | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| # Check if tag already exists | |
| if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then | |
| echo "⚠️ Tag $TAG_NAME already exists, skipping tag creation" | |
| else | |
| # Create tag (signed if GPG available, annotated otherwise) | |
| if [[ "${{ steps.import_gpg.outcome }}" == "success" ]]; then | |
| git tag -s "$TAG_NAME" -m "Release $TAG_NAME" | |
| echo "✅ Created signed tag: $TAG_NAME" | |
| else | |
| git tag -a "$TAG_NAME" -m "Release $TAG_NAME" | |
| echo "ℹ️ Created unsigned tag: $TAG_NAME (add GPG_PRIVATE_KEY secret for signed tags)" | |
| fi | |
| # Push via GITHUB_TOKEN so this doesn't re-trigger push:tags:v* (PAT pushes would) | |
| git push "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" "$TAG_NAME" | |
| echo "✅ Pushed tag: $TAG_NAME" | |
| fi | |
| - name: Update package version in config | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| # Update .dc-package.yaml if it exists | |
| if [[ -f ".dc-package.yaml" ]]; then | |
| sed -i "s/^version:.*/version: ${VERSION}/" .dc-package.yaml | |
| echo "✅ Updated .dc-package.yaml to version ${VERSION}" | |
| fi | |
| # Create temporary config if none exists | |
| if [[ ! -f ".dc-package.yaml" ]]; then | |
| cat > .dc-package.yaml << EOF | |
| name: $(basename "$GITHUB_REPOSITORY") | |
| version: ${VERSION} | |
| description: "Version ${VERSION}" | |
| homepage: https://github.com/$GITHUB_REPOSITORY | |
| licence: GPL-3.0-or-later | |
| entry_point: ./dc | |
| include: | |
| - scripts/ | |
| - config/ | |
| - docs/ | |
| - plugins/ | |
| - README.md | |
| - LICENSE | |
| - install.sh | |
| dependencies: | |
| - git | |
| - gh | |
| - jq | |
| - gum | |
| EOF | |
| echo "✅ Created temporary .dc-package.yaml" | |
| fi | |
| - name: Install Gum | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| curl -fsSL "https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/gum_${GUM_VERSION}_Linux_x86_64.tar.gz" \ | |
| | tar xz --strip-components=1 -C /tmp "gum_${GUM_VERSION}_Linux_x86_64/gum" | |
| sudo mv /tmp/gum /usr/local/bin/ | |
| echo "✅ Installed Gum v${GUM_VERSION}" | |
| - name: Build full tarball | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| if [[ ! -f "scripts/packaging.sh" ]]; then | |
| echo "⚠️ No packaging.sh found - skipping tarball build" | |
| echo "build_skipped=true" >> $GITHUB_ENV | |
| exit 0 | |
| fi | |
| chmod +x scripts/packaging.sh scripts/lib/*.sh 2>/dev/null || true | |
| bash scripts/packaging.sh --tarball --verbose | |
| echo "✅ Full tarball built successfully" | |
| # ======================================================================== | |
| # CUSTOMISE: Build additional packages (optional) | |
| # ======================================================================== | |
| # Uncomment and modify this step to build additional tarballs. | |
| # Example: A lightweight lib-only package, docs package, etc. | |
| # | |
| # - name: Build additional packages | |
| # run: | | |
| # VERSION="${{ steps.version.outputs.version }}" | |
| # PKG_NAME="myproject-extra-${VERSION}" | |
| # PKG_DIR="dist/${PKG_NAME}" | |
| # | |
| # mkdir -p "$PKG_DIR" | |
| # | |
| # # Copy files for additional package | |
| # cp -r src/lib/* "$PKG_DIR/" | |
| # cp LICENSE "$PKG_DIR/" | |
| # | |
| # # Create README with origin attribution | |
| # { | |
| # echo "# My Project Extra Package" | |
| # echo "" | |
| # echo "## Origin" | |
| # echo "" | |
| # echo "This package was built from: https://github.com/$GITHUB_REPOSITORY" | |
| # echo "Release: ${VERSION}" | |
| # echo "" | |
| # echo "## Installation" | |
| # echo "..." | |
| # } > "$PKG_DIR/README.md" | |
| # | |
| # echo "${VERSION}" > "$PKG_DIR/VERSION" | |
| # | |
| # cd dist | |
| # tar czf "${PKG_NAME}.tar.gz" "${PKG_NAME}" | |
| # sha256sum "${PKG_NAME}.tar.gz" > "${PKG_NAME}.tar.gz.sha256" | |
| # cd .. | |
| # ======================================================================== | |
| - name: List build artifacts | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| if [[ ! -d "dist" ]]; then | |
| echo "⚠️ No dist/ directory - no tarballs were built" | |
| echo "tarball_count=0" >> $GITHUB_ENV | |
| exit 0 | |
| fi | |
| echo "📦 Build artifacts:" | |
| ls -la dist/ || true | |
| # Get main tarball info (first .tar.gz that's not a sub-package) | |
| TARBALL=$(ls dist/*.tar.gz 2>/dev/null | head -1 || echo "") | |
| if [[ -n "$TARBALL" ]]; then | |
| echo "tarball_path=${TARBALL}" >> $GITHUB_ENV | |
| echo "tarball_name=$(basename "$TARBALL")" >> $GITHUB_ENV | |
| if [[ -f "${TARBALL}.sha256" ]]; then | |
| SHA256=$(cat "${TARBALL}.sha256" | awk '{print $1}') | |
| echo "tarball_sha256=${SHA256}" >> $GITHUB_ENV | |
| echo "🔐 Tarball SHA256: ${SHA256}" | |
| fi | |
| fi | |
| # Count all tarballs for summary | |
| TARBALL_COUNT=$(ls dist/*.tar.gz 2>/dev/null | wc -l || echo "0") | |
| echo "tarball_count=${TARBALL_COUNT}" >> $GITHUB_ENV | |
| - name: Generate release notes | |
| id: release_notes | |
| if: steps.check_release.outputs.exists != 'true' | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| # Previous version tag — sourced from 'Gather commit history' step | |
| PREV_TAG="${{ steps.commits.outputs.prev_tag }}" | |
| # Generate release notes | |
| NOTES_FILE=$(mktemp) | |
| cat > "$NOTES_FILE" << EOF | |
| ## What's Changed | |
| EOF | |
| if [[ -n "$PREV_TAG" ]]; then | |
| echo "### Commits since ${PREV_TAG}" >> "$NOTES_FILE" | |
| echo "" >> "$NOTES_FILE" | |
| git log "${PREV_TAG}..${TAG_NAME}" --pretty=format:"- %s (%h)" >> "$NOTES_FILE" | |
| else | |
| echo "### Initial Release" >> "$NOTES_FILE" | |
| echo "" >> "$NOTES_FILE" | |
| git log --pretty=format:"- %s (%h)" -10 >> "$NOTES_FILE" | |
| fi | |
| # Add installation instructions only if tarball exists | |
| if [[ -n "${tarball_name:-}" && -n "${tarball_sha256:-}" ]]; then | |
| cat >> "$NOTES_FILE" << EOF | |
| ## Installation | |
| ### Quick Install (tarball) | |
| \`\`\`bash | |
| curl -fsSL https://github.com/$GITHUB_REPOSITORY/releases/download/${TAG_NAME}/${tarball_name} | tar xz | |
| \`\`\` | |
| ## Checksums | |
| | File | SHA256 | | |
| |------|--------| | |
| | ${tarball_name} | \`${tarball_sha256}\` | | |
| EOF | |
| else | |
| # No custom tarball - show git installation | |
| cat >> "$NOTES_FILE" << EOF | |
| ## Installation | |
| ### From Source | |
| \`\`\`bash | |
| git clone https://github.com/$GITHUB_REPOSITORY.git | |
| cd $(basename $GITHUB_REPOSITORY) | |
| git checkout ${TAG_NAME} | |
| \`\`\` | |
| ### Download Release Archive | |
| \`\`\`bash | |
| curl -fsSL https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/${TAG_NAME}.tar.gz | tar xz | |
| \`\`\` | |
| EOF | |
| fi | |
| cat >> "$NOTES_FILE" << EOF | |
| --- | |
| **Full Changelog**: https://github.com/$GITHUB_REPOSITORY/compare/${PREV_TAG}...${TAG_NAME} | |
| --- | |
| *Release workflow powered by [dev-control](https://github.com/XAOSTECH/dev-control)* | |
| EOF | |
| # Output for next step | |
| echo "notes_file=${NOTES_FILE}" >> $GITHUB_OUTPUT | |
| echo "📝 Release notes generated" | |
| cat "$NOTES_FILE" | |
| - name: Create GitHub Release | |
| if: steps.check_release.outputs.exists != 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| VERSION="${{ steps.version.outputs.version }}" | |
| NOTES_FILE="${{ steps.release_notes.outputs.notes_file }}" | |
| # Build file list (may be empty if no tarb alls) | |
| FILES=$(find dist/ -name "*.tar.gz" -o -name "*.sha256" 2>/dev/null | tr '\n' ' ' || echo "") | |
| # Create release (idempotent — skip if already exists) | |
| if gh release view "$TAG_NAME" &>/dev/null; then | |
| echo "✅ Release $TAG_NAME already exists— skipping creation" | |
| else | |
| if [[ -n "$FILES" ]]; then | |
| gh release create "$TAG_NAME" \ | |
| --title "Release $TAG_NAME" \ | |
| --notes-file "$NOTES_FILE" \ | |
| ${{ github.event.inputs.draft == 'true' && '--draft' || '' }} \ | |
| ${{ github.event.inputs.prerelease == 'true' && '--prerelease' || '' }} \ | |
| $FILES | |
| else | |
| # Release without attachments (notes only) | |
| gh release create "$TAG_NAME" \ | |
| --title "Release $TAG_NAME" \ | |
| --notes-file "$NOTES_FILE" \ | |
| ${{ github.event.inputs.draft == 'true' && '--draft' || '' }} \ | |
| ${{ github.event.inputs.prerelease == 'true' && '--prerelease' || '' }} | |
| fi | |
| echo "✅ Release created: $TAG_NAME" | |
| fi | |
| - name: Add source archive checksums | |
| if: steps.check_release.outputs.exists != 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| VERSION="${{ steps.version.outputs.version }}" | |
| # Only add checksums if no custom tarball was built | |
| if [[ -z "${tarball_name:-}" ]]; then | |
| echo "📦 Calculating SHA256 for GitHub-generated source archives..." | |
| # Download and checksum the archives | |
| TAR_URL="https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/${TAG_NAME}.tar.gz" | |
| ZIP_URL="https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/${TAG_NAME}.zip" | |
| TAR_SHA=$(curl -fsSL "$TAR_URL" | sha256sum | awk '{print $1}') | |
| ZIP_SHA=$(curl -fsSL "$ZIP_URL" | sha256sum | awk '{print $1}') | |
| echo "🔐 Source archive SHA256:" | |
| echo " tar.gz: $TAR_SHA" | |
| echo " zip: $ZIP_SHA" | |
| # Get current release notes and append checksums | |
| CURRENT_NOTES=$(gh release view "$TAG_NAME" --json body --jq '.body') | |
| # Update release notes with checksums table | |
| printf -v NEW_NOTES "%s\n\n## Checksums\n\n| File | SHA256 |\n|------|--------|\n| Source code (tar.gz) | \`%s\` |\n| Source code (zip) | \`%s\` |\n" \ | |
| "$CURRENT_NOTES" "$TAR_SHA" "$ZIP_SHA" | |
| gh release edit "$TAG_NAME" --notes "$NEW_NOTES" | |
| echo "✅ Updated release notes with source archive checksums" | |
| else | |
| echo "⏭️ Custom tarball exists - source archive checksums not needed" | |
| fi | |
| - name: Update 'latest' tag | |
| env: | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| # Skip if 'latest' already points to this tag's commit (idempotent) | |
| LATEST_COMMIT=$(git rev-parse refs/tags/latest^{} 2>/dev/null || echo "") | |
| TARGET_COMMIT=$(git rev-parse "${TAG_NAME}^{}" 2>/dev/null || echo "") | |
| if [[ -n "$LATEST_COMMIT" && "$LATEST_COMMIT" == "$TARGET_COMMIT" ]]; then | |
| echo "✅ 'latest' already points to ${TAG_NAME} — skipping" | |
| exit 0 | |
| fi | |
| # Delete existing 'latest' tag (local and remote) | |
| git tag -d latest 2>/dev/null || true | |
| git push origin :refs/tags/latest 2>/dev/null || true | |
| # Create new 'latest' tag pointing to the commit this release tag resolves to | |
| # Use ^{} to dereference the version tag to its commit (avoids a nested tag) | |
| if [[ "${{ steps.import_gpg.outcome }}" == "success" ]]; then | |
| git tag -s -m "Latest release: ${TAG_NAME}" latest "${TAG_NAME}^{}" | |
| echo "✅ Created signed 'latest' tag pointing to ${TAG_NAME}" | |
| else | |
| git tag -a -m "Latest release: ${TAG_NAME}" latest "${TAG_NAME}^{}" | |
| echo "ℹ️ Created unsigned 'latest' tag pointing to ${TAG_NAME} (add GPG secret for signed tags)" | |
| fi | |
| git push origin latest | |
| echo "✅ Updated 'latest' tag to point to ${TAG_NAME}" | |
| - name: Summary | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| TAG_NAME="${{ steps.version.outputs.tag_name }}" | |
| TARBALL_COUNT="${tarball_count:-0}" | |
| echo "## 🎉 Release Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY | |
| echo "|------|-------|" >> $GITHUB_STEP_SUMMARY | |
| echo "| Version | ${VERSION} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Tag | ${TAG_NAME} |" >> $GITHUB_STEP_SUMMARY | |
| echo "| Artifacts | ${TARBALL_COUNT} tarball(s) |" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "### Links" >> $GITHUB_STEP_SUMMARY | |
| echo "- [Release](https://github.com/$GITHUB_REPOSITORY/releases/tag/${TAG_NAME})" >> $GITHUB_STEP_SUMMARY | |
| if [[ -n "${tarball_name:-}" ]]; then | |
| echo "- [Download](https://github.com/$GITHUB_REPOSITORY/releases/download/${TAG_NAME}/${tarball_name})" >> $GITHUB_STEP_SUMMARY | |
| fi | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "---" >> $GITHUB_STEP_SUMMARY | |
| echo "*Workflow template from [dev-control](https://github.com/XAOSTECH/dev-control)*" >> $GITHUB_STEP_SUMMARY |