Skip to content
Merged
Show file tree
Hide file tree
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
73 changes: 73 additions & 0 deletions .github/actions/native-tool-smoke/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Native tool smoke test
description: >-
Runs .github/scripts/native-tool-smoke.sh for one runtime identifier: builds the solution, packs the
Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages, installs both tools from them, and runs
tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit. linux-musl-* RIDs run
inside an Alpine .NET SDK container. Expects the repository to be checked out.

inputs:
rid:
description: Runtime identifier to pack. The runner must match its OS and architecture (Linux for linux-musl-*).
required: true
version:
description: Package version, passed as -p:Version to every build and pack.
required: true

runs:
using: composite
steps:
- name: Setup .NET
if: ${{ !startsWith(inputs.rid, 'linux-musl-') }}
uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
cache: true
cache-dependency-path: Directory.Packages.props

- name: Install Native AOT prerequisites
if: ${{ runner.os == 'Linux' && !startsWith(inputs.rid, 'linux-musl-') }}
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends clang zlib1g-dev

- name: Stamp the MCP server.json version
shell: bash
env:
VERSION: ${{ inputs.version }}
run: bash .github/scripts/set-server-json-version.sh "$VERSION"

- name: Pack and smoke test
if: ${{ !startsWith(inputs.rid, 'linux-musl-') }}
shell: bash
env:
RID: ${{ inputs.rid }}
VERSION: ${{ inputs.version }}
run: bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION"

# musl binaries must be linked on a musl system. The container runs as root, so the files it
# writes are handed back to the runner user even when the script fails.
- name: Pack and smoke test in Alpine
if: ${{ startsWith(inputs.rid, 'linux-musl-') }}
shell: bash
env:
RID: ${{ inputs.rid }}
VERSION: ${{ inputs.version }}
run: |
docker run --rm --volume "$PWD:/src" --workdir /src \
--env RID --env VERSION --env GITHUB_ACTIONS --env DOTNET_CLI_TELEMETRY_OPTOUT=1 \
mcr.microsoft.com/dotnet/sdk:10.0-alpine \
sh -c 'apk add --no-cache bash clang build-base zlib-dev &&
status=0 &&
{ bash .github/scripts/native-tool-smoke.sh "$RID" "$VERSION" || status=$?; } &&
chown -R "$(stat -c %u:%g /src)" /src &&
exit "$status"'

- name: Upload smoke test results
if: failure()
uses: actions/upload-artifact@v7
with:
name: smoke-results-${{ inputs.rid }}
path: artifacts/smoke-results
if-no-files-found: ignore
retention-days: 14
92 changes: 92 additions & 0 deletions .github/scripts/native-tool-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Builds the solution, packs the Native AOT ProjGraph.Cli and ProjGraph.Mcp tool packages for one
# runtime identifier, installs both tools from those packages the way users do, and runs
# tests/ProjGraph.Tests.Smoke.Aot against the JIT build of the same commit.
# Usage: native-tool-smoke.sh <rid> <version>
# Run it from the repository root, on an OS that matches the RID (Alpine for linux-musl-*).
# Leaves the RID-specific packages in artifacts/packages, the pointer packages in artifacts/pointers,
# and the test results in artifacts/smoke-results.
set -euo pipefail

if [ "$#" -ne 2 ]; then
echo "usage: $0 <rid> <version>" >&2
exit 2
fi

rid=$1
version=$2

dotnet restore ProjGraph.slnx
dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version="$version"

# The pointer packages are only needed to install the tools below; a release publishes the ones
# from the pack-portable job.
dotnet pack src/ProjGraph.Cli --no-build --configuration Release -p:Version="$version" --output artifacts/pointers
dotnet pack src/ProjGraph.Mcp --no-build --configuration Release -p:Version="$version" --output artifacts/pointers

