From 891252fee2239e7be695228c4d50439ba09a755f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Fri, 23 Oct 2026 15:10:42 +0800 Subject: [PATCH 1/2] feat: prepare Core Sonar publication gate and coverage evidence --- docs/nuget-quality-gate.md | 127 +++++ docs/sonar-new-code.md | 66 +++ docs/sonar.md | 8 +- scripts/sonar/contract.json | 2 +- scripts/sonar/nuget-policy.json | 8 + scripts/sonar/publication-target.json | 6 + scripts/sonar/publication/nuget-prepare.yml | 134 +++++ scripts/sonar/publication/nuget-publish.yml | 464 ++++++++++++++++++ .../publication/release-tags.ruleset.json | 23 + src/AndreGoepel.Core/Result.cs | 14 +- tests/AndreGoepel.Core.Tests/Result.Tests.cs | 22 + 11 files changed, 869 insertions(+), 5 deletions(-) create mode 100644 docs/nuget-quality-gate.md create mode 100644 docs/sonar-new-code.md create mode 100644 scripts/sonar/nuget-policy.json create mode 100644 scripts/sonar/publication-target.json create mode 100644 scripts/sonar/publication/nuget-prepare.yml create mode 100644 scripts/sonar/publication/nuget-publish.yml create mode 100644 scripts/sonar/publication/release-tags.ruleset.json diff --git a/docs/nuget-quality-gate.md b/docs/nuget-quality-gate.md new file mode 100644 index 0000000..c994bf8 --- /dev/null +++ b/docs/nuget-quality-gate.md @@ -0,0 +1,127 @@ +# Core publication gate: review and migration + +Tracks [#29](https://github.com/andregoepel/core/issues/29) and the source-change +evidence in [#30](https://github.com/andregoepel/core/issues/30). This change prepares +the publication path. It does not enable it or remove the existing tag publisher. + +## Inventory and proposed mapping + +Read-only inventory on 2026-10-23: + +| Item | Observed state | Proposed state | +| --- | --- | --- | +| Package | `AndreGoepel.Core`; authenticated owner list contains only `andre.goepel` | Same package and owner | +| Publication | `ci.yml`, `pack-and-publish`, `v*` push, official NuGet/login then dotnet push | Separate verified preparation and protected publication | +| Readiness | `sonar-main.yml`, workflow 392994102 | Reuse exact successful analysis; leave this workflow unchanged | +| Required CI | `ci.yml`, workflow 320576462 | Successful exact-main run/attempt and approved workflow fingerprint | +| Repository secrets | `NUGET_USER` name present; values not read | Owner identity only; no persistent publication API key | +| Environments | `sonar-main` exists | Add `sonar-nuget-prepare` and `nuget-publish` only after approval | +| Rulesets | Default-branch protection; no release-tag ruleset observed | Immutable `v*` tags, no bypass actors | +| NuGet Trusted Publisher | One active Core policy: `andregoepel/core`, `ci.yml`, no environment restriction, exact package `AndreGoepel.Core`, push new versions only | Replace with the reviewed environment-bound Core publication policy | +| NuGet API keys | Authenticated account reports no API keys | Keep persistent API keys absent; recheck before cutover | + +Public ownership source: . +The authenticated NuGet audit confirmed the current owner list, the matching +Core Trusted Publisher policy and an empty API-key inventory. No other policy in +that account matched Core or a wildcard covering Core. No credentials were read, +created or changed. This is a point-in-time audit; repeat it before cutover. The +owner retains administrative control and can create new credentials or upload +manually, so the pipeline does not constrain future owner actions. The active +`ci.yml` policy remains a known bypass until the separately approved migration. + +Proposed single policy: package `AndreGoepel.Core`; account `andre.goepel`; +GitHub owner `andregoepel`; repository `core`; workflow `nuget-publish.yml`; +environment `nuget-publish`. Restrict the production environment to `main`, require +`andregoepel` as reviewer, allow self-review for the single operator, and disallow +administrator bypass. Preparation receives only read/analysis credentials in its +own environment with a reviewed `v*` tag policy; it has no OIDC permission. + +## Prepared files and shared dependency + +- `scripts/sonar/publication-target.json` binds repository, readiness workflow, + exact package inventory and the independently pinned publication verifier. +- `scripts/sonar/nuget-policy.json` pins preparation workflow identity, normalized + content hash, required successful steps and a 120-minute maximum age. +- `scripts/sonar/publication/nuget-prepare.yml` and `nuget-publish.yml` are review + templates **outside `.github/workflows`**, with literal-false job guards. +- `scripts/sonar/publication/release-tags.ruleset.json` is an unapplied proposal. +- The companion [workflows PR #31](https://github.com/andregoepel/workflows/pull/31) + adapts the accepted app-foundation verifier + to consumer-supplied identities. Core does not copy its C# implementation or + add tool dependencies to the published Core library. + +The verifier revision is deliberately empty and the preparation workflow ID is +zero until the companion revision has been reviewed and the standby workflow +registered. Both are rejected by the verifier. Registering a workflow, recording +its actual ID/hash and replacing the empty pin require review; never guess IDs or +reuse app-foundation's values. The publication verifier pin is separate from +`main-policy.json.toolRevision`, which continues to identify the accepted analysis +runner. This avoids changing the existing Sonar Action for this preparation. + +## Verification before credentials and upload + +1. Require an original, first-attempt release tag creation for the current main + revision. Reject moved/deleted tags, another main tip, changed tag objects and + a mismatched workflow revision or event. +2. Authenticate the latest completed readiness producer and its artifacts. + Validate exact CI run/attempt and mandatory jobs/steps, source and contract + hashes, accepted baseline, frozen policy/profiles, completed Sonar task, + current analysis, gate conditions and freshness. Reruns, newer producers, + changed policy or expired evidence require new validation. +3. Build and test the versioned package, compare compilation inputs with the + analyzed build, and bind both `.nupkg` and `.snupkg` to package ID, version, + source commit and content hashes. Preserve authenticated preparation origin. +4. In the protected publication job, verify the complete handoff and retain an + immutable intent artifact. Revalidate the stored intent and live evidence + before official `NuGet/login`. Revalidate the complete package batch and each + file hash immediately before `dotnet nuget push`. Record intent, individual + attempts and outcomes for investigation; do not blindly retry partial pushes. + +`library-analysis-readiness` with `releaseEligible: false` is only analysis +evidence. It cannot authorize a push by itself. Preparation and verifier outputs +also remain non-publishing; authorization additionally requires the reviewed +enabled pipeline, current live checks, protected approval and scoped NuGet policy. +The C# tool cannot log in to NuGet or upload a package. Rehearsal uses an in-memory +sink; actual publication uses only NuGet/login and dotnet's native push command. + +## Controlled cutover and rollback + +This sequence is proposed, not executed: + +1. Review and merge the shared support and this Core preparation. Retain green + main CI and complete #30's nonempty central new-code check against the existing + accepted baseline. Do not change thresholds, exclusions or small-change policy. +2. Recheck the recorded NuGet owner/policy/API-key audit. Review the exact final + package-to-policy mapping and retire every identified alternative upload route. +3. Submit one activation preparation PR that removes only the legacy + `pack-and-publish` job, installs the new workflows with false guards, records + the merged verifier pin and registers the real preparation ID/hash. Review the + resulting CI fingerprint update; preserve all other CI behavior. Merge only + with operator approval. Do not create `v*` test tags while the old route exists. +4. Configure the reviewed environments and immutable tag rules, prove tag update + and deletion rejection using separately authorized non-release tags, then + migrate the NuGet policy. Retain the resulting read-back evidence privately. +5. Run an explicitly approved hosted rehearsal with isolated non-release tags, + no publication credentials and no upload. Test failed/pending/missing gate, + expired/wrong-revision/superseded evidence, altered artifact and successful + eligible handoff. Record exact run/attempt and verify cleanup. The production + templates require reviewed test-event adaptations for this drill; their + current `v*` triggers must not be used as test triggers. +6. Review the combined evidence and authorize the final activation separately. + Enable preparation and publication only after all bypasses are removed. First + real release is a separate explicit operation with fresh evidence and approval. + +On failure, keep publication disabled, retain evidence and revalidate main. After +activation, disable the new publication path and revoke its policy if needed; +never restore the unguarded legacy route as a rollback. Published package versions +are not transactional or safely overwriteable: reconcile NuGet state and use a +new reviewed version where necessary. External evidence storage remains deferred. + +## Remaining acceptance + +#29 remains open until hosted integration, reviewed cutover and bypass removal +are evidenced, with a fresh private audit at cutover. Inactive templates do not enforce the current +legacy publisher. #30 remains open until the reviewed source change is merged and +the exact central main analysis shows nonzero new lines to cover, measured passing +new-code coverage, full test/coverage inventory and successful readiness. Neither +issue is closed by merging this preparation alone. diff --git a/docs/sonar-new-code.md b/docs/sonar-new-code.md new file mode 100644 index 0000000..4f34788 --- /dev/null +++ b/docs/sonar-new-code.md @@ -0,0 +1,66 @@ +# New-code acceptance evidence + +Tracking: [#30](https://github.com/andregoepel/core/issues/30). +Status: local validation complete; exact merged-main central evidence pending. + +## Reviewed source change + +Both `Result.Fail(null!)` and `Result.Fail(null!)` previously returned a failure +with a null error, contradicting the factories' non-null argument contract and +the `MemberNotNullWhen` annotations. Two regression tests failed before the fix. +The factories now reject null with `ArgumentNullException` naming `error`. +Valid calls retain their behavior; this does not redesign the public record +initializers or claim to prevent every manually constructed invalid state. + +The production test inventory increases from six to eight, with no coverage +exclusions. Full local Windows analysis discovered and passed all eight tests, +imported coverage for `Result.cs`, reported 100% overall coverage on seven lines +to cover, zero findings and a passing gate. Local analysis: +`3241c35e-7406-41ac-95df-36188a0ba2b5`. + +The full Linux analysis also passed all eight discovered tests, imported coverage +for the same source and reported 100% coverage on seven coverable lines with zero +findings. Analysis: `5b9267e7-c16b-4793-a54e-3350b6a1e098`; task: +`392f998a-a912-4512-a8ff-954c9a12406e`. Dependency audits and formatting passed. + +## Isolated new-code failure and recovery + +Executed on 2026-10-23 in a separate loopback-only fixture project using the same +reviewed gate thresholds and frozen Core profiles. Its own clean analysis +`42d63cb1-c27c-42b5-a6e0-37415bdcd923` was selected as its specific baseline. +The central Core project, accepted baseline and policy were not changed. + +One untested coverable line was added only to an ignored source copy, then tested +in a second ignored copy. The fixture baseline remained identical across both +analyses; small-change exemption remained disabled. + +| Check | Negative | Covered recovery | +| --- | --- | --- | +| New lines to cover | 1 | 1 | +| New-code coverage | 0% — failed | 100% — passed | +| Overall coverage | 87.5% — passed | 100% — passed | +| Tests discovered/passed | 8/8 | 8/8 | +| Tool exit | 1 | 0 | +| Gate | ERROR | OK | +| Analysis | `73adb702-95aa-47df-8396-b9afdd5f26f8` | `e86b1823-fb03-43d7-a44e-3861806795d6` | +| Task | `1b458850-ea38-48c1-9b6d-66e518811557` | `45c1cfbc-bff4-47d7-89f7-8199a0b351a1` | + +The negative run retained a failed summary and produced no trusted readiness +receipt. The recovery remained a local analysis with `releaseEligible: false`; +it is not a central readiness run or publication authorization. No probe code is +part of the proposed source change. Raw scanner, test and metric records remain +in ignored local evidence, including the initial reproduction and both fixture +results. + +## Post-merge check required before closure + +After operator review and merge, validate CI and the unchanged Sonar workflow for +the exact resulting main SHA. Record the actual run IDs and attempts, task and +analysis IDs, baseline identity, discovered/passed tests, imported coverage, +nonzero `new_lines_to_cover`, measured `new_coverage` and all applicable new-code +conditions. Verify a successful fresh readiness receipt for that exact attempt. +Do not substitute this local report or overall coverage for the central result. + +Keep the accepted specific baseline, frozen profiles, thresholds, small-change +setting and exclusions unchanged. If another merge or rerun supersedes the +evidence, repeat the exact-main check instead of accepting a stale receipt. diff --git a/docs/sonar.md b/docs/sonar.md index 3a45f45..de2528e 100644 --- a/docs/sonar.md +++ b/docs/sonar.md @@ -5,12 +5,16 @@ Tracking: [core #24](https://github.com/andregoepel/core/issues/24), [epic #238](https://github.com/andregoepel/app-foundation/issues/238). Core uses opt-in analysis through the shared C# runner in `workflows`. -The library and its dependencies, existing CI and tag publisher are unchanged. +The first wave left the library, its dependencies, existing CI and tag publisher unchanged. Central main analysis is enabled with `SONAR_MAIN_ENABLED=true`. The initial baseline was accepted and selected on October 23, 2026. There is no package upload, release tag, publisher migration or deployment in this wave. The existing tag publisher is **not Sonar-protected** by this addition. +Follow-up preparation for #29/#30 is documented in +[the publication migration plan](nuget-quality-gate.md) and +[the new-code evidence report](sonar-new-code.md). + ## Reviewed scope | Item | Inventory | @@ -18,7 +22,7 @@ The existing tag publisher is **not Sonar-protected** by this addition. | Solution | `AndreGoepel.Core.slnx` | | Production project | `src/AndreGoepel.Core/AndreGoepel.Core.csproj` | | Test project | `tests/AndreGoepel.Core.Tests/AndreGoepel.Core.Tests.csproj` | -| Current tests | Six; discovery must find at least six and all discovered tests must pass | +| Current tests | Eight; discovery must find at least eight and all discovered tests must pass | | Executed coverage anchor | `src/AndreGoepel.Core/Result.cs` | | SDK / scanner / coverage | 10.0.402 / 12.0.0 / 18.12.0 | | Sonar Community Build | 26.10.0.133078 | diff --git a/scripts/sonar/contract.json b/scripts/sonar/contract.json index 1c992a9..9c02caa 100644 --- a/scripts/sonar/contract.json +++ b/scripts/sonar/contract.json @@ -4,7 +4,7 @@ "centralServerId": "AA2DD71C-AaFDycIuQLOXJXX6YWNZ", "gate": "Core Library 1.0.0", "solution": "AndreGoepel.Core.slnx", - "tests": { "AndreGoepel.Core.Tests": 6 }, + "tests": { "AndreGoepel.Core.Tests": 8 }, "coverageAnchors": ["src/AndreGoepel.Core/Result.cs"], "sourceExclusions": ["**/bin/**", "**/obj/**", "**/.sonarqube/**", "artifacts/**"] } diff --git a/scripts/sonar/nuget-policy.json b/scripts/sonar/nuget-policy.json new file mode 100644 index 0000000..3fbad16 --- /dev/null +++ b/scripts/sonar/nuget-policy.json @@ -0,0 +1,8 @@ +{ + "workflowId": 0, + "workflowSha256": "", + "maximumAgeMinutes": 120, + "jobs": { + "prepare": ["Verify tag push", "Prepare NuGet batch", "Publish package batch", "Publish origin"] + } +} diff --git a/scripts/sonar/publication-target.json b/scripts/sonar/publication-target.json new file mode 100644 index 0000000..483bfbd --- /dev/null +++ b/scripts/sonar/publication-target.json @@ -0,0 +1,6 @@ +{ + "repository": "andregoepel/core", + "readinessWorkflowId": 392994102, + "packages": ["AndreGoepel.Core"], + "verifierRevision": "" +} diff --git a/scripts/sonar/publication/nuget-prepare.yml b/scripts/sonar/publication/nuget-prepare.yml new file mode 100644 index 0000000..bd48de0 --- /dev/null +++ b/scripts/sonar/publication/nuget-prepare.yml @@ -0,0 +1,134 @@ +# Review template only; outside .github/workflows. Preparation workflow ID 0 is deliberately unregistered. +# Standby only. Activation and NuGet policy cutover require separate approval. +name: NuGet preparation + +on: + push: + tags: ["v*"] + +permissions: + contents: read + actions: read + +concurrency: + group: core-nuget-prepare-${{ github.ref }} + cancel-in-progress: false + +jobs: + prepare: + if: ${{ false && github.repository == 'andregoepel/core' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-24.04 + environment: sonar-nuget-prepare + timeout-minutes: 75 + defaults: + run: + shell: bash + steps: + - name: Verify tag push + id: event + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = andregoepel/core + test "$GITHUB_EVENT_NAME" = push + test "$GITHUB_REF_TYPE" = tag + test "$GITHUB_REF" = "refs/tags/$GITHUB_REF_NAME" + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + test "$GITHUB_WORKFLOW_REF" = "$GITHUB_REPOSITORY/.github/workflows/nuget-prepare.yml@$GITHUB_REF" + [[ "$GITHUB_SHA" =~ ^[a-f0-9]{40}$ ]] + [[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})$ ]] + version="${GITHUB_REF_NAME#v}" + jq -e --arg ref "$GITHUB_REF" ' + .repository.full_name == "andregoepel/core" and + .ref == $ref and .created == true and .deleted == false and .forced == false and + .before == "0000000000000000000000000000000000000000" and + (.after | test("^[a-f0-9]{40}\\z")) and .after != .before + ' "$GITHUB_EVENT_PATH" > /dev/null + main_sha=$(gh api repos/andregoepel/core/git/ref/heads/main --jq '.object.sha') + test "$main_sha" = "$GITHUB_SHA" + readiness_run=$(gh api --method GET repos/andregoepel/core/actions/workflows/392994102/runs \ + -f branch=main -f event=workflow_run -f head_sha="$GITHUB_SHA" -f per_page=100 \ + --jq '.workflow_runs | if length > 0 then max_by(.id).id else error("No main readiness run") end') + [[ "$readiness_run" =~ ^[1-9][0-9]*$ ]] + event_digest=$(sha256sum -- "$GITHUB_EVENT_PATH") + printf 'version=%s\nreadiness-run=%s\nevent-sha256=%s\n' "$version" "$readiness_run" "${event_digest%% *}" >> "$GITHUB_OUTPUT" + after=$(jq -r '.after' "$GITHUB_EVENT_PATH") + printf '### Preparation tag-event evidence\n\nTag: %s\n\nCandidate commit: %s\n\nOriginal push after: %s\n\nReadiness run: %s\n' \ + "$GITHUB_REF_NAME" "$GITHUB_SHA" "$after" "$readiness_run" >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Read reviewed publication verifier revision + id: verifier-source + run: | + set -euo pipefail + revision=$(jq -er '.verifierRevision' scripts/sonar/publication-target.json) + [[ "$revision" =~ ^[a-f0-9]{40}$ && "$revision" != 0000000000000000000000000000000000000000 ]] + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + + - name: Check out reviewed publication verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: andregoepel/workflows + ref: ${{ steps.verifier-source.outputs.revision }} + path: artifacts/sonar/publication-tool-source + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + global-json-file: global.json + + - name: Build reviewed publication verifier + run: | + set -euo pipefail + unset WORKFLOWS_GITHUB_TOKEN WORKFLOWS_SONAR_TOKEN WORKFLOWS_SONAR_VERIFY_TOKEN + unset GITHUB_TOKEN GH_TOKEN ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL + unset NUGET_API_KEY NUGET_SYMBOL_API_KEY NUGET_AUTH_TOKEN + revision=$(git -C artifacts/sonar/publication-tool-source rev-parse HEAD) + test "$revision" = "$(jq -er '.verifierRevision' scripts/sonar/publication-target.json)" + printf 'WORKFLOWS_SONAR_TOOL_SHA=%s\n' "$revision" >> "$GITHUB_ENV" + cd artifacts/sonar/publication-tool-source/sonar + project=src/AndreGoepel.Workflows.Sonar/AndreGoepel.Workflows.Sonar.csproj + dotnet restore "$project" --locked-mode + dotnet publish "$project" -c Release --no-restore --no-self-contained -o "$RUNNER_TEMP/core-publication-verifier" + + - name: Prepare NuGet batch + id: batch + env: + READINESS_RUN: ${{ steps.event.outputs.readiness-run }} + EVENT_SHA256: ${{ steps.event.outputs.event-sha256 }} + PACKAGE_VERSION: ${{ steps.event.outputs.version }} + WORKFLOWS_GITHUB_TOKEN: ${{ github.token }} + WORKFLOWS_SONAR_TOKEN: ${{ secrets.SONAR_SCAN_TOKEN }} + WORKFLOWS_SONAR_VERIFY_TOKEN: ${{ secrets.SONAR_VERIFY_TOKEN }} + run: | + set -euo pipefail + event_digest=$(sha256sum -- "$GITHUB_EVENT_PATH") + test "${event_digest%% *}" = "$EVENT_SHA256" + dotnet "$RUNNER_TEMP/core-publication-verifier/AndreGoepel.Workflows.Sonar.dll" --root "$GITHUB_WORKSPACE" --nuget-prepare \ + --sha "$GITHUB_SHA" --version "$PACKAGE_VERSION" \ + --readiness-run "$READINESS_RUN" --tag "$GITHUB_REF_NAME" + + - name: Publish package batch + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.batch.outputs.batch-artifact-name }} + path: | + ${{ steps.batch.outputs.manifest-path }} + ${{ steps.batch.outputs.packages-path }} + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + - name: Publish origin + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.batch.outputs.origin-artifact-name }} + path: ${{ steps.batch.outputs.origin-path }} + if-no-files-found: error + retention-days: 1 diff --git a/scripts/sonar/publication/nuget-publish.yml b/scripts/sonar/publication/nuget-publish.yml new file mode 100644 index 0000000..6365372 --- /dev/null +++ b/scripts/sonar/publication/nuget-publish.yml @@ -0,0 +1,464 @@ +# Review template only; outside .github/workflows. Preparation workflow ID 0 is deliberately unregistered. +# Inactive caller template. Render with Build-NuGetPublishWorkflow.ps1 for review. +name: NuGet verified publication + +on: + workflow_run: + workflows: ["NuGet preparation"] + types: [completed] + +permissions: {} + +concurrency: + group: core-nuget-publish + cancel-in-progress: false + +jobs: + publish: + if: ${{ false && github.repository == 'andregoepel/core' && github.event.workflow_run.workflow_id == 0 && github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') }} + runs-on: ubuntu-24.04 + environment: nuget-publish + timeout-minutes: 20 + permissions: + contents: read + actions: read + id-token: write + defaults: + run: + shell: bash + steps: + - name: Validate publication trigger + if: ${{ false && success() }} + id: event + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = andregoepel/core + test "$GITHUB_EVENT_NAME" = workflow_run + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_RUN_ATTEMPT" = 1 + test "$GITHUB_WORKFLOW_REF" = "$GITHUB_REPOSITORY/.github/workflows/nuget-publish.yml@refs/heads/main" + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + [[ "$GITHUB_SHA" =~ ^[a-f0-9]{40}$ ]] + test -f "$GITHUB_EVENT_PATH" && test ! -L "$GITHUB_EVENT_PATH" + test "$(stat -c %s -- "$GITHUB_EVENT_PATH")" -le 1048576 + event=$(cat -- "$GITHUB_EVENT_PATH") + jq -e --arg sha "$GITHUB_SHA" ' + .action == "completed" and .repository.full_name == "andregoepel/core" and + (.workflow_run | .repository.full_name == "andregoepel/core" and + .head_repository.full_name == "andregoepel/core" and + .workflow_id == 0 and .path == ".github/workflows/nuget-prepare.yml" and + .name == "NuGet preparation" and .event == "push" and + .status == "completed" and .conclusion == "success" and .head_sha == $sha and + (.head_branch | test("^v(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})\\.(0|[1-9][0-9]{0,4})\\z")) and + (.id | type == "number" and . > 0 and . < 9007199254740992 and . == floor) and + .run_attempt == 1) + ' <<< "$event" > /dev/null + main_sha=$(gh api repos/andregoepel/core/git/ref/heads/main --jq '.object.sha') + test "$main_sha" = "$GITHUB_SHA" + run=$(jq -r '.workflow_run.id' <<< "$event") + tag=$(jq -r '.workflow_run.head_branch' <<< "$event") + printf 'sha=%s\nrun=%s\nattempt=1\ntag=%s\nversion=%s\n' "$GITHUB_SHA" "$run" "$tag" "${tag#v}" >> "$GITHUB_OUTPUT" + + - name: Check out trusted candidate + if: ${{ false && success() }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.event.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Read reviewed publication verifier revision + if: ${{ false && success() }} + id: verifier-source + run: | + set -euo pipefail + revision=$(jq -er '.verifierRevision' scripts/sonar/publication-target.json) + [[ "$revision" =~ ^[a-f0-9]{40}$ && "$revision" != 0000000000000000000000000000000000000000 ]] + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + + - name: Check out reviewed publication verifier + if: ${{ false && success() }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: andregoepel/workflows + ref: ${{ steps.verifier-source.outputs.revision }} + path: artifacts/sonar/publication-tool-source + persist-credentials: false + + - name: Set up pinned SDK + if: ${{ false && success() }} + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 + with: + global-json-file: global.json + + - name: Bootstrap trusted verifier + if: ${{ false && success() }} + id: bootstrap + env: + CANDIDATE_SHA: ${{ steps.event.outputs.sha }} + run: | + set -euo pipefail + umask 077 + unset WORKFLOWS_GITHUB_TOKEN WORKFLOWS_SONAR_TOKEN WORKFLOWS_SONAR_VERIFY_TOKEN + unset GITHUB_TOKEN GH_TOKEN ACTIONS_ID_TOKEN_REQUEST_TOKEN ACTIONS_ID_TOKEN_REQUEST_URL + unset NUGET_API_KEY NUGET_SYMBOL_API_KEY NUGET_AUTH_TOKEN + test "$(pwd -P)" = "$GITHUB_WORKSPACE" + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + root=$(mktemp -d "$RUNNER_TEMP/nuget-verifier.XXXXXXXX") + printf 'root=%s\n' "$root" >> "$GITHUB_OUTPUT" + revision=$(git -C artifacts/sonar/publication-tool-source rev-parse HEAD) + test "$revision" = "$(jq -er '.verifierRevision' scripts/sonar/publication-target.json)" + printf 'WORKFLOWS_SONAR_TOOL_SHA=%s\n' "$revision" >> "$GITHUB_ENV" + cd artifacts/sonar/publication-tool-source/sonar + project=src/AndreGoepel.Workflows.Sonar/AndreGoepel.Workflows.Sonar.csproj + dotnet restore "$project" --locked-mode + dotnet publish "$project" -c Release --no-restore --no-self-contained -p:SonarQubeTargetsImported=true -o "$root" + dll="$root/AndreGoepel.Workflows.Sonar.dll" + test -f "$dll" && test ! -L "$dll" + printf 'dll=%s\n' "$dll" >> "$GITHUB_OUTPUT" + + - name: Download publication handoff + if: ${{ false && success() }} + id: download + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_SHA: ${{ steps.event.outputs.sha }} + PREPARATION_RUN: ${{ steps.event.outputs.run }} + PREPARATION_ATTEMPT: ${{ steps.event.outputs.attempt }} + RELEASE_TAG: ${{ steps.event.outputs.tag }} + run: | + set -euo pipefail + umask 077 + handoff=$(mktemp -d "$RUNNER_TEMP/nuget-handoff.XXXXXXXX") + printf 'root=%s\n' "$handoff" >> "$GITHUB_OUTPUT" + gh run download "$PREPARATION_RUN" --repo andregoepel/core \ + --name "nuget-origin-$CANDIDATE_SHA-$PREPARATION_RUN-$PREPARATION_ATTEMPT" --dir "$handoff/origin" + origin="$handoff/origin/origin.json" + test -f "$origin" && test ! -L "$origin" + test "$(stat -c %s -- "$origin")" -le 1048576 + pins=$(jq -ce --arg sha "$CANDIDATE_SHA" --arg tag "$RELEASE_TAG" \ + --argjson run "$PREPARATION_RUN" --argjson attempt "$PREPARATION_ATTEMPT" ' + if .schemaVersion == 1 and .repository == "andregoepel/core" and + .sha == $sha and .tag == $tag and .runId == $run and .attempt == $attempt and + .dryRun == true and .releaseEligible == false and + (.readinessRunId | type == "number" and . > 0 and . < 9007199254740992 and . == floor) and + (.tagObjectSha | test("^[a-f0-9]{40}\\z")) and + (.manifestSha256 | test("^[a-f0-9]{64}\\z")) + then {readiness: .readinessRunId, tagObject: .tagObjectSha, manifest: .manifestSha256} + else error("Invalid preparation origin") end' "$origin") + gh run download "$PREPARATION_RUN" --repo andregoepel/core \ + --name "nuget-batch-$CANDIDATE_SHA-$PREPARATION_RUN-$PREPARATION_ATTEMPT" --dir "$handoff/batch" + printf 'readiness=%s\ntag-object=%s\nmanifest=%s\n' \ + "$(jq -r '.readiness' <<< "$pins")" "$(jq -r '.tagObject' <<< "$pins")" "$(jq -r '.manifest' <<< "$pins")" >> "$GITHUB_OUTPUT" + + - name: Export publication context + if: ${{ false && success() }} + env: + CANDIDATE_SHA: ${{ steps.event.outputs.sha }} + PREPARATION_RUN: ${{ steps.event.outputs.run }} + PREPARATION_ATTEMPT: ${{ steps.event.outputs.attempt }} + RELEASE_TAG: ${{ steps.event.outputs.tag }} + PACKAGE_VERSION: ${{ steps.event.outputs.version }} + BOOTSTRAP_ROOT: ${{ steps.bootstrap.outputs.root }} + VERIFIER_DLL: ${{ steps.bootstrap.outputs.dll }} + HANDOFF_ROOT: ${{ steps.download.outputs.root }} + READINESS_RUN: ${{ steps.download.outputs.readiness }} + TAG_OBJECT: ${{ steps.download.outputs.tag-object }} + MANIFEST_SHA256: ${{ steps.download.outputs.manifest }} + run: | + set -euo pipefail + test "$(pwd -P)" = "$GITHUB_WORKSPACE" + test "$(git rev-parse HEAD)" = "$CANDIDATE_SHA" + test "$CANDIDATE_SHA" = "$GITHUB_SHA" + test "$PREPARATION_ATTEMPT" = 1 + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})$ ]] + test "$RELEASE_TAG" = "v$PACKAGE_VERSION" + [[ "$READINESS_RUN" =~ ^[1-9][0-9]{0,15}$ && "$PREPARATION_RUN" =~ ^[1-9][0-9]{0,15}$ ]] + [[ "$TAG_OBJECT" =~ ^[a-f0-9]{40}$ && "$MANIFEST_SHA256" =~ ^[a-f0-9]{64}$ ]] + for root in "$BOOTSTRAP_ROOT" "$HANDOFF_ROOT"; do + test "$(dirname -- "$root")" = "$RUNNER_TEMP" + test -d "$root" && test ! -L "$root" + test "$(realpath -e -- "$root")" = "$root" + done + [[ "$(basename -- "$BOOTSTRAP_ROOT")" =~ ^nuget-verifier\.[A-Za-z0-9]{8}$ ]] + [[ "$(basename -- "$HANDOFF_ROOT")" =~ ^nuget-handoff\.[A-Za-z0-9]{8}$ ]] + test "$VERIFIER_DLL" = "$BOOTSTRAP_ROOT/AndreGoepel.Workflows.Sonar.dll" + test -f "$VERIFIER_DLL" && test ! -L "$VERIFIER_DLL" + export REPOSITORY_ROOT="$GITHUB_WORKSPACE" + names=(VERIFIER_DLL REPOSITORY_ROOT CANDIDATE_SHA PACKAGE_VERSION READINESS_RUN RELEASE_TAG HANDOFF_ROOT MANIFEST_SHA256 TAG_OBJECT PREPARATION_RUN PREPARATION_ATTEMPT) + for name in "${names[@]}"; do + value="${!name}" + [[ -n "$value" && "$value" != *$'\n'* && "$value" != *$'\r'* ]] + done + for name in "${names[@]}"; do printf '%s=%s\n' "$name" "${!name}" >> "$GITHUB_ENV"; done + + - name: Verify full handoff before login + if: ${{ false && success() }} + id: verified-handoff + shell: bash + env: + WORKFLOWS_GITHUB_TOKEN: ${{ github.token }} + WORKFLOWS_SONAR_TOKEN: ${{ secrets.SONAR_SCAN_TOKEN }} + WORKFLOWS_SONAR_VERIFY_TOKEN: ${{ secrets.SONAR_VERIFY_TOKEN }} + run: | + set -euo pipefail + umask 077 + test "$GITHUB_REPOSITORY" = andregoepel/core + test "$GITHUB_RUN_ATTEMPT" = 1 + [[ "$GITHUB_RUN_ID" =~ ^[1-9][0-9]{0,15}$ ]] + test "$GITHUB_SHA" = "$CANDIDATE_SHA" + test "$GITHUB_WORKFLOW_SHA" = "$CANDIDATE_SHA" + test "$GITHUB_WORKFLOW_REF" = "$GITHUB_REPOSITORY/.github/workflows/nuget-publish.yml@refs/heads/main" + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})\.(0|[1-9][0-9]{0,4})$ ]] + test "$RELEASE_TAG" = "v$PACKAGE_VERSION" + test -f "$VERIFIER_DLL" && test ! -L "$VERIFIER_DLL" + work=$(mktemp -d "$RUNNER_TEMP/nuget-publish.XXXXXXXX") + printf 'root=%s\n' "$work" >> "$GITHUB_OUTPUT" + printf '\n' > "$work/NuGet.Config" + env -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u NUGET_API_KEY -u NUGET_AUTH_TOKEN -u NUGET_SYMBOL_API_KEY \ + dotnet "$VERIFIER_DLL" --root "$REPOSITORY_ROOT" --nuget-verify-handoff \ + --sha "$CANDIDATE_SHA" --version "$PACKAGE_VERSION" \ + --readiness-run "$READINESS_RUN" --tag "$RELEASE_TAG" \ + --handoff "$HANDOFF_ROOT/batch" --manifest-sha256 "$MANIFEST_SHA256" \ + --tag-object "$TAG_OBJECT" --preparation-run "$PREPARATION_RUN" \ + --preparation-attempt "$PREPARATION_ATTEMPT" > "$work/verified.json" + jq -e '.dryRun == true and .releaseEligible == false and (.packages | length == 2)' "$work/verified.json" > /dev/null + jq -S -c '.packages | sort_by(.file)' "$work/verified.json" > "$work/inventory.json" + jq -n --slurpfile packages "$work/inventory.json" \ + --arg repository "$GITHUB_REPOSITORY" --arg sha "$CANDIDATE_SHA" \ + --arg tag "$RELEASE_TAG" --arg tagObject "$TAG_OBJECT" --arg version "$PACKAGE_VERSION" \ + --arg manifest "$MANIFEST_SHA256" --arg readiness "$READINESS_RUN" \ + --arg preparation "$PREPARATION_RUN" --arg preparationAttempt "$PREPARATION_ATTEMPT" \ + --arg run "$GITHUB_RUN_ID" --arg attempt "$GITHUB_RUN_ATTEMPT" \ + --arg workflow "$GITHUB_WORKFLOW_REF" --arg revision "$GITHUB_WORKFLOW_SHA" \ + --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' + {schemaVersion: 1, kind: "publication-intent", repository: $repository, sha: $sha, + tag: $tag, tagObjectSha: $tagObject, version: $version, manifestSha256: $manifest, + readinessRunId: $readiness, preparationRunId: $preparation, preparationAttempt: $preparationAttempt, + publishRunId: $run, publishAttempt: $attempt, workflowRef: $workflow, workflowRevision: $revision, + createdAt: $time, packages: $packages[0], authorizesRetry: false}' > "$work/intent.json" + digest=$(sha256sum -- "$work/intent.json") + printf 'intent-sha256=%s\n' "${digest%% *}" >> "$GITHUB_OUTPUT" + + - name: Preserve publication intent before login + if: ${{ false && success() }} + id: publish-intent + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nuget-publish-intent-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.verified-handoff.outputs.root }}/intent.json + if-no-files-found: error + retention-days: 90 + overwrite: false + include-hidden-files: false + + - name: Reverify after preserving intent + if: ${{ false && success() }} + id: recorded-handoff + shell: bash + env: + PUBLISH_ROOT: ${{ steps.verified-handoff.outputs.root }} + INTENT_SHA256: ${{ steps.verified-handoff.outputs.intent-sha256 }} + INTENT_ARTIFACT_ID: ${{ steps.publish-intent.outputs.artifact-id }} + INTENT_ARTIFACT_DIGEST: ${{ steps.publish-intent.outputs.artifact-digest }} + WORKFLOWS_GITHUB_TOKEN: ${{ github.token }} + WORKFLOWS_SONAR_TOKEN: ${{ secrets.SONAR_SCAN_TOKEN }} + WORKFLOWS_SONAR_VERIFY_TOKEN: ${{ secrets.SONAR_VERIFY_TOKEN }} + run: | + set -euo pipefail + [[ "$INTENT_ARTIFACT_ID" =~ ^[1-9][0-9]{0,15}$ ]] + [[ "$INTENT_ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] + digest=$(sha256sum -- "$PUBLISH_ROOT/intent.json") + test "${digest%% *}" = "$INTENT_SHA256" + env -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u NUGET_API_KEY -u NUGET_AUTH_TOKEN -u NUGET_SYMBOL_API_KEY \ + dotnet "$VERIFIER_DLL" --root "$REPOSITORY_ROOT" --nuget-verify-intent \ + --sha "$CANDIDATE_SHA" --version "$PACKAGE_VERSION" \ + --readiness-run "$READINESS_RUN" --tag "$RELEASE_TAG" \ + --handoff "$HANDOFF_ROOT/batch" --manifest-sha256 "$MANIFEST_SHA256" \ + --tag-object "$TAG_OBJECT" --preparation-run "$PREPARATION_RUN" \ + --preparation-attempt "$PREPARATION_ATTEMPT" > "$PUBLISH_ROOT/recorded.json" + jq -e '.dryRun == true and .releaseEligible == false and (.packages | length == 2)' "$PUBLISH_ROOT/recorded.json" > /dev/null + jq -S -c '.packages | sort_by(.file)' "$PUBLISH_ROOT/recorded.json" > "$PUBLISH_ROOT/recorded-inventory.json" + jq -S -c '.packages' "$PUBLISH_ROOT/intent.json" > "$PUBLISH_ROOT/intent-inventory.json" + cmp -- "$PUBLISH_ROOT/intent-inventory.json" "$PUBLISH_ROOT/recorded-inventory.json" + cmp -- "$PUBLISH_ROOT/inventory.json" "$PUBLISH_ROOT/recorded-inventory.json" + + - name: Obtain temporary NuGet credentials + if: ${{ false && success() }} + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push each reverified package and symbol file + if: ${{ false && success() }} + id: publish + shell: bash + env: + PUBLISH_ROOT: ${{ steps.verified-handoff.outputs.root }} + INTENT_SHA256: ${{ steps.verified-handoff.outputs.intent-sha256 }} + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + NUGET_SYMBOL_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + WORKFLOWS_GITHUB_TOKEN: ${{ github.token }} + WORKFLOWS_SONAR_TOKEN: ${{ secrets.SONAR_SCAN_TOKEN }} + WORKFLOWS_SONAR_VERIFY_TOKEN: ${{ secrets.SONAR_VERIFY_TOKEN }} + run: | + set -euo pipefail + umask 077 + : "${NUGET_API_KEY:?}" "${NUGET_SYMBOL_API_KEY:?}" + test "$GITHUB_RUN_ATTEMPT" = 1 + test "$(dirname -- "$PUBLISH_ROOT")" = "$RUNNER_TEMP" + [[ "$(basename -- "$PUBLISH_ROOT")" =~ ^nuget-publish\.[A-Za-z0-9]{8}$ ]] + test -d "$PUBLISH_ROOT" && test ! -L "$PUBLISH_ROOT" + test "$(realpath -e -- "$PUBLISH_ROOT")" = "$PUBLISH_ROOT" + test "$(cat -- "$PUBLISH_ROOT/NuGet.Config")" = '' + digest=$(sha256sum -- "$PUBLISH_ROOT/intent.json") + test "${digest%% *}" = "$INTENT_SHA256" + jq -S -c '.packages' "$PUBLISH_ROOT/intent.json" > "$PUBLISH_ROOT/intent-inventory.json" + cmp -- "$PUBLISH_ROOT/inventory.json" "$PUBLISH_ROOT/intent-inventory.json" + test ! -e "$PUBLISH_ROOT/attempts.tsv" + : > "$PUBLISH_ROOT/attempts.tsv" + jq -er '.[].file' "$PUBLISH_ROOT/inventory.json" > "$PUBLISH_ROOT/files" + while IFS= read -r file; do + env -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u NUGET_API_KEY -u NUGET_AUTH_TOKEN -u NUGET_SYMBOL_API_KEY \ + dotnet "$VERIFIER_DLL" --root "$REPOSITORY_ROOT" --nuget-verify-handoff \ + --sha "$CANDIDATE_SHA" --version "$PACKAGE_VERSION" \ + --readiness-run "$READINESS_RUN" --tag "$RELEASE_TAG" \ + --handoff "$HANDOFF_ROOT/batch" --manifest-sha256 "$MANIFEST_SHA256" \ + --tag-object "$TAG_OBJECT" --preparation-run "$PREPARATION_RUN" \ + --preparation-attempt "$PREPARATION_ATTEMPT" > "$PUBLISH_ROOT/current.json" + jq -e '.dryRun == true and .releaseEligible == false and (.packages | length == 2)' "$PUBLISH_ROOT/current.json" > /dev/null + jq -S -c '.packages | sort_by(.file)' "$PUBLISH_ROOT/current.json" > "$PUBLISH_ROOT/current-inventory.json" + cmp -- "$PUBLISH_ROOT/inventory.json" "$PUBLISH_ROOT/current-inventory.json" + expected=$(jq -er --arg file "$file" '.[] | select(.file == $file) | .sha256' "$PUBLISH_ROOT/inventory.json") + package="$HANDOFF_ROOT/batch/packages/$file" + test -f "$package" && test ! -L "$package" + arguments=(nuget push "$package" --source https://api.nuget.org/v3/index.json --configfile "$PUBLISH_ROOT/NuGet.Config" --timeout 300) + case "$file" in + *.snupkg) arguments+=(--symbol-source https://www.nuget.org/api/v2/symbolpackage --no-service-endpoint) ;; + *.nupkg) arguments+=(--no-symbols) ;; + *) exit 1 ;; + esac + printf '%s\tstarted\n' "$file" >> "$PUBLISH_ROOT/attempts.tsv" + actual=$(sha256sum -- "$package") + test "${actual%% *}" = "$expected" + if env -u WORKFLOWS_GITHUB_TOKEN -u WORKFLOWS_SONAR_TOKEN -u WORKFLOWS_SONAR_VERIFY_TOKEN \ + -u GITHUB_TOKEN -u GH_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u NUGET_AUTH_TOKEN dotnet "${arguments[@]}"; then + printf '%s\tcli-success\n' "$file" >> "$PUBLISH_ROOT/attempts.tsv" + else + code=$? + printf '%s\tuncertain-exit-%s\n' "$file" "$code" >> "$PUBLISH_ROOT/attempts.tsv" + echo 'Publication stopped; reconcile remote outcomes before any retry.' >&2 + exit "$code" + fi + done < "$PUBLISH_ROOT/files" + + - name: Record publication outcomes + if: ${{ false && always() && steps.publish-intent.outcome == 'success' }} + id: publish-outcomes + shell: bash + env: + PUBLISH_ROOT: ${{ steps.verified-handoff.outputs.root }} + INTENT_SHA256: ${{ steps.verified-handoff.outputs.intent-sha256 }} + INTENT_ARTIFACT_ID: ${{ steps.publish-intent.outputs.artifact-id }} + INTENT_ARTIFACT_DIGEST: ${{ steps.publish-intent.outputs.artifact-digest }} + LOGIN_OUTCOME: ${{ steps.nuget-login.outcome }} + PUSH_OUTCOME: ${{ steps.publish.outcome }} + run: | + set -euo pipefail + umask 077 + test "$(dirname -- "$PUBLISH_ROOT")" = "$RUNNER_TEMP" + [[ "$(basename -- "$PUBLISH_ROOT")" =~ ^nuget-publish\.[A-Za-z0-9]{8}$ ]] + test -d "$PUBLISH_ROOT" && test ! -L "$PUBLISH_ROOT" + test "$(realpath -e -- "$PUBLISH_ROOT")" = "$PUBLISH_ROOT" + [[ "$INTENT_ARTIFACT_ID" =~ ^[1-9][0-9]{0,15}$ ]] + [[ "$INTENT_ARTIFACT_DIGEST" =~ ^[a-f0-9]{64}$ ]] + test -f "$PUBLISH_ROOT/intent.json" && test ! -L "$PUBLISH_ROOT/intent.json" + digest=$(sha256sum -- "$PUBLISH_ROOT/intent.json") + test "${digest%% *}" = "$INTENT_SHA256" + for outcome in "$LOGIN_OUTCOME" "$PUSH_OUTCOME"; do + case "$outcome" in success|failure|cancelled|skipped) ;; *) exit 1 ;; esac + done + printf '[]\n' > "$PUBLISH_ROOT/events.json" + if test -e "$PUBLISH_ROOT/attempts.tsv" || test -L "$PUBLISH_ROOT/attempts.tsv"; then + test -f "$PUBLISH_ROOT/attempts.tsv" && test ! -L "$PUBLISH_ROOT/attempts.tsv" + test "$(stat -c %s -- "$PUBLISH_ROOT/attempts.tsv")" -le 16384 + jq -Rn '[inputs | split("\t") | if length == 2 then {file: .[0], status: .[1]} else error("Invalid attempt row") end]' \ + "$PUBLISH_ROOT/attempts.tsv" > "$PUBLISH_ROOT/events.json" + fi + jq -e -n --slurpfile intent "$PUBLISH_ROOT/intent.json" --slurpfile events "$PUBLISH_ROOT/events.json" \ + --arg hash "$INTENT_SHA256" --arg artifact "$INTENT_ARTIFACT_ID" --arg digest "$INTENT_ARTIFACT_DIGEST" \ + --arg login "$LOGIN_OUTCOME" --arg push "$PUSH_OUTCOME" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' + $intent[0] as $i | $events[0] as $e | + if ($e | length) > 24 then error("Too many attempt rows") else . end | + reduce range(0; $e | length) as $n (.; + if $e[$n].file != $i.packages[($n / 2 | floor)].file or + (if $n % 2 == 0 then $e[$n].status != "started" + else ($e[$n].status != "cli-success" and + (($e[$n].status | test("^uncertain-exit-([1-9][0-9]{0,2})$")) | not)) or + ($e[$n].status != "cli-success" and $n != ($e | length) - 1) end) + then error("Invalid attempt sequence") else . end) | + if ($push == "skipped" and ($e | length) != 0) or + ($push != "skipped" and $login != "success") or + ($push == "success" and (($e | length) != 24 or any($e[]; .status != "started" and .status != "cli-success"))) + then error("Inconsistent step outcomes") else . end | + {schemaVersion: 1, kind: "publication-outcomes", intentSha256: $hash, + intentArtifactId: $artifact, intentArtifactDigest: $digest, + repository: $i.repository, sha: $i.sha, publishRunId: $i.publishRunId, + publishAttempt: $i.publishAttempt, workflowRef: $i.workflowRef, workflowRevision: $i.workflowRevision, + recordedAt: $time, loginOutcome: $login, pushOutcome: $push, + authorizesRetry: false, remoteAcceptanceVerified: false, + packages: [$i.packages[] | . as $package | + [$e[] | select(.file == $package.file)] as $rows | + . + {cliObservation: (if $push == "skipped" then "not-dispatched" + elif ($rows | length) == 0 then "unrecorded" + elif $rows[-1].status == "cli-success" then "cli-success" else "uncertain" end), + events: [$rows[].status]}]}' > "$PUBLISH_ROOT/outcomes.pending.json" + mv -- "$PUBLISH_ROOT/outcomes.pending.json" "$PUBLISH_ROOT/outcomes.json" + + - name: Preserve publication outcomes + if: ${{ false && always() && steps.publish-outcomes.outcome == 'success' }} + id: stored-outcomes + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nuget-publish-outcomes-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.verified-handoff.outputs.root }}/outcomes.json + if-no-files-found: error + retention-days: 90 + overwrite: false + include-hidden-files: false + + - name: Remove private publishing files + if: ${{ false && always() && steps.verified-handoff.outputs.root != '' && (steps.publish-intent.outcome != 'success' || steps.stored-outcomes.outcome == 'success') }} + shell: bash + env: + PUBLISH_ROOT: ${{ steps.verified-handoff.outputs.root }} + run: | + set -euo pipefail + test "$(dirname -- "$PUBLISH_ROOT")" = "$RUNNER_TEMP" + [[ "$(basename -- "$PUBLISH_ROOT")" =~ ^nuget-publish\.[A-Za-z0-9]{8}$ ]] + test -d "$PUBLISH_ROOT" && test ! -L "$PUBLISH_ROOT" + test "$(realpath -e -- "$PUBLISH_ROOT")" = "$PUBLISH_ROOT" + rm -rf -- "$PUBLISH_ROOT" + + - name: Remove verifier and downloaded handoff + if: ${{ false && always() && (steps.publish-intent.outcome != 'success' || steps.stored-outcomes.outcome == 'success') }} + env: + BOOTSTRAP_ROOT: ${{ steps.bootstrap.outputs.root }} + HANDOFF_ROOT: ${{ steps.download.outputs.root }} + run: | + set -euo pipefail + for root in "$BOOTSTRAP_ROOT" "$HANDOFF_ROOT"; do + if test -z "$root"; then continue; fi + test "$(dirname -- "$root")" = "$RUNNER_TEMP" + [[ "$(basename -- "$root")" =~ ^nuget-(verifier|handoff)\.[A-Za-z0-9]{8}$ ]] + test -d "$root" && test ! -L "$root" + test "$(realpath -e -- "$root")" = "$root" + rm -rf -- "$root" + done diff --git a/scripts/sonar/publication/release-tags.ruleset.json b/scripts/sonar/publication/release-tags.ruleset.json new file mode 100644 index 0000000..adaf133 --- /dev/null +++ b/scripts/sonar/publication/release-tags.ruleset.json @@ -0,0 +1,23 @@ +{ + "name": "Immutable NuGet release tags", + "target": "tag", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": ["refs/tags/v*"], + "exclude": [] + } + }, + "rules": [ + { + "type": "update", + "parameters": { + "update_allows_fetch_and_merge": false + } + }, + { + "type": "deletion" + } + ] +} diff --git a/src/AndreGoepel.Core/Result.cs b/src/AndreGoepel.Core/Result.cs index 087f1f4..70f469d 100644 --- a/src/AndreGoepel.Core/Result.cs +++ b/src/AndreGoepel.Core/Result.cs @@ -18,11 +18,21 @@ public record Result public static Result Ok() => new() { IsSuccess = true }; - public static Result Fail(string error) => new() { IsSuccess = false, Error = error }; + public static Result Fail(string error) + { + ArgumentNullException.ThrowIfNull(error); + + return new() { IsSuccess = false, Error = error }; + } public static Result Ok(T value) => new() { IsSuccess = true, Value = value }; - public static Result Fail(string error) => new() { IsSuccess = false, Error = error }; + public static Result Fail(string error) + { + ArgumentNullException.ThrowIfNull(error); + + return new() { IsSuccess = false, Error = error }; + } } /// diff --git a/tests/AndreGoepel.Core.Tests/Result.Tests.cs b/tests/AndreGoepel.Core.Tests/Result.Tests.cs index 34d1d31..f7e87ff 100644 --- a/tests/AndreGoepel.Core.Tests/Result.Tests.cs +++ b/tests/AndreGoepel.Core.Tests/Result.Tests.cs @@ -2,6 +2,28 @@ namespace AndreGoepel.Core.Tests; public sealed class ResultTests { + [Fact] + public void Fail_NullError_ThrowsArgumentNullException() + { + // Act + var error = Record.Exception(() => Result.Fail(null!)); + + // Assert + var exception = Assert.IsType(error); + Assert.Equal("error", exception.ParamName); + } + + [Fact] + public void FailOfT_NullError_ThrowsArgumentNullException() + { + // Act + var error = Record.Exception(() => Result.Fail(null!)); + + // Assert + var exception = Assert.IsType(error); + Assert.Equal("error", exception.ParamName); + } + [Fact] public void Ok_Called_ReturnsSuccessWithNoError() { From f524863693b739fd32914c005f4ccc1adda697b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 24 Oct 2026 16:17:54 +0800 Subject: [PATCH 2/2] fix: derive publication outcome counts from package inventory --- docs/nuget-quality-gate.md | 7 ++ scripts/sonar/publication/nuget-publish.yml | 5 +- scripts/sonar/publication/tests/outcomes.sh | 103 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 scripts/sonar/publication/tests/outcomes.sh diff --git a/docs/nuget-quality-gate.md b/docs/nuget-quality-gate.md index c994bf8..4d1a2da 100644 --- a/docs/nuget-quality-gate.md +++ b/docs/nuget-quality-gate.md @@ -84,6 +84,13 @@ enabled pipeline, current live checks, protected approval and scoped NuGet polic The C# tool cannot log in to NuGet or upload a package. Rehearsal uses an in-memory sink; actual publication uses only NuGet/login and dotnet's native push command. +Run `bash scripts/sonar/publication/tests/outcomes.sh` on Linux with Bash, jq and +the standard GNU utilities to test the template's actual outcome-recording step +offline. It uses synthetic intent/event files, without credentials or uploads, +and checks successful single/multiple-package batches, partial outcomes and +malformed event sequences. The expected event count comes from the verified file +inventory: one `started` and one terminal event per package or symbol file. + ## Controlled cutover and rollback This sequence is proposed, not executed: diff --git a/scripts/sonar/publication/nuget-publish.yml b/scripts/sonar/publication/nuget-publish.yml index 6365372..5b1fbdd 100644 --- a/scripts/sonar/publication/nuget-publish.yml +++ b/scripts/sonar/publication/nuget-publish.yml @@ -396,7 +396,8 @@ jobs: --arg hash "$INTENT_SHA256" --arg artifact "$INTENT_ARTIFACT_ID" --arg digest "$INTENT_ARTIFACT_DIGEST" \ --arg login "$LOGIN_OUTCOME" --arg push "$PUSH_OUTCOME" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" ' $intent[0] as $i | $events[0] as $e | - if ($e | length) > 24 then error("Too many attempt rows") else . end | + (($i.packages | length) * 2) as $expectedRows | + if ($e | length) > $expectedRows then error("Too many attempt rows") else . end | reduce range(0; $e | length) as $n (.; if $e[$n].file != $i.packages[($n / 2 | floor)].file or (if $n % 2 == 0 then $e[$n].status != "started" @@ -406,7 +407,7 @@ jobs: then error("Invalid attempt sequence") else . end) | if ($push == "skipped" and ($e | length) != 0) or ($push != "skipped" and $login != "success") or - ($push == "success" and (($e | length) != 24 or any($e[]; .status != "started" and .status != "cli-success"))) + ($push == "success" and (($e | length) != $expectedRows or any($e[]; .status != "started" and .status != "cli-success"))) then error("Inconsistent step outcomes") else . end | {schemaVersion: 1, kind: "publication-outcomes", intentSha256: $hash, intentArtifactId: $artifact, intentArtifactDigest: $digest, diff --git a/scripts/sonar/publication/tests/outcomes.sh b/scripts/sonar/publication/tests/outcomes.sh new file mode 100644 index 0000000..eed9e5e --- /dev/null +++ b/scripts/sonar/publication/tests/outcomes.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Offline regression tests for the actual inactive template's outcome recorder. +set -euo pipefail + +template=${1:-"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)/nuget-publish.yml"} +workspace=$(mktemp -d) +trap 'rm -rf -- "$workspace"' EXIT +export RUNNER_TEMP="$workspace" +export INTENT_ARTIFACT_ID=123 +export INTENT_ARTIFACT_DIGEST +INTENT_ARTIFACT_DIGEST=$(printf 'a%.0s' {1..64}) + +# Extract only this step's run block, so tests exercise the shipped Bash/jq. +awk ' + /^ - name: Record publication outcomes$/ { in_step = 1; next } + in_step && /^ run: \|$/ { in_run = 1; found = 1; next } + in_run && /^ / { print substr($0, 11); next } + in_run { exit } + END { if (!found) exit 1 } +' "$template" > "$workspace/record.sh" +test -s "$workspace/record.sh" + +run_case() { + local name=$1 count=$2 rows=$3 login=$4 push=$5 error=$6 observations=$7 + local root file index code + + # Arrange + root=$(mktemp -d "$workspace/nuget-publish.XXXXXXXX") + jq -n --argjson count "$count" '{ + repository: "andregoepel/core", sha: "fixture", publishRunId: 42, + publishAttempt: 1, workflowRef: "fixture", workflowRevision: "fixture", + packages: [range(0; $count) | { + file: ("Fixture." + ((. / 2 | floor) | tostring) + ".1.0.0" + + (if . % 2 == 0 then ".nupkg" else ".snupkg" end)), + sha256: "fixture" + }] + }' > "$root/intent.json" + if [[ "$rows" != absent ]]; then + : > "$root/attempts.tsv" + index=0 + while IFS= read -r file; do + case "$rows" in + incomplete) [[ $index -eq 0 ]] || break ;; + partial) [[ $index -eq 0 ]] || break ;; + esac + printf '%s\tstarted\n' "$file" >> "$root/attempts.tsv" + if [[ "$rows" == partial ]]; then break; fi + if [[ "$rows" == uncertain && $index -eq 1 ]]; then + printf '%s\tuncertain-exit-1\n' "$file" >> "$root/attempts.tsv" + break + fi + printf '%s\tcli-success\n' "$file" >> "$root/attempts.tsv" + index=$((index + 1)) + done < <(jq -r '.packages[].file' "$root/intent.json") + case "$rows" in + extra) printf 'extra.nupkg\tstarted\n' >> "$root/attempts.tsv" ;; + reordered) sort -r "$root/attempts.tsv" > "$root/reordered.tsv"; mv "$root/reordered.tsv" "$root/attempts.tsv" ;; + esac + fi + export PUBLISH_ROOT="$root" LOGIN_OUTCOME="$login" PUSH_OUTCOME="$push" + INTENT_SHA256=$(sha256sum -- "$root/intent.json") + export INTENT_SHA256=${INTENT_SHA256%% *} + + # Act + code=0 + bash "$workspace/record.sh" > "$root/stdout.log" 2> "$root/stderr.log" || code=$? + + # Assert + if [[ -n "$error" ]]; then + if [[ $code -eq 0 ]] || [[ -e "$root/outcomes.json" ]] || ! grep -Fq -- "$error" "$root/stderr.log"; then + printf 'FAIL %s: expected rejection: %s\n' "$name" "$error" >&2 + cat "$root/stderr.log" >&2 + exit 1 + fi + else + if [[ $code -ne 0 ]]; then + printf 'FAIL %s: recorder exited %s\n' "$name" "$code" >&2 + cat "$root/stderr.log" >&2 + exit 1 + fi + jq -e --slurpfile intent "$root/intent.json" --argjson expected "$observations" \ + --arg hash "$INTENT_SHA256" --arg login "$login" --arg push "$push" ' + .schemaVersion == 1 and .kind == "publication-outcomes" and + .intentSha256 == $hash and .loginOutcome == $login and .pushOutcome == $push and + .authorizesRetry == false and .remoteAcceptanceVerified == false and + (.packages | map(.cliObservation)) == $expected and + (.packages | map(del(.cliObservation, .events))) == $intent[0].packages + ' "$root/outcomes.json" > /dev/null + fi + printf 'PASS %s\n' "$name" +} + +run_case single-package-success 2 complete success success '' '["cli-success","cli-success"]' +run_case three-package-success 6 complete success success '' '["cli-success","cli-success","cli-success","cli-success","cli-success","cli-success"]' +run_case six-package-success 12 complete success success '' '["cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success","cli-success"]' +run_case uncertain-second-file 2 uncertain success failure '' '["cli-success","uncertain"]' +run_case interrupted-first-file 2 partial success cancelled '' '["uncertain","unrecorded"]' +run_case failed-login 2 absent failure skipped '' '["not-dispatched","not-dispatched"]' +run_case too-many-rows 2 extra success success 'Too many attempt rows' '[]' +run_case incomplete-success 2 incomplete success success 'Inconsistent step outcomes' '[]' +run_case reordered-rows 2 reordered success success 'Invalid attempt sequence' '[]' +run_case skipped-push-with-events 2 complete success skipped 'Inconsistent step outcomes' '[]' +run_case push-with-failed-login 2 complete failure success 'Inconsistent step outcomes' '[]'