Skip to content

chore: Layer 3 polish — SHA pinning, modern C# rules, metadata, trim/AOT #66

chore: Layer 3 polish — SHA pinning, modern C# rules, metadata, trim/AOT

chore: Layer 3 polish — SHA pinning, modern C# rules, metadata, trim/AOT #66

Workflow file for this run

name: CI and Release
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
prerelease_suffix:
description: 'Pre-release channel suffix (e.g. rc.1, beta.2). Leave empty for standard CI build.'
required: false
default: ''
permissions:
contents: read
checks: write
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
NUKE_TELEMETRY_OPTOUT: true
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
ENABLE_ANDROID: 'false'
ENABLE_IOS: 'false'
jobs:
resolve-version:
name: Resolve Version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.resolve.outputs.version }}
full_version: ${{ steps.resolve.outputs.full_version }}
version_suffix: ${{ steps.resolve.outputs.version_suffix }}
tag: ${{ steps.resolve.outputs.tag }}
sha: ${{ steps.resolve.outputs.sha }}
is_release: ${{ steps.resolve.outputs.is_release }}
build_matrix: ${{ steps.matrix.outputs.build_matrix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-tags: true
- name: Resolve version and context
id: resolve
shell: bash
env:
PRERELEASE_SUFFIX: ${{ inputs.prerelease_suffix }}
EVENT_NAME: ${{ github.event_name }}
GIT_REF: ${{ github.ref }}
RUN_NUMBER: ${{ github.run_number }}
run: |
SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
VERSION=$(perl -0777 -ne 'if (m{<VersionPrefix>\s*([^<]+?)\s*</VersionPrefix>}s) { print $1 }' Directory.Build.props | head -n1 | xargs)
if [ -z "$VERSION" ]; then
echo "::error::VersionPrefix not found in Directory.Build.props"
exit 1
fi
if ! [[ "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error::Invalid VersionPrefix: $VERSION"
exit 1
fi
SHA="$(git rev-parse HEAD)"
TAG="v$VERSION"
IS_RELEASE="false"
if [ "$EVENT_NAME" != "pull_request" ] && [ "$GIT_REF" = "refs/heads/main" ]; then
git fetch --tags --force
if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then
echo "::notice::Tag $TAG already exists. Version not bumped — skipping release."
else
IS_RELEASE="true"
fi
fi
VERSION_SUFFIX=""
if [ -n "$PRERELEASE_SUFFIX" ]; then
if ! [[ "$PRERELEASE_SUFFIX" =~ ^[0-9A-Za-z.-]+$ ]]; then
echo "::error::Invalid prerelease_suffix: must match [0-9A-Za-z.-]+"
exit 1
fi
VERSION_SUFFIX="$PRERELEASE_SUFFIX"
elif [ "$IS_RELEASE" != "true" ]; then
VERSION_SUFFIX="ci.${RUN_NUMBER}"
fi
if [ -n "$VERSION_SUFFIX" ]; then
FULL_VERSION="${VERSION}-${VERSION_SUFFIX}"
else
FULL_VERSION="${VERSION}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "full_version=$FULL_VERSION" >> "$GITHUB_OUTPUT"
echo "version_suffix=$VERSION_SUFFIX" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved: version=$VERSION full_version=$FULL_VERSION tag=$TAG sha=$SHA is_release=$IS_RELEASE"
- name: Version summary
shell: bash
run: |
VERSION="${{ steps.resolve.outputs.version }}"
FULL_VERSION="${{ steps.resolve.outputs.full_version }}"
VERSION_SUFFIX="${{ steps.resolve.outputs.version_suffix }}"
IS_RELEASE="${{ steps.resolve.outputs.is_release }}"
SHA="${{ steps.resolve.outputs.sha }}"
TAG="${{ steps.resolve.outputs.tag }}"
IFS='.' read -r CUR_MAJOR CUR_MINOR CUR_PATCH <<< "$VERSION"
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
PREV_VER="${PREV_TAG#v}"
IFS='.' read -r PRE_MAJOR PRE_MINOR PRE_PATCH <<< "$PREV_VER"
if [ "$CUR_MAJOR" != "$PRE_MAJOR" ]; then
BUMP="MAJOR"
elif [ "$CUR_MINOR" != "$PRE_MINOR" ]; then
BUMP="MINOR"
elif [ "$CUR_PATCH" != "$PRE_PATCH" ]; then
BUMP="PATCH"
else
BUMP="NONE (same as $PREV_TAG)"
fi
else
PREV_TAG="(none)"
BUMP="INITIAL"
fi
MODE=$( [ "$IS_RELEASE" = "true" ] && echo "Release" || echo "CI" )
CHANNEL="stable"
if [ -n "$VERSION_SUFFIX" ]; then
CHANNEL="$VERSION_SUFFIX"
fi
REPO="${{ github.repository }}"
OWNER="${REPO%%/*}"
REPO_NAME="${REPO##*/}"
DOCS_URL="https://${OWNER,,}.github.io/${REPO_NAME}/"
{
echo "## Version Info"
echo ""
echo "| Property | Value |"
echo "|----------|-------|"
echo "| **Version** | \`$VERSION\` |"
echo "| **Full Version** | \`$FULL_VERSION\` |"
echo "| **Channel** | \`$CHANNEL\` |"
echo "| **Tag** | \`$TAG\` |"
echo "| **Previous Tag** | \`$PREV_TAG\` |"
echo "| **Bump Type** | **$BUMP** |"
echo "| **Mode** | $MODE |"
echo "| **Commit** | \`${SHA::8}\` |"
echo "| **Expected Docs URL** | $DOCS_URL |"
echo "| **Docs Auto-Deploy** | Enable Pages (GitHub Actions) + add \`docs/package.json\` |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Compute build matrix
id: matrix
shell: bash
run: |
IS_RELEASE="${{ steps.resolve.outputs.is_release }}"
if [ "$IS_RELEASE" = "true" ]; then
ENTRIES='{"os":"ubuntu-latest","runtime":"linux-x64","pack":true,"publish":true,"workloads":""}'
ENTRIES+=',{"os":"windows-latest","runtime":"win-x64","pack":false,"publish":true,"workloads":""}'
ENTRIES+=',{"os":"macos-latest","runtime":"osx-arm64","pack":false,"publish":true,"workloads":""}'
if [ "$ENABLE_ANDROID" = "true" ]; then
ENTRIES+=',{"os":"ubuntu-latest","runtime":"android","pack":false,"publish":false,"workloads":"android"}'
fi
if [ "$ENABLE_IOS" = "true" ]; then
ENTRIES+=',{"os":"macos-latest","runtime":"ios","pack":false,"publish":false,"workloads":"ios"}'
fi
else
ENTRIES='{"os":"ubuntu-latest","runtime":"linux-x64","pack":true,"publish":false,"workloads":""}'
ENTRIES+=',{"os":"windows-latest","runtime":"win-x64","pack":false,"publish":false,"workloads":""}'
ENTRIES+=',{"os":"macos-latest","runtime":"osx-arm64","pack":false,"publish":false,"workloads":""}'
if [ "$ENABLE_ANDROID" = "true" ]; then
ENTRIES+=',{"os":"ubuntu-latest","runtime":"android","pack":false,"publish":false,"workloads":"android"}'
fi
if [ "$ENABLE_IOS" = "true" ]; then
ENTRIES+=',{"os":"macos-latest","runtime":"ios","pack":false,"publish":false,"workloads":"ios"}'
fi
fi
MATRIX="{\"include\":[$ENTRIES]}"
echo "build_matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "Matrix: $MATRIX"
build-and-test:
name: Build and Test (${{ matrix.runtime }})
needs: resolve-version
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
BuildNumber: ${{ github.event_name != 'pull_request' && github.run_number || '' }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.resolve-version.outputs.build_matrix) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-version.outputs.sha }}
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: global.json
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }}
restore-keys: ${{ runner.os }}-nuget-
- name: Install .NET workloads
if: matrix.workloads != ''
run: dotnet workload install ${{ matrix.workloads }}
- name: Mark build script executable
if: runner.os != 'Windows'
run: chmod +x build.sh
- name: Check code format
if: matrix.runtime == 'linux-x64'
shell: bash
run: ./build.sh Format
- name: Build and test
if: runner.os != 'Windows'
shell: bash
env:
VERSION_SUFFIX: ${{ needs.resolve-version.outputs.version_suffix }}
run: |
if [ -n "$VERSION_SUFFIX" ]; then
./build.sh Test --VersionSuffix "$VERSION_SUFFIX"
else
./build.sh Test
fi
- name: Build and test (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
VERSION_SUFFIX: ${{ needs.resolve-version.outputs.version_suffix }}
run: |
if ($env:VERSION_SUFFIX) {
./build.ps1 Test --VersionSuffix "$env:VERSION_SUFFIX"
} else {
./build.ps1 Test
}
- name: Generate coverage report
if: matrix.runtime == 'linux-x64'
shell: bash
run: ./build.sh CoverageReport --skip Test
- name: Upload coverage report
if: matrix.runtime == 'linux-x64'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: coverage-report
path: artifacts/test-results/coverage-report/**
if-no-files-found: warn
- name: Pack
if: matrix.pack
shell: bash
run: ./build.sh Pack --skip Build Test
- name: Generate release manifest
if: needs.resolve-version.outputs.is_release == 'true' && matrix.pack
shell: bash
run: ./build.sh GenerateReleaseManifest --skip Build Test Pack
- name: Validate package versions
if: needs.resolve-version.outputs.is_release == 'true' && matrix.pack
shell: bash
run: ./build.sh ValidatePackageVersions --skip Build Test Pack
- name: Publish app
if: needs.resolve-version.outputs.is_release == 'true' && matrix.publish && runner.os != 'Windows'
shell: bash
run: ./build.sh Publish --Runtime "${{ matrix.runtime }}"
- name: Publish app (Windows)
if: needs.resolve-version.outputs.is_release == 'true' && matrix.publish && runner.os == 'Windows'
shell: pwsh
run: ./build.ps1 Publish --Runtime "${{ matrix.runtime }}"
- name: Package app
if: needs.resolve-version.outputs.is_release == 'true' && matrix.publish && runner.os != 'Windows'
shell: bash
run: ./build.sh PackageApp --Runtime "${{ matrix.runtime }}" --skip Publish
- name: Package app (Windows)
if: needs.resolve-version.outputs.is_release == 'true' && matrix.publish && runner.os == 'Windows'
shell: pwsh
run: ./build.ps1 PackageApp --Runtime "${{ matrix.runtime }}" --skip Publish
- name: Upload test results
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-results-${{ matrix.runtime }}
path: artifacts/test-results/**
if-no-files-found: warn
- name: Report test results
if: always() && !cancelled()
uses: dorny/test-reporter@3d76b34a4535afbd0600d347b09a6ee5deb3ed7f # v2.6.0
with:
name: Tests (${{ matrix.runtime }})
path: artifacts/test-results/**/*.trx
reporter: dotnet-trx
- name: Upload packages
if: needs.resolve-version.outputs.is_release == 'true' && matrix.pack
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: packages
path: artifacts/packages/**
if-no-files-found: error
- name: Upload installer artifacts
if: needs.resolve-version.outputs.is_release == 'true' && matrix.publish
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: installer-${{ matrix.runtime }}
path: artifacts/installers/**
if-no-files-found: error
build-docs:
name: Build Documentation
needs: resolve-version
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has_docs: ${{ steps.check.outputs.has_docs }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-version.outputs.sha }}
- name: Check for documentation source
id: check
shell: bash
run: |
if [ -f "docs/package.json" ]; then
if [ ! -f "docs/package-lock.json" ]; then
echo "::error::docs/package.json exists but docs/package-lock.json is missing. Run 'npm install' in docs/ and commit the lock file."
exit 1
fi
echo "has_docs=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::No docs/package.json found. Skipping documentation build."
echo "has_docs=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
if: steps.check.outputs.has_docs == 'true'
with:
node-version: 22
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
if: steps.check.outputs.has_docs == 'true'
run: npm ci
working-directory: docs
- name: Build documentation
if: steps.check.outputs.has_docs == 'true'
run: npx vitepress build
working-directory: docs
- name: Upload docs artifact
if: steps.check.outputs.has_docs == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docs-site
path: docs/.vitepress/dist
if-no-files-found: error
release:
name: Release
needs: [resolve-version, build-and-test]
if: needs.resolve-version.outputs.is_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: release
permissions:
contents: write
attestations: write
id-token: write
concurrency:
group: release-tag-${{ needs.resolve-version.outputs.tag }}
cancel-in-progress: false
env:
HAS_NUGET_KEY: ${{ secrets.NUGET_API_KEY != '' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-version.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
global-json-file: global.json
- name: Download packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packages
path: artifacts/packages
- name: Download installers
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts/installers
pattern: installer-*
merge-multiple: true
- name: Mark build script executable
run: chmod +x build.sh
- name: Verify release manifest
shell: bash
run: ./build.sh ValidateReleaseManifest
# NuGet Publishing: prefers Trusted Publishing (OIDC, no secrets) over API key.
# To use Trusted Publishing: configure a policy at nuget.org → Manage Packages → Trusted Publishers.
# Falls back to NUGET_API_KEY secret. If neither is configured, push is skipped.
- name: NuGet login (Trusted Publishing)
if: env.HAS_NUGET_KEY != 'true'
id: nuget-login
uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1.1.0
with:
user: ${{ vars.NUGET_USER || github.repository_owner }}
continue-on-error: true
- name: Push to NuGet.org
if: env.HAS_NUGET_KEY == 'true' || steps.nuget-login.outcome == 'success'
shell: bash
env:
API_KEY: ${{ env.HAS_NUGET_KEY == 'true' && secrets.NUGET_API_KEY || steps.nuget-login.outputs.NUGET_API_KEY }}
run: |
shopt -s nullglob
PACKAGES=(artifacts/packages/*.nupkg)
if [ ${#PACKAGES[@]} -eq 0 ]; then
echo "::error::No .nupkg files found"
exit 1
fi
for pkg in "${PACKAGES[@]}"; do
echo "Pushing $pkg"
dotnet nuget push "$pkg" \
--api-key "$API_KEY" \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate
done
echo "All packages pushed to NuGet.org"
- name: NuGet push skipped
if: env.HAS_NUGET_KEY != 'true' && steps.nuget-login.outcome != 'success'
shell: bash
run: echo "::warning::No NUGET_API_KEY secret and Trusted Publishing not configured. Skipping NuGet push."
- name: Generate SBOM
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
with:
artifact-name: sbom-spdx.json
output-file: sbom-spdx.json
format: spdx-json
- name: Attest build provenance
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/packages/*.nupkg
artifacts/installers/*.zip
- name: Attest SBOM
if: hashFiles('sbom-spdx.json') != ''
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-path: |
artifacts/packages/*.nupkg
artifacts/installers/*.zip
sbom-path: sbom-spdx.json
- name: Create and push tag
shell: bash
run: |
TAG="${{ needs.resolve-version.outputs.tag }}"
SHA="${{ needs.resolve-version.outputs.sha }}"
git fetch --tags --force
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "::error::Tag $TAG already exists locally"
exit 1
fi
if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then
echo "::error::Tag $TAG already exists on remote"
exit 1
fi
git tag "$TAG" "$SHA"
git push origin "$TAG"
echo "Created and pushed tag $TAG at $SHA"
- name: Create GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.resolve-version.outputs.tag }}"
RELEASE_NAME="dotnet.CI.template $TAG"
shopt -s nullglob
RELEASE_FILES=(artifacts/installers/*.zip)
if [ ${#RELEASE_FILES[@]} -eq 0 ]; then
echo "::error::No installer zip files found"
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::Release already exists for tag $TAG"
exit 1
fi
SBOM_FILE=""
if [ -f "sbom-spdx.json" ]; then
SBOM_FILE="sbom-spdx.json"
fi
gh release create "$TAG" "${RELEASE_FILES[@]}" $SBOM_FILE \
--title "$RELEASE_NAME" \
--generate-notes
echo "Created release $TAG with ${#RELEASE_FILES[@]} assets"
deploy-docs:
name: Deploy Documentation
needs: [resolve-version, release, build-docs]
if: needs.resolve-version.outputs.is_release == 'true' && needs.build-docs.outputs.has_docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check if deployment is possible
id: check
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
HAS_PAGES="$(gh api repos/${{ github.repository }} --jq '.has_pages' 2>/dev/null || echo "false")"
if [ "$HAS_PAGES" != "true" ]; then
echo "::notice::GitHub Pages is not enabled. Enable Settings > Pages > Source: GitHub Actions."
echo "should_deploy=false" >> "$GITHUB_OUTPUT"
else
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
fi
- name: Download docs artifact
if: steps.check.outputs.should_deploy == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs-site
path: docs-site
- name: Upload pages artifact
if: steps.check.outputs.should_deploy == 'true'
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: docs-site
- name: Deploy to GitHub Pages
if: steps.check.outputs.should_deploy == 'true'
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5