diff --git a/.github/workflows/builds-docs.yml b/.github/workflows/builds-docs.yml
deleted file mode 100644
index 0f1d4bbf38..0000000000
--- a/.github/workflows/builds-docs.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: github pages
-
-on:
- push:
- branches:
- - main
- pull_request:
- branches:
- - main
-
-permissions:
- contents: write
- pages: write
-
-jobs:
- deploy:
- runs-on: windows-latest
- steps:
-
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Install MAUI workload
- run: dotnet workload install maui
-
- - name: Install wasm-tools workload
- run: dotnet workload install wasm-tools
-
- - name: Restore .NET tools
- run: dotnet tool restore
-
- - name: Build the assemblies
- run: dotnet build scripts/SkiaSharp.Extended-Pack.slnf -c Release -f net10.0
-
- - name: Publish Blazor sample
- run: dotnet publish samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj -c Release -o publish/sample
-
- - name: Build docs
- run: dotnet docfx docs/docfx.json
-
- - name: Copy Blazor sample into docs site
- run: |
- New-Item -ItemType Directory -Path docs/_site/sample -Force
- Get-ChildItem -Path publish/sample/wwwroot -Force | Copy-Item -Destination docs/_site/sample -Recurse -Force
-
- # Rewrite base href for GitHub Pages deployment
- $indexPath = "docs/_site/sample/index.html"
- (Get-Content $indexPath -Raw) -replace '', '' | Set-Content $indexPath
- shell: pwsh
-
- - name: Upload artifact
- uses: actions/upload-pages-artifact@v3
- with:
- path: 'docs/_site'
-
- - name: Deploy
- if: github.ref == 'refs/heads/main'
- uses: peaceiris/actions-gh-pages@v3.6.1
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./docs/_site
diff --git a/.github/workflows/docs-cleanup-staging-pr.yml b/.github/workflows/docs-cleanup-staging-pr.yml
new file mode 100644
index 0000000000..3b7ca73cfb
--- /dev/null
+++ b/.github/workflows/docs-cleanup-staging-pr.yml
@@ -0,0 +1,61 @@
+name: "Docs - PR Staging - Cleanup"
+
+on:
+ pull_request:
+ branches: [ main ]
+ types: [ closed ]
+
+# Default to no permissions โ each job declares only what it needs.
+permissions: {}
+
+# Ensure documentation workflows run sequentially
+concurrency:
+ group: "docs-deployment"
+ cancel-in-progress: false
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # push to gh-pages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Remove staging directory from gh-pages
+ env:
+ PR_NUMBER: ${{ github.event.number }}
+ run: |
+ # Validate PR_NUMBER is a positive integer before using in paths
+ if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Invalid PR number: $PR_NUMBER"
+ exit 1
+ fi
+
+ # Switch to gh-pages branch; exit cleanly if it doesn't exist yet
+ if ! git fetch origin gh-pages:gh-pages 2>/dev/null; then
+ echo "gh-pages branch does not exist, nothing to clean up"
+ exit 0
+ fi
+ if ! git checkout gh-pages 2>/dev/null; then
+ echo "Failed to checkout gh-pages, nothing to clean up"
+ exit 0
+ fi
+
+ if [ -d "staging/$PR_NUMBER" ]; then
+ rm -rf staging/$PR_NUMBER
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add -A
+ if ! git diff --cached --quiet; then
+ git commit -m "Remove staging docs for PR #$PR_NUMBER"
+ git push origin gh-pages
+ echo "Removed staging directory for PR #$PR_NUMBER"
+ else
+ echo "No changes to commit for PR #$PR_NUMBER"
+ fi
+ else
+ echo "No staging directory found for PR #$PR_NUMBER"
+ fi
diff --git a/.github/workflows/docs-deploy-fork-pr.yml b/.github/workflows/docs-deploy-fork-pr.yml
new file mode 100644
index 0000000000..963967bf60
--- /dev/null
+++ b/.github/workflows/docs-deploy-fork-pr.yml
@@ -0,0 +1,152 @@
+name: "Docs - Fork PR - Deploy"
+
+# This workflow runs in a TRUSTED context (base repo token with write access).
+# It is triggered AFTER "Docs - Deploy" completes for a fork PR.
+# The "Docs - Deploy" build job uploads the site artifact; this workflow
+# downloads it and deploys to gh-pages/staging/{pr}/, then comments on the PR.
+#
+# Security: no untrusted code is executed here โ we only deploy a pre-built
+# artifact produced by our own workflow. The workflow_run trigger always runs
+# with the base repository's token, regardless of where the PR originated.
+
+on:
+ workflow_run:
+ workflows: [ "Docs - Deploy" ]
+ types: [ completed ]
+
+# Default to no permissions โ each job declares only what it needs.
+permissions: {}
+
+# Ensure documentation workflows run sequentially
+concurrency:
+ group: "docs-deployment"
+ cancel-in-progress: false
+
+jobs:
+ deploy-fork-pr-staging:
+ # Only run for successful fork PR builds
+ if: |
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.event == 'pull_request' &&
+ github.event.workflow_run.head_repository.full_name != github.repository
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # push to gh-pages
+ pull-requests: write # comment on PR
+ steps:
+ - name: Find staging artifact for this PR
+ id: artifact
+ uses: actions/github-script@v7
+ env:
+ WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
+ with:
+ script: |
+ const runId = parseInt(process.env.WORKFLOW_RUN_ID, 10);
+ const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ run_id: runId
+ });
+ const staging = artifacts.data.artifacts.find(a => a.name.startsWith('staging-site-'));
+ if (!staging) {
+ core.setFailed('No staging artifact found for this workflow run');
+ return;
+ }
+ // Extract and validate PR number (must be a positive integer)
+ const prNumberStr = staging.name.replace('staging-site-', '');
+ const prNumber = parseInt(prNumberStr, 10);
+ if (!prNumber || prNumber <= 0 || String(prNumber) !== prNumberStr) {
+ core.setFailed(`Invalid PR number extracted from artifact name: ${staging.name}`);
+ return;
+ }
+ core.setOutput('artifact_name', staging.name);
+ core.setOutput('pr_number', String(prNumber));
+
+ - name: Download staging artifact
+ uses: actions/download-artifact@v4
+ with:
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ name: ${{ steps.artifact.outputs.artifact_name }}
+ path: /tmp/site-temp
+
+ - name: Checkout gh-pages branch
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ fetch-depth: 0
+
+ - name: Deploy staging content
+ env:
+ PR_NUMBER: ${{ steps.artifact.outputs.pr_number }}
+ run: |
+ # Validate PR_NUMBER is a positive integer before using in paths
+ if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Invalid PR number: $PR_NUMBER"
+ exit 1
+ fi
+ mkdir -p staging/$PR_NUMBER
+ rm -rf staging/$PR_NUMBER/*
+ cp -r /tmp/site-temp/. staging/$PR_NUMBER/
+ rm -rf /tmp/site-temp
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add staging/$PR_NUMBER
+ if ! git diff --cached --quiet; then
+ git commit -m "Deploy staging docs for PR #$PR_NUMBER"
+ git push origin gh-pages
+ else
+ echo "No changes to commit"
+ fi
+
+ - name: Comment on PR
+ uses: actions/github-script@v7
+ env:
+ PR_NUMBER: ${{ steps.artifact.outputs.pr_number }}
+ with:
+ script: |
+ const prNumber = parseInt(process.env.PR_NUMBER, 10);
+ if (!prNumber || prNumber <= 0) {
+ core.setFailed('Invalid PR number');
+ return;
+ }
+ const stagingUrl = `https://mono.github.io/SkiaSharp.Extended/staging/${prNumber}/`;
+ const sampleUrl = `https://mono.github.io/SkiaSharp.Extended/staging/${prNumber}/sample/`;
+
+ // Only comment once; check for an existing bot comment
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber
+ });
+ const botComment = comments.data.find(c =>
+ c.user.type === 'Bot' &&
+ c.body.includes('Documentation Preview')
+ );
+ if (botComment) {
+ console.log('Bot comment already exists, skipping.');
+ return;
+ }
+
+ const commentBody = [
+ '๐ **Documentation Preview**',
+ '',
+ 'The documentation for this PR has been deployed and is available at:',
+ '',
+ `๐ **[View Staging Documentation](${stagingUrl})**`,
+ '',
+ `๐ **[View Staging Blazor Sample](${sampleUrl})**`,
+ '',
+ 'This preview will be updated automatically when you push new commits to this PR.',
+ '',
+ '---',
+ '*This comment is automatically updated by the documentation staging workflow.*'
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body: commentBody
+ });
+
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
new file mode 100644
index 0000000000..43f2654ba3
--- /dev/null
+++ b/.github/workflows/docs-deploy.yml
@@ -0,0 +1,242 @@
+name: "Docs - Deploy"
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+ - 'samples/SkiaSharpDemo.Blazor/**'
+ - 'source/**'
+ - 'scripts/**'
+ - '.github/workflows/docs-deploy.yml'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'docs/**'
+ - 'samples/SkiaSharpDemo.Blazor/**'
+ - 'source/**'
+ - 'scripts/**'
+ - '.github/workflows/docs-deploy.yml'
+ types: [ opened, synchronize, reopened ]
+ workflow_dispatch:
+
+# Default to no permissions โ each job declares only what it needs.
+permissions: {}
+
+# Ensure documentation workflows run sequentially
+concurrency:
+ group: "docs-deployment"
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read # checkout only
+ outputs:
+ artifact_name: ${{ steps.vars.outputs.artifact_name }}
+ is_staging: ${{ steps.vars.outputs.is_staging }}
+ pr_number: ${{ steps.vars.outputs.pr_number }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup build variables
+ id: vars
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ PR_NUMBER: ${{ github.event.number }}
+ shell: bash
+ run: |
+ if [ "$EVENT_NAME" == "pull_request" ]; then
+ # Validate PR_NUMBER is a positive integer before using it in artifact names/paths
+ if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Invalid PR number: $PR_NUMBER"
+ exit 1
+ fi
+ echo "artifact_name=staging-site-${PR_NUMBER}" >> $GITHUB_OUTPUT
+ echo "sample_base_href=/SkiaSharp.Extended/staging/${PR_NUMBER}/sample/" >> $GITHUB_OUTPUT
+ echo "sample_segment_count=4" >> $GITHUB_OUTPUT
+ echo "is_staging=true" >> $GITHUB_OUTPUT
+ echo "pr_number=${PR_NUMBER}" >> $GITHUB_OUTPUT
+ echo "source_label=PR #${PR_NUMBER}" >> $GITHUB_OUTPUT
+ else
+ echo "artifact_name=main-site" >> $GITHUB_OUTPUT
+ echo "sample_base_href=/SkiaSharp.Extended/sample/" >> $GITHUB_OUTPUT
+ echo "sample_segment_count=2" >> $GITHUB_OUTPUT
+ echo "is_staging=false" >> $GITHUB_OUTPUT
+ echo "pr_number=" >> $GITHUB_OUTPUT
+ echo "source_label=main" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Install MAUI workload
+ run: dotnet workload install maui-android
+
+ - name: Install wasm-tools workload
+ run: dotnet workload install wasm-tools
+
+ - name: Restore .NET tools
+ run: dotnet tool restore
+
+ - name: Build the assemblies
+ run: dotnet build scripts/SkiaSharp.Extended-Pack.slnf -c Release -f net10.0
+
+ - name: Publish Blazor sample
+ env:
+ SOURCE_LABEL: ${{ steps.vars.outputs.source_label }}
+ run: |
+ COMMIT_SHORT="${GITHUB_SHA:0:7}"
+ BUILD_INFO="${SOURCE_LABEL} ยท ${COMMIT_SHORT}"
+ dotnet publish samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj \
+ -c Release -o publish/sample \
+ -p:BuildInfo="$BUILD_INFO"
+
+ - name: Build docs
+ env:
+ SOURCE_LABEL: ${{ steps.vars.outputs.source_label }}
+ run: |
+ COMMIT_SHORT="${GITHUB_SHA:0:7}"
+ APP_FOOTER="source: ${SOURCE_LABEL} ยท ${COMMIT_SHORT}"
+ jq --arg footer "$APP_FOOTER" \
+ '.build.globalMetadata._appFooter = $footer' \
+ docs/docfx.json > docs/docfx-build.json
+ dotnet docfx docs/docfx-build.json
+
+ - name: Copy Blazor sample into docs site
+ env:
+ BASE_HREF: ${{ steps.vars.outputs.sample_base_href }}
+ SEGMENT_COUNT: ${{ steps.vars.outputs.sample_segment_count }}
+ run: |
+ mkdir -p docs/_site/sample
+ cp -r publish/sample/wwwroot/. docs/_site/sample/
+
+ # Rewrite base href for deployment
+ sed -i "s|||" docs/_site/sample/index.html
+
+ # Update segmentCount in 404.html
+ if [ -f docs/_site/sample/404.html ]; then
+ sed -i "s|var segmentCount = [0-9]*;|var segmentCount = $SEGMENT_COUNT;|" docs/_site/sample/404.html
+ fi
+
+ - name: Upload site artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ steps.vars.outputs.artifact_name }}
+ path: docs/_site
+ retention-days: 1
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout gh-pages branch
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ fetch-depth: 0
+
+ - name: Download site artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ needs.build.outputs.artifact_name }}
+ path: /tmp/site-temp
+
+ - name: Setup main site content
+ if: needs.build.outputs.is_staging == 'false'
+ run: |
+ find . -maxdepth 1 \
+ -not -name 'staging' \
+ -not -name '.git' \
+ -not -name '.' \
+ -not -name '..' \
+ -exec rm -rf {} \;
+ cp -r /tmp/site-temp/. .
+ rm -rf /tmp/site-temp
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add .
+ if ! git diff --cached --quiet; then
+ git commit -m "Deploy main documentation from commit ${{ github.sha }}"
+ git push origin gh-pages
+ else
+ echo "No changes to commit"
+ fi
+
+ - name: Setup staging site content
+ if: needs.build.outputs.is_staging == 'true'
+ env:
+ PR_NUMBER: ${{ needs.build.outputs.pr_number }}
+ run: |
+ # Validate PR_NUMBER is a positive integer before using in paths
+ if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Invalid PR number: $PR_NUMBER"
+ exit 1
+ fi
+ mkdir -p staging/$PR_NUMBER
+ rm -rf staging/$PR_NUMBER/*
+ cp -r /tmp/site-temp/. staging/$PR_NUMBER/
+ rm -rf /tmp/site-temp
+ git config --local user.email "action@github.com"
+ git config --local user.name "GitHub Action"
+ git add staging/$PR_NUMBER
+ if ! git diff --cached --quiet; then
+ git commit -m "Deploy staging docs for PR #$PR_NUMBER"
+ git push origin gh-pages
+ else
+ echo "No changes to commit"
+ fi
+
+ comment:
+ runs-on: ubuntu-latest
+ needs: [build, deploy]
+ if: needs.build.outputs.is_staging == 'true' && (github.event.action == 'opened' || github.event.action == 'reopened')
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Comment on PR
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const prNumber = context.issue.number;
+ const stagingUrl = `https://mono.github.io/SkiaSharp.Extended/staging/${prNumber}/`;
+ const sampleUrl = `https://mono.github.io/SkiaSharp.Extended/staging/${prNumber}/sample/`;
+
+ // Only comment once; check for an existing bot comment to avoid duplicates on reopen
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber
+ });
+ const botComment = comments.data.find(c =>
+ c.user.type === 'Bot' &&
+ c.body.includes('Documentation Preview')
+ );
+ if (botComment) {
+ console.log('Bot comment already exists, skipping.');
+ return;
+ }
+
+ const commentBody = [
+ '๐ **Documentation Preview**',
+ '',
+ 'The documentation for this PR has been deployed and is available at:',
+ '',
+ `๐ **[View Staging Documentation](${stagingUrl})**`,
+ '',
+ `๐ **[View Staging Blazor Sample](${sampleUrl})**`,
+ '',
+ 'This preview will be updated automatically when you push new commits to this PR.',
+ '',
+ '---',
+ '*This comment is automatically updated by the documentation staging workflow.*'
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body: commentBody
+ });
diff --git a/.github/workflows/docs-go-live.yml b/.github/workflows/docs-go-live.yml
new file mode 100644
index 0000000000..1185c29591
--- /dev/null
+++ b/.github/workflows/docs-go-live.yml
@@ -0,0 +1,48 @@
+name: "Docs - Go Live!"
+
+on:
+ workflow_run:
+ workflows:
+ - "Docs - Deploy"
+ - "Docs - Fork PR - Deploy"
+ - "Docs - PR Staging - Cleanup"
+ types:
+ - completed
+ workflow_dispatch:
+
+# Default to no permissions โ each job declares only what it needs.
+permissions: {}
+
+# Ensure go-live runs one at a time (latest wins)
+concurrency:
+ group: "docs-go-live"
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout gh-pages
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: .
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/docs/docfx.json b/docs/docfx.json
index ebced96514..56932bc04c 100644
--- a/docs/docfx.json
+++ b/docs/docfx.json
@@ -5,7 +5,7 @@
"src": [
{
"files": [
- "**/bin/Release/net9.0/**.dll"
+ "**/bin/Release/net10.0/**.dll"
],
"src": "../source/"
}
diff --git a/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor b/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor
index 5773ded5ca..e1527227a8 100644
--- a/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor
+++ b/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor
@@ -1,4 +1,5 @@
-๏ปฟ@inherits LayoutComponentBase
+@inherits LayoutComponentBase
+@using System.Reflection
+
+@code {
+ private string _buildInfo = typeof(App).Assembly
+ .GetCustomAttributes
()
+ .FirstOrDefault(a => a.Key == "BuildInfo")?.Value ?? "local";
+}
diff --git a/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor.css b/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor.css
index baef3ee5fc..2375260d01 100644
--- a/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor.css
+++ b/samples/SkiaSharpDemo.Blazor/Layout/MainLayout.razor.css
@@ -45,6 +45,15 @@ main {
margin-left: 0;
}
}
+
+.build-info {
+ text-align: right;
+ padding: 0.25rem 1.5rem;
+ font-size: 0.65rem;
+ font-family: monospace;
+ color: #999;
+ border-top: 1px solid #eee;
+}
@media (min-width: 641px) {
.page {
diff --git a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj
index 4cdae9a4cf..e22a8ae99e 100644
--- a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj
+++ b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj
@@ -12,8 +12,19 @@
false
false
+
+
+ local
+
+
+
+ <_Parameter1>BuildInfo
+ <_Parameter2>$(BuildInfo)
+
+
+
diff --git a/samples/SkiaSharpDemo.Blazor/wwwroot/css/app.css b/samples/SkiaSharpDemo.Blazor/wwwroot/css/app.css
index d919e040b1..1b97224f29 100644
--- a/samples/SkiaSharpDemo.Blazor/wwwroot/css/app.css
+++ b/samples/SkiaSharpDemo.Blazor/wwwroot/css/app.css
@@ -111,4 +111,4 @@ code {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
-}
\ No newline at end of file
+}