Skip to content

Commit 909a64b

Browse files
committed
feat(ci-cd): integrate BadgeSmith API for self-hosted test badges
- Replace Gist-based badge system with direct BadgeSmith API integration - Add HMAC authentication using TESTDATASECRET organization secret - Use dorny/test-reporter to extract real test counts from TRX files - Update badge URLs to point to api.localstackfor.net endpoints - Configure proper error handling with continue-on-error for badge updates - Fix .gitignore to allow Features/TestResults/ source folder Self-hosting achievement: BadgeSmith now badges itself using its own API! API endpoints that will be updated on every CI run: - Badge: https://api.localstackfor.net/badges/tests/linux/localstack-dotnet/badge-smith/master - Redirect: https://api.localstackfor.net/redirect/test-results/linux/localstack-dotnet/badge-smith/master This validates the complete HMAC authentication and test result storage pipeline in production.
1 parent ddcba3e commit 909a64b

6 files changed

Lines changed: 286 additions & 2 deletions

File tree

.github/workflows/ci-cd.yml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,41 @@ jobs:
4848
- name: Build solution
4949
run: dotnet build --no-restore --configuration Release
5050

51-
- name: Run unit tests
52-
run: dotnet test --no-build --configuration Release --verbosity normal
51+
- name: Run tests with BadgeSmith integration
52+
uses: ./.github/workflows/run-dotnet-tests
53+
with:
54+
project-path: 'tests/BadgeSmith.Api.Tests/BadgeSmith.Api.Tests.csproj'
55+
results-dir: 'test-results'
56+
configuration: 'Release'
57+
58+
- name: Publish Test Results
59+
id: test-results
60+
uses: dorny/test-reporter@v1
61+
if: success() || failure()
62+
with:
63+
name: 'Test Results (Linux)'
64+
path: 'test-results/**/*.trx'
65+
reporter: 'dotnet-trx'
66+
path-replace-backslashes: true
67+
fail-on-error: true
68+
max-annotations: 50
69+
70+
- name: Update test badge via BadgeSmith API
71+
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/master'
72+
continue-on-error: true
73+
uses: ./.github/workflows/update-test-badge
74+
with:
75+
platform: 'Linux'
76+
test_passed: '${{ steps.test-results.outputs.passed || 0 }}'
77+
test_failed: '${{ steps.test-results.outputs.failed || 0 }}'
78+
test_skipped: '${{ steps.test-results.outputs.skipped || 0 }}'
79+
test_url_html: ${{ steps.test-results.outputs.url_html || '' }}
80+
commit_sha: '${{ github.sha }}'
81+
run_id: '${{ github.run_id }}'
82+
repository: '${{ github.repository }}'
83+
server_url: '${{ github.server_url }}'
84+
api_domain: 'api.localstackfor.net'
85+
hmac_secret: '${{ secrets.TESTDATASECRET }}'
5386

