Skip to content

Commit 701c8c1

Browse files
mackowskiCopilot
andauthored
feat(ci-cd): add comprehensive pull request workflow with multi-level testing (#3)
* feat(ci-cd): add comprehensive pull request workflow with multi-level testing Add GitHub Actions workflow for automated testing, code quality checks, and coverage reporting on pull requests. Includes comprehensive documentation for workflow structure, security practices, and troubleshooting. Changes: - Add .github/workflows/pull-request.yml with lint, unit, component, integration, and contract test jobs - Implement parallel test execution and coverage aggregation - Add automated PR status comments with test results and coverage - Pin all GitHub Actions to commit SHAs for security compliance - Add docs/ci-cd-workflows.md with detailed workflow documentation - Update .gitignore to exclude coverage-report/ and coverage/ directories - Update CHANGELOG.md with version 1.7 release notes Features: - Multi-level testing pipeline (lint → unit → component → integration/contract) - Code coverage collection and reporting with ReportGenerator - Codecov integration with separate flags per test level - Automated PR status comments - Security: All actions pinned to commit SHAs with minimal permissions * Update CHANGELOG.md Co-authored-by: Copilot <[email protected]> * Update docs/ci-cd-workflows.md Co-authored-by: Copilot <[email protected]> * refactor(ci): remove coverage reporting and simplify pr workflow - Remove code coverage collection from test jobs - Remove publish-coverage job and Codecov integration - Simplify test artifacts to TRX files only - Update test results directory to ./test-results - Remove coverage metrics from PR status comment test(integration): fix SSL certificate handling for WireMock HTTPS - Configure ServicePointManager to accept self-signed certificates - Properly reset certificate validation callback in cleanup - Add SSL certificate handling documentation docs: add local workflow testing documentation - Document test-workflow-local.sh script usage - Add local workflow execution guide - Update integration test docs with SSL handling details - Update CHANGELOG with version 1.8 changes feat(tooling): add local workflow testing script - Add test-workflow-local.sh to replicate CI/CD pipeline locally - Execute same test sequence as GitHub Actions workflow - Output test results to ./coverage directory as TRX files - Provide faster feedback cycle for development * fix(tests): TLS confing in CI/CD --------- Co-authored-by: Copilot <[email protected]>
1 parent db5eb49 commit 701c8c1

21 files changed

+903
-75
lines changed

.cursor/commands/commit-changes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
3. Propose cmmmit message following conventional commits and conventional commits guidelines
55
4. Wait for my review and approval!
66
5. After my review commit the changes
7+
6. propose nam of the git branch and ask if I want to push changes. Wait for my review and approval!
8+
7. After my review push to the new branch. Do not create new branch locally. Example command `git push origin HEAD:type/very-short-description`
79

810

911
Guidelines for version control using conventional commits:

.github/workflows/pull-request.yml

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
name: Pull Request
2+
3+
# Note: All actions are pinned to full-length commit SHAs for security compliance
4+
# To verify/update commit SHAs, visit each action's GitHub repository and check the Releases page
5+
# Example: https://github.com/actions/checkout/releases/tag/v4
6+
7+
on:
8+
pull_request:
9+
branches:
10+
- main
11+
- develop
12+
13+
permissions:
14+
contents: read
15+
packages: read
16+
pull-requests: write
17+
18+
env:
19+
DOTNET_VERSION: '8.0.x'
20+
21+
jobs:
22+
lint:
23+
name: Lint Code
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout code
27+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
28+
29+
- name: Setup .NET
30+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
31+
with:
32+
dotnet-version: ${{ env.DOTNET_VERSION }}
33+
34+
- name: Restore dependencies
35+
run: dotnet restore
36+
37+
- name: Format check
38+
run: dotnet format --verify-no-changes --verbosity diagnostic
39+
40+
unit-tests:
41+
name: Unit Tests
42+
runs-on: ubuntu-latest
43+
needs: lint
44+
steps:
45+
- name: Checkout code
46+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
47+
48+
- name: Setup .NET
49+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
50+
with:
51+
dotnet-version: ${{ env.DOTNET_VERSION }}
52+
53+
- name: Restore dependencies
54+
run: dotnet restore
55+
56+
- name: Run unit tests
57+
run: |
58+
dotnet test \
59+
--filter "Category=Unit" \
60+
--results-directory ./test-results \
61+
--logger "trx;LogFileName=unit-tests.trx" \
62+
--logger "console;verbosity=detailed"
63+
64+
- name: Upload unit test results
65+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
66+
if: always()
67+
with:
68+
name: unit-test-results
69+
path: ./test-results/**/*.trx
70+
71+
component-tests:
72+
name: Component Tests
73+
runs-on: ubuntu-latest
74+
needs: lint
75+
steps:
76+
- name: Checkout code
77+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
78+
79+
- name: Setup .NET
80+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
81+
with:
82+
dotnet-version: ${{ env.DOTNET_VERSION }}
83+
84+
- name: Restore dependencies
85+
run: dotnet restore
86+
87+
- name: Run component tests
88+
run: |
89+
dotnet test \
90+
--filter "Category=Component" \
91+
--results-directory ./test-results \
92+
--logger "trx;LogFileName=component-tests.trx" \
93+
--logger "console;verbosity=detailed"
94+
95+
- name: Upload component test results
96+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
97+
if: always()
98+
with:
99+
name: component-test-results
100+
path: ./test-results/**/*.trx
101+
102+
integration-tests:
103+
name: Integration Tests
104+
runs-on: ubuntu-latest
105+
needs: [unit-tests, component-tests]
106+
steps:
107+
- name: Checkout code
108+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
109+
110+
- name: Setup .NET
111+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
112+
with:
113+
dotnet-version: ${{ env.DOTNET_VERSION }}
114+
115+
- name: Restore dependencies
116+
run: dotnet restore
117+
118+
- name: Run integration tests
119+
run: |
120+
dotnet test \
121+
--filter "Category=Integration" \
122+
--results-directory ./test-results \
123+
--logger "trx;LogFileName=integration-tests.trx" \
124+
--logger "console;verbosity=detailed"
125+
126+
- name: Upload integration test results
127+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
128+
if: always()
129+
with:
130+
name: integration-test-results
131+
path: ./test-results/**/*.trx
132+
133+
contract-tests:
134+
name: Contract Tests
135+
runs-on: ubuntu-latest
136+
needs: [unit-tests, component-tests]
137+
steps:
138+
- name: Checkout code
139+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
140+
141+
- name: Setup .NET
142+
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
143+
with:
144+
dotnet-version: ${{ env.DOTNET_VERSION }}
145+
146+
- name: Restore dependencies
147+
run: dotnet restore
148+
149+
- name: Run contract tests
150+
run: |
151+
dotnet test \
152+
--filter "Category=Contract" \
153+
--results-directory ./test-results \
154+
--logger "trx;LogFileName=contract-tests.trx" \
155+
--logger "console;verbosity=detailed"
156+
157+
- name: Upload contract test results
158+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
159+
if: always()
160+
with:
161+
name: contract-test-results
162+
path: ./test-results/**/*.trx
163+
164+
status-comment:
165+
name: PR Status Comment
166+
runs-on: ubuntu-latest
167+
needs: [lint, unit-tests, component-tests, integration-tests, contract-tests]
168+
if: always() && github.event_name == 'pull_request'
169+
steps:
170+
- name: Checkout code
171+
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
172+
173+
- name: Download all test results
174+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
175+
continue-on-error: true
176+
with:
177+
path: ./test-results
178+
179+
- name: Parse test results
180+
id: test-results
181+
run: |
182+
set -e
183+
184+
# Initialize counters
185+
TOTAL_TESTS=0
186+
PASSED_TESTS=0
187+
FAILED_TESTS=0
188+
189+
# Find and parse all TRX files
190+
for trx in $(find ./test-results -name "*.trx" 2>/dev/null || true); do
191+
if [ -f "$trx" ]; then
192+
TOTAL=$(grep -oP '(?<=total=")[0-9]+' "$trx" || echo "0")
193+
PASSED=$(grep -oP '(?<=passed=")[0-9]+' "$trx" || echo "0")
194+
FAILED=$(grep -oP '(?<=failed=")[0-9]+' "$trx" || echo "0")
195+
TOTAL_TESTS=$((TOTAL_TESTS + TOTAL))
196+
PASSED_TESTS=$((PASSED_TESTS + PASSED))
197+
FAILED_TESTS=$((FAILED_TESTS + FAILED))
198+
fi
199+
done
200+
201+
echo "total=$TOTAL_TESTS" >> $GITHUB_OUTPUT
202+
echo "passed=$PASSED_TESTS" >> $GITHUB_OUTPUT
203+
echo "failed=$FAILED_TESTS" >> $GITHUB_OUTPUT
204+
205+
# Determine overall status
206+
if [ "${{ needs.lint.result }}" == "failure" ]; then
207+
echo "status=❌ Linting failed" >> $GITHUB_OUTPUT
208+
elif [ "${{ needs.unit-tests.result }}" == "failure" ] || \
209+
[ "${{ needs.component-tests.result }}" == "failure" ] || \
210+
[ "${{ needs.integration-tests.result }}" == "failure" ] || \
211+
[ "${{ needs.contract-tests.result }}" == "failure" ]; then
212+
echo "status=❌ Tests failed" >> $GITHUB_OUTPUT
213+
else
214+
echo "status=✅ All checks passed" >> $GITHUB_OUTPUT
215+
fi
216+
217+
- name: Create PR comment
218+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
219+
with:
220+
script: |
221+
const status = '${{ steps.test-results.outputs.status }}';
222+
const total = '${{ steps.test-results.outputs.total }}';
223+
const passed = '${{ steps.test-results.outputs.passed }}';
224+
const failed = '${{ steps.test-results.outputs.failed }}';
225+
226+
const lintStatus = '${{ needs.lint.result }}' === 'success' ? '✅' : '❌';
227+
const unitStatus = '${{ needs.unit-tests.result }}' === 'success' ? '✅' : '❌';
228+
const componentStatus = '${{ needs.component-tests.result }}' === 'success' ? '✅' : '❌';
229+
const integrationStatus = '${{ needs.integration-tests.result }}' === 'success' ? '✅' : '❌';
230+
const contractStatus = '${{ needs.contract-tests.result }}' === 'success' ? '✅' : '❌';
231+
232+
const body = `## 🔍 Pull Request CI Status
233+
234+
${status}
235+
236+
### Test Results Summary
237+
- **Total Tests:** ${total}
238+
- **Passed:** ${passed}
239+
- **Failed:** ${failed}
240+
241+
### Job Status
242+
| Job | Status |
243+
|-----|--------|
244+
| Linting | ${lintStatus} |
245+
| Unit Tests | ${unitStatus} |
246+
| Component Tests | ${componentStatus} |
247+
| Integration Tests | ${integrationStatus} |
248+
| Contract Tests | ${contractStatus} |
249+
250+
---
251+
*This comment is automatically updated on each workflow run.*
252+
`;
253+
254+
// Find existing comment
255+
const comments = await github.rest.issues.listComments({
256+
owner: context.repo.owner,
257+
repo: context.repo.repo,
258+
issue_number: context.issue.number,
259+
});
260+
261+
const botComment = comments.data.find(comment =>
262+
comment.user.type === 'Bot' &&
263+
comment.body.includes('Pull Request CI Status')
264+
);
265+
266+
if (botComment) {
267+
await github.rest.issues.updateComment({
268+
owner: context.repo.owner,
269+
repo: context.repo.repo,
270+
comment_id: botComment.id,
271+
body: body,
272+
});
273+
} else {
274+
await github.rest.issues.createComment({
275+
owner: context.repo.owner,
276+
repo: context.repo.repo,
277+
issue_number: context.issue.number,
278+
body: body,
279+
});
280+
}
281+

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@
77
appsettings.Development.json
88
**/Properties/launchSettings.json
99

10-
TestResults/
10+
TestResults/
11+
coverage-report/
12+
coverage/

10xGitHubPolicies.App/Services/GitHub/GitHubClientFactory.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Net.Http;
2+
13
using Octokit;
24
using Octokit.Internal;
35

@@ -10,14 +12,17 @@ namespace _10xGitHubPolicies.App.Services.GitHub;
1012
public class GitHubClientFactory : IGitHubClientFactory
1113
{
1214
private readonly string? _baseUrl;
15+
private readonly HttpClientHandler? _httpClientHandler;
1316

1417
/// <summary>
1518
/// Initializes a new instance of the GitHubClientFactory.
1619
/// </summary>
1720
/// <param name="baseUrl">Optional custom base URL for GitHub API. If null, uses default GitHub API URL.</param>
18-
public GitHubClientFactory(string? baseUrl = null)
21+
/// <param name="httpClientHandler">Optional HttpClientHandler for custom HTTP configuration (e.g., for test environments with self-signed certificates).</param>
22+
public GitHubClientFactory(string? baseUrl = null, HttpClientHandler? httpClientHandler = null)
1923
{
2024
_baseUrl = baseUrl;
25+
_httpClientHandler = httpClientHandler;
2126
}
2227

2328
/// <inheritdoc />
@@ -27,6 +32,18 @@ public GitHubClient CreateClient(string token)
2732
var credentials = new Credentials(token);
2833
var credentialStore = new InMemoryCredentialStore(credentials);
2934

35+
if (_httpClientHandler != null)
36+
{
37+
// For custom base URLs (test scenarios), Connection should add /api/v3 automatically for Enterprise mode
38+
// However, with custom HttpClientAdapter, Connection may not detect Enterprise mode correctly
39+
// So we need to ensure the base URL format triggers Enterprise mode detection
40+
var baseUri = _baseUrl != null ? new Uri(_baseUrl) : GitHubClient.GitHubApiUrl;
41+
var httpClientAdapter = new HttpClientAdapter(() => _httpClientHandler);
42+
var connection = new Connection(productHeader, baseUri, credentialStore, httpClientAdapter, new SimpleJsonSerializer());
43+
44+
return new GitHubClient(connection);
45+
}
46+
3047
if (_baseUrl != null)
3148
{
3249
return new GitHubClient(productHeader, credentialStore, new Uri(_baseUrl));
@@ -42,6 +59,18 @@ public GitHubClient CreateAppClient(string jwt)
4259
var credentials = new Credentials(jwt, AuthenticationType.Bearer);
4360
var credentialStore = new InMemoryCredentialStore(credentials);
4461

62+
if (_httpClientHandler != null)
63+
{
64+
// For custom base URLs (test scenarios), Connection should add /api/v3 automatically for Enterprise mode
65+
// However, with custom HttpClientAdapter, Connection may not detect Enterprise mode correctly
66+
// So we need to ensure the base URL format triggers Enterprise mode detection
67+
var baseUri = _baseUrl != null ? new Uri(_baseUrl) : GitHubClient.GitHubApiUrl;
68+
var httpClientAdapter = new HttpClientAdapter(() => _httpClientHandler);
69+
var connection = new Connection(productHeader, baseUri, credentialStore, httpClientAdapter, new SimpleJsonSerializer());
70+
71+
return new GitHubClient(connection);
72+
}
73+
4574
if (_baseUrl != null)
4675
{
4776
return new GitHubClient(productHeader, credentialStore, new Uri(_baseUrl));

10xGitHubPolicies.Tests.Integration/Fixtures/GitHubApiFixture.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
using WireMock.Server;
22
using WireMock.Settings;
3+
using System.Net.Http;
34

45
namespace _10xGitHubPolicies.Tests.Integration.Fixtures;
56

67
public class GitHubApiFixture : IAsyncLifetime
78
{
89
public WireMockServer MockServer { get; private set; } = null!;
910
public string BaseUrl => MockServer.Url!;
11+
public HttpClientHandler HttpClientHandler { get; private set; } = null!;
1012

1113
public async Task InitializeAsync()
1214
{
15+
// Create HttpClientHandler that accepts self-signed certificates
16+
// This is the .NET Core/.NET 5+ way to handle certificate validation for test scenarios
17+
// ServicePointManager is legacy and doesn't work reliably with HttpClient in .NET Core
18+
HttpClientHandler = new HttpClientHandler
19+
{
20+
ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true
21+
};
22+
1323
MockServer = WireMockServer.Start(new WireMockServerSettings
1424
{
1525
UseSSL = true,
@@ -20,6 +30,7 @@ public async Task InitializeAsync()
2030

2131
public async Task DisposeAsync()
2232
{
33+
HttpClientHandler?.Dispose();
2334
MockServer?.Stop();
2435
MockServer?.Dispose();
2536
await Task.CompletedTask;

0 commit comments

Comments
 (0)