diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9153ce..ce0a597 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: # to both ran every PR twice. A branch with no PR gets no run, which is the trade. push: branches: [master] + tags: ['v*'] pull_request: workflow_dispatch: @@ -302,7 +303,11 @@ jobs: publish: name: publish prerelease needs: [build, wasm] - if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' + # Also on a v* tag, where the push to GitHub Packages is a no-op against an existing + # version but the packed artifact is what release.yml then sends to nuget.org. + if: >- + (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + && github.event_name != 'pull_request' runs-on: ubuntu-latest steps: @@ -319,5 +324,14 @@ jobs: - name: Pack run: dotnet pack ImpromptuInterface/ImpromptuInterface.csproj --configuration Release --output packages + # Before the push, not after: release.yml republishes exactly these to nuget.org when the + # commit is tagged, rather than packing again, so what ships is the artifact this run's + # tests passed against - and a failed push to GitHub Packages must not cost it that. + - name: Upload the packages + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: packages/*.nupkg + - name: Push to GitHub Packages run: dotnet nuget push 'packages/*.nupkg' --source https://nuget.pkg.github.com/ekonbenefits/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..614e2d1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +# Publishes to nuget.org when a v* tag is pushed, and creates the GitHub Release. +# +# It republishes the packages build.yml's own prerelease job produced for that commit rather +# than packing a second time, so what reaches nuget.org is the artifact that run's tests passed +# against - not a separately-produced, almost-certainly-identical set that never went through +# them. That is why it triggers off build.yml completing rather than off the tag directly. +# +# A workflow_run trigger is always read from the default branch, whatever is in the tag being +# released, so this file has to be on master before it will fire for any future tag. +# +# Authentication is nuget.org Trusted Publishing (OIDC): no long-lived API key in a secret, +# only NUGET_USER, which is a plain account name rather than a credential. It requires a +# Trusted Publishing policy on nuget.org for this exact repository AND this exact workflow +# filename - so renaming this file breaks publishing until the policy is updated to match. +name: release + +# Not workflow_dispatch: every step here reads github.event.workflow_run, which a manual run +# does not have. Re-run this workflow from the failed run's own page instead. +on: + workflow_run: + workflows: [build] + types: [completed] + +jobs: + publish: + name: publish to nuget.org + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v') + runs-on: ubuntu-latest + permissions: + id-token: write # the OIDC token Trusted Publishing exchanges for a temporary key + contents: write # creating the Release + actions: read # reading the build run's artifact; an explicit map makes the rest none + + steps: + # head_branch is the ref name for either kind of push, so a *branch* called "v-something" + # would otherwise satisfy the condition above. Publishing is irreversible; check. + - name: Verify the ref is a tag, still pointing at the commit that was tested + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.event.workflow_run.head_branch }} + TESTED_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + if ! tag_sha=$(gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" --jq '.object.sha' 2>/dev/null); then + echo "::error::'$REF_NAME' is not a tag in this repository - refusing to publish." + exit 1 + fi + + # An annotated tag's ref points at the tag object; dereference to the commit. + type=$(gh api "repos/${{ github.repository }}/git/ref/tags/$REF_NAME" --jq '.object.type') + if [ "$type" = "tag" ]; then + tag_sha=$(gh api "repos/${{ github.repository }}/git/tags/$tag_sha" --jq '.object.sha') + fi + + if [ "$tag_sha" != "$TESTED_SHA" ]; then + echo "::error::'$REF_NAME' now points at $tag_sha, but the tested build was $TESTED_SHA." + echo "The tag moved after the build; refusing to publish packages under it." + exit 1 + fi + echo "Confirmed '$REF_NAME' is a tag at the tested commit $TESTED_SHA." + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Download the packages that run's tests passed against + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: packages + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Show what is about to be published + run: ls -l packages + + - name: NuGet login (OIDC -> temporary API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push to nuget.org + shell: bash + run: | + set -e + for pkg in packages/*.nupkg; do + dotnet nuget push "$pkg" \ + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done + + # Idempotent, so re-running a release after a partial failure works: --skip-duplicate + # covers the pushes, and this covers the Release. + - name: Create or update the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.event.workflow_run.head_branch }} + run: | + set -e + if gh release view "$REF_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $REF_NAME exists; refreshing its assets." + gh release upload "$REF_NAME" packages/*.nupkg \ + --repo "${{ github.repository }}" --clobber + else + gh release create "$REF_NAME" packages/*.nupkg \ + --repo "${{ github.repository }}" \ + --title "$REF_NAME" \ + --generate-notes \ + --verify-tag + fi