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/workflows/_publish-package.yml b/.github/workflows/_publish-package.yml index 535c5fb..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 @@ -18,6 +14,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,68 +31,21 @@ jobs: package: name: Build and verify ${{ inputs.packageId }} runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read outputs: 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@v5 + - 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@v5 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x @@ -104,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 }} @@ -153,127 +80,64 @@ 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@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.packageId }}-${{ steps.release.outputs.version }} path: | nugets/*.nupkg nugets/*.snupkg nugets/SHA256SUMS + ${{ runner.temp }}/release-metadata.json if-no-files-found: error publish: name: Publish ${{ inputs.packageId }} needs: package runs-on: ubuntu-latest + timeout-minutes: 20 + environment: release permissions: 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@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 - with: - organization: meiermade - requested-token-type: urn:pulumi:token-type:access_token:personal - scope: user:meiermade - - name: Publish verified package - env: - PACKAGE_DIRECTORY: ${{ runner.temp }}/package - PACKAGE_ID: ${{ inputs.packageId }} - VERSION: ${{ needs.package.outputs.version }} + - name: Restore build tooling 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" - - 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@v5 + 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: - 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 + user: ${{ vars.NUGET_USER }} + - name: Publish and reconcile package 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 }} + GITHUB_REPOSITORY: ${{ github.repository }} + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} PACKAGE_DIRECTORY: ${{ runner.temp }}/package - run: | - if gh release view "$TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then - echo "GitHub Release $TAG already exists." - else - release_args=( - --repo "$REPOSITORY" - --verify-tag - --title "$PACKAGE_ID $VERSION" - ) - - 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.") - fi - - gh release create "$TAG" \ - "${release_args[@]}" \ - "$PACKAGE_DIRECTORY"/*.nupkg \ - "$PACKAGE_DIRECTORY"/*.snupkg \ - "$PACKAGE_DIRECTORY/SHA256SUMS" - fi + PACKAGE_ID: ${{ inputs.packageId }} + PACKAGE_VERSION: ${{ needs.package.outputs.version }} + DOCS_MINIMUM_CORE_VERSION: ${{ inputs.minimumCoreVersion }} + MARK_LATEST: ${{ inputs.markLatest }} + RELEASE_METADATA_PATH: ${{ runner.temp }}/package/release-metadata.json + run: ./fake.sh PublishPackageRelease --single-target + working-directory: ./sln 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..a17d89f 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/workflows/_publish-package.yml with: packageId: FSharp.ViewEngine.Docs - 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..a06b766 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,6 +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/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 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