# One native pack at a time: each ILC run takes several GB of memory.
dotnet pack src/ProjGraph.Cli --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages
dotnet pack src/ProjGraph.Mcp --configuration Release --runtime "$rid" -p:Version="$version" --output artifacts/packages

# The tools resolve from the local packages: no ProjGraph package with this version is on NuGet.org
# before a release pushes it, and a fresh package folder keeps a cached copy of the same version from
# being installed instead. NuGet.org is listed for the SDK itself: on macOS arm64, `dotnet tool install`
# downloads microsoft.netcore.app.host.osx-x64 for any RID-specific package (its tools/any folder reads
# as a pre-net6 framework), even though a native tool gets a symlink rather than an apphost shim.
cat > artifacts/smoke-nuget.config <<'XML'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="packages" value="packages" />
<add key="pointers" value="pointers" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
XML
rm -rf artifacts/tools artifacts/tool-install-packages
for tool in Cli Mcp; do
NUGET_PACKAGES="$PWD/artifacts/tool-install-packages" dotnet tool install "ProjGraph.$tool" --version "$version" \
--configfile artifacts/smoke-nuget.config --tool-path "artifacts/tools/$tool"
done

# Prints the one file named $2 inside the package store of the tool path $1, and fails otherwise.
store_executable() {
found=''
for candidate in "$1"/.store/*/*/*/*/tools/*/*/"$2"; do
if [ -f "$candidate" ]; then
if [ -n "$found" ]; then
echo "::error::More than one $2 in $1/.store." >&2
exit 1
fi
found=$candidate
fi
done
if [ -z "$found" ]; then
echo "::error::No $2 in $1/.store." >&2
exit 1
fi
printf '%s\n' "$found"
}

cli_native=artifacts/tools/Cli/projgraph
mcp_native=artifacts/tools/Mcp/ProjGraph.Mcp
mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.dll
if [ "${RUNNER_OS:-}" = 'Windows' ]; then
# On Windows the tool path holds .cmd launchers, not links, so the smoke suite runs the executables
# the packages installed into the tool store.
cli_native=$(store_executable artifacts/tools/Cli ProjGraph.Cli.exe)
mcp_native=$(store_executable artifacts/tools/Mcp ProjGraph.Mcp.exe)
# The MCP SDK starts stdio servers through `cmd.exe /c` on Windows, which is fragile with a quoted
# dotnet host path. The JIT apphost runs the same build without one.
mcp_reference=src/ProjGraph.Mcp/bin/Release/net10.0/ProjGraph.Mcp.exe
fi

PROJGRAPH_SMOKE_REQUIRED=true \
PROJGRAPH_SMOKE_CLI_NATIVE="$cli_native" \
PROJGRAPH_SMOKE_CLI_REFERENCE=src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll \
PROJGRAPH_SMOKE_MCP_NATIVE="$mcp_native" \
PROJGRAPH_SMOKE_MCP_REFERENCE="$mcp_reference" \
dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release \
--logger "console;verbosity=normal" --logger "trx;LogFileName=smoke.trx" \
--results-directory artifacts/smoke-results
48 changes: 48 additions & 0 deletions .github/scripts/pointer-package-ids.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Prints the ID of every package the pointer packages in a directory list, one per line, sorted and
# unique. A pointer package lists them in tools/any/any/DotnetToolSettings.xml, as one
# <RuntimeIdentifierPackage RuntimeIdentifier="<rid>" Id="<id>" /> element per runtime identifier.
# Usage: pointer-package-ids.sh <pointers-directory>
# Keep it Bash 3.2 compatible, like the other packaging scripts.
set -euo pipefail

if [ "$#" -ne 1 ]; then
echo "usage: $0 <pointers-directory>" >&2
exit 2
fi

directory=$1
settings_path=tools/any/any/DotnetToolSettings.xml
ids=""
packages=0

