diff --git a/.github/scripts/check-unreleased-changelog.sh b/.github/scripts/check-unreleased-changelog.sh new file mode 100755 index 0000000..b9975bd --- /dev/null +++ b/.github/scripts/check-unreleased-changelog.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "$0")/../.." && pwd) +cd "$ROOT_DIR" + +BASE_REF="${GITHUB_BASE_REF:-}" +REQUIRE_CHANGELOG_ALWAYS="${REQUIRE_CHANGELOG_ALWAYS:-false}" +ENFORCE_UNRELEASED_BULLET="${ENFORCE_UNRELEASED_BULLET:-false}" + +if [[ -n "$BASE_REF" ]]; then + git fetch --no-tags --depth=1 origin "$BASE_REF" >/dev/null 2>&1 || true + DIFF_RANGE="origin/${BASE_REF}...HEAD" + mapfile -t CHANGED_FILES < <(git diff --name-only "$DIFF_RANGE") +else + if [[ -n "$(git status --porcelain)" ]]; then + mapfile -t CHANGED_FILES < <( + { + git diff --name-only HEAD + git ls-files --others --exclude-standard + } | sort -u + ) + elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + DIFF_RANGE="HEAD~1...HEAD" + mapfile -t CHANGED_FILES < <(git diff --name-only "$DIFF_RANGE") + else + echo "No comparable git range available; skipping changelog check." + exit 0 + fi +fi + +if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No changed files detected; skipping changelog check." + exit 0 +fi + +if ! printf '%s\n' "${CHANGED_FILES[@]}" | grep -qx 'CHANGELOG.md'; then + if [[ "$REQUIRE_CHANGELOG_ALWAYS" == "true" ]]; then + echo "ERROR: CHANGELOG.md must be updated in every commit." + exit 1 + fi + + TRIGGERS=( + '^src/' + '^tests/' + '^Makefile$' + ) + + EXEMPTS=( + '^\.github/' + '^LICENSE$' + '^README\.md$' + '^RELEASE_NOTES\.md$' + '^CHANGELOG\.md$' + '^\.gitignore$' + '^global\.json$' + ) + + needs_changelog=false + for file in "${CHANGED_FILES[@]}"; do + is_trigger=false + for pat in "${TRIGGERS[@]}"; do + if [[ "$file" =~ $pat ]]; then + is_trigger=true + break + fi + done + + if [[ "$is_trigger" == false ]]; then + continue + fi + + is_exempt=false + for pat in "${EXEMPTS[@]}"; do + if [[ "$file" =~ $pat ]]; then + is_exempt=true + break + fi + done + + if [[ "$is_exempt" == false ]]; then + needs_changelog=true + break + fi + done + + if [[ "$needs_changelog" == true ]]; then + echo "ERROR: Functional changes detected but CHANGELOG.md was not updated." + echo "Add a short one-line bullet under ## [Unreleased]." + exit 1 + fi + + echo "No changelog-required files changed." + exit 0 +fi + +if ! grep -q '^## \[Unreleased\]' CHANGELOG.md; then + echo "ERROR: CHANGELOG.md must contain a top-level '## [Unreleased]' section." + exit 1 +fi + +UNRELEASED_BLOCK=$( + awk ' + /^## \[Unreleased\]/{in_block=1; next} + /^## \[/{if(in_block){exit}} + in_block{print} + ' CHANGELOG.md +) + +if [[ -z "${UNRELEASED_BLOCK//$'\n'/}" ]]; then + HEAD_SUBJECT="$(git log -1 --pretty=%s)" + if [[ "$HEAD_SUBJECT" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Changelog gate passed (release commit allows empty ## [Unreleased])." + exit 0 + fi + echo "ERROR: ## [Unreleased] section is empty." + exit 1 +fi + +if [[ "$ENFORCE_UNRELEASED_BULLET" == "true" ]]; then + if ! printf '%s\n' "$UNRELEASED_BLOCK" | grep -qE '^- '; then + echo "ERROR: ## [Unreleased] must contain at least one bullet line starting with '- '." + exit 1 + fi +fi + +echo "Changelog gate passed." diff --git a/.github/scripts/extract-release-notes.sh b/.github/scripts/extract-release-notes.sh new file mode 100755 index 0000000..e90501b --- /dev/null +++ b/.github/scripts/extract-release-notes.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + exit 2 +fi + +version="$1" +section_header="## [${version}]" + +if [[ ! -f CHANGELOG.md ]]; then + echo "ERROR: CHANGELOG.md not found." + exit 1 +fi + +section_body="$( + awk -v header="$section_header" ' + BEGIN { in_section = 0 } + $0 == header { in_section = 1; next } + /^## \[/ && in_section { exit } + in_section { print } + ' CHANGELOG.md +)" + +if [[ -z "${section_body//[[:space:]]/}" ]]; then + echo "ERROR: Missing or empty changelog section '${section_header}'." + echo "Add a '${section_header}' section to CHANGELOG.md before tagging." + exit 1 +fi + +if ! grep -q '^[[:space:]]*-\s\+' <<<"$section_body"; then + echo "ERROR: Section '${section_header}' must include at least one bullet line." + exit 1 +fi + +if ! grep -q '\*\*Full Changelog\*\*:' <<<"$section_body"; then + echo "ERROR: Section '${section_header}' must include a '**Full Changelog**' compare link." + exit 1 +fi + +printf '%s\n' "$section_body" diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh new file mode 100755 index 0000000..04e622b --- /dev/null +++ b/.github/scripts/release.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [dryrun]" + echo " version: X.Y.Z or X.Y.Z-suffix" + echo " dryrun : true|false (default: false)" + exit 2 +fi + +version="$1" +dryrun="${2:-false}" + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "ERROR: Invalid version '$version'. Expected semantic version format." + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if [[ ! -f CHANGELOG.md ]]; then + echo "ERROR: CHANGELOG.md not found." + exit 1 +fi + +if [[ "$dryrun" != "true" && -n "$(git status --porcelain)" ]]; then + echo "ERROR: Working tree is not clean. Commit or stash changes before running release." + exit 1 +fi + +if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then + echo "ERROR: Tag '${version}' already exists." + exit 1 +fi + +if grep -q "^## \[${version}\]$" CHANGELOG.md; then + echo "ERROR: CHANGELOG section '## [${version}]' already exists." + exit 1 +fi + +unreleased_body="$( + awk ' + BEGIN { in_section = 0 } + $0 == "## [Unreleased]" { in_section = 1; next } + /^## \[/ && in_section { exit } + in_section { print } + ' CHANGELOG.md +)" + +if [[ -z "${unreleased_body//[[:space:]]/}" ]]; then + echo "ERROR: Unreleased section is empty." + exit 1 +fi + +if ! grep -q '^[[:space:]]*-\s\+' <<<"$unreleased_body"; then + echo "ERROR: Unreleased section must include at least one bullet." + exit 1 +fi + +candidate_tags="$(git tag --list | grep -E '^(r)?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$' | sort -V || true)" + +previous_tag="$( + { + printf '%s\n' "$candidate_tags" + printf '%s\n' "$version" + } \ + | sed '/^$/d' \ + | sort -V \ + | awk -v target="$version" ' + $0 == target { print prev; exit } + { prev = $0 } + ' +)" + +if [[ -z "$previous_tag" ]]; then + echo "ERROR: Could not determine previous tag for '$version'." + echo "Create the compare link manually in CHANGELOG.md and tag manually." + exit 1 +fi + +remote_url="$(git remote get-url origin 2>/dev/null || true)" +if [[ -z "$remote_url" ]]; then + echo "ERROR: Could not determine origin remote URL." + exit 1 +fi + +repo_slug="" +if [[ "$remote_url" =~ github.com[:/]([^/]+/[^/]+)(\.git)?$ ]]; then + repo_slug="${BASH_REMATCH[1]}" +fi +repo_slug="${repo_slug%.git}" + +if [[ -z "$repo_slug" ]]; then + echo "ERROR: Could not parse GitHub owner/repo from origin remote '$remote_url'." + exit 1 +fi + +compare_link="**Full Changelog**: https://github.com/${repo_slug}/compare/${previous_tag}...${version}" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +stripped_changelog="${tmp_dir}/changelog-stripped.md" +new_section_file="${tmp_dir}/new-section.md" +updated_changelog="${tmp_dir}/CHANGELOG.md" + +awk ' + BEGIN { skip = 0 } + $0 == "## [Unreleased]" { + print + print "" + skip = 1 + next + } + skip && /^## \[/ { skip = 0 } + !skip { print } +' CHANGELOG.md > "$stripped_changelog" + +{ + echo "## [${version}]" + echo "" + printf '%s\n' "$unreleased_body" + echo "" + echo "$compare_link" +} > "$new_section_file" + +awk -v section_file="$new_section_file" ' + BEGIN { inserted = 0; skip_next_blank = 0 } + $0 == "## [Unreleased]" && inserted == 0 { + print + print "" + while ((getline line < section_file) > 0) { + print line + } + close(section_file) + print "" + inserted = 1 + skip_next_blank = 1 + next + } + skip_next_blank == 1 && $0 == "" { + skip_next_blank = 0 + next + } + { print } +' "$stripped_changelog" > "$updated_changelog" + +if ! grep -q "^## \[${version}\]$" "$updated_changelog"; then + echo "ERROR: Failed to materialize CHANGELOG section for ${version}." + exit 1 +fi + +if [[ "$dryrun" == "true" ]]; then + echo "[DRY RUN] Would update CHANGELOG.md, commit and create annotated tag '${version}'." + echo "[DRY RUN] Previous tag: ${previous_tag}" + echo "[DRY RUN] Compare link: ${compare_link}" + exit 0 +fi + +cp "$updated_changelog" CHANGELOG.md + +git add CHANGELOG.md +git commit -m "chore(release): ${version}" +git tag -a "${version}" -m "Release ${version}" + +echo "Release prepared successfully." +echo "Next steps:" +echo " git push origin main --follow-tags" diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..c133472 --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,62 @@ +name: CI PR + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + contents: read + checks: write + +jobs: + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce unreleased changelog updates + env: + REQUIRE_CHANGELOG_ALWAYS: "true" + run: .github/scripts/check-unreleased-changelog.sh + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.100 + + - name: Restore + run: dotnet restore FSharp.MongoDB.sln + + - name: Build + run: dotnet build FSharp.MongoDB.sln --configuration Release --no-restore + + - name: Test + run: dotnet test FSharp.MongoDB.sln --configuration Release --no-build --logger "trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-pr + path: TestResults/**/*.trx + if-no-files-found: warn + + - name: Publish test report + if: always() + continue-on-error: true + uses: dorny/test-reporter@v2 + with: + name: Test Results (PR) + path: TestResults/**/*.trx + reporter: dotnet-trx diff --git a/.github/workflows/on-push-branch.yml b/.github/workflows/on-push-branch.yml index 1078a64..c5b71bc 100644 --- a/.github/workflows/on-push-branch.yml +++ b/.github/workflows/on-push-branch.yml @@ -1,4 +1,4 @@ -name: 🍿 On Push Branch +name: CI Branches on: push: @@ -6,32 +6,56 @@ on: - '**' workflow_dispatch: +permissions: + contents: read + checks: write + jobs: - build: + build-and-test: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - name: Cloning repository + - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce changelog updates on every commit + env: + REQUIRE_CHANGELOG_ALWAYS: "true" + run: .github/scripts/check-unreleased-changelog.sh - - name: Setup .NET Core - uses: actions/setup-dotnet@v4.2.0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.100 - - - name: Build & Test - run: make test config=Release - - name: Create Test Report - uses: magnusopera/test-reporter@main + - name: Restore + run: dotnet restore FSharp.MongoDB.sln + + - name: Build + run: dotnet build FSharp.MongoDB.sln --configuration Release --no-restore + + - name: Test + run: dotnet test FSharp.MongoDB.sln --configuration Release --no-build --logger "trx" --results-directory TestResults + + - name: Build NuGet package + run: dotnet pack src/FSharp.MongoDB.Bson/FSharp.MongoDB.Bson.fsproj --configuration Release --no-build -o .out + + - name: Upload test results if: always() + uses: actions/upload-artifact@v4 with: - name: Unit Tests Report - path: '**/*.trx' - reporter: dotnet-trx - fail-on-error: false - fail-on-empty: false - use-actions-summary: true + name: test-results-branches + path: TestResults/**/*.trx + if-no-files-found: warn - - name: Build NuGet - run: make nuget config=Release + - name: Publish test report + if: always() + continue-on-error: true + uses: dorny/test-reporter@v2 + with: + name: Test Results (Branches) + path: TestResults/**/*.trx + reporter: dotnet-trx diff --git a/.github/workflows/on-push-tag.yml b/.github/workflows/on-push-tag.yml index e9dd3fb..8bacfd9 100644 --- a/.github/workflows/on-push-tag.yml +++ b/.github/workflows/on-push-tag.yml @@ -1,5 +1,5 @@ -name: 🍿 On Push Tag -run-name: Prepare release ${{github.ref_name}} +name: Prepare Release +run-name: Prepare release ${{ github.ref_name }} on: push: @@ -8,55 +8,45 @@ on: permissions: contents: write - packages: write env: BUILD_VERSION: ${{ github.ref_name }} jobs: - build: + build-and-release: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - name: Cloning repository + - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET Core - uses: actions/setup-dotnet@v4.2.0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.100 - - name: Extract Version Suffix + - name: Extract version suffix run: | - s=${{github.ref_name}} + s=${{ github.ref_name }} pat="([^-]*)-?([^-]*)" [[ $s =~ $pat ]] BUILD_VERSION_SUFFIX=${BASH_REMATCH[2]} - echo "BUILD_VERSION_SUFFIX=$BUILD_VERSION_SUFFIX" >> $GITHUB_ENV - echo "BUILD_VERSION_SUFFIX: $BUILD_VERSION_SUFFIX" + echo "BUILD_VERSION_SUFFIX=$BUILD_VERSION_SUFFIX" >> "$GITHUB_ENV" - - name: Build & Test - run: make test config=Release version=${{ env.BUILD_VERSION }} + - name: Build, test, and pack + run: make publish-all config=Release version=${{ env.BUILD_VERSION }} - - name: Create Test Report - uses: magnusopera/test-reporter@main - if: always() - with: - name: Unit Tests Report - path: '**/*.trx' - reporter: dotnet-trx - fail-on-error: false - fail-on-empty: false - use-actions-summary: true - - - name: Build NuGet - run: make nuget config=Release version=${{ env.BUILD_VERSION }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2.0.6 + - name: Extract release notes from changelog + shell: bash + run: ./.github/scripts/extract-release-notes.sh "${GITHUB_REF_NAME}" > "${RUNNER_TEMP}/release-notes.md" + + - name: Create draft GitHub release + uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.ref_name }} draft: true prerelease: ${{ env.BUILD_VERSION_SUFFIX != '' }} - generate_release_notes: true - files: + body_path: ${{ runner.temp }}/release-notes.md + files: | .out/*.nupkg diff --git a/.github/workflows/on-release-published.yml b/.github/workflows/on-release-published.yml index 85f2ee4..5b56ed6 100644 --- a/.github/workflows/on-release-published.yml +++ b/.github/workflows/on-release-published.yml @@ -1,25 +1,34 @@ -name: 🍿 On Release Published -run-name: Release ${{github.ref_name}} +name: Release +run-name: Release ${{ github.ref_name }} on: workflow_dispatch: release: - types: [published] + types: + - published + +env: + RELEASE_TAG: ${{ github.event.release.tag_name || github.ref_name }} jobs: release-nuget: runs-on: ubuntu-latest + steps: - - name: Setup .NET Core - uses: actions/setup-dotnet@v4.2.0 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.100 - - name: Download Release artifacts + - name: Download GitHub release artifacts uses: robinraju/release-downloader@v1.11 with: - tag: ${{github.ref_name}} + tag: ${{ env.RELEASE_TAG }} fileName: '*.nupkg' - - name: Publish NuGet - run: dotnet nuget push *.nupkg -k ${{secrets.NUGET_KEY}} -s https://api.nuget.org/v3/index.json --skip-duplicate + - name: Push packages to NuGet + run: | + shopt -s nullglob + for pkg in ./*.nupkg; do + dotnet nuget push "$pkg" --skip-duplicate --api-key "${{ secrets.NUGET_KEY }}" --source https://api.nuget.org/v3/index.json + done diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0537a8d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to FSharp.MongoDB are documented in this file. + +## [Unreleased] + +- Restored the isomorphic serialization test coverage to guard C# and F# BSON parity again. + +## [0.4.0-beta] + +- Refreshed the README to reflect the current package usage and project status. + +**Full Changelog**: https://github.com/fsprojects/FSharp.MongoDB/compare/0.3.0-beta...0.4.0-beta + +## [0.3.0-beta] + +- Updated the supported .NET and MongoDB dependency versions. +- Fixed option serialization behavior. + +**Full Changelog**: https://github.com/fsprojects/FSharp.MongoDB/compare/0.2.0-beta...0.3.0-beta + +## [0.2.0-beta] + +- Migrated repository URLs to the `fsprojects` organization. +- Refined package metadata and description. + +**Full Changelog**: https://github.com/fsprojects/FSharp.MongoDB/compare/0.1.0-beta...0.2.0-beta + +## [0.1.0-beta] + +- Migrated the library to .NET Core and added GitHub Actions CI. +- Added `voption` support, including default value handling for `ValueOption`. +- Ensured C# and F# produce isomorphic BSON and tightened package metadata, docs, and release automation. + +**Full Changelog**: https://github.com/fsprojects/FSharp.MongoDB/compare/r0.0.1...0.1.0-beta + +## [0.0.1] + +- Added support for serializing and deserializing `internal` F# types. +- Changed `MongoCollection<'Document>` to return query results as `AsyncSeq` instances. +- Added helpers to `MongoClient` for executing the `dropDatabase` and `listDatabases` commands. +- Added helpers to `MongoDatabase` for executing the `create`, `drop`, `listCollections`, and `renameCollection` commands. +- Added helpers to `MongoCollection<'Document>` for executing the `aggregate`, `count`, and `distinct` commands. +- Removed support for executing inserts, updates, and deletes against a collection. +- Removed support for executing queries and updates using a `mongo { ... }` computation expression. +- Removed support for specifying queries and updates as code quotations converted to `BsonDocument` values. + +**Full Changelog**: https://github.com/fsprojects/FSharp.MongoDB/compare/r0.0.0...r0.0.1 + +## [0.0.0] + +- Added support for serializing and deserializing F# lists, maps, options, records, sets, and discriminated unions. +- Added support for executing basic CRUD operations against a collection. +- Added support for executing queries and updates using a `mongo { ... }` computation expression. +- Added support for specifying queries and updates as code quotations, both type-checked and unchecked, that convert to `BsonDocument` values. diff --git a/Makefile b/Makefile index ef22016..0a7dc4a 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,32 @@ +.PHONY: build test verify-changelog release-prepare clean nuget publish-all upgrade + config ?= Debug version ?= 0.0.0 - +dryrun ?= false +solution ?= FSharp.MongoDB.sln +project ?= src/FSharp.MongoDB.Bson/FSharp.MongoDB.Bson.fsproj +results ?= TestResults build: - dotnet build -c $(config) + dotnet build $(solution) -c $(config) test: - dotnet test -c $(config) + dotnet test $(solution) -c $(config) --logger "trx" --results-directory $(results) + +verify-changelog: + REQUIRE_CHANGELOG_ALWAYS=true .github/scripts/check-unreleased-changelog.sh + +release-prepare: + ./.github/scripts/release.sh "$(version)" "$(dryrun)" nuget: - dotnet pack -c $(config) -p:Version=$(version) -o .out + dotnet pack $(project) -c $(config) -p:Version=$(version) -o $(PWD)/.out + +publish-all: build test nuget + +clean: + dotnet clean $(solution) -c $(config) + find . -type d \( -name bin -o -name obj \) -exec rm -rf {} + upgrade: - dotnet restore --force-evaluate + dotnet restore $(solution) --force-evaluate diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 4dda510..0000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,28 +0,0 @@ -#### 0.0.1 - Async-client interface prototype (Not released) - - * Added support for serializing and deserializing `internal` F# types. - * Changed `MongoCollection<'Document>` to return results of queries as `AsyncSeq` instances. - * Added helpers to `MongoClient` for executing the "dropDatabase" and "listDatabases" commands. - * Added helpers to `MongoDatabase` for executing the "create", "drop", "listCollections", and - "renameCollection" commands. - * Added helpers to `MongoCollection<'Document>` for executing the "aggregate", "count", and - "distinct" commands. - * Removed support for executing inserts, updates, and deletes against a collection. - * Removed support for executing queries and updates using a `mongo { ... }` computation - expression. - * Removed support for specifying queries and updates as code quotations that are converted to - `BsonDocument` instances. - -#### 0.0.0 - End of intern project (Not released) - - * Support for serializing and deserializing F# types: - * lists - * maps - * options - * records - * sets - * discriminated unions - * Support for executing basic CRUD operations against a collection. - * Support for executing queries and updates using a `mongo { ... }` computation expression. - * Support for specifying queries and updates as code quotations (in both type-checked and - -unchecked forms) that are convertible to `BsonDocument` instances.