Skip to content

Deep copy an object: retarget net10.0, drop AutoMapper, fix the benchmark #305

Deep copy an object: retarget net10.0, drop AutoMapper, fix the benchmark

Deep copy an object: retarget net10.0, drop AutoMapper, fix the benchmark #305

Workflow file for this run

# "CI result" (the last job) is a required status check on main. The workflow
# builds and tests exactly the article folder(s) a PR touches -- nothing else --
# and checks that the PR carries no AI attribution (see no-ai-attribution).
# A folder is classified once:
# "dotnet" when it holds a .sln, "npm" when it holds a package.json at its root
# or in an immediate subfolder (the layout an Angular series folder uses).
# Folders in .github/ci-skip-folders.txt are filtered out before the matrix is
# built, so they never show up as a run, passing or failing (edit that file, not
# this one, to change what's excluded).
name: PR Build
on:
pull_request:
branches: [main]
# "edited" re-runs the attribution check when the title or description changes.
types: [opened, synchronize, reopened, edited]
# Cancel superseded runs on the same PR when new commits land.
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changes:
name: Detect changed article folders
runs-on: ubuntu-latest
outputs:
folders: ${{ steps.compute.outputs.folders }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute changed, buildable folders
id: compute
shell: bash
run: |
set -euo pipefail
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
SKIP_FILE=".github/ci-skip-folders.txt"
# Skip list: strip comments and blank lines.
mapfile -t skip_list < <(
sed 's/\r$//' "$SKIP_FILE" | grep -vE '^\s*#' | grep -vE '^\s*$'
)
is_skipped() {
local f="$1"
for s in "${skip_list[@]:-}"; do
[ "$f" = "$s" ] && return 0
done
return 1
}
# Reduce every changed path to its "<category>/<article>" prefix.
# Every article folder in this repo is exactly two levels deep.
mapfile -t folders < <(
git diff --name-only "$BASE_SHA" "$HEAD_SHA" \
| awk -F/ 'NF>=2 {print $1"/"$2}' \
| sort -u
)
entries="[]"
for f in "${folders[@]:-}"; do
[ -z "$f" ] && continue
if is_skipped "$f"; then
echo "::notice::Skipping '$f' -- listed in $SKIP_FILE."
continue
fi
# Classify the folder. A .sln makes it a .NET folder; otherwise a
# package.json at the folder root or one level down makes it an npm
# folder. Anything else has nothing to build.
kind=""
if compgen -G "$f"/*.sln > /dev/null 2>&1; then
kind="dotnet"
elif [ -f "$f/package.json" ] || compgen -G "$f"/*/package.json > /dev/null 2>&1; then
kind="npm"
fi
# Folder may have been deleted in this PR -- skip if nothing buildable remains at HEAD.
if [ -z "$kind" ]; then
echo "::notice::Skipping '$f' -- no .sln at HEAD (deleted or restructured)."
continue
fi
entries=$(jq -c --arg folder "$f" --arg kind "$kind" '. + [{folder: $folder, kind: $kind}]' <<<"$entries")
done
echo "folders=$entries" >> "$GITHUB_OUTPUT"
echo "Changed, buildable folders:"
echo "$entries" | jq .
# Public repo: commits and PRs go out under the Code Maze name only. Fails when a
# PR commit message, the PR title or the PR description carries an AI co-author
# trailer or a "generated with" footer. Squash merges build their message from
# these commits, so a clean PR also means a clean commit on main.
no-ai-attribution:
name: No AI attribution
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check commit messages, title and description
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
set -euo pipefail
pattern='co-authored-by:.*(claude|anthropic|copilot|chatgpt|openai)|generated (with|by) .{0,3}(claude|chatgpt|copilot)|noreply@anthropic\.com'
found=0
check() {
local where="$1" text="$2"
local hits
hits=$(printf '%s\n' "$text" | grep -Ein "$pattern" || true)
if [ -n "$hits" ]; then
echo "::error::AI attribution in $where:"
printf '%s\n' "$hits"
found=1
fi
}
check "PR title" "$PR_TITLE"
check "PR description" "$PR_BODY"
while IFS= read -r sha; do
check "commit ${sha:0:7}" "$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" --jq .commit.message)"
done < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" --jq '.[].sha')
if [ "$found" -ne 0 ]; then
echo "Remove the lines above (amend or reword the commits, edit the PR), then push again."
exit 1
fi
echo "No AI attribution found."
build-and-test:
name: Build & test (${{ matrix.folder }})
needs: detect-changes
if: needs.detect-changes.outputs.folders != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.detect-changes.outputs.folders) }}
steps:
- name: Checkout (sparse)
uses: actions/checkout@v4
with:
sparse-checkout: |
${{ matrix.folder }}
sparse-checkout-cone-mode: true
- name: Setup .NET SDKs
if: matrix.kind == 'dotnet'
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
7.0.x
8.0.x
9.0.x
10.0.x
- name: Build and test
if: matrix.kind == 'dotnet'
working-directory: ${{ matrix.folder }}
shell: bash
run: |
set -euo pipefail
sln=$(find . -maxdepth 1 -iname "*.sln" | head -n1)
if [ -z "$sln" ]; then
echo "::notice::No .sln found in ${{ matrix.folder }} -- skipping."
exit 0
fi
echo "Building $sln"
dotnet build "$sln" --configuration Release
echo "Testing $sln (no-op for folders with no test project)"
dotnet test "$sln" --configuration Release --no-build --filter "FullyQualifiedName!~Live"
- name: Setup Node
if: matrix.kind == 'npm'
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: ${{ matrix.folder }}/**/package-lock.json
- name: Build (npm)
if: matrix.kind == 'npm'
working-directory: ${{ matrix.folder }}
shell: bash
run: |
set -euo pipefail
# Every directory at depth 0 or 1 that holds a package.json is its own
# project: one folder may carry a single application or a set of them.
mapfile -t projects < <(
find . -maxdepth 2 -name package.json -not -path "*/node_modules/*" -printf '%h\n' \
| sort -u
)
if [ ${#projects[@]} -eq 0 ]; then
echo "::error::No package.json found in ${{ matrix.folder }}."
exit 1
fi
for p in "${projects[@]}"; do
echo "::group::${{ matrix.folder }}/${p#./} -- npm ci && npm run build"
(
cd "$p"
npm ci
npm run build
)
echo "::endgroup::"
done
# Stable, folder-independent name for branch protection to require -- the
# build-and-test job's displayed name varies with the matrix (one leg per
# changed folder), so it can't be set as a required check directly. This
# job always runs (even if earlier jobs are skipped) and only fails when a
# needed job actually failed or was cancelled; an empty build matrix (a PR
# touching no buildable folders) is a skip, not a failure, and passes here.
ci-result:
name: CI result
needs: [detect-changes, build-and-test, no-ai-attribution]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check required jobs
shell: bash
run: |
detect="${{ needs.detect-changes.result }}"
build="${{ needs.build-and-test.result }}"
attribution="${{ needs.no-ai-attribution.result }}"
echo "detect-changes: $detect"
echo "build-and-test: $build"
echo "no-ai-attribution: $attribution"
if [ "$detect" = "failure" ] || [ "$detect" = "cancelled" ] || [ "$build" = "failure" ] || [ "$build" = "cancelled" ]; then
echo "A required job failed or was cancelled."
exit 1
fi
# The attribution check never legitimately skips, so anything but success fails.
if [ "$attribution" != "success" ]; then
echo "The AI attribution check did not pass."
exit 1
fi
echo "All required jobs passed (or were skipped)."