-
Notifications
You must be signed in to change notification settings - Fork 1
557 lines (523 loc) · 22.5 KB
/
Copy pathcsharp-ci.yaml
File metadata and controls
557 lines (523 loc) · 22.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# Copyright (c) 2026 Peaceful Studio OÜ
# SPDX-License-Identifier: Apache-2.0
name: CSharp CI
on:
workflow_call:
outputs:
coverage:
description: >-
Integer line-coverage percentage (e.g. '85') produced by the
coverage matrix shard, or an empty string when no shard set
`coverage: true`. Feed it as the `percent` field of an entry in the
`update-badges` reusable workflow's `coverage-data` input.
value: ${{ jobs.coverage-output.outputs.coverage }}
matrix-status:
description: >-
JSON array of per-shard build results
`[{ "name", "os", "arch", "passed" }]`, one entry per matrix shard,
suitable to feed (after tagging each entry with a `lang`) into the
`update-badges` reusable workflow's `matrix-data` input.
value: ${{ jobs.matrix-output.outputs.matrix-status }}
secrets:
BOT_GITHUB_TOKEN:
description: >-
PAT or GitHub App token with `read:packages` for `dotnet restore`
against private NuGet feeds (e.g. GitHub Packages). Optional;
omit for public-only restores. Pass via `secrets: inherit` or an
explicit `secrets:` block on the caller.
required: false
inputs:
concurrency-group:
description: >-
Override concurrency group (defaults to workflow + ref). Set this
when the caller invokes csharp-ci from multiple jobs in the same
workflow on the same ref and each should run in parallel. Note:
under `workflow_call`, the `github.workflow` context resolves to
the caller's workflow `name:`, not `"CSharp CI"` — so two
unrelated callers that happen to share the same workflow name
will share the default concurrency group; pass an explicit
override here to isolate them.
required: false
type: string
default: ''
dotnet-version:
description: >-
Explicit .NET SDK version passed to actions/setup-dotnet
(e.g. '10.0.x'). Empty (the default) resolves the SDK from the
caller repo's `global.json` under `working-directory` — the file
must exist or setup fails. Set this only to override the
global.json pin.
required: false
type: string
default: ''
working-directory:
description: >-
Path (relative to the repo root) where the .NET workspace lives.
Used as the working directory for restore / build / test / pack.
required: false
type: string
default: '.'
os-list:
description: >-
JSON array of runner labels for the build-and-test matrix.
Examples: '["ubuntu-latest"]', '["ubuntu-latest","macos-latest","windows-latest"]'.
Coverage report generation (merge, summary, PR comment, job summary)
only runs on the `ubuntu-latest` shard; if your matrix excludes
`ubuntu-latest`, no coverage report will be produced. Ignored when
`build-matrix` is set. When both `os-list` and `build-matrix` are
empty, the matrix defaults by repository visibility: public repos
get ubuntu-latest + windows-latest + macos-latest (coverage on
ubuntu); private and internal repos get a single shard on the
self-hosted Hetzner pool (`["self-hosted","hetzner"]`).
required: false
type: string
default: ''
build-matrix:
description: >-
Optional JSON array of build shards, each an object
`{ "name": <label>, "runner": <runner>, "coverage": <bool> }`.
When non-empty it fully replaces `os-list`: each entry becomes one
`build-and-test` shard, `runner` is passed verbatim to `runs-on`
(a plain label, or a JSON array of labels such as
["self-hosted", "hetzner"] to require multiple labels), and
`coverage` (default false) selects the shard that produces the
coverage report, sticky PR comment, and job summary. At most one
entry may set `coverage: true` (zero is allowed — no report is then
produced). Use this to mix self-hosted and hosted runners (e.g.
linux on self-hosted, windows/arm on hosted).
required: false
type: string
default: ''
matrix-mode:
description: >-
Cost-routing override for the build-and-test matrix. `cheap`
collapses the matrix to a single FREE self-hosted Hetzner shard
(`["self-hosted","hetzner"]`, coverage on), ignoring `os-list` and
`build-matrix` — use it to route paid GitHub-hosted legs onto idle
self-hosted runners. `full` forces the normal matrix (visibility
default, `os-list`, or `build-matrix`). Empty (the default) defers
to the org/repo variable `CI_MATRIX_MODE`. Precedence: this input
over `CI_MATRIX_MODE` over the normal matrix.
required: false
type: string
default: ''
test-project:
description: >-
Optional path (relative to working-directory) of a specific test
project or solution to pass to `dotnet test`. Empty runs the
workspace default (the .sln/.slnx in working-directory).
required: false
type: string
default: ''
test-filter:
description: >-
Argument forwarded to `dotnet test --filter`. Empty disables
filtering. Example: 'Category!=Integration'.
required: false
type: string
default: ''
coverage-pr-comment-header:
description: >-
Hidden HTML-comment dedup key used by
marocchino/sticky-pull-request-comment to find and rewrite its
own comment on re-runs — NOT shown to the reader. Make this
unique per language / per repo (e.g. 'csharp-coverage') so
multiple sticky coverage comments on the same PR don't collide.
For the visible heading, see `coverage-title`.
required: false
type: string
default: 'csharp-coverage'
coverage-title:
description: >-
Heading prepended to the rendered coverage report so a reader
can tell the language at a glance when multiple coverage sticky
comments coexist on the same PR. Default identifies the
language. Rendered as a markdown H2. `coverage-pr-comment-header`
is HTML-comment-only, so the rendered comment shows ONLY this
H2 — no doubling.
required: false
type: string
default: 'C# coverage'
tests-glob:
description: >-
Glob (relative to working-directory) used to locate the per-project
`*.cobertura.xml` files emitted by MTP's coverage extension. The
matching files are union-merged into a single report
(`coverage/merged.cobertura.xml`) by `dotnet-coverage merge`
before irongut/CodeCoverageSummary summarizes it. The default
narrows to `bin/Release/net*/` so that stale cobertura files
left over in source-controlled or scratch directories do not get
merged. Adjust if your tests live outside a top-level `tests/`
directory or target a non-Release configuration.
required: false
type: string
default: 'tests/**/bin/Release/net*/**/*.cobertura.xml'
pack:
description: >-
Whether to run `dotnet pack` and upload the resulting .nupkg as a
workflow artifact. When true, `pack-project` is required.
required: false
type: boolean
default: false
pack-project:
description: >-
Path (relative to working-directory) of the project to pack.
Required when `pack` is true.
required: false
type: string
default: ''
pack-output:
description: >-
Path (relative to working-directory) of the directory where
`dotnet pack` writes .nupkg files. Must not start with `./` or
contain `..` segments — actions/upload-artifact rejects relative
pathing in its `path:` input. Validated at runtime.
required: false
type: string
default: 'artifacts'
artifact-name:
description: Name of the uploaded nupkg artifact.
required: false
type: string
default: 'nupkg'
artifact-prefix:
description: >-
Prefix applied to per-run artifact names (coverage-percent,
ci-result-<shard>) to namespace them by language. Set a distinct
value per language when multiple CI workflows (e.g. csharp-ci and
scala-ci) run as sibling jobs in the same caller run, so their
run-level artifacts don't collide.
required: false
type: string
default: 'csharp'
concurrency:
group: ${{ inputs.concurrency-group != '' && inputs.concurrency-group || format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
normalize:
name: normalize matrix
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.compute.outputs.matrix }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
# job.workflow_sha pins helpers to the exact commit of this called workflow, so SHA-pinned callers don't get v1-floating helpers
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers
- name: Compute effective matrix
id: compute
env:
GH_TOKEN: ${{ github.token }}
BUILD_MATRIX: ${{ inputs.build-matrix }}
OS_LIST: ${{ inputs.os-list }}
MATRIX_MODE: ${{ inputs.matrix-mode != '' && inputs.matrix-mode || vars.CI_MATRIX_MODE }}
run: |
set -euo pipefail
mode=$(printf '%s' "$MATRIX_MODE" | tr '[:upper:]' '[:lower:]')
visibility=""
if [ "$mode" = "cheap" ] || { [ -z "$BUILD_MATRIX" ] && [ -z "$OS_LIST" ]; }; then
visibility=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.visibility')
if [ -z "$visibility" ]; then
echo "::error::could not determine repository visibility (empty result from gh api)" >&2
exit 1
fi
fi
matrix=$(.github-actions-helpers/scripts/normalize-ci-matrix.sh "$BUILD_MATRIX" "$OS_LIST" "$visibility" "$MATRIX_MODE")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
build-and-test:
name: build-and-test (${{ matrix.name }})
needs: normalize
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.normalize.outputs.matrix) }}
permissions:
contents: read
packages: read
pull-requests: write
defaults:
run:
shell: bash
working-directory: ${{ inputs.working-directory }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
# actions/setup-dotnet installs BOTH dotnet-version and global-json-file when both are non-empty, and hard-fails on a missing global-json-file path — so the global.json path is only passed when no explicit version overrides it
with:
dotnet-version: ${{ inputs.dotnet-version }}
global-json-file: ${{ inputs.dotnet-version == '' && ((inputs.working-directory == '.' || inputs.working-directory == '') && 'global.json' || format('{0}/global.json', inputs.working-directory)) || '' }}
- name: Checkout CI helpers
if: ${{ matrix.coverage }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers
- name: Resolve dotnet-coverage version
if: ${{ matrix.coverage }}
id: dotnet-coverage
run: |
set -euo pipefail
version=$(python3 "$GITHUB_WORKSPACE/.github-actions-helpers/scripts/resolve-dotnet-coverage-version.py" .)
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install dotnet-coverage
if: ${{ matrix.coverage }}
env:
DOTNET_COVERAGE_VERSION: ${{ steps.dotnet-coverage.outputs.version }}
run: |
set -euo pipefail
dotnet tool update -g dotnet-coverage --version "$DOTNET_COVERAGE_VERSION"
- name: Restore
env:
GITHUB_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
run: |
set -euo pipefail
dotnet restore
- name: Build
run: |
set -euo pipefail
dotnet build --configuration Release --no-restore
- name: Test with MTP coverage
env:
TEST_PROJECT: ${{ inputs.test-project }}
TEST_FILTER: ${{ inputs.test-filter }}
run: |
set -euo pipefail
args=(test --configuration Release --no-build)
if [ -n "$TEST_FILTER" ]; then
args+=(--filter "$TEST_FILTER")
fi
if [ -n "$TEST_PROJECT" ]; then
args+=("$TEST_PROJECT")
fi
dotnet "${args[@]}"
- name: Merge per-project cobertura reports
# irongut/CodeCoverageSummary v1.3.0 concatenates multiple cobertura files instead of union-merging them — duplicate package rows with diluted totals (see #18-adjacent fix, daml-codegen-csharp-internal#311) — so the files must be merged into one before it runs
if: ${{ matrix.coverage }}
env:
TESTS_GLOB: ${{ inputs.tests-glob }}
run: |
set -euo pipefail
mkdir -p "$GITHUB_WORKSPACE/coverage"
shopt -s globstar nullglob
# shellcheck disable=SC2206 # intentional word-split: $TESTS_GLOB is a glob pattern (sourced from inputs.tests-glob via env:), expanded under globstar+nullglob
cobertura_files=($TESTS_GLOB)
if [ ${#cobertura_files[@]} -eq 0 ]; then
echo "::error::No .cobertura.xml files produced under '$TESTS_GLOB' — did MTP coverage extension run? See the 'Caller prerequisites' subsection under csharp-ci.yaml in the peacefulstudio/github-actions README."
exit 1
fi
echo "Merging ${#cobertura_files[@]} cobertura files: ${cobertura_files[*]}"
dotnet-coverage merge \
-o "$GITHUB_WORKSPACE/coverage/merged.cobertura.xml" \
-f cobertura \
"${cobertura_files[@]}"
- name: Extract coverage percentage
if: ${{ matrix.coverage }}
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
mkdir -p coverage
.github-actions-helpers/scripts/extract_coverage.py "$GITHUB_WORKSPACE/coverage/merged.cobertura.xml" > coverage/coverage-percent.txt
- name: Upload coverage value
if: ${{ matrix.coverage }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact-prefix }}-coverage-percent
path: coverage/coverage-percent.txt
if-no-files-found: error
retention-days: 1
- name: Code Coverage Summary Report
if: ${{ matrix.coverage }}
uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0
with:
filename: coverage/merged.cobertura.xml
badge: true
format: markdown
output: both
- name: Sort coverage table alphabetically
if: ${{ matrix.coverage }}
uses: ./.github-actions-helpers/.github/actions/sort-coverage-table
- name: Prepend coverage title
if: ${{ matrix.coverage }}
working-directory: ${{ github.workspace }}
env:
COVERAGE_TITLE: ${{ inputs.coverage-title }}
run: |
set -euo pipefail
if [ ! -f code-coverage-results.md ]; then
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matched valid cobertura files and that the merge step succeeded"
exit 1
fi
printf '## %s\n\n' "$COVERAGE_TITLE" | cat - code-coverage-results.md > code-coverage-results.tmp
mv code-coverage-results.tmp code-coverage-results.md
- name: Add Coverage PR Comment
if: ${{ matrix.coverage && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
with:
recreate: true
header: ${{ inputs.coverage-pr-comment-header }}
path: code-coverage-results.md
- name: Write to Job Summary
if: ${{ matrix.coverage }}
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
if [ ! -f code-coverage-results.md ]; then
echo "::error::code-coverage-results.md not produced by irongut/CodeCoverageSummary — check that tests-glob matched valid cobertura files and that the merge step succeeded"
exit 1
fi
cat code-coverage-results.md >> "$GITHUB_STEP_SUMMARY"
- name: Record shard result
if: ${{ !cancelled() }}
working-directory: ${{ github.workspace }}
env:
SHARD_NAME: ${{ matrix.name }}
SHARD_STATUS: ${{ job.status }}
run: |
set -euo pipefail
mkdir -p ci-result
printf '%s %s\n' "$SHARD_NAME" "$SHARD_STATUS" > "ci-result/${SHARD_NAME}.txt"
- name: Upload shard result
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact-prefix }}-ci-result-${{ matrix.name }}
path: ci-result/${{ matrix.name }}.txt
if-no-files-found: error
retention-days: 1
coverage-output:
name: coverage-output
needs: build-and-test
if: ${{ success() }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
coverage: ${{ steps.read.outputs.coverage }}
steps:
- name: Download coverage value
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact-prefix }}-coverage-percent
path: coverage
- name: Read coverage value
id: read
run: |
set -euo pipefail
coverage=""
if [ -f coverage/coverage-percent.txt ]; then
coverage=$(tr -d '[:space:]' < coverage/coverage-percent.txt)
fi
echo "coverage=$coverage" >> "$GITHUB_OUTPUT"
matrix-output:
name: matrix-output
needs: build-and-test
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix-status: ${{ steps.aggregate.outputs.matrix-status }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: peacefulstudio/github-actions
ref: ${{ job.workflow_sha }}
path: .github-actions-helpers
- name: Download shard results
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: ${{ inputs.artifact-prefix }}-ci-result-*
path: ci-results
merge-multiple: true
- name: Aggregate shard results
id: aggregate
run: |
set -euo pipefail
status="$(.github-actions-helpers/scripts/aggregate_matrix_status.py ci-results)"
echo "matrix-status=$status" >> "$GITHUB_OUTPUT"
pack:
name: pack
if: ${{ inputs.pack }}
needs: build-and-test
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
defaults:
run:
shell: bash
working-directory: ${{ inputs.working-directory }}
steps:
- name: Validate inputs
working-directory: ${{ github.workspace }}
env:
PACK_PROJECT: ${{ inputs.pack-project }}
PACK_OUTPUT: ${{ inputs.pack-output }}
run: |
set -euo pipefail
if [ -z "$PACK_PROJECT" ]; then
echo "::error::pack-project input is required when pack=true"
exit 1
fi
case "$PACK_OUTPUT" in
/*|./*|..|../*|*/..|*/../*)
echo "::error::pack-output must be a relative path without './' prefix or '..' path segments (got: $PACK_OUTPUT)"
exit 1
;;
esac
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
# actions/setup-dotnet installs BOTH dotnet-version and global-json-file when both are non-empty, and hard-fails on a missing global-json-file path — so the global.json path is only passed when no explicit version overrides it
with:
dotnet-version: ${{ inputs.dotnet-version }}
global-json-file: ${{ inputs.dotnet-version == '' && ((inputs.working-directory == '.' || inputs.working-directory == '') && 'global.json' || format('{0}/global.json', inputs.working-directory)) || '' }}
- name: Restore
env:
GITHUB_USERNAME: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
run: |
set -euo pipefail
dotnet restore
- name: Pack
env:
PACK_PROJECT: ${{ inputs.pack-project }}
PACK_OUTPUT: ${{ inputs.pack-output }}
run: |
set -euo pipefail
dotnet pack "$PACK_PROJECT" \
--configuration Release \
--output "$PACK_OUTPUT"
- name: Compute artifact path
id: artifact-path
working-directory: ${{ github.workspace }}
env:
WD: ${{ inputs.working-directory }}
PO: ${{ inputs.pack-output }}
run: |
set -euo pipefail
if [ "$WD" = "." ] || [ -z "$WD" ]; then
path="$PO/*.nupkg"
else
path="$WD/$PO/*.nupkg"
fi
echo "path=$path" >> "$GITHUB_OUTPUT"
- name: Upload nupkg artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact-name }}
path: ${{ steps.artifact-path.outputs.path }}
if-no-files-found: error