From d8c9fe376bd1a06c30b2000181928d589393d5e3 Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Thu, 13 Aug 2026 13:22:51 -0400 Subject: [PATCH 1/2] Harden package release workflows --- .github/dependabot.yml | 10 +++ .github/scripts/verify-published-package.sh | 16 ++++ .github/workflows/_publish-package.yml | 81 +++++++++++++++------ .github/workflows/deploy.yml | 19 ++--- .github/workflows/preview.yml | 24 +++--- .github/workflows/publish-docs.yml | 1 + .github/workflows/publish.yml | 1 + README.md | 2 +- 8 files changed, 105 insertions(+), 49 deletions(-) create mode 100644 .github/dependabot.yml create mode 100755 .github/scripts/verify-published-package.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c5049e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/scripts/verify-published-package.sh b/.github/scripts/verify-published-package.sh new file mode 100755 index 0000000..bd8509a --- /dev/null +++ b/.github/scripts/verify-published-package.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +package=${1:?package path is required} +published_package=${2:?published package path is required} +work_directory=${3:-"$RUNNER_TEMP/verify-published-package"} + +rm -rf "$work_directory" +mkdir -p "$work_directory/expected" "$work_directory/published" +unzip -qq "$package" -d "$work_directory/expected" +unzip -qq "$published_package" -d "$work_directory/published" + +# NuGet.org automatically repository-signs uploaded packages. +rm -f "$work_directory/published/.signature.p7s" + +diff -qr "$work_directory/expected" "$work_directory/published" diff --git a/.github/workflows/_publish-package.yml b/.github/workflows/_publish-package.yml index 535c5fb..b348294 100644 --- a/.github/workflows/_publish-package.yml +++ b/.github/workflows/_publish-package.yml @@ -18,6 +18,10 @@ on: description: Minimum published FSharp.ViewEngine version required by Docs required: false type: string + markLatest: + description: Whether this package release becomes the repository-wide Latest release + required: true + type: boolean permissions: contents: write @@ -31,6 +35,7 @@ jobs: package: name: Build and verify ${{ inputs.packageId }} runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read outputs: @@ -74,7 +79,7 @@ jobs: exit 1 ;; esac - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true @@ -92,7 +97,7 @@ jobs: exit 1 fi - name: Setup dotnet - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -156,7 +161,7 @@ jobs: run: sha256sum ./*.nupkg ./*.snupkg > SHA256SUMS working-directory: ./nugets - name: Upload verified package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.packageId }}-${{ steps.release.outputs.version }} path: | @@ -169,43 +174,53 @@ jobs: name: Publish ${{ inputs.packageId }} needs: package runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release permissions: contents: write id-token: write steps: - name: Setup dotnet - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x - name: Download verified package - uses: actions/download-artifact@v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.packageId }}-${{ needs.package.outputs.version }} path: ${{ runner.temp }}/package - name: Verify package checksums run: sha256sum --check SHA256SUMS working-directory: ${{ runner.temp }}/package - - name: Authenticate Pulumi - uses: pulumi/auth-actions@v1 + - name: Authenticate to NuGet with GitHub OIDC + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: - organization: meiermade - requested-token-type: urn:pulumi:token-type:access_token:personal - scope: user:meiermade - - name: Publish verified package + user: ${{ vars.NUGET_USER }} + - name: Publish or verify package env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} PACKAGE_DIRECTORY: ${{ runner.temp }}/package PACKAGE_ID: ${{ inputs.packageId }} VERSION: ${{ needs.package.outputs.version }} run: | package="$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.nupkg" test -f "$package" - pulumi env run fsharpviewengine/nuget -- \ - bash -euo pipefail -c ' - dotnet nuget push "$1" \ - --source https://api.nuget.org/v3/index.json \ - --api-key "$NUGET_API_KEY" \ - --skip-duplicate - ' bash "$package" + package_slug="${PACKAGE_ID,,}" + package_url="https://api.nuget.org/v3-flatcontainer/$package_slug/$VERSION/$package_slug.$VERSION.nupkg" + + if curl --fail --silent --show-error --output "$RUNNER_TEMP/published.nupkg" "$package_url"; then + if ! .github/scripts/verify-published-package.sh "$package" "$RUNNER_TEMP/published.nupkg"; then + echo "$PACKAGE_ID $VERSION already exists on NuGet but does not match the verified artifact after removing NuGet.org's repository signature." >&2 + exit 1 + fi + + echo "$PACKAGE_ID $VERSION already exists on NuGet and matches the verified artifact." + else + dotnet nuget push "$package" \ + --source https://api.nuget.org/v3/index.json \ + --api-key "$NUGET_API_KEY" + fi - name: Wait for NuGet availability env: PACKAGE_ID: ${{ inputs.packageId }} @@ -225,7 +240,7 @@ jobs: echo "$PACKAGE_ID $VERSION was not available from NuGet after 10 minutes." >&2 exit 1 - name: Check out published package commit - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.package.outputs.commit }} fetch-depth: 0 @@ -254,26 +269,44 @@ jobs: TAG: ${{ needs.package.outputs.tag }} PREVIOUS_TAG: ${{ needs.package.outputs.previousTag }} VERSION: ${{ needs.package.outputs.version }} + MARK_LATEST: ${{ inputs.markLatest }} PACKAGE_DIRECTORY: ${{ runner.temp }}/package run: | + release_assets=( + "$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.nupkg" + "$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.snupkg" + "$PACKAGE_DIRECTORY/SHA256SUMS" + ) + latest_args=(--latest=false) + if [[ "$MARK_LATEST" == "true" ]]; then latest_args=(--latest); fi + if gh release view "$TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - echo "GitHub Release $TAG already exists." + existing_assets=$(gh release view "$TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name' | sort) + expected_assets=$(printf '%s\n' "${release_assets[@]##*/}" | sort) + + if [[ "$existing_assets" != "$expected_assets" ]]; then + echo "GitHub Release $TAG does not contain exactly the verified package assets." >&2 + diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$existing_assets") || true + exit 1 + fi + + gh release edit "$TAG" --repo "$REPOSITORY" "${latest_args[@]}" + echo "GitHub Release $TAG already exists with the expected assets." else release_args=( --repo "$REPOSITORY" --verify-tag --title "$PACKAGE_ID $VERSION" + "${latest_args[@]}" ) if [[ -n "$PREVIOUS_TAG" ]]; then release_args+=(--generate-notes --notes-start-tag "$PREVIOUS_TAG") else - release_args+=(--notes "Initial $PACKAGE_ID package release. See the package changelog for release details.") + release_args+=(--notes "Initial $PACKAGE_ID package release. See https://fsharpviewengine.meiermade.com/changelog for release details.") fi gh release create "$TAG" \ "${release_args[@]}" \ - "$PACKAGE_DIRECTORY"/*.nupkg \ - "$PACKAGE_DIRECTORY"/*.snupkg \ - "$PACKAGE_DIRECTORY/SHA256SUMS" + "${release_assets[@]}" fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f2f128..dc83bf6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,7 @@ jobs: deploy: name: Deploy runs-on: ubuntu-latest + timeout-minutes: 15 environment: name: production url: https://fsharpviewengine.meiermade.com @@ -48,7 +49,7 @@ jobs: version: ${{ steps.release.outputs.version }} commit: ${{ steps.release.outputs.commit }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} - name: Resolve release metadata @@ -81,24 +82,20 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" echo "commit=$commit" >> "$GITHUB_OUTPUT" - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 - with: - install_components: gke-gcloud-auth-plugin - name: Install Packages run: npm ci working-directory: ./pulumi - name: Authenticate Pulumi - uses: pulumi/auth-actions@v1 + uses: pulumi/auth-actions@141415910c3beb54e03b48e9057c204c97b956f2 # v2.1.0 with: organization: meiermade requested-token-type: urn:pulumi:token-type:access_token:personal scope: user:meiermade - name: Deploy - uses: pulumi/actions@v6 + uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0 env: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_COMMIT: ${{ steps.release.outputs.commit }} @@ -115,11 +112,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.deploy.outputs.commit }} - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: npm @@ -160,7 +157,7 @@ jobs: DOCS_EXPECTED_COMMIT: ${{ needs.deploy.outputs.commit }} - name: Upload E2E diagnostics if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fsharp-viewengine-deploy-e2e path: | diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 7fc30ae..661c4e3 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -7,10 +7,11 @@ jobs: unit: name: Unit runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup dotnet - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -60,9 +61,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: npm @@ -78,7 +79,7 @@ jobs: working-directory: ./e2e - name: Upload E2E diagnostics if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fsharp-viewengine-preview-e2e path: | @@ -91,6 +92,7 @@ jobs: # Keep this terminal job named Test because it is the protected branch check. name: Test runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write @@ -99,26 +101,22 @@ jobs: - unit - e2e steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Setup gcloud - uses: google-github-actions/setup-gcloud@v2 - with: - install_components: gke-gcloud-auth-plugin - name: Install Packages run: npm ci working-directory: ./pulumi - name: Authenticate Pulumi - uses: pulumi/auth-actions@v1 + uses: pulumi/auth-actions@141415910c3beb54e03b48e9057c204c97b956f2 # v2.1.0 with: organization: meiermade requested-token-type: urn:pulumi:token-type:access_token:personal scope: user:meiermade - name: Preview - uses: pulumi/actions@v6 + uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d # v7.0.0 env: RELEASE_VERSION: preview RELEASE_COMMIT: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a626ac6..758e3a2 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -23,4 +23,5 @@ jobs: tagPrefix: docs/v version: ${{ inputs.version }} minimumCoreVersion: ${{ inputs.minimumCoreVersion }} + markLatest: false secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 94f86d0..8470ac0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,4 +18,5 @@ jobs: packageId: FSharp.ViewEngine tagPrefix: v version: ${{ inputs.version }} + markLatest: true secrets: inherit diff --git a/README.md b/README.md index 75372de..6fbf3e7 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The two NuGet packages have independent release trains: - `FSharp.ViewEngine` uses tags such as `v2026.8.1` and the **Publish Core** workflow. - `FSharp.ViewEngine.Docs` uses tags such as `docs/v2026.8.0` and the **Publish Docs** workflow. -A Docs release declares its minimum compatible published Core version. Matching package versions are not required. Publish Core first whenever Docs needs APIs that are not already available on NuGet. Both publish workflows require an explicit version and a matching released-package changelog entry. Directly packing `FSharp.ViewEngine.Docs` also requires explicit Docs and minimum Core MSBuild version properties so it cannot silently produce incorrect dependency metadata. Documentation-site deployment is separate from package publication and deploys the selected source revision through the **Deploy** workflow. +A Docs release declares its minimum compatible published Core version. Matching package versions are not required. Publish Core first whenever Docs needs APIs that are not already available on NuGet. Both publish workflows require an explicit version and a matching released-package changelog entry, publish from the protected `release` environment with NuGet Trusted Publishing, and reconcile retries against the verified package artifacts. Core releases become the repository-wide GitHub “Latest” release; Docs releases do not. Directly packing `FSharp.ViewEngine.Docs` also requires explicit Docs and minimum Core MSBuild version properties so it cannot silently produce incorrect dependency metadata. Documentation-site deployment is separate from package publication and deploys the selected source revision through the **Deploy** workflow. ## Core rendering helpers From 6d17058827133d06fca75389c3ecf6f17d001a4e Mon Sep 17 00:00:00 2001 From: Andy Meier Date: Thu, 13 Aug 2026 13:57:33 -0400 Subject: [PATCH 2/2] Centralize release operations in FAKE --- .github/scripts/verify-published-package.sh | 16 -- .github/workflows/_publish-package.yml | 229 +++---------------- .github/workflows/publish-docs.yml | 1 - .github/workflows/publish.yml | 1 - sln/FSharp.ViewEngine.slnx | 1 + sln/src/Build.Tests/Build.Tests.fsproj | 15 ++ sln/src/Build.Tests/Program.fs | 123 ++++++++++ sln/src/Build.Tests/paket.references | 1 + sln/src/Build/Build.fsproj | 1 + sln/src/Build/PackagePublishing.fs | 237 ++++++++++++++++++++ sln/src/Build/Program.fs | 107 ++++++--- sln/src/Build/Release.fs | 28 ++- 12 files changed, 514 insertions(+), 246 deletions(-) delete mode 100755 .github/scripts/verify-published-package.sh create mode 100644 sln/src/Build.Tests/Build.Tests.fsproj create mode 100644 sln/src/Build.Tests/Program.fs create mode 100644 sln/src/Build.Tests/paket.references create mode 100644 sln/src/Build/PackagePublishing.fs diff --git a/.github/scripts/verify-published-package.sh b/.github/scripts/verify-published-package.sh deleted file mode 100755 index bd8509a..0000000 --- a/.github/scripts/verify-published-package.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -package=${1:?package path is required} -published_package=${2:?published package path is required} -work_directory=${3:-"$RUNNER_TEMP/verify-published-package"} - -rm -rf "$work_directory" -mkdir -p "$work_directory/expected" "$work_directory/published" -unzip -qq "$package" -d "$work_directory/expected" -unzip -qq "$published_package" -d "$work_directory/published" - -# NuGet.org automatically repository-signs uploaded packages. -rm -f "$work_directory/published/.signature.p7s" - -diff -qr "$work_directory/expected" "$work_directory/published" diff --git a/.github/workflows/_publish-package.yml b/.github/workflows/_publish-package.yml index b348294..94eccfb 100644 --- a/.github/workflows/_publish-package.yml +++ b/.github/workflows/_publish-package.yml @@ -6,10 +6,6 @@ on: description: NuGet package ID required: true type: string - tagPrefix: - description: Package-specific release tag prefix - required: true - type: string version: description: Package calendar version (YYYY.M.MINOR) required: true @@ -42,60 +38,12 @@ jobs: tag: ${{ steps.release.outputs.tag }} version: ${{ steps.release.outputs.version }} commit: ${{ steps.release.outputs.commit }} - previousTag: ${{ steps.previous-tag.outputs.tag }} + previousTag: ${{ steps.release.outputs.previousTag }} steps: - - name: Require main branch - run: | - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then - echo "Releases must run from main, not $GITHUB_REF." >&2 - exit 1 - fi - - name: Validate package inputs - env: - PACKAGE_ID: ${{ inputs.packageId }} - VERSION: ${{ inputs.version }} - MINIMUM_CORE_VERSION: ${{ inputs.minimumCoreVersion }} - run: | - if [[ ! "$VERSION" =~ ^[0-9]{4}\.[0-9]{1,2}\.[0-9]+$ ]]; then - echo "Package releases require an explicit version in YYYY.M.MINOR form." >&2 - exit 1 - fi - - case "$PACKAGE_ID" in - FSharp.ViewEngine) - if [[ -n "$MINIMUM_CORE_VERSION" ]]; then - echo "Core releases must not specify a minimum Core version." >&2 - exit 1 - fi - ;; - FSharp.ViewEngine.Docs) - if [[ ! "$MINIMUM_CORE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Docs releases require a minimum Core version in YYYY.M.MINOR form." >&2 - exit 1 - fi - ;; - *) - echo "Unsupported package: $PACKAGE_ID" >&2 - exit 1 - ;; - esac - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true - - name: Confirm changelog contains selected package release - env: - PACKAGE_ID: ${{ inputs.packageId }} - VERSION: ${{ inputs.version }} - run: | - changelog="sln/src/Docs/src/Pages/Changelog.fs" - marker="title = \"$PACKAGE_ID $VERSION ·" - matches=$(grep -F -c "$marker" "$changelog" || true) - - if [[ "$matches" -ne 1 ]]; then - echo "Expected exactly one released-package changelog entry beginning '$PACKAGE_ID $VERSION ·'; found $matches." >&2 - exit 1 - fi - name: Setup dotnet uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -109,47 +57,21 @@ jobs: dotnet paket install dotnet restore FSharp.ViewEngine.slnx working-directory: ./sln - - name: Confirm minimum Core package is published - if: inputs.packageId == 'FSharp.ViewEngine.Docs' - env: - CORE_VERSION: ${{ inputs.minimumCoreVersion }} - run: | - url="https://api.nuget.org/v3-flatcontainer/fsharp.viewengine/$CORE_VERSION/fsharp.viewengine.$CORE_VERSION.nupkg" - curl --fail --silent --show-error --output /dev/null "$url" || { - echo "FSharp.ViewEngine $CORE_VERSION must be available from NuGet before publishing Docs." >&2 - exit 1 - } - name: Prepare package release env: + PACKAGE_ID: ${{ inputs.packageId }} + PACKAGE_VERSION: ${{ inputs.version }} + DOCS_MINIMUM_CORE_VERSION: ${{ inputs.minimumCoreVersion }} + MARK_LATEST: ${{ inputs.markLatest }} RELEASE_METADATA_PATH: ${{ runner.temp }}/release-metadata.json - RELEASE_TAG_PREFIX: ${{ inputs.tagPrefix }} - RELEASE_VERSION: ${{ inputs.version }} - run: ./fake.sh PrepareRelease + run: ./fake.sh PrepareRelease --single-target working-directory: ./sln - name: Read release metadata id: release env: RELEASE_METADATA_PATH: ${{ runner.temp }}/release-metadata.json - run: | - { - echo "tag=$(jq -r '.tag' "$RELEASE_METADATA_PATH")" - echo "version=$(jq -r '.version' "$RELEASE_METADATA_PATH")" - echo "commit=$(jq -r '.commit' "$RELEASE_METADATA_PATH")" - } >> "$GITHUB_OUTPUT" - - name: Find previous package release tag - id: previous-tag - env: - RELEASE_TAG_PREFIX: ${{ inputs.tagPrefix }} - TAG: ${{ steps.release.outputs.tag }} - run: | - previous="" - while IFS= read -r candidate; do - if [[ "$candidate" == "$TAG" ]]; then - break - fi - previous="$candidate" - done < <({ git tag --list "$RELEASE_TAG_PREFIX*"; echo "$TAG"; } | sort -Vu) - echo "tag=$previous" >> "$GITHUB_OUTPUT" + run: ./fake.sh ReadReleaseMetadata --single-target + working-directory: ./sln - name: Build, test, pack, and verify selected package env: PACKAGE_ID: ${{ inputs.packageId }} @@ -158,8 +80,8 @@ jobs: run: ./fake.sh VerifyPackage working-directory: ./sln - name: Record package checksums - run: sha256sum ./*.nupkg ./*.snupkg > SHA256SUMS - working-directory: ./nugets + run: ./fake.sh RecordPackageChecksums --single-target + working-directory: ./sln - name: Upload verified package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -168,6 +90,7 @@ jobs: nugets/*.nupkg nugets/*.snupkg nugets/SHA256SUMS + ${{ runner.temp }}/release-metadata.json if-no-files-found: error publish: @@ -180,6 +103,11 @@ jobs: contents: write id-token: write steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.package.outputs.commit }} + fetch-depth: 0 + fetch-tags: true - name: Setup dotnet uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -189,124 +117,27 @@ jobs: with: name: ${{ inputs.packageId }}-${{ needs.package.outputs.version }} path: ${{ runner.temp }}/package - - name: Verify package checksums - run: sha256sum --check SHA256SUMS - working-directory: ${{ runner.temp }}/package + - name: Restore build tooling + run: | + dotnet tool restore + dotnet paket install + dotnet restore src/Build/Build.fsproj + working-directory: ./sln - name: Authenticate to NuGet with GitHub OIDC id: nuget-login uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 with: user: ${{ vars.NUGET_USER }} - - name: Publish or verify package + - name: Publish and reconcile package release env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} PACKAGE_DIRECTORY: ${{ runner.temp }}/package PACKAGE_ID: ${{ inputs.packageId }} - VERSION: ${{ needs.package.outputs.version }} - run: | - package="$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.nupkg" - test -f "$package" - package_slug="${PACKAGE_ID,,}" - package_url="https://api.nuget.org/v3-flatcontainer/$package_slug/$VERSION/$package_slug.$VERSION.nupkg" - - if curl --fail --silent --show-error --output "$RUNNER_TEMP/published.nupkg" "$package_url"; then - if ! .github/scripts/verify-published-package.sh "$package" "$RUNNER_TEMP/published.nupkg"; then - echo "$PACKAGE_ID $VERSION already exists on NuGet but does not match the verified artifact after removing NuGet.org's repository signature." >&2 - exit 1 - fi - - echo "$PACKAGE_ID $VERSION already exists on NuGet and matches the verified artifact." - else - dotnet nuget push "$package" \ - --source https://api.nuget.org/v3/index.json \ - --api-key "$NUGET_API_KEY" - fi - - name: Wait for NuGet availability - env: - PACKAGE_ID: ${{ inputs.packageId }} - VERSION: ${{ needs.package.outputs.version }} - run: | - package_slug="${PACKAGE_ID,,}" - package_url="https://api.nuget.org/v3-flatcontainer/$package_slug/$VERSION/$package_slug.$VERSION.nupkg" - - for _ in $(seq 1 60); do - if curl --fail --silent --show-error --output /dev/null "$package_url"; then - echo "$PACKAGE_ID $VERSION is available from NuGet." - exit 0 - fi - sleep 10 - done - - echo "$PACKAGE_ID $VERSION was not available from NuGet after 10 minutes." >&2 - exit 1 - - name: Check out published package commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.package.outputs.commit }} - fetch-depth: 0 - fetch-tags: true - - name: Create package release tag - env: - TAG: ${{ needs.package.outputs.tag }} - COMMIT: ${{ needs.package.outputs.commit }} - run: | - existing_commit=$(git rev-list -n 1 "$TAG" 2>/dev/null || true) - - if [[ -n "$existing_commit" && "$existing_commit" != "$COMMIT" ]]; then - echo "Release tag $TAG points to $existing_commit, not $COMMIT." >&2 - exit 1 - fi - - if [[ -z "$existing_commit" ]]; then - git tag "$TAG" "$COMMIT" - git push origin "refs/tags/$TAG" - fi - - name: Create package GitHub release - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - PACKAGE_ID: ${{ inputs.packageId }} - TAG: ${{ needs.package.outputs.tag }} - PREVIOUS_TAG: ${{ needs.package.outputs.previousTag }} - VERSION: ${{ needs.package.outputs.version }} + PACKAGE_VERSION: ${{ needs.package.outputs.version }} + DOCS_MINIMUM_CORE_VERSION: ${{ inputs.minimumCoreVersion }} MARK_LATEST: ${{ inputs.markLatest }} - PACKAGE_DIRECTORY: ${{ runner.temp }}/package - run: | - release_assets=( - "$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.nupkg" - "$PACKAGE_DIRECTORY/$PACKAGE_ID.$VERSION.snupkg" - "$PACKAGE_DIRECTORY/SHA256SUMS" - ) - latest_args=(--latest=false) - if [[ "$MARK_LATEST" == "true" ]]; then latest_args=(--latest); fi - - if gh release view "$TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - existing_assets=$(gh release view "$TAG" --repo "$REPOSITORY" --json assets --jq '.assets[].name' | sort) - expected_assets=$(printf '%s\n' "${release_assets[@]##*/}" | sort) - - if [[ "$existing_assets" != "$expected_assets" ]]; then - echo "GitHub Release $TAG does not contain exactly the verified package assets." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$existing_assets") || true - exit 1 - fi - - gh release edit "$TAG" --repo "$REPOSITORY" "${latest_args[@]}" - echo "GitHub Release $TAG already exists with the expected assets." - else - release_args=( - --repo "$REPOSITORY" - --verify-tag - --title "$PACKAGE_ID $VERSION" - "${latest_args[@]}" - ) - - if [[ -n "$PREVIOUS_TAG" ]]; then - release_args+=(--generate-notes --notes-start-tag "$PREVIOUS_TAG") - else - release_args+=(--notes "Initial $PACKAGE_ID package release. See https://fsharpviewengine.meiermade.com/changelog for release details.") - fi - - gh release create "$TAG" \ - "${release_args[@]}" \ - "${release_assets[@]}" - fi + RELEASE_METADATA_PATH: ${{ runner.temp }}/package/release-metadata.json + run: ./fake.sh PublishPackageRelease --single-target + working-directory: ./sln diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 758e3a2..a17d89f 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -20,7 +20,6 @@ jobs: uses: ./.github/workflows/_publish-package.yml with: packageId: FSharp.ViewEngine.Docs - tagPrefix: docs/v version: ${{ inputs.version }} minimumCoreVersion: ${{ inputs.minimumCoreVersion }} markLatest: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8470ac0..a06b766 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,6 @@ jobs: uses: ./.github/workflows/_publish-package.yml with: packageId: FSharp.ViewEngine - tagPrefix: v version: ${{ inputs.version }} markLatest: true secrets: inherit diff --git a/sln/FSharp.ViewEngine.slnx b/sln/FSharp.ViewEngine.slnx index 3a9bdc9..e7460e5 100644 --- a/sln/FSharp.ViewEngine.slnx +++ b/sln/FSharp.ViewEngine.slnx @@ -2,6 +2,7 @@ + diff --git a/sln/src/Build.Tests/Build.Tests.fsproj b/sln/src/Build.Tests/Build.Tests.fsproj new file mode 100644 index 0000000..cf10c57 --- /dev/null +++ b/sln/src/Build.Tests/Build.Tests.fsproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + false + + + + + + + + + + diff --git a/sln/src/Build.Tests/Program.fs b/sln/src/Build.Tests/Program.fs new file mode 100644 index 0000000..797dcdc --- /dev/null +++ b/sln/src/Build.Tests/Program.fs @@ -0,0 +1,123 @@ +module Build.Tests.Program + +open System +open System.IO +open System.IO.Compression +open Expecto + +let private writePackage path (entries:(string * string) list) = + use archive = ZipFile.Open(path, ZipArchiveMode.Create) + for name, content in entries do + let entry = archive.CreateEntry name + use writer = new StreamWriter(entry.Open()) + writer.Write content + +let tests = + testList "Package publishing" [ + test "Core release inputs select Core and Latest" { + let inputs = PackagePublishing.validateInputs "FSharp.ViewEngine" "2026.8.2" None true + Expect.equal inputs.package PackagePublishing.Package.ViewEngine "Core package" + Expect.equal inputs.version "2026.8.2" "Core version" + Expect.isNone inputs.minimumCoreVersion "Core has no Core dependency" + Expect.isTrue inputs.markLatest "Core is Latest" + } + + test "Docs release inputs require Core and remain non-Latest" { + let inputs = PackagePublishing.validateInputs "FSharp.ViewEngine.Docs" "2026.8.1" (Some "2026.8.2") false + Expect.equal inputs.package PackagePublishing.Package.Docs "Docs package" + Expect.equal inputs.minimumCoreVersion (Some "2026.8.2") "minimum Core" + Expect.isFalse inputs.markLatest "Docs is not Latest" + } + + testCase "Invalid package release inputs fail" <| fun _ -> + let invalidCases = [ + fun () -> PackagePublishing.validateInputs "Other" "2026.8.0" None false |> ignore + fun () -> PackagePublishing.validateInputs "FSharp.ViewEngine" "2026.8.0" (Some "2026.8.0") true |> ignore + fun () -> PackagePublishing.validateInputs "FSharp.ViewEngine.Docs" "2026.8.0" None false |> ignore + fun () -> PackagePublishing.validateInputs "FSharp.ViewEngine.Docs" "2026.8.0" (Some "2026.8.0") true |> ignore + fun () -> PackagePublishing.validateInputs "FSharp.ViewEngine" "preview" None true |> ignore + ] + for invalid in invalidCases do Expect.throws invalid "invalid release input" + + test "Changelog requires exactly one selected release" { + let text = "title = \"FSharp.ViewEngine 2026.8.2 · August 14, 2026\"" + PackagePublishing.validateChangelog "FSharp.ViewEngine" "2026.8.2" text + Expect.throws + (fun () -> PackagePublishing.validateChangelog "FSharp.ViewEngine.Docs" "2026.8.2" text) + "missing Docs release" + Expect.throws + (fun () -> PackagePublishing.validateChangelog "FSharp.ViewEngine" "2026.8.2" $"{text}\n{text}") + "duplicate Core release" + } + + test "Expected release assets are exact" { + Expect.sequenceEqual + (PackagePublishing.expectedAssetNames "FSharp.ViewEngine.Docs" "2026.8.0") + [ "FSharp.ViewEngine.Docs.2026.8.0.nupkg" + "FSharp.ViewEngine.Docs.2026.8.0.snupkg" + "SHA256SUMS" ] + "release assets" + } + + test "Release metadata preserves same-package previous tag" { + let directory = Path.Combine(Path.GetTempPath(), $"fve-release-tests.{Guid.NewGuid():N}") + Directory.CreateDirectory directory |> ignore + try + let run arguments = PackagePublishing.runProcess true "git" ([ "-C"; directory ] @ arguments) |> ignore + run [ "init"; "--initial-branch=main" ] + run [ "config"; "user.email"; "test@example.com" ] + run [ "config"; "user.name"; "Test" ] + File.WriteAllText(Path.Combine(directory, "file"), "content") + run [ "add"; "file" ] + run [ "commit"; "-m"; "initial" ] + run [ "tag"; "v2026.8.0" ] + run [ "tag"; "docs/v2026.8.0" ] + + let metadata = Release.prepare directory (Path.Combine(directory, "release.json")) "v" "2026.8.1" + Expect.equal metadata.previousTag (Some "v2026.8.0") "previous Core tag excludes Docs" + finally + Directory.Delete(directory, true) + } + + test "GitHub Release assets must match exactly" { + let expected = PackagePublishing.expectedAssetNames "FSharp.ViewEngine" "2026.8.2" + PackagePublishing.validateReleaseAssets expected (List.rev expected) + Expect.throws + (fun () -> PackagePublishing.validateReleaseAssets expected ("extra" :: expected)) + "extra release asset" + Expect.throws + (fun () -> PackagePublishing.validateReleaseAssets expected (List.tail expected)) + "missing release asset" + } + + test "Repository-signed NuGet package matches verified package" { + let directory = Path.Combine(Path.GetTempPath(), $"fve-build-tests.{Guid.NewGuid():N}") + Directory.CreateDirectory directory |> ignore + try + let expected = Path.Combine(directory, "expected.nupkg") + let signed = Path.Combine(directory, "signed.nupkg") + writePackage expected [ "lib/net8.0/a.dll", "same"; "README.md", "same" ] + writePackage signed [ "lib/net8.0/a.dll", "same"; "README.md", "same"; ".signature.p7s", "signature" ] + PackagePublishing.verifyPublishedPackage expected signed + finally + Directory.Delete(directory, true) + } + + test "Changed NuGet package is rejected" { + let directory = Path.Combine(Path.GetTempPath(), $"fve-build-tests.{Guid.NewGuid():N}") + Directory.CreateDirectory directory |> ignore + try + let expected = Path.Combine(directory, "expected.nupkg") + let changed = Path.Combine(directory, "changed.nupkg") + writePackage expected [ "README.md", "expected" ] + writePackage changed [ "README.md", "changed"; ".signature.p7s", "signature" ] + Expect.throws + (fun () -> PackagePublishing.verifyPublishedPackage expected changed) + "changed package" + finally + Directory.Delete(directory, true) + } + ] + +[] +let main args = runTestsWithCLIArgs [] args tests diff --git a/sln/src/Build.Tests/paket.references b/sln/src/Build.Tests/paket.references new file mode 100644 index 0000000..011ea03 --- /dev/null +++ b/sln/src/Build.Tests/paket.references @@ -0,0 +1 @@ +Expecto diff --git a/sln/src/Build/Build.fsproj b/sln/src/Build/Build.fsproj index c4947ee..8107bf3 100644 --- a/sln/src/Build/Build.fsproj +++ b/sln/src/Build/Build.fsproj @@ -7,6 +7,7 @@ + diff --git a/sln/src/Build/PackagePublishing.fs b/sln/src/Build/PackagePublishing.fs new file mode 100644 index 0000000..4da9fd9 --- /dev/null +++ b/sln/src/Build/PackagePublishing.fs @@ -0,0 +1,237 @@ +module PackagePublishing + +open System +open System.Diagnostics +open System.IO +open System.IO.Compression +open System.Net +open System.Net.Http +open System.Security.Cryptography +open System.Text.RegularExpressions + +[] +type Package = + | ViewEngine + | Docs + + member this.Id = + match this with + | Package.ViewEngine -> "FSharp.ViewEngine" + | Package.Docs -> "FSharp.ViewEngine.Docs" + + member this.TagPrefix = + match this with + | Package.ViewEngine -> "v" + | Package.Docs -> "docs/v" + +type Inputs = + { package:Package + version:string + minimumCoreVersion:string option + markLatest:bool } + +let private stableVersionPattern = Regex("^[0-9]{4}\\.[0-9]{1,2}\\.[0-9]+$") + +let private requireStableVersion (description:string) (value:string) = + if not (stableVersionPattern.IsMatch value) then + invalidArg description $"{description} must use YYYY.M.MINOR form, found: {value}" + value + +let validateInputs packageId version minimumCoreVersion markLatest = + let version = requireStableVersion "Package version" version + + match packageId, minimumCoreVersion with + | "FSharp.ViewEngine", None when markLatest -> + { package = Package.ViewEngine + version = version + minimumCoreVersion = None + markLatest = true } + | "FSharp.ViewEngine", Some _ -> + invalidArg (nameof minimumCoreVersion) "Core releases must not specify a minimum Core version." + | "FSharp.ViewEngine", None -> + invalidArg (nameof markLatest) "Core releases must be the repository-wide Latest release." + | "FSharp.ViewEngine.Docs", Some coreVersion when not markLatest -> + { package = Package.Docs + version = version + minimumCoreVersion = Some(requireStableVersion "Minimum Core version" coreVersion) + markLatest = false } + | "FSharp.ViewEngine.Docs", None -> + invalidArg (nameof minimumCoreVersion) "Docs releases require a minimum Core version." + | "FSharp.ViewEngine.Docs", Some _ -> + invalidArg (nameof markLatest) "Docs releases must not become the repository-wide Latest release." + | package, _ -> invalidArg (nameof packageId) $"Unsupported package: {package}" + +let validateChangelog packageId version (content:string) = + let marker = $"title = \"{packageId} {version} ·" + let matches = Regex.Matches(content, Regex.Escape marker).Count + if matches <> 1 then + invalidOp $"Expected exactly one released-package changelog entry beginning '{packageId} {version} ·'; found {matches}." + +let expectedAssetNames packageId version = + [ $"{packageId}.{version}.nupkg" + $"{packageId}.{version}.snupkg" + "SHA256SUMS" ] + +let validateReleaseAssets expected actual = + let expected = List.sort expected + let actual = List.sort actual + if actual <> expected then + let expectedList = String.concat ", " expected + let actualList = String.concat ", " actual + invalidOp $"GitHub Release assets differ. Expected: {expectedList}. Actual: {actualList}." + +let private archiveContents path = + use archive = ZipFile.OpenRead path + archive.Entries + |> Seq.filter (fun entry -> + not (String.IsNullOrEmpty entry.Name) + && not (String.Equals(entry.FullName, ".signature.p7s", StringComparison.OrdinalIgnoreCase))) + |> Seq.map (fun entry -> + use stream = entry.Open() + use content = new MemoryStream() + stream.CopyTo content + entry.FullName, content.ToArray()) + |> Map.ofSeq + +let verifyPublishedPackage expectedPath publishedPath = + let expected = archiveContents expectedPath + let published = archiveContents publishedPath + let expectedNames = expected |> Map.keys |> Set.ofSeq + let publishedNames = published |> Map.keys |> Set.ofSeq + + if expectedNames <> publishedNames then + let expectedList = String.concat ", " expectedNames + let publishedList = String.concat ", " publishedNames + invalidOp $"Published package entries differ. Expected: {expectedList}. Actual: {publishedList}." + + for KeyValue(name, expectedContent) in expected do + if expectedContent <> published[name] then + invalidOp $"Published package entry differs: {name}" + +let private sha256 path = + use stream = File.OpenRead path + SHA256.HashData stream |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() + +let writeChecksums outputPath packagePaths = + let lines = + packagePaths + |> List.map (fun path -> $"{sha256 path} {Path.GetFileName path}") + File.WriteAllLines(outputPath, lines) + +let verifyChecksums checksumPath packageDirectory = + for line in File.ReadAllLines checksumPath do + let parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries) + if parts.Length <> 2 then invalidOp $"Invalid checksum line: {line}" + let path = Path.Combine(packageDirectory, parts[1]) + if not (File.Exists path) then invalidOp $"Missing checksummed package asset: {path}" + let actual = sha256 path + if not (String.Equals(actual, parts[0], StringComparison.OrdinalIgnoreCase)) then + invalidOp $"Checksum mismatch for {parts[1]}. Expected {parts[0]}, found {actual}." + +let runProcess captureOutput command arguments = + let startInfo = ProcessStartInfo(command) + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- captureOutput + startInfo.RedirectStandardError <- captureOutput + for argument in arguments do startInfo.ArgumentList.Add argument + + use childProcess = Process.Start startInfo + let output = if captureOutput then childProcess.StandardOutput.ReadToEnd() else "" + let error = if captureOutput then childProcess.StandardError.ReadToEnd() else "" + childProcess.WaitForExit() + + if childProcess.ExitCode <> 0 then + invalidOp $"{command} failed with exit code {childProcess.ExitCode}. {error.Trim()}" + output.Trim() + +let private processExitCode command arguments = + let startInfo = ProcessStartInfo(command) + startInfo.UseShellExecute <- false + startInfo.RedirectStandardOutput <- true + startInfo.RedirectStandardError <- true + for argument in arguments do startInfo.ArgumentList.Add argument + use childProcess = Process.Start startInfo + childProcess.WaitForExit() + childProcess.ExitCode + +let private packageUrl (packageId:string) (version:string) = + let slug = packageId.ToLowerInvariant() + $"https://api.nuget.org/v3-flatcontainer/{slug}/{version}/{slug}.{version}.nupkg" + +let private tryDownload (client:HttpClient) (url:string) (outputPath:string) = + use response = client.GetAsync(url).GetAwaiter().GetResult() + if response.StatusCode = HttpStatusCode.NotFound then false + else + response.EnsureSuccessStatusCode() |> ignore + use source = response.Content.ReadAsStream() + use destination = File.Create outputPath + source.CopyTo destination + true + +let confirmPublished packageId version = + use client = new HttpClient() + use response = client.GetAsync(packageUrl packageId version).GetAwaiter().GetResult() + response.IsSuccessStatusCode + +let publishOrVerify packagePath packageId version apiKey temporaryDirectory = + Directory.CreateDirectory temporaryDirectory |> ignore + let publishedPath = Path.Combine(temporaryDirectory, "published.nupkg") + use client = new HttpClient() + + if tryDownload client (packageUrl packageId version) publishedPath then + verifyPublishedPackage packagePath publishedPath + printfn "%s %s already exists on NuGet and matches the verified artifact." packageId version + else + runProcess false "dotnet" [ + "nuget"; "push"; packagePath + "--source"; "https://api.nuget.org/v3/index.json" + "--api-key"; apiKey + ] |> ignore + +let waitForPublished packageId version attempts (delay:TimeSpan) = + let rec loop remaining = + if confirmPublished packageId version then () + elif remaining <= 1 then invalidOp $"{packageId} {version} was not available from NuGet in time." + else + Threading.Thread.Sleep delay + loop (remaining - 1) + loop attempts + +let ensureTag repository tag commit = + let head = runProcess true "git" [ "-C"; repository; "rev-parse"; "HEAD" ] + if head <> commit then invalidOp $"Release commit is {commit}, but HEAD is {head}." + + if processExitCode "git" [ "-C"; repository; "rev-parse"; "--verify"; $"refs/tags/{tag}" ] = 0 then + let existingCommit = runProcess true "git" [ "-C"; repository; "rev-list"; "-n"; "1"; tag ] + if existingCommit <> commit then invalidOp $"Release tag {tag} points to {existingCommit}, not {commit}." + else + runProcess false "git" [ "-C"; repository; "tag"; tag; commit ] |> ignore + runProcess false "git" [ "-C"; repository; "push"; "origin"; $"refs/tags/{tag}" ] |> ignore + +let reconcileGitHubRelease repository packageId version tag previousTag markLatest (assetPaths:string list) = + let expectedAssets = assetPaths |> List.map Path.GetFileName |> List.sort + let latestArgument = if markLatest then "--latest" else "--latest=false" + let common = [ "--repo"; repository ] + + if processExitCode "gh" ([ "release"; "view"; tag ] @ common) = 0 then + let existingAssets = + runProcess true "gh" ([ "release"; "view"; tag ] @ common @ [ "--json"; "assets"; "--jq"; ".assets[].name" ]) + |> fun output -> output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) |> Array.sort |> Array.toList + + try validateReleaseAssets expectedAssets existingAssets + with :? InvalidOperationException as error -> invalidOp $"GitHub Release {tag}: {error.Message}" + + runProcess false "gh" ([ "release"; "edit"; tag ] @ common @ [ latestArgument ]) |> ignore + else + let notes = + match previousTag with + | Some previous -> [ "--generate-notes"; "--notes-start-tag"; previous ] + | None -> [ "--notes"; $"Initial {packageId} package release. See https://fsharpviewengine.meiermade.com/changelog for release details." ] + + runProcess false "gh" ( + [ "release"; "create"; tag ] + @ common + @ [ "--verify-tag"; "--title"; $"{packageId} {version}"; latestArgument ] + @ notes + @ assetPaths) + |> ignore diff --git a/sln/src/Build/Program.fs b/sln/src/Build/Program.fs index d4cb1c3..5c1abed 100644 --- a/sln/src/Build/Program.fs +++ b/sln/src/Build/Program.fs @@ -23,7 +23,9 @@ let nugetsDir = rootDir "nugets" let testsDir = srcDir "Tests" let docsDir = srcDir "Docs" let docsTestsDir = srcDir "Docs.Tests" +let buildTestsDir = srcDir "Build.Tests" let benchmarksDir = srcDir "Benchmarks" +let changelogPath = docsDir "src" "Pages" "Changelog.fs" let releaseRepository = Environment.environVarOrDefault "RELEASE_REPOSITORY" rootDir let releaseMetadataPath = Environment.environVarOrDefault @@ -55,21 +57,27 @@ let availableLocalPort () = listener.Start() (listener.LocalEndpoint :?> IPEndPoint).Port -type Package = - | ViewEngine - | Docs +let packageProject (package:PackagePublishing.Package) = + srcDir package.Id $"{package.Id}.fsproj" - member this.Id = - match this with - | ViewEngine -> "FSharp.ViewEngine" - | Docs -> "FSharp.ViewEngine.Docs" +let boolEnvironment name = + match Environment.environVarOrFail name with + | "true" -> true + | "false" -> false + | value -> failwith $"{name} must be true or false, found: {value}" - member this.Project = srcDir this.Id $"{this.Id}.fsproj" +let releaseInputs () = + let minimumCoreVersion = Environment.environVarOrNone "DOCS_MINIMUM_CORE_VERSION" + PackagePublishing.validateInputs + (Environment.environVarOrFail "PACKAGE_ID") + (Environment.environVarOrFail "PACKAGE_VERSION") + minimumCoreVersion + (boolEnvironment "MARK_LATEST") let selectedPackage () = match Environment.environVarOrFail "PACKAGE_ID" with - | "FSharp.ViewEngine" -> ViewEngine - | "FSharp.ViewEngine.Docs" -> Docs + | "FSharp.ViewEngine" -> PackagePublishing.Package.ViewEngine + | "FSharp.ViewEngine.Docs" -> PackagePublishing.Package.Docs | packageId -> failwith $"Unsupported package: {packageId}" let getVersion () = @@ -78,12 +86,66 @@ let getVersion () = if matched.Success then matched.Groups[1].Value else failwith $"invalid package version: {value}" Target.create "PrepareRelease" <| fun _ -> - let version = Environment.environVarOrFail "RELEASE_VERSION" - let tagPrefix = Environment.environVarOrFail "RELEASE_TAG_PREFIX" - let metadata = Release.prepare releaseRepository releaseMetadataPath tagPrefix version + let inputs = releaseInputs () + let expectedRef = Environment.environVarOrDefault "GITHUB_REF" "refs/heads/main" + if expectedRef <> "refs/heads/main" then failwith $"Releases must run from main, not {expectedRef}." + + PackagePublishing.validateChangelog inputs.package.Id inputs.version (System.IO.File.ReadAllText changelogPath) + + match inputs.minimumCoreVersion with + | Some coreVersion when not (PackagePublishing.confirmPublished "FSharp.ViewEngine" coreVersion) -> + failwith $"FSharp.ViewEngine {coreVersion} must be available from NuGet before publishing Docs." + | _ -> () + + let metadata = Release.prepare releaseRepository releaseMetadataPath inputs.package.TagPrefix inputs.version Trace.trace $"Prepared {metadata.tag} for {metadata.commit}" Trace.trace $"Release metadata: {releaseMetadataPath}" +Target.create "ReadReleaseMetadata" <| fun _ -> + let metadata = Release.readMetadata releaseMetadataPath + let outputPath = Environment.environVarOrFail "GITHUB_OUTPUT" + let previousTag = metadata.previousTag |> Option.defaultValue "" + System.IO.File.AppendAllLines(outputPath, [ + $"tag={metadata.tag}" + $"version={metadata.version}" + $"commit={metadata.commit}" + $"previousTag={previousTag}" ]) + +Target.create "RecordPackageChecksums" <| fun _ -> + let packagePaths = + !! $"{nugetsDir}/*.nupkg" + ++ $"{nugetsDir}/*.snupkg" + |> Seq.sort + |> Seq.toList + PackagePublishing.writeChecksums (nugetsDir "SHA256SUMS") packagePaths + +Target.create "PublishPackageRelease" <| fun _ -> + let inputs = releaseInputs () + let packageDirectory = Environment.environVarOrFail "PACKAGE_DIRECTORY" |> Path.getFullName + let metadata = Release.readMetadata releaseMetadataPath + let assets = + PackagePublishing.expectedAssetNames inputs.package.Id inputs.version + |> List.map (fun name -> packageDirectory name) + let packagePath = packageDirectory $"{inputs.package.Id}.{inputs.version}.nupkg" + + PackagePublishing.verifyChecksums (packageDirectory "SHA256SUMS") packageDirectory + PackagePublishing.publishOrVerify + packagePath + inputs.package.Id + inputs.version + (Environment.environVarOrFail "NUGET_API_KEY") + (Environment.environVarOrDefault "RUNNER_TEMP" (System.IO.Path.GetTempPath()) "fsharp-viewengine-publish") + PackagePublishing.waitForPublished inputs.package.Id inputs.version 60 (System.TimeSpan.FromSeconds 10.) + PackagePublishing.ensureTag releaseRepository metadata.tag metadata.commit + PackagePublishing.reconcileGitHubRelease + (Environment.environVarOrFail "GITHUB_REPOSITORY") + inputs.package.Id + inputs.version + metadata.tag + metadata.previousTag + inputs.markLatest + assets + Target.create "CleanNugets" <| fun _ -> Shell.cleanDir nugetsDir Target.create "Test" <| fun _ -> @@ -96,12 +158,15 @@ Target.create "Test" <| fun _ -> dotnet docsTestsDir ["run"] |> Async.RunSynchronously + dotnet buildTestsDir ["run"] + |> Async.RunSynchronously + Target.create "Pack" (fun _ -> let package = selectedPackage () let version = getVersion () let arguments = [ "pack" - package.Project + packageProject package "--configuration" "Release" "--output" @@ -109,8 +174,8 @@ Target.create "Pack" (fun _ -> let arguments = match package with - | ViewEngine -> arguments @ [ $"/p:FSharpViewEnginePackageVersion={version}" ] - | Docs -> + | PackagePublishing.Package.ViewEngine -> arguments @ [ $"/p:FSharpViewEnginePackageVersion={version}" ] + | PackagePublishing.Package.Docs -> let minimumCoreVersion = Environment.environVarOrFail "DOCS_MINIMUM_CORE_VERSION" arguments @ [ $"/p:FSharpViewEngineDocsPackageVersion={version}" @@ -147,15 +212,6 @@ Target.create "VerifyPackage" (fun _ -> package ) -Target.create "PushNugets" (fun _ -> - let package = selectedPackage () - let packagePath = $"{nugetsDir}/{package.Id}.*.nupkg" - Trace.trace $"Publishing {packagePath} and its associated symbol package" - let apiKey = Environment.environVarOrFail "NUGET_API_KEY" - dotnet rootDir ["nuget"; "push"; packagePath; "--source"; "https://api.nuget.org/v3/index.json"; "--api-key"; apiKey] - |> Async.RunSynchronously -) - Target.create "WatchDocs" (fun _ -> let docsUrl = System.Environment.GetEnvironmentVariable("SERVER_URL") @@ -200,7 +256,6 @@ Target.create "Default" (fun _ -> Target.listAvailable()) "Test" ==>! "Pack" "CleanNugets" ==>! "Pack" "Pack" ==>! "VerifyPackage" -"VerifyPackage" ==>! "PushNugets" "BuildDocsCss" ==>! "PublishDocs" Target.runOrDefaultWithArguments "Default" diff --git a/sln/src/Build/Release.fs b/sln/src/Build/Release.fs index a557e25..69ebd01 100644 --- a/sln/src/Build/Release.fs +++ b/sln/src/Build/Release.fs @@ -18,7 +18,8 @@ type private CalendarVersion = type Metadata = { tag:string version:string - commit:string } + commit:string + previousTag:string option } let private versionPattern = Regex("^v?(?[0-9]{4})\\.(?[0-9]{1,2})\\.(?[0-9]+)$") @@ -73,17 +74,38 @@ let private resolveMetadata (repository:string) (tagPrefix:string) (requestedVer | Some(tag, _, _) -> tag | None -> $"{tagPrefix}{version}" + let previousTag = + tags + |> List.filter (fun (_, existingVersion, _) -> versionKey existingVersion < versionKey version) + |> List.sortBy (fun (_, existingVersion, _) -> versionKey existingVersion) + |> List.tryLast + |> Option.map (fun (existingTag, _, _) -> existingTag) + { tag = tag version = version.ToString() - commit = commit } + commit = commit + previousTag = previousTag } -let private writeMetadata (path:string) (metadata:Metadata) = +let writeMetadata (path:string) (metadata:Metadata) = let directory = Path.GetDirectoryName(Path.GetFullPath path) Directory.CreateDirectory directory |> ignore let options = JsonSerializerOptions(WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase) File.WriteAllText(path, JsonSerializer.Serialize(metadata, options) + Environment.NewLine) +let readMetadata (path:string) = + use document = JsonDocument.Parse(File.ReadAllText path) + let root = document.RootElement + let previousTag = + match root.GetProperty("previousTag") with + | value when value.ValueKind = JsonValueKind.String -> value.GetString() |> Option.ofObj + | _ -> None + + { tag = root.GetProperty("tag").GetString() + version = root.GetProperty("version").GetString() + commit = root.GetProperty("commit").GetString() + previousTag = previousTag } + let prepare (repository:string) (outputPath:string) (tagPrefix:string) (version:string) = let metadata = resolveMetadata repository tagPrefix version writeMetadata outputPath metadata