for package in "$directory"/*.nupkg; do
if [ ! -e "$package" ]; then
continue
fi
packages=$((packages + 1))

if ! settings=$(unzip -p "$package" "$settings_path" 2> /dev/null) || [ -z "$settings" ]; then
echo "::error::$package has no $settings_path." >&2
exit 1
fi

# Joining the lines first keeps an element that spans several lines matchable.
package_ids=$(printf '%s\n' "$settings" | tr -s '[:space:]' ' ' |
grep -o '<RuntimeIdentifierPackage [^>]*>' |
grep -o '[[:space:]]Id="[^"]*"' |
sed 's/^[[:space:]]Id="//; s/"$//' || true)
if [ -z "$package_ids" ]; then
echo "::error::$package lists no RuntimeIdentifierPackage IDs in $settings_path." >&2
exit 1
fi

ids="$ids$package_ids"$'\n'
done

if [ "$packages" -eq 0 ]; then
echo "::error::$directory holds no .nupkg files." >&2
exit 1
fi

printf '%s' "$ids" | LC_ALL=C sort -u
29 changes: 29 additions & 0 deletions .github/scripts/set-server-json-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Stamps <version> into every "version" field in src/ProjGraph.Mcp/.mcp/server.json: the top-level
# server version and the packaged ProjGraph.Mcp version. Every ProjGraph.Mcp* package embeds this
# file, so it must run before packing, not only once in publish.
# Usage: set-server-json-version.sh <version>
# Keep it Bash 3.2 compatible: it also runs on macOS (osx-arm64 pack job).
set -euo pipefail

if [ "$#" -ne 1 ]; then
echo "usage: $0 <version>" >&2
exit 2
fi

version=$1
file=src/ProjGraph.Mcp/.mcp/server.json

# -i.bak works on both GNU sed (Linux, Windows Git Bash) and BSD sed (macOS); [^\"]* replaces
# GNU-only \+ so the pattern matches on both too.
sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/g" "$file" && rm "$file.bak"

echo "Set server.json version to $version:"
# grep exits 1 when nothing matches; `|| true` on both lets the error below report the zero count.
grep '"version"' "$file" || true

count=$(grep -c "\"version\": \"$version\"" "$file" || true)
if [ "$count" -ne 2 ]; then
echo "::error::Expected 2 occurrences of \"version\": \"$version\" in $file, found $count." >&2
exit 1
fi
56 changes: 56 additions & 0 deletions .github/scripts/verify-packages.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Fails unless a directory holds exactly the .nupkg files of the named package sets.
# Usage: verify-packages.sh <directory> <version> <set>...
# Sets: libraries, pointers, any, win-x64, linux-x64, linux-arm64, linux-musl-x64, linux-musl-arm64, osx-arm64.
# Keep it Bash 3.2 compatible: the osx-arm64 pack job runs it on macOS.
set -euo pipefail

if [ "$#" -lt 3 ]; then
echo "usage: $0 <directory> <version> <set>..." >&2
exit 2
fi

directory=$1
version=$2
shift 2

expected=""
add() {
expected="$expected$1.$version.nupkg"$'\n'
}

for set in "$@"; do
case "$set" in
libraries)
for id in ProjGraph.Core ProjGraph.Lib ProjGraph.Lib.Core ProjGraph.Lib.Dependencies \
ProjGraph.Lib.ClassDiagram ProjGraph.Lib.EntityFramework; do
add "$id"
done
;;
pointers)
add ProjGraph.Cli
add ProjGraph.Mcp
;;
any | win-x64 | linux-x64 | linux-arm64 | linux-musl-x64 | linux-musl-arm64 | osx-arm64)
add "ProjGraph.Cli.$set"
add "ProjGraph.Mcp.$set"
;;
*)
echo "::error::Unknown package set '$set'." >&2
exit 2
;;
esac
done

expected=$(printf '%s' "$expected" | sort)
actual=$(for package in "$directory"/*.nupkg; do
if [ -e "$package" ]; then basename "$package"; fi
done | sort)

if [ "$actual" != "$expected" ]; then
echo "::error::$directory doesn't hold exactly the packages for: $*" >&2
diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true
exit 1
fi

echo "$directory holds the $(printf '%s\n' "$actual" | wc -l | tr -d ' ') expected packages."
44 changes: 44 additions & 0 deletions .github/scripts/wait-for-nuget.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Waits until NuGet.org's flat container lists a version for every package ID, so a pointer package
# is pushed only once every package it points to can be installed.
# Usage: wait-for-nuget.sh <version> <package-id>...
# NUGET_WAIT_TIMEOUT_SECONDS (default 1800) and NUGET_WAIT_INTERVAL_SECONDS (default 30) set the timing.
set -euo pipefail

if [ "$#" -lt 2 ]; then
echo "usage: $0 <version> <package-id>..." >&2
exit 2
fi

version=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
shift
timeout=${NUGET_WAIT_TIMEOUT_SECONDS:-1800}
interval=${NUGET_WAIT_INTERVAL_SECONDS:-30}
deadline=$((SECONDS + timeout))
pending=("$@")

while :; do
waiting=()
for id in "${pending[@]}"; do
lower_id=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]')
if curl --silent --fail --max-time 30 "https://api.nuget.org/v3-flatcontainer/$lower_id/index.json" |
jq --exit-status --arg version "$version" '.versions | index($version) != null' > /dev/null; then
echo "$id $version is listed on NuGet.org."
else
waiting+=("$id")
fi
done

if [ "${#waiting[@]}" -eq 0 ]; then
exit 0
fi

if [ "$SECONDS" -ge "$deadline" ]; then
echo "::error::Timed out after ${timeout}s waiting for $version of: ${waiting[*]}" >&2
exit 1
fi

echo "Waiting ${interval}s for: ${waiting[*]}"
sleep "$interval"
pending=("${waiting[@]}")
done
38 changes: 5 additions & 33 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,37 +64,9 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v6
# The same pack → install → smoke path that pack.yml runs for every release platform.
- name: Pack and smoke test the native tools
uses: ./.github/actions/native-tool-smoke
with:
global-json-file: global.json
cache: true
cache-dependency-path: Directory.Packages.props

- name: Install Native AOT prerequisites
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends clang zlib1g-dev

- name: Restore dependencies
run: dotnet restore ProjGraph.slnx

- name: Build
run: dotnet build ProjGraph.slnx --no-restore --configuration Release

# --artifacts-path keeps the native publish out of src/*/bin and obj. ProjGraph.Mcp is
# self-contained, so an in-place publish would overwrite the JIT reference build's apphost
# and runtimeconfig.json in bin/Release/net10.0/linux-x64.
- name: Publish native executables
run: |
dotnet publish src/ProjGraph.Cli --configuration Release --runtime linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build --output artifacts/native/cli
dotnet publish src/ProjGraph.Mcp --configuration Release --runtime linux-x64 -p:PublishAot=true --artifacts-path artifacts/aot-build --output artifacts/native/mcp

- name: Smoke test native executables
env:
PROJGRAPH_SMOKE_REQUIRED: 'true'
PROJGRAPH_SMOKE_CLI_NATIVE: artifacts/native/cli/ProjGraph.Cli
PROJGRAPH_SMOKE_CLI_REFERENCE: src/ProjGraph.Cli/bin/Release/net10.0/ProjGraph.Cli.dll
PROJGRAPH_SMOKE_MCP_NATIVE: artifacts/native/mcp/ProjGraph.Mcp
PROJGRAPH_SMOKE_MCP_REFERENCE: src/ProjGraph.Mcp/bin/Release/net10.0/linux-x64/ProjGraph.Mcp.dll
run: dotnet test tests/ProjGraph.Tests.Smoke.Aot --no-build --configuration Release --logger "console;verbosity=normal"
rid: linux-x64
version: 0.0.0-ci.${{ github.run_number }}
Loading
Loading