5487
continuous-deployment:
5588
needs: build-and-test
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: "Run .NET tests (multi-TFM)"
2+
description: "Build once, test each target framework, unique TRX per framework"
3+
inputs:
4+
project-path:
5+
description: "Path to the test .csproj file"
6+
required: true
7+
results-dir:
8+
description: "Directory for test result artefacts"
9+
required: true
10+
configuration:
11+
description: "Build configuration"
12+
default: "Release"
13+
runs:
14+
using: "composite"
15+
steps:
16+
# Windows step -----------------------------------------------------------
17+
- if: runner.os == 'Windows'
18+
shell: pwsh
19+
run: |
20+
& "${{ github.action_path }}\run-win.ps1" `
21+
-ProjectPath "${{ inputs.project-path }}" `
22+
-ResultsDir "${{ inputs.results-dir }}" `
23+
-Configuration "${{ inputs.configuration }}"
24+
25+
# Linux/macOS step -------------------------------------------------------
26+
- if: runner.os != 'Windows'
27+
shell: bash
28+
run: |
29+
"${{ github.action_path }}/run-unix.sh" \
30+
"${{ inputs.project-path }}" \
31+
"${{ inputs.results-dir }}" \
32+
"${{ inputs.configuration }}"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
PROJECT_PATH="$1"
5+
RESULTS_DIR="$2"
6+
CONFIGURATION="${3:-Release}"
7+
8+
# 1️⃣ Get the multi-TFM list first …
9+
TFM_RAW=$(dotnet msbuild "$PROJECT_PATH" \
10+
-getProperty:TargetFrameworks -nologo -v:q)
11+
12+
# 2️⃣ … fallback to single-TFM if empty
13+
if [[ -z "$TFM_RAW" ]]; then
14+
TFM_RAW=$(dotnet msbuild "$PROJECT_PATH" \
15+
-getProperty:TargetFramework -nologo -v:q)
16+
fi
17+
18+
if [[ -z "$TFM_RAW" ]]; then
19+
echo "Unable to determine target frameworks for $PROJECT_PATH" >&2
20+
exit 1
21+
fi
22+
23+
# Normalise newlines → semicolons, then explode into an array
24+
IFS=';' read -ra TFMS <<< "$(echo "$TFM_RAW" | tr -d '\r\n')"
25+
26+
echo "📋 Target frameworks: ${TFMS[*]}"
27+
28+
for tfm in "${TFMS[@]}"; do
29+
tfm="$(echo "$tfm" | xargs)" # trim
30+
[[ -z "$tfm" ]] && continue
31+
32+
echo "🧪 $tfm ..."
33+
dotnet test "$PROJECT_PATH" -c "$CONFIGURATION" -f "$tfm" --no-build \
34+
--logger "trx;LogFileName=testResults-$tfm.trx" \
35+
--results-directory "$RESULTS_DIR"
36+
done
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
Param(
2+
[string]$ProjectPath,
3+
[string]$ResultsDir,
4+
[string]$Configuration = "Release"
5+
)
6+
7+
$ErrorActionPreference = 'Stop'
8+
9+
# 1️⃣ Multi-target first
10+
$tfmRaw = dotnet msbuild $ProjectPath `
11+
-getProperty:TargetFrameworks -nologo -v:q
12+
13+
# 2️⃣ Fallback to single-target
14+
if ([string]::IsNullOrWhiteSpace($tfmRaw)) {
15+
$tfmRaw = dotnet msbuild $ProjectPath `
16+
-getProperty:TargetFramework -nologo -v:q
17+
}
18+
19+
if ([string]::IsNullOrWhiteSpace($tfmRaw)) {
20+
throw "Unable to determine target frameworks for $ProjectPath"
21+
}
22+
23+
$tfms = $tfmRaw -split ';' |
24+
ForEach-Object { $_.Trim() } |
25+
Where-Object { $_ } |
26+
Select-Object -Unique
27+
28+
Write-Host "📋 Target frameworks: $($tfms -join ', ')"
29+
30+
foreach ($tfm in $tfms) {
31+
Write-Host "🧪 $tfm ..."
32+
dotnet test $ProjectPath -c $Configuration -f $tfm --no-build `
33+
--logger "trx;LogFileName=testResults-$tfm.trx" `
34+
--results-directory $ResultsDir
35+
}

.github/workflows/update-test-badge/README.md

Whitespace-only changes.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
name: 'Update Test Results Badge'
2+
description: 'Posts test results to BadgeSmith API with HMAC authentication'
3+
author: 'LocalStack .NET Team'
4+
5+
inputs:
6+
platform:
7+
description: 'Platform name (Linux, Windows, macOS)'
8+
required: true
9+
test_passed:
10+
description: 'Number of passed tests'
11+
required: true
12+
test_failed:
13+
description: 'Number of failed tests'
14+
required: true
15+
test_skipped:
16+
description: 'Number of skipped tests'
17+
required: true
18+
test_url_html:
19+
description: 'URL to test results page'
20+
required: false
21+
default: ''
22+
commit_sha:
23+
description: 'Git commit SHA'
24+
required: true
25+
run_id:
26+
description: 'GitHub Actions run ID'
27+
required: true
28+
repository:
29+
description: 'Repository in owner/repo format'
30+
required: true
31+
server_url:
32+
description: 'GitHub server URL'
33+
required: true
34+
api_domain:
35+
description: 'BadgeSmith API domain'
36+
required: false
37+
default: 'api.localstackfor.net'
38+
hmac_secret:
39+
description: 'HMAC secret for BadgeSmith authentication'
40+
required: true
41+
42+
runs:
43+
using: 'composite'
44+
steps:
45+
- name: 'Post Test Results to BadgeSmith API'
46+
shell: bash
47+
run: |
48+
# Extract owner and repo from repository input
49+
IFS='/' read -ra REPO_PARTS <<< "${{ inputs.repository }}"
50+
OWNER="${REPO_PARTS[0]}"
51+
REPO="${REPO_PARTS[1]}"
52+
53+
# Normalize platform name
54+
PLATFORM_LOWER=$(echo "${{ inputs.platform }}" | tr '[:upper:]' '[:lower:]')
55+
56+
# Extract branch from GitHub context
57+
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
58+
BRANCH="${{ github.head_ref }}"
59+
else
60+
BRANCH="${{ github.ref_name }}"
61+
fi
62+
63+
# Calculate totals
64+
TOTAL=$((${{ inputs.test_passed }} + ${{ inputs.test_failed }} + ${{ inputs.test_skipped }}))
65+
66+
# Create JSON payload for BadgeSmith API
67+
cat > test-results.json << EOF
68+
{
69+
"platform": "${{ inputs.platform }}",
70+
"passed": ${{ inputs.test_passed }},
71+
"failed": ${{ inputs.test_failed }},
72+
"skipped": ${{ inputs.test_skipped }},
73+
"total": ${TOTAL},
74+
"url_html": "${{ inputs.test_url_html }}",
75+
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
76+
"commit": "${{ inputs.commit_sha }}",
77+
"run_id": "${{ inputs.run_id }}",
78+
"workflow_run_url": "${{ inputs.server_url }}/${{ inputs.repository }}/actions/runs/${{ inputs.run_id }}"
79+
}
80+
EOF
81+
82+
echo "📊 Generated test results JSON for ${{ inputs.platform }}:"
83+
cat test-results.json | jq '.' 2>/dev/null || cat test-results.json
84+
85+
# Prepare HMAC authentication
86+
PAYLOAD_JSON=$(cat test-results.json)
87+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
88+
NONCE=$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]')
89+
90+
# Compute HMAC-SHA256 signature
91+
SIGNATURE="sha256=$(echo -n "$PAYLOAD_JSON" | openssl dgst -sha256 -hmac "${{ inputs.hmac_secret }}" -binary | xxd -p -c 256)"
92+
93+
# Build BadgeSmith API URL
94+
API_URL="https://${{ inputs.api_domain }}/tests/results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
95+
96+
echo "🚀 Posting to BadgeSmith API: ${API_URL}"
97+
98+
# Send request to BadgeSmith API
99+
HTTP_CODE=$(curl -s -w "%{http_code}" -o response.tmp \
100+
-X POST "${API_URL}" \
101+
-H "Content-Type: application/json" \
102+
-H "X-Signature: ${SIGNATURE}" \
103+
-H "X-Timestamp: ${TIMESTAMP}" \
104+
-H "X-Nonce: ${NONCE}" \
105+
-d "$PAYLOAD_JSON")
106+
107+
RESPONSE_BODY=$(cat response.tmp)
108+
rm -f response.tmp
109+
110+
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
111+
echo "✅ Successfully posted test results to BadgeSmith API (HTTP $HTTP_CODE)"
112+
echo "Response:"
113+
echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
114+
else
115+
echo "⚠️ Failed to post test results to BadgeSmith API (HTTP $HTTP_CODE)"
116+
echo "Response:"
117+
echo "$RESPONSE_BODY" | jq . 2>/dev/null || echo "$RESPONSE_BODY"
118+
# Don't fail the build for badge update failures
119+
fi
120+
121+
- name: 'Display Badge URLs'
122+
shell: bash
123+
run: |
124+
# Extract owner and repo from repository input
125+
IFS='/' read -ra REPO_PARTS <<< "${{ inputs.repository }}"
126+
OWNER="${REPO_PARTS[0]}"
127+
REPO="${REPO_PARTS[1]}"
128+
129+
PLATFORM_LOWER=$(echo "${{ inputs.platform }}" | tr '[:upper:]' '[:lower:]')
130+
131+
# Extract branch from GitHub context
132+
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
133+
BRANCH="${{ github.head_ref }}"
134+
else
135+
BRANCH="${{ github.ref_name }}"
136+
fi
137+
138+
echo "🎯 BadgeSmith URLs for ${{ inputs.platform }}:"
139+
echo ""
140+
echo "**${{ inputs.platform }} Badge:**"
141+
echo "[![Test Results (${{ inputs.platform }})](https://${{ inputs.api_domain }}/badges/tests/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH})](https://${{ inputs.api_domain }}/redirect/test-results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH})"
142+
echo ""
143+
echo "**Raw URLs:**"
144+
echo "- Badge: https://${{ inputs.api_domain }}/badges/tests/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
145+
echo "- Redirect: https://${{ inputs.api_domain }}/redirect/test-results/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}"
146+
echo ""
147+
echo "**API Test:**"
148+
echo "curl \"https://${{ inputs.api_domain }}/badges/tests/${PLATFORM_LOWER}/${OWNER}/${REPO}/${BRANCH}\""

0 commit comments

Comments
 (0)