diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 56c8594..e594429 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,17 +1,85 @@ name: Deploy on: + workflow_call: + inputs: + ref: + description: Git ref containing the Docs release + required: true + type: string + expectedVersion: + description: Release version expected from the deployed health endpoint + required: true + type: string + expectedCommit: + description: Commit expected from the deployed health endpoint + required: true + type: string workflow_dispatch: - release: - types: [published] + inputs: + ref: + description: Git ref to deploy + required: true + default: main + type: string + expectedVersion: + description: Optional release version; inferred from version tags or reported as unreleased + required: false + type: string + expectedCommit: + description: Optional commit assertion; defaults to the selected ref's commit + required: false + type: string + +concurrency: + group: fsharp-viewengine-production + cancel-in-progress: false + jobs: deploy: name: Deploy runs-on: ubuntu-latest + environment: + name: production + url: https://fsharpviewengine.meiermade.com permissions: contents: read id-token: write + outputs: + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref }} + - name: Resolve release metadata + id: release + env: + REQUESTED_VERSION: ${{ inputs.expectedVersion }} + EXPECTED_COMMIT: ${{ inputs.expectedCommit }} + DEPLOY_REF: ${{ inputs.ref }} + run: | + version="$REQUESTED_VERSION" + if [[ -z "$version" ]]; then + if [[ "$DEPLOY_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + version="${DEPLOY_REF#v}" + else + version="unreleased" + fi + fi + + commit="$EXPECTED_COMMIT" + if [[ -z "$commit" ]]; then + commit="$(git rev-parse HEAD)" + fi + + actual_commit="$(git rev-parse HEAD)" + if [[ "$commit" != "$actual_commit" ]]; then + echo "Expected commit $commit, but $DEPLOY_REF resolved to $actual_commit." >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "commit=$commit" >> "$GITHUB_OUTPUT" - name: Setup Node uses: actions/setup-node@v5 with: @@ -21,7 +89,7 @@ jobs: with: install_components: gke-gcloud-auth-plugin - name: Install Packages - run: npm install + run: npm ci working-directory: ./pulumi - name: Authenticate Pulumi uses: pulumi/auth-actions@v1 @@ -31,7 +99,71 @@ jobs: scope: user:meiermade - name: Deploy uses: pulumi/actions@v6 + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_COMMIT: ${{ steps.release.outputs.commit }} with: work-dir: ./pulumi command: up stack-name: prod + + e2e: + name: E2E + needs: deploy + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.deploy.outputs.commit }} + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Install E2E packages + run: npm ci + working-directory: ./e2e + - name: Install Firefox + run: npx playwright install --with-deps firefox + working-directory: ./e2e + - name: Wait for deployed Docs release + env: + EXPECTED_VERSION: ${{ needs.deploy.outputs.version }} + EXPECTED_COMMIT: ${{ needs.deploy.outputs.commit }} + run: | + for _ in $(seq 1 60); do + health="$(curl --fail --silent --show-error https://fsharpviewengine.meiermade.com/health || true)" + status="$(jq -r '.status // empty' <<< "$health" 2>/dev/null || true)" + version="$(jq -r '.version // empty' <<< "$health" 2>/dev/null || true)" + commit="$(jq -r '.commit // empty' <<< "$health" 2>/dev/null || true)" + + if [[ "$status" == "ok" && "$version" == "$EXPECTED_VERSION" && "$commit" == "$EXPECTED_COMMIT" ]]; then + exit 0 + fi + + echo "Waiting for Docs $EXPECTED_VERSION at $EXPECTED_COMMIT; found version=${version:-unknown}, commit=${commit:-unknown}." + sleep 5 + done + + echo "FSharp.ViewEngine Docs did not report the expected release." >&2 + exit 1 + - name: Test deployed Docs + run: npm run test:published + working-directory: ./e2e + env: + DOCS_E2E_BASE_URL: https://fsharpviewengine.meiermade.com + DOCS_EXPECTED_VERSION: ${{ needs.deploy.outputs.version }} + DOCS_EXPECTED_COMMIT: ${{ needs.deploy.outputs.commit }} + - name: Upload E2E diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fsharp-viewengine-deploy-e2e + path: | + e2e/playwright-report + e2e/test-results + if-no-files-found: ignore diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 5407c22..b34a25c 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,11 +1,11 @@ name: Preview on: pull_request: - paths-ignore: - - '**/*.md' + branches: + - main jobs: - test: - name: Test + unit: + name: Unit runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -20,20 +20,68 @@ jobs: run: dotnet tool restore working-directory: ./sln - name: Install Packages - run: dotnet paket install + run: | + dotnet paket install + dotnet restore FSharp.ViewEngine.slnx + working-directory: ./sln + - name: Build solution + run: dotnet build FSharp.ViewEngine.slnx --configuration Release --no-restore working-directory: ./sln - name: Test run: ./fake.sh Test working-directory: ./sln + - name: Pack + run: dotnet pack src/FSharp.ViewEngine/FSharp.ViewEngine.fsproj --configuration Release --no-restore --output "$RUNNER_TEMP/package" /p:PackageVersion=0.0.0-preview + working-directory: ./sln + - name: Verify package compatibility + env: + PACKAGE_PATH: ${{ runner.temp }}/package/FSharp.ViewEngine.0.0.0-preview.nupkg + run: ./fake.sh VerifyPackage --single-target + working-directory: ./sln + + e2e: + name: E2E + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Install E2E packages + run: npm ci + working-directory: ./e2e + - name: Install Firefox + run: npx playwright install --with-deps firefox + working-directory: ./e2e + - name: Test production Docs image + run: npm test -- --project=firefox + working-directory: ./e2e + - name: Upload E2E diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fsharp-viewengine-preview-e2e + path: | + e2e/playwright-report + e2e/test-results + /tmp/fsharp-viewengine-e2e + if-no-files-found: ignore + preview: - name: Preview + # Keep this terminal job named Test because it is the protected branch check. + name: Test runs-on: ubuntu-latest permissions: contents: read id-token: write pull-requests: write needs: - - test + - unit + - e2e steps: - uses: actions/checkout@v5 - name: Setup Node @@ -45,7 +93,7 @@ jobs: with: install_components: gke-gcloud-auth-plugin - name: Install Packages - run: npm install + run: npm ci working-directory: ./pulumi - name: Authenticate Pulumi uses: pulumi/auth-actions@v1 @@ -55,6 +103,9 @@ jobs: scope: user:meiermade - name: Preview uses: pulumi/actions@v6 + env: + RELEASE_VERSION: preview + RELEASE_COMMIT: ${{ github.event.pull_request.head.sha }} with: work-dir: ./pulumi command: preview diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cbb79f9..6cd0d2c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,19 +1,41 @@ name: Publish on: - release: - branches: - - main - types: - - published + workflow_dispatch: + inputs: + version: + description: Optional calendar version override (YYYY.M.MINOR) + required: false + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: fsharp-viewengine-release + cancel-in-progress: false + jobs: - publish: - name: Publish + package: + name: Package runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: write + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} 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 - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true - name: Setup dotnet uses: actions/setup-dotnet@v5 with: @@ -25,14 +47,117 @@ jobs: run: dotnet tool restore working-directory: ./sln - name: Install Packages - run: dotnet paket install + run: | + dotnet paket install + dotnet restore FSharp.ViewEngine.slnx + working-directory: ./sln + - name: Prepare release + env: + RELEASE_METADATA_PATH: ${{ runner.temp }}/release-metadata.json + RELEASE_VERSION_OVERRIDE: ${{ inputs.version }} + run: ./fake.sh PrepareRelease + 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: Build, test, pack, and verify release + env: + PACKAGE_VERSION: ${{ steps.release.outputs.version }} + run: ./fake.sh VerifyPackage working-directory: ./sln + - name: Record package checksums + run: sha256sum ./*.nupkg ./*.snupkg > SHA256SUMS + working-directory: ./nugets + - name: Upload verified package + uses: actions/upload-artifact@v4 + with: + name: fsharp-viewengine-nuget + path: | + nugets/*.nupkg + nugets/*.snupkg + nugets/SHA256SUMS + if-no-files-found: error + - name: Create release tag + env: + RELEASE_METADATA_PATH: ${{ runner.temp }}/release-metadata.json + run: ./fake.sh TagRelease + working-directory: ./sln + + deploy-and-verify: + name: Deploy and verify Docs + needs: package + uses: ./.github/workflows/deploy.yml + with: + ref: ${{ needs.package.outputs.tag }} + expectedVersion: ${{ needs.package.outputs.version }} + expectedCommit: ${{ needs.package.outputs.commit }} + secrets: inherit + + publish: + name: Publish NuGet + needs: + - package + - deploy-and-verify + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Download verified package + uses: actions/download-artifact@v5 + with: + name: fsharp-viewengine-nuget + 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 - run: pulumi env run fsharpviewengine/nuget -- ./fake.sh PushNugets - working-directory: ./sln + - name: Publish verified package + env: + PACKAGE_PATH: ${{ runner.temp }}/package/FSharp.ViewEngine.${{ needs.package.outputs.version }}.nupkg + run: | + pulumi env run fsharpviewengine/nuget -- \ + bash -euo pipefail -c \ + "dotnet nuget push \"$PACKAGE_PATH\" --source https://api.nuget.org/v3/index.json --api-key \"\$NUGET_API_KEY\" --skip-duplicate" + - name: Wait for NuGet availability + env: + VERSION: ${{ needs.package.outputs.version }} + run: | + package_url="https://api.nuget.org/v3-flatcontainer/fsharp.viewengine/$VERSION/fsharp.viewengine.$VERSION.nupkg" + + for _ in $(seq 1 60); do + if curl --fail --silent --show-error --output /dev/null "$package_url"; then + echo "FSharp.ViewEngine $VERSION is available from NuGet." + exit 0 + fi + sleep 10 + done + + echo "FSharp.ViewEngine $VERSION was not available from NuGet after 10 minutes." >&2 + exit 1 + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.package.outputs.tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub Release $TAG already exists." + else + gh release create "$TAG" --verify-tag --title "$TAG" --generate-notes + fi diff --git a/.gitignore b/.gitignore index 5c3f262..7fbeb67 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ .vscode .claude .pi* +.plans/ +node_modules/ +playwright-report/ +test-results/ +BenchmarkDotNet.Artifacts/ diff --git a/README.md b/README.md index 67bcc58..c2dc1f2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ FSharp.ViewEngine combines ideas from several F# view engines into a clean, unif - **Computation expression syntax** (like Oxpecker.ViewEngine and Bolero) for building elements - **Feliz-style single sequence** of attributes and child elements — no separate attribute and children lists -- **Attributes prefixed with underscore** by convention (like Giraffe.ViewEngine, e.g. `_class`, `_id`, `_hxGet`), giving clean syntax and nice syntax highlighting +- **Attributes prefixed with underscore** by convention (like Giraffe.ViewEngine, e.g. `_class`, `_id`, `_dataOn`), giving clean syntax and nice syntax highlighting - **Mixed yielding** in computation expressions — you can yield strings, elements, and attributes in any order without needing a special `_children` attribute The result is a DSL that is as minimal and fast as possible while remaining expressive and type-safe. @@ -35,14 +35,18 @@ dotnet package add FSharp.ViewEngine dotnet paket add FSharp.ViewEngine ``` +## Runtime compatibility + +The package ships a single `net8.0` compatibility asset and is tested on supported .NET 8, .NET 9, and .NET 10 runtimes. NuGet automatically selects the `net8.0` asset for compatible newer runtimes. + +Portable symbols are published separately with Source Link metadata, so supported debuggers can retrieve the matching source from GitHub without increasing the main package size. + ## Usage ```fsharp open FSharp.ViewEngine open type Html -open type Htmx -open type Alpine open type Datastar -open type Tailwind +open type TailwindElements html { _lang "en" @@ -52,17 +56,21 @@ html { link { _href "/css/compiled.css"; _rel "stylesheet" } } body { - _xData "{showContent: false}" + _dataSignals "{showContent: false}" _class "bg-gray-50" div { _id "page" _class [ "flex"; "flex-col" ] - h1 { _hxGet "/hello"; _hxTarget "#page"; "Hello" } - h1 { _hxGet "/world"; _hxTarget "#page"; "World" } + h1 { "Hello from FSharp.ViewEngine" } + button { + _dataOn ("click", "$showContent = !$showContent") + "Toggle content" + } } br div { - _xShow "showContent" + _dataShow "$showContent" + _style "display: none" h2 { "Content" } p { "Some content" } ul { @@ -82,13 +90,13 @@ html { - +
-

Hello

-

World

+

Hello from FSharp.ViewEngine

+

-
+

Content

Some content