Skip to content

Release

Release #53

name: Release
on:
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
catalog_version:
description: "Catalog version to publish, for example 1.0.0, 1.2.3-preview.1, or 2026.3.15.0"
required: false
type: string
force_release:
description: "Force a release even when the latest catalog-v* release already covers the current main commit"
required: false
default: false
type: boolean
permissions:
contents: write
pages: write
id-token: write
env:
CATALOG_TAG_PREFIX: "catalog-v"
DOTNET_SKILLS_SITE_URL: https://skills.managed-code.com/
DOTNET_VERSION: "10.0.x"
SOLUTION_FILE: "dotnet-skills.slnx"
TOOL_PROJECTS: "cli/ManagedCode.DotnetSkills/ManagedCode.DotnetSkills.csproj cli/ManagedCode.DotnetAgents/ManagedCode.DotnetAgents.csproj cli/ManagedCode.Agents/ManagedCode.Agents.csproj"
PACKAGE_SOURCE: "https://api.nuget.org/v3/index.json"
PACKAGE_OUTPUT_GLOB: "artifacts/nuget/*.nupkg"
concurrency:
group: "release"
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
environment: release
outputs:
should_release: ${{ steps.changes.outputs.has_new_commits }}
previous_tag: ${{ steps.changes.outputs.latest_tag }}
catalog_version: ${{ steps.version.outputs.version }}
catalog_tag: ${{ steps.version.outputs.tag }}
package_version: ${{ steps.package_version.outputs.package_version }}
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for new commits on main since latest catalog release
id: changes
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
git fetch --force --tags origin main
latest_tag=$(gh api "repos/${{ github.repository }}/releases?per_page=100" --jq 'map(select(.draft == false and (.tag_name | startswith("'"${{ env.CATALOG_TAG_PREFIX }}"'")))) | sort_by(.published_at) | reverse | (.[0].tag_name // "")')
main_ref="origin/main"
force_release='${{ inputs.force_release }}'
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "$force_release" == "true" ]]; then
echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "override=true" >> "$GITHUB_OUTPUT"
echo "reason=manual_force_release" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -z "$latest_tag" ]]; then
if [[ "$(git rev-list --count "$main_ref")" -gt 0 ]]; then
echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
else
echo "has_new_commits=false" >> "$GITHUB_OUTPUT"
fi
echo "latest_tag=" >> "$GITHUB_OUTPUT"
echo "override=false" >> "$GITHUB_OUTPUT"
echo "reason=initial_catalog_release" >> "$GITHUB_OUTPUT"
exit 0
fi
commits_since_tag=$(git rev-list --count "${latest_tag}..${main_ref}")
if [[ "$commits_since_tag" -gt 0 ]]; then
echo "has_new_commits=true" >> "$GITHUB_OUTPUT"
else
echo "has_new_commits=false" >> "$GITHUB_OUTPUT"
fi
echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "override=false" >> "$GITHUB_OUTPUT"
echo "reason=$([ "$commits_since_tag" -gt 0 ] && echo commits_since_last_release || echo no_new_commits)" >> "$GITHUB_OUTPUT"
- name: Skip release when no new commits exist
if: steps.changes.outputs.has_new_commits != 'true'
shell: bash
run: |
echo "No unreleased commits on main since latest non-draft catalog release '${{ steps.changes.outputs.latest_tag }}'. Skipping release."
- name: Set up Python
if: steps.changes.outputs.has_new_commits == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Set up .NET
if: steps.changes.outputs.has_new_commits == 'true'
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Validate catalog version
if: steps.changes.outputs.has_new_commits == 'true'
id: version
shell: bash
run: |
version='${{ inputs.catalog_version }}'
if [[ -z "$version" ]]; then
year="$(date -u +%Y)"
month=$((10#$(date -u +%m)))
day=$((10#$(date -u +%d)))
today_pattern="${{ env.CATALOG_TAG_PREFIX }}${year}.${month}.${day}.*"
latest_today_tag=$(git tag --list "$today_pattern" --sort=-v:refname | head -n 1)
if [[ -z "$latest_today_tag" ]]; then
daily_build_index=0
else
latest_today_index="${latest_today_tag##*.}"
if [[ ! "$latest_today_index" =~ ^[0-9]+$ ]]; then
echo "Latest tag for today has a non-numeric daily build index: $latest_today_tag" >&2
exit 1
fi
daily_build_index=$((10#$latest_today_index + 1))
fi
version="${year}.${month}.${day}.${daily_build_index}"
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
echo "Catalog version must be SemVer-compatible. Got: $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=${{ env.CATALOG_TAG_PREFIX }}$version" >> "$GITHUB_OUTPUT"
- name: Generate catalog outputs in CI
if: steps.changes.outputs.has_new_commits == 'true'
run: python3 scripts/generate_catalog.py
- name: Determine package version
if: steps.changes.outputs.has_new_commits == 'true'
id: package_version
shell: bash
run: |
base_version=""
for project in ${{ env.TOOL_PROJECTS }}; do
project_version=$(sed -n 's:.*<VersionPrefix>\(.*\)</VersionPrefix>.*:\1:p' "$project" | head -n 1 | tr -d '[:space:]')
if [[ -z "$project_version" ]]; then
echo "VersionPrefix is required in $project." >&2
exit 1
fi
if [[ ! "$project_version" =~ ^[0-9]+\.[0-9]+$ ]]; then
echo "VersionPrefix must use <major>.<minor>. Got: $project_version in $project" >&2
exit 1
fi
if [[ -z "$base_version" ]]; then
base_version="$project_version"
continue
fi
if [[ "$project_version" != "$base_version" ]]; then
echo "All tool projects must share the same VersionPrefix. Found $base_version and $project_version." >&2
exit 1
fi
done
version="${base_version}.${GITHUB_RUN_NUMBER}"
echo "base_version=$base_version" >> "$GITHUB_OUTPUT"
echo "package_version=$version" >> "$GITHUB_OUTPUT"
- name: Generate release notes
if: steps.changes.outputs.has_new_commits == 'true'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
previous_tag='${{ steps.changes.outputs.latest_tag }}'
previous_args=()
if [[ -n "$previous_tag" ]]; then
previous_args+=(--previous-tag "$previous_tag")
fi
python3 scripts/generate_release_notes.py \
--repo "${{ github.repository }}" \
--tag "${{ steps.version.outputs.tag }}" \
--catalog-version "${{ steps.version.outputs.version }}" \
--package-version "${{ steps.package_version.outputs.package_version }}" \
--target-commit "${{ github.sha }}" \
--output artifacts/release/release-notes.md \
"${previous_args[@]}"
- name: Build dotnet tool
if: steps.changes.outputs.has_new_commits == 'true'
run: dotnet build "${{ env.SOLUTION_FILE }}" -c Release -p:ContinuousIntegrationBuild=true -p:Version="${{ steps.package_version.outputs.package_version }}"
- name: Run dotnet tests
if: steps.changes.outputs.has_new_commits == 'true'
run: dotnet test "${{ env.SOLUTION_FILE }}" -c Release --no-build -p:ContinuousIntegrationBuild=true -p:Version="${{ steps.package_version.outputs.package_version }}"
- name: Pack dotnet tool
if: steps.changes.outputs.has_new_commits == 'true'
run: dotnet pack "${{ env.SOLUTION_FILE }}" -c Release --no-build -p:ContinuousIntegrationBuild=true -p:Version="${{ steps.package_version.outputs.package_version }}"
- name: Smoke test installable tool
if: steps.changes.outputs.has_new_commits == 'true'
run: bash scripts/smoke_test_tool.sh artifacts/nuget
- name: Package catalog release assets
if: steps.changes.outputs.has_new_commits == 'true'
shell: bash
run: |
rm -rf artifacts/catalog-release artifacts/catalog-payload
mkdir -p artifacts/catalog-release artifacts/catalog-payload
python3 scripts/generate_catalog.py --manifest-output artifacts/catalog-release/dotnet-skills-manifest.json
cp -R catalog artifacts/catalog-payload/catalog
(
cd artifacts/catalog-payload
zip -r ../catalog-release/dotnet-skills-catalog.zip catalog
)
- name: Upload packaged catalog artifacts
if: steps.changes.outputs.has_new_commits == 'true'
uses: actions/upload-artifact@v7
with:
name: dotnet-skills-catalog-${{ steps.version.outputs.version }}
path: artifacts/catalog-release/*
if-no-files-found: error
- name: Upload NuGet package artifact
if: steps.changes.outputs.has_new_commits == 'true'
uses: actions/upload-artifact@v7
with:
name: managedcode-dotnet-tools-${{ steps.package_version.outputs.package_version }}
path: ${{ env.PACKAGE_OUTPUT_GLOB }}
if-no-files-found: error
- name: Create or update catalog release
if: steps.changes.outputs.has_new_commits == 'true'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag='${{ steps.version.outputs.tag }}'
title="Catalog ${{ steps.version.outputs.version }}"
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --title "$title" --notes-file artifacts/release/release-notes.md
else
gh release create "$tag" --title "$title" --notes-file artifacts/release/release-notes.md --latest
fi
gh release upload "$tag" artifacts/catalog-release/dotnet-skills-manifest.json artifacts/catalog-release/dotnet-skills-catalog.zip --clobber
- name: Publish to NuGet
if: steps.changes.outputs.has_new_commits == 'true'
shell: bash
run: |
set -euo pipefail
if [[ -z "${{ secrets.NUGET_API_KEY }}" ]]; then
echo "NUGET_API_KEY is required for NuGet publish." >&2
exit 1
fi
shopt -s nullglob
packages=( ${{ env.PACKAGE_OUTPUT_GLOB }} )
if [[ ${#packages[@]} -eq 0 ]]; then
echo "No NuGet packages found for publish." >&2
exit 1
fi
for package in "${packages[@]}"; do
exit_code=0
echo "Publishing $package..."
result=$(dotnet nuget push "$package" \
--api-key "${{ secrets.NUGET_API_KEY }}" \
--source "${{ env.PACKAGE_SOURCE }}" \
--skip-duplicate 2>&1) || exit_code=$?
echo "$result"
if [[ "$exit_code" -eq 0 ]]; then
continue
fi
if echo "$result" | grep -qi "already exists"; then
echo "Package already exists, skipping."
continue
fi
exit "$exit_code"
done
build-pages:
if: needs.release.outputs.should_release == 'true'
needs: release
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Generate GitHub Pages payload
run: python3 scripts/generate_catalog.py
- name: Render GitHub Pages
env:
DOTNET_SKILLS_RELEASE_VERSION: ${{ needs.release.outputs.catalog_version }}
DOTNET_SKILLS_RELEASE_TAG: ${{ needs.release.outputs.catalog_tag }}
DOTNET_SKILLS_RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release.outputs.catalog_tag }}
run: python3 scripts/generate_pages.py
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: "artifacts/github-pages"
deploy-pages:
if: needs.release.outputs.should_release == 'true'
needs:
- release
- build-pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v5