CI Integration¶
A CI workflow should choose its mode boundary deliberately. When one workflow requires
both strict and audit results from the same build state, use the combined
--mode strict,audit --ensure-built invocation: it owns one immutable analysis
snapshot and one snapshot-owned build/preflight preparation (including any
post-build receipt verification), then evaluates both modes from that snapshot.
The combined command fails when either requested mode fails. When audit is
intentionally advisory, retain separate strict-blocking and non-blocking-audit
steps instead; those independent CLI processes do not reuse one another's
prepared state.
The complete single-tool workflow shows how policy contexts, base/current change snapshots, an explicit baseline, required external evidence, Architecture Health, PR Markdown, and the Health badge compose. This page focuses on CI responsibility and transport.
The provider-neutral 0.5.1 contract, offline schema commands, sequential mode, and safe POSIX/PowerShell/Make/Task/Tilt templates are in 0.5.1 reference entrypoints. GitHub Actions below is one example provider, not a product dependency.
Recommended pull-request workflow¶
Make complete architecture validation authoritative before merge. Do not add an ordinary
push: main trigger to this same full matrix merely to replay an already-required candidate after
merge.
name: Architecture validation
on:
pull_request:
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
- name: Restore tools
run: dotnet tool restore
- name: Restore dependencies
run: dotnet restore
- name: Validate architecture (strict + audit)
run: |
dotnet arch-linter-net --mode strict,audit --ensure-built --no-restore \
--report json=architecture-results.json \
--report sarif=architecture-results.sarif
- name: Upload architecture diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: architecture-results
path: |
architecture-results.json
architecture-results.sarif
Use dotnet tool restore with a local tool manifest when the repository should pin the ArchLinterNet version. Use dotnet tool install --global ArchLinterNet.Cli only when global installation is acceptable for your pipeline.
A project may deliberately choose additional default-branch validation, but it is not required by
ArchLinterNet semantics. ArchLinterNet's own repository keeps the complete lint, architecture,
cross-platform, package, E2E, and packed-artifact matrix PR-authoritative. Its ordinary main
workflows are focused: three Linux coverage shards produce one current-SHA canonical receipt for
independent SonarCloud/Codecov refresh, while a separate lane publishes installable main.N
development packages. Neither lane becomes a second architecture-governance implementation.
Exit code behavior¶
| Code | Meaning | CI action |
|---|---|---|
0 |
The command completed and its requested validation/comparison gate passed. | Pass |
1 |
The command completed, but its requested validation/comparison gate failed. | Fail required jobs; expected only for deliberately non-blocking inspection. |
2 |
The command could not complete normally, or health produced a valid gate: unassessable result. |
Fail closed; inspect structured output. |
For a combined strict,audit command, code 1 is the aggregate result: either
requested mode failing makes the command fail. The JSON and SARIF files above
contain the completed result for each mode; report routing renders those
outcomes and does not run analysis again.
A failing or unassessable health invocation can still write a valid
architecture-health/v1 document while exiting 1 or 2. A report-producing job may retain and
schema-check that document so reviewers see the real state, but a separate required gate must still
block the pull request. Do not globally coerce a Health exit to success.
See Exit codes for details.
Architecture Health badge payload¶
arch-linter-net badge architecture-health \
--input architecture-health.json \
--output architecture-health-badge.json
This is a local projection over the canonical Health artifact and its canonical policy-inventory receipt. The primary message contains Health, accumulated explicit ignores, and effective policy controls. A rule count is transparency about configured controls, not a coverage percentage or quality score. Health, ignore debt, rule count, and colors belong to the CLI; CI only transports the complete generated JSON.
For this repository, the required read-only PR Architecture Coverage job emits
the exact payload with a bounded manifest binding repository, PR/base context,
head SHA, head Git-tree identity, producer run, byte count, and SHA-256. A
trusted push main publisher resolves the merged PR and promotes that payload
only if the validated PR tree equals the merged main tree. This tree proof is
required for squash merge: matching commit SHA alone is not sufficient.
If the PR, required producer, artifact, manifest, hash, or tree proof is
missing, stale, failed, expired, ambiguous, or invalid, the publisher replaces
the fixed public endpoint with the CLI-generated UNASSESSABLE · ? ignores · ? rules payload and publication metadata. It does not reuse a prior healthy
payload as current, rerun architecture analysis, mutate policy/baselines, or
deploy MkDocs/GitHub Pages. The public endpoint is a fixed raw JSON file on an
automation-owned static branch, suitable for Shields' endpoint image.
Verify Architecture Health badge freshness¶
The README image links to the canonical v2 publication receipt, not to a generic workflow-status result. For a current publication, inspect that receipt first. It records the repository, analyzed/base/head and merged-main commit/tree identities, pull request, producer and publisher run/attempt provenance, payload SHA-256, status/reason, and publication time. Compare the receipt's payload digest with the bytes returned by the raw Architecture Health payload. status: unassessable is an explicit current result, not permission to treat an older healthy payload as current.
Diagnose apparent lag in layers:
- Compare the receipt's merged-main identities and payload digest with the
current
maincommit/tree and raw payload. If those agree, the canonical publication is fresh. A new receipt can be fresh even when the deterministic Gate, Health, ignore, and rule values have not changed. - Request the Shields endpoint directly and compare its response with the raw payload. Shields may cache the endpoint response, so this transport layer can lag the canonical raw source.
- Compare the Shields response with the image rendered in the README. GitHub's README image proxy (Camo) and rendered page can add another rendering delay.
No fixed Shields or Camo delay is promised here. Do not treat an unchanged semantic message as stale evidence, and do not add cache-busting commits or mutate canonical payload values to make transport refreshes visible. The receipt and raw payload are the evidence for publication freshness; Shields and README/Camo are downstream rendering layers.
Reusable trusted promotion¶
Consumers that need the same provenance contract can call the versioned reusable workflow at an approved immutable reference:
jobs:
badge:
uses: eugenemalaschuk-source/arch-linter-net/.github/workflows/architecture-health-badge-promotion.yml@<reviewed-sha>
with:
configuration-id: <approved-registry-entry>
adapter: relay # github-raw, relay, or none
operation: publish # renew performs metadata-only revalidation
The configuration ID resolves to reviewed repository IDs, event/ref, producer workflow/job/check, artifact contract, disclosure profile, and adapter. It is not an arbitrary workflow, artifact, repository, URL, or source selector. The publisher never checks out or executes consumer main/PR code, generated artifacts, hooks, or inherited secrets. It validates the exact merged-tree relationship, workflow run and attempt, producer job, artifact bytes, manifest, digest, and canonical disclosure before publication.
github-raw remains a public-repository snapshot adapter; private repositories
must use an adopter-owned relay or select none. Renewal only revalidates
fresh authorization, producer evidence, artifact retention, and the
product-owned semantic horizon. It does not rerun architecture analysis or
extend expired evidence. Missing gates, unsupported GitHub capability shapes,
corruption, stale context, OIDC failure, and transport uncertainty produce an
actionable unavailable result and never preserve an old ready result.
The reusable workflow is a candidate component, not a publication authority. Its immutable component/configuration identity is handed to the existing #806 release process; no package, tag, Relay deployment, or public release is created by this workflow alone.
Legacy architecture-policy badge payload¶
arch-linter-net badge architecture-policy --input architecture-strict.json
projects strict validation JSON into a Shields endpoint payload without rerunning analysis.
It returns 0 with passing/brightgreen, 1 with failing/red, and 2 with
unavailable/red. A workflow can use its exit status as the blocking gate while a
badge service consumes the JSON endpoint.
arch-linter-net coverage report --input architecture-strict.json --output architecture-coverage.md
remains the standalone coverage projection. It is useful for a coverage artifact or local review,
but it is not the repository's pull-request comment; use --max-failure-diagnostics 3 for a
compact coverage view and pass --changed-files, --repo-root, and --diff-status failed when
applicable.
Strict vs audit jobs¶
Strict validation is the blocking current-architecture mode. The separate gate command adds
reviewed baseline comparison and policy-weakening guardrails when CI needs an explicit no-new-debt
decision.
Audit validation is visibility for migration work. It can be uploaded as an artifact, posted to a dashboard, or inspected periodically, but it should not accidentally become the strict gate unless the team intentionally promotes the audit rule.
If audit is intentionally advisory, keep the backward-compatible two-step workflow and make only the audit step non-blocking:
- name: Validate architecture (strict)
run: |
dotnet arch-linter-net --mode strict --ensure-built --no-restore \
--report json=architecture-strict.json
- name: Architecture audit report
if: always()
continue-on-error: true
run: |
dotnet arch-linter-net --mode audit --ensure-built --no-restore \
--report json=architecture-audit.json
Each step is a separate CLI process with its own preparation. Choose this alternative when audit findings should remain visible without contributing to the blocking decision; choose the combined invocation when both mode results must be required from one build-state snapshot.
Baseline in CI¶
For existing repositories with known debt:
- name: Validate architecture with baseline
run: dotnet arch-linter-net \
--policy architecture/dependencies.arch.yml \
--baseline architecture/baseline.arch.yml \
--mode strict
The baseline should be reviewed like code and cleaned up as violations are fixed.
New-debt gate with policy-weakening guardrails¶
Use gate when CI needs one read-only decision over both exact reviewed
persistent debt and the separate change-time policy-weakening guardrail. It is
not a third validation mode: strict and audit retain their usual meanings,
and --mode all merely collects complete candidates from both existing modes.
- name: Export base policy context
run: git worktree add --detach .ci-base origin/main && dotnet arch-linter-net policy context --policy .ci-base/architecture/dependencies.arch.yml --format json > base-policy-context.json
- name: Export current policy context
run: dotnet arch-linter-net policy context --policy architecture/dependencies.arch.yml --format json > current-policy-context.json
- name: Reject new architecture debt and policy weakening
run: dotnet arch-linter-net gate \
--policy architecture/dependencies.arch.yml \
--baseline architecture/baseline.arch.yml \
--base-context base-policy-context.json \
--current-context current-policy-context.json \
--format json > architecture-debt-gate.json
The base context must be exported from the base policy state, not reloaded from
the current checkout. Both context artifacts must be produced with the same reviewed CLI version.
The gate returns 1 for a new, resolved, stale, ambiguous, or configuration-error persistent-debt
comparison and for an error policy-weakening finding. warn and
impact_not_proven weakening records remain visible without becoming baseline debt. It returns 2
for missing/incomplete inputs or blocked complete analysis; CI must fail closed.
gate requires an explicit baseline path. A repository with no reviewed baseline can supply a
workflow-local empty v3 baseline (version: 3, baseline: {}, metric_baselines: []) as explicit
zero-debt authority. The command never creates that file or mutates repository policy.
gate never writes a baseline. Use baseline diff, update, or prune in a
separate reviewed maintenance change.
CI reads baselines; it never writes them¶
CI runs only the read-only baseline commands:
- name: Verify the baseline is still in sync
run: dotnet arch-linter-net baseline verify \
--policy architecture/dependencies.arch.yml \
--baseline architecture/baseline.arch.yml
baseline verify exits non-zero when the baseline has drifted — stale entries whose violation is
gone, entries that now match more than one violation, or entries naming a contract the policy no
longer has. baseline diff reports the same comparison without gating.
Do not wire baseline generate, baseline update, baseline prune, or baseline migrate into
a workflow that runs on every push, and do not commit their output automatically. A baseline is a
record of debt somebody accepted; a job that regenerates it turns every new violation into
pre-approved debt and removes the review step the file exists to create. Run those commands locally,
review the diff, and commit it like any other change. --dry-run prints exactly what would change,
which is the form worth pasting into a pull request description.
If you want CI to notice that a baseline is out of date rather than fix it, add
baseline verify as above, or baseline update --dry-run --json as a reporting step whose output is
uploaded as an artifact — neither writes a file.
Baseline debt semantics in the coverage gate¶
When architecture coverage is wired into CI as a quality gate (the repository's read-only architecture report producer runs on the protected pull-request candidate), baseline entries change how findings are reported, not whether they exist:
- Existing accepted debt lives in the baseline file and does not fail the pull request. The strict run still reports it in
coverage_findings/coverage_summary, but a finding matched by a baseline entry is treated as known debt rather than a regression. - New coverage findings — anything not matched by an existing baseline entry — fail the pull request. This is what keeps the gate "no new debt" instead of "no debt."
- Resolved baseline entries become stale: once the underlying violation no longer exists, the baseline entry has nothing left to match. Stale baseline entries should be removed during normal maintenance so the baseline file reflects only real outstanding debt.
- Exclusions require a
reason. An exclusion is a deliberate, reviewed decision to leave a unit out of coverage scope — it is not a way to silently bypass the gate. Treat thereasonfield as required documentation, not boilerplate, and review exclusions the same way you'd review a baseline entry.
To inspect the full-solution coverage report locally before pushing, run
make architecture-coverage-report; it prints the standalone coverage Markdown and raw JSON
view. The unified pull-request report is a separate Core/CLI projection over compatible Health and
architecture-change artifacts.
Secure unified Architecture PR report publication¶
The repository renders the reviewer-facing architecture PR report with
arch-linter-net report pr before any comment is written. The pull-request workflow has only
read permission: it uploads the exact Markdown plus a bounded manifest that binds the report to
the repository, PR number, head SHA, CI run and attempt, report schema/kind/marker, byte count,
and SHA-256.
The read-only producer passes trusted transport context to that CLI invocation. In GitHub Actions,
the values are the repository base URL (github.server_url/github.repository), the pull request's
current head (github.event.pull_request.head.sha), and the immutable workflow-attempt URL
(github.server_url/github.repository/actions/runs/github.run_id/attempts/github.run_attempt). The
attempt component is required because GitHub reuses run_id when a workflow is re-run:
dotnet run --no-build --project src/ArchLinterNet.Cli/ArchLinterNet.Cli.csproj -- report pr \
--health architecture-pr-report/architecture-health.json \
--change architecture-pr-report/architecture-change.json \
--max-details 20 \
--repository-url "$REPORT_REPOSITORY_URL" \
--head-sha "$REPORT_HEAD_SHA" \
--artifact-url "$REPORT_ARTIFACT_URL" \
--output architecture-pr-report/architecture-pr-report.md
Those values are navigation-only transport context. The CLI validates the HTTPS GitHub Actions
run/artifact URL against the repository/run context and records the current head context, then places the immutable full-report
bundle/run link outside the bounded detail sections. It does not calculate Gate or Health in YAML,
and the link cannot change canonical evidence, status, or remediation semantics. The uploaded
architecture-pr-report-v1 bundle is the exact Markdown and manifest pair for that producer run;
reviewers can use the link even when --max-details omits ordinary rows.
The rendered report explains Gate and Health separately. Gate is the merge acceptance result;
Health is the independent healthy/debt/degrading/failing/unassessable state. A gate=pass report
can still be health=debt or health=degrading: the report's Blockers section is reserved for
canonical blocking reasons, while its Health explanation and non-blocking debt sections retain
advisory causes and complete lifecycle totals. Applicability, topology, external evidence,
architecture change, remediation, and canonical navigation are each bounded independently with
stable totals and omitted counts.
For local runs, legacy artifacts, or a producer that cannot provide a valid repository/head/run
binding, full bundle navigation is explicitly unavailable. The report must not guess a URL or
turn an absent required authority into a zero or pass. An invalid supplied transport context fails
closed. This does not alter publication behavior: the completed-CI publisher still validates the
manifest and current run/head/hash and moves only the exact inert Markdown bytes.
A separate completed-CI publisher is the only job with pull-request write permission. It performs no checkout and treats downloaded report bytes as inert data. Before updating the one sticky comment it verifies the current PR head, producer run identity, exact artifact shape, bounded sizes, manifest fields, and report hash. It neither reconstructs Architecture Health nor adds build, test, quality-service, or security-service status.
This separation also applies to fork and Dependabot pull requests: their producer can execute with read-only permissions, while the publisher never checks out or executes fork-controlled source or artifact content. If a report is missing, cancelled, stale, malformed, or exceeds the transport limit, publication fails closed and can show only a fixed integration-unavailable message. It never reuses an older green report as evidence for a new head. The raw strict/audit/coverage artifacts and the standalone coverage command remain available for drill-down.
All-zero counts can mean two different things. If coverage_summary is an empty list, the policy defines no coverage contracts at all (strict_coverage/audit_coverage are absent) — the report's note line calls this out explicitly. That is different from a policy that does define coverage contracts and reports zero uncovered/stale/unknown items, which means real coverage contracts exist and nothing is currently failing them. This repository's own architecture/dependencies.arch.yml defines assembly-, project-, namespace-, and rule_input-scope strict_coverage contracts covering all four first-party assemblies, every discovered production project, their root namespaces, and the rule inputs of its source-sensitive strict rules, so the gate reflects real coverage rather than an empty, trivially-passing policy.
Repository badge policy¶
ArchLinterNet's README deliberately distinguishes merge authority from post-merge telemetry:
- Main quality is the GitHub Actions badge for
main-quality.ymlon the mergedmainbranch. It means the current merged revision completed the current-SHA coverage receipt plus SonarCloud/Codecov delivery and verification. A processed red Sonar quality gate remains a warning/branch badge signal rather than making the telemetry transport itself incomplete. - Test coverage is the Codecov badge explicitly scoped to
branch=main. It is refreshed by the same post-merge coverage reports. - Sonar Quality Gate / Maintainability / Reliability / Security are direct
SonarCloud project badges for
branch=main; the main telemetry workflow sends OpenCover/TRX plus Python coverage before ending the scanner. The direct Quality Gate badge can be red while the Main quality workflow is green because the analysis was delivered and verified. - Architecture Health is a canonical ArchLinterNet badge, not a workflow status. It contains Health, explicit ignore debt, and effective policy controls from required PR evidence only after exact merged-tree proof. Its unassessable state is explicit when that promotion proof is unavailable.
The repository's full self-policy and architecture-coverage validation remain required PR checks. They are not repeated after merge merely to refresh generic quality badges.
Test coverage with Codecov and SonarCloud¶
This repository treats line test coverage and architecture coverage as two separate CI signals:
make test-coverageruns the NUnit unit bucket withXPlat Code Coverage, writes Cobertura XML for Codecov, writes OpenCover XML for SonarCloud, and emits TRX test result files undertest-results/.make architecture-coverage-reportevaluates ArchLinterNet coverage contracts and prints architecture-specific Markdown + JSON diagnostics.
The required PR workflow uses three isolated Linux coverage shards and aggregates
them into the PR Sonar/Codecov path. After merge, main-quality.yml runs the
same coverage shard targets independently of the full PR validation matrix,
downloads the reports, collects Python tooling coverage, uploads Cobertura to
Codecov, and ends a SonarCloud main analysis that imports the OpenCover/TRX and
Python coverage data.
That post-merge run is what keeps the README's Main quality, Codecov, and
SonarCloud main-branch badges current for the merged revision.
To inspect the same test-coverage input locally before pushing, run:
make test-coverage
make test-coverage-badge
The first command regenerates the raw Cobertura XML reports, OpenCover XML reports, and TRX files in test-results/. The second command merges the Cobertura reports locally and prints the same overall line-coverage percentage that the README badge is expected to reflect once Codecov ingests the upload from main.
Codecov auth and fork behavior¶
The upload steps use CODECOV_TOKEN from GitHub Actions secrets.
- Trusted same-repository PRs may upload PR coverage; fork PRs still run the coverage tests but skip secret-backed uploads because GitHub does not expose repository secrets to untrusted forks.
- The
main-quality.ymlpush runs on the protected repository branch and has access to the existing repository secret, so it uploads the authoritative merged-main coverage.
No additional secret is required by the main.N package workflow; GitHub
Packages uses its job-scoped built-in GITHUB_TOKEN instead.
Failure mode expectations¶
The two coverage contexts deliberately have different external-service failure semantics:
- PR coverage execution remains required; the existing PR Codecov upload is best-effort so a transient Codecov outage does not make an otherwise valid PR flaky.
- Post-merge
Main Quality Telemetryis fail-closed for incomplete delivery: a missing token, failed coverage shard or canonical inventory, scanner/upload/processing failure, unrecognized Sonar result, wrong revision, missing coverage import, or failed Codecov upload makes the workflow red. An explicitly processed Sonar quality-gate failure is instead surfaced as warning plus the direct Sonar branch badge/dashboard state.
SonarCloud analysis¶
SonarCloud has separate PR and merged-main roles.
Pull requests¶
The ci.yml coverage/Sonar job runs SonarCloud analysis for trusted pull
requests from branches in this repository:
- The workflow checks out the repository with
fetch-depth: 0so SonarCloud can compare a pull request branch against its base branch. - The scanner waits for the SonarCloud quality gate result, so the workflow fails when the Sonar quality gate fails.
- The workflow publishes a job summary link to
https://sonarcloud.io/summary/new_code?id=<project-key>&pullRequest=<number>so reviewers have a direct path to the SonarCloud PR analysis in addition to the GitHub PR decoration/check created by SonarCloud. - The gate is evaluated on new code introduced by the PR, as configured by SonarCloud for pull-request analysis.
Merged main¶
main-quality.yml is the only ordinary post-merge Sonar path. It does not rerun
repository lint, architecture validation, Windows/macOS test matrices, E2E, or
packed-artifact acceptance. It runs the Linux coverage shards needed to produce
fresh coverage evidence, performs the Sonar build inside the scanner context,
imports .NET/Python coverage, and ends the scanner on the merged main commit.
The main workflow fails closed when telemetry delivery cannot be trusted: missing configuration,
coverage/inventory failure, scanner/upload/processing failure, unknown status, wrong analysis
revision, or missing coverage import keeps Main quality red. If Sonar explicitly processes the
current revision and reports QUALITY GATE STATUS: FAILED, the workflow records that as a warning
and direct Sonar badge/dashboard signal while keeping the successful telemetry refresh green. This
post-merge result never retroactively weakens or bypasses the PR merge gate.
Required GitHub configuration¶
The repository workflow expects:
SONAR_TOKENGitHub Actions secret for SonarCloud authentication.CODECOV_TOKENGitHub Actions secret for Codecov authentication.- Optional
SONAR_PROJECT_KEYrepository variable. If unset, the workflow uses the public project keyeugenemalaschuk-source_arch-linter-net. - Optional
SONAR_ORGANIZATIONrepository variable. If unset, the workflow uses the public organization keyeugenemalaschuk-source.
These are the existing quality-service credentials; the main package lane does not introduce a shared repository PAT for publication.
If a trusted same-repository PR or main telemetry run is missing required
SonarCloud configuration, the relevant workflow fails with an explicit
diagnostic instead of silently claiming a completed scan.
Fork pull requests¶
GitHub does not expose repository secrets to untrusted fork pull requests. For that reason, fork PRs do not run the trusted SonarCloud analysis path from this repository workflow. The job summary explains that the SonarCloud PR gate was skipped for that fork run, while same-repository PRs remain fail-closed.
Automatic analysis caveat¶
The current public SonarCloud project metadata indicates that automatic analysis is enabled. For CI-based analysis with coverage import and PR quality-gate enforcement to be the source of truth, maintainers should confirm the project is using the intended CI-based analysis mode in SonarCloud and disable automatic analysis there if it would otherwise compete with the GitHub Actions scan.
Recommended required check¶
After the first successful decorated pull request run, configure GitHub branch protection manually to require the Sonar-created PR status/check for this repository. For this repository's validated PR flow, GitHub currently renders that check as SonarCloud Code Analysis, but maintainers should still verify the exact displayed check name in GitHub before making it required.
Post-merge verification¶
After merging a CI topology change:
- confirm
Main Quality Telemetryran for the mergedmainSHA; - confirm its three Linux coverage shards and canonical inventory completed;
- confirm the Codecov repository page and README coverage badge show
maindata from the merged revision; - confirm the SonarCloud
mainpage and direct project badges refresh for the merged revision; - confirm an explicit processed red Sonar Quality Gate is visible as warning/direct badge state without being confused with delivery failure;
- confirm the ordinary
CIworkflow, CodeQL push job, Windows/macOS matrices, architecture coverage and packed-artifact acceptance did not rerun merely because of the merge.
Azure Pipelines example¶
- task: DotNetCoreCLI@2
displayName: Restore local tools
inputs:
command: custom
custom: tool
arguments: restore
- script: dotnet arch-linter-net --mode strict
displayName: Validate architecture
Documentation publication note¶
PR CI and both ordinary main workflows may validate or reference documentation
sources, but they never deploy MkDocs. GitHub Pages deployment remains owned by
release-nuget.yml and runs only when the maintainer explicitly starts a real
public release with publish: true.