diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d3be25..62014e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,16 @@ on: required: false default: false type: boolean + publish_nuget: + description: Publish packages to NuGet.org + required: false + default: false + type: boolean + dry_run_nuget: + description: Dry-run NuGet publish (simulate push commands) + required: false + default: true + type: boolean github-env: description: GitHub environment for code signing secrets (auto, test, or prod). required: false @@ -35,6 +45,8 @@ jobs: release_target: ${{ steps.validate.outputs.release_target }} release_version: ${{ steps.validate.outputs.release_version }} dry_run: ${{ steps.validate.outputs.dry_run }} + publish_nuget: ${{ steps.validate.outputs.publish_nuget }} + dry_run_nuget: ${{ steps.validate.outputs.dry_run_nuget }} publish_env: ${{ steps.validate.outputs.publish_env }} steps: @@ -53,6 +65,8 @@ jobs: CHECKOUT_REF: ${{ github.ref }} SOURCE_BRANCH: ${{ github.ref_name }} DRY_RUN: ${{ github.event.inputs.dry_run }} + PUBLISH_NUGET: ${{ github.event.inputs.publish_nuget }} + DRY_RUN_NUGET: ${{ github.event.inputs.dry_run_nuget }} REQUESTED_ENVIRONMENT: ${{ inputs['github-env'] }} run: | dry_run="$(echo "${DRY_RUN}" | tr '[:upper:]' '[:lower:]')" @@ -60,6 +74,16 @@ jobs: dry_run="false" fi + publish_nuget="$(echo "${PUBLISH_NUGET}" | tr '[:upper:]' '[:lower:]')" + if [[ "${publish_nuget}" != "true" && "${publish_nuget}" != "false" ]]; then + publish_nuget="false" + fi + + dry_run_nuget="$(echo "${DRY_RUN_NUGET}" | tr '[:upper:]' '[:lower:]')" + if [[ "${dry_run_nuget}" != "true" && "${dry_run_nuget}" != "false" ]]; then + dry_run_nuget="true" + fi + requested_environment="$(echo "${REQUESTED_ENVIRONMENT:-auto}" | tr '[:upper:]' '[:lower:]')" case "${requested_environment}" in auto) @@ -98,6 +122,10 @@ jobs: release_version="${multi_version}" + if [[ "${dry_run}" == "true" || "${SOURCE_BRANCH}" != "master" || "${publish_nuget}" != "true" ]]; then + dry_run_nuget="true" + fi + if [[ "${dry_run}" != "true" ]]; then if [[ -z "${tag}" ]]; then echo "Release tag is required when dry_run is false." >&2 @@ -124,8 +152,13 @@ jobs: echo "release_target=${release_target}" >> "$GITHUB_OUTPUT" echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT" + echo "publish_nuget=${publish_nuget}" >> "$GITHUB_OUTPUT" + echo "dry_run_nuget=${dry_run_nuget}" >> "$GITHUB_OUTPUT" echo "publish_env=${publish_env}" >> "$GITHUB_OUTPUT" + echo "::notice::Publish NuGet: ${publish_nuget}" + echo "::notice::NuGet dry-run: ${dry_run_nuget}" + build: name: Build ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -641,3 +674,108 @@ jobs: gh release create @createArgs } + + publish-nuget: + name: Publish NuGet packages + runs-on: ubuntu-latest + needs: + - validate + - publish + if: needs.validate.outputs.publish_nuget == 'true' + environment: ${{ needs.validate.outputs.publish_env }} + permissions: + id-token: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.checkout_ref }} + + - name: Resolve publish mode + id: publish_mode + shell: pwsh + env: + NUGET_BOT_USERNAME: ${{ secrets.NUGET_BOT_USERNAME }} + run: | + $dryRun = [System.Boolean]::Parse('${{ needs.validate.outputs.dry_run_nuget }}') + $hasLoginUser = -not [string]::IsNullOrWhiteSpace($env:NUGET_BOT_USERNAME) + + "dry_run=$($dryRun.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "has_login_user=$($hasLoginUser.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + if (-not $dryRun -and -not $hasLoginUser) { + throw 'NUGET_BOT_USERNAME is required when dry_run_nuget is false.' + } + + Write-Host "NuGet dry-run mode: $dryRun" + + - name: Download native NuGet package artifacts + if: steps.publish_mode.outputs.dry_run == 'true' || steps.publish_mode.outputs.has_login_user == 'true' + uses: actions/download-artifact@v4 + with: + name: native-nuget + path: dist + + - name: Install .NET SDK + if: steps.publish_mode.outputs.dry_run == 'true' || steps.publish_mode.outputs.has_login_user == 'true' + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: NuGet login (OIDC) + if: steps.publish_mode.outputs.dry_run != 'true' && steps.publish_mode.outputs.has_login_user == 'true' + id: nuget-login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_BOT_USERNAME }} + + - name: Publish to NuGet.org + if: steps.publish_mode.outputs.dry_run == 'true' || steps.publish_mode.outputs.has_login_user == 'true' + shell: pwsh + env: + NUGET_SOURCE: https://api.nuget.org/v3/index.json + run: | + $dryRun = [System.Boolean]::Parse('${{ steps.publish_mode.outputs.dry_run }}') + $apiKey = '${{ steps.nuget-login.outputs.NUGET_API_KEY }}' + + if ($dryRun) { + $apiKey = 'dry-run-key' + Write-Host 'Dry Run: NuGet push commands will be printed but not executed.' + } + elseif ([string]::IsNullOrWhiteSpace($apiKey)) { + throw 'NuGet login did not return an API key.' + } + + $packagePatterns = @( + 'Devolutions.MultiPwsh.Cli.*.nupkg', + 'Devolutions.MultiPwsh.AppHost.*.nupkg' + ) + + $packages = @() + foreach ($pattern in $packagePatterns) { + $matchingPackages = Get-ChildItem -Path dist -Recurse -File -Filter $pattern | Sort-Object Name + if (-not $matchingPackages) { + throw "Missing NuGet package artifact matching '$pattern'." + } + + $packages += $matchingPackages + } + + foreach ($package in $packages) { + $pushArgs = @( + 'nuget', 'push', "$($package.FullName)", + '--api-key', "$apiKey", + '--source', "$env:NUGET_SOURCE", + '--skip-duplicate', '--no-symbols' + ) + + Write-Host "dotnet nuget push `"$($package.FullName)`" --api-key *** --source `"$env:NUGET_SOURCE`" --skip-duplicate --no-symbols" + if (-not $dryRun) { + & dotnet @pushArgs + if ($LASTEXITCODE -ne 0) { + throw "dotnet nuget push failed for $($package.Name) with exit code $LASTEXITCODE." + } + } + } diff --git a/RELEASE.md b/RELEASE.md index 5670081..b77bdf0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -35,10 +35,13 @@ Dispatch `.github/workflows/release.yml` from the branch or commit you want to r - For an actual publish, set `dry_run` to `false` and provide the matching `tag` value, for example `v0.9.0`. - For an inspection build, set `dry_run` to `true`. This builds and packs artifacts, uploads the `.nupkg` as a workflow artifact, and skips GitHub release publishing. -- Set `github-env` to `auto` unless you need to force signing secrets from `publish-test` or `publish-prod`. In `auto`, runs from `master` use `publish-prod`; other branches use `publish-test`. +- To publish packages to NuGet.org, set `publish_nuget` to `true` and `dry_run_nuget` to `false`. NuGet publishing uses trusted publishing through the selected GitHub environment's `NUGET_BOT_USERNAME` secret. +- Set `github-env` to `auto` unless you need to force signing and NuGet publishing secrets from `publish-test` or `publish-prod`. In `auto`, runs from `master` use `publish-prod`; other branches use `publish-test`. You do **not** need to create the tag ahead of time. If the tag does not exist yet, the workflow creates it at the dispatched commit when it creates the GitHub release. +NuGet publishing is dry-run by default, and it is forced to dry-run when `dry_run` is `true`, when the workflow is not dispatched from `master`, or when `publish_nuget` is `false`. + The workflow: - validates that the tag input matches both crate versions @@ -46,7 +49,8 @@ The workflow: - signs Windows executables on a Linux runner with Devolutions `psign` and the selected environment's Key Vault secrets - creates the tag at that commit if needed - uploads archives and `checksums.txt` to the GitHub release, with Windows archives containing signed executables -- builds and uploads `Devolutions.MultiPwsh.Cli..nupkg` to the GitHub release, with signed Windows payloads under `runtimes\win-*\native` +- builds and uploads `Devolutions.MultiPwsh.Cli..nupkg` and `Devolutions.MultiPwsh.AppHost..nupkg` to the GitHub release, with signed Windows payloads under `runtimes\win-*\native` +- optionally publishes `Devolutions.MultiPwsh.Cli..nupkg` and `Devolutions.MultiPwsh.AppHost..nupkg` to NuGet.org - uploads install/uninstall bootstrap scripts to the GitHub release so users do not need `raw.githubusercontent.com` If the release already exists, the workflow uploads the refreshed assets with `--clobber`. @@ -55,7 +59,7 @@ Non-dry-run releases fail if code-signing secrets are unavailable. Dry runs sign ### Dry-run artifact download -When `dry_run` is `true`, download the `cli-nuget` artifact from the workflow run page to inspect `Devolutions.MultiPwsh.Cli..nupkg`. +When `dry_run` is `true`, download the `native-nuget` artifact from the workflow run page to inspect the `.nupkg` files. ## 4. Verify release assets @@ -63,6 +67,7 @@ Confirm the release contains: - all platform zip archives - `Devolutions.MultiPwsh.Cli..nupkg` +- `Devolutions.MultiPwsh.AppHost..nupkg` - `checksums.txt` - `install-multi-pwsh.ps1` and `install-multi-pwsh.sh` - `uninstall-multi-pwsh.ps1` and `uninstall-multi-pwsh.sh`