diff --git a/.github/PR-PREVIEW.md b/.github/PR-PREVIEW.md new file mode 100644 index 0000000..b7d64d8 --- /dev/null +++ b/.github/PR-PREVIEW.md @@ -0,0 +1,51 @@ +# PR docs preview setup + +This repository publishes docs previews for pull requests under: + +- `https://psmodule.io/docs/previews/pr-/` + +## What the workflow does + +1. On PR open/reopen/synchronize: + - builds docs with preview-specific `site_url`, + - updates `previews/pr-/` content by pushing directly to `gh-pages` as the Scribbler app, + - comments on the source PR with the preview URL, + - reports the preview URL through a named environment (`pr-preview-`). +2. On PR close (merge or abandon): + - removes `previews/pr-/` by pushing directly to `gh-pages` as the Scribbler app, + - deletes all preview deployments and the preview environment. + +## Flow diagram + +```mermaid +flowchart TD + A[Contributor opens or updates PR to main] --> B[Docs workflow builds preview site] + B --> C[Scribbler app token pushes preview files to gh-pages under previews/pr-N] + C --> D[Workflow sets environment pr-preview-N with preview URL] + D --> E[Workflow comments on PR with preview link] + E --> F[Contributor iterates on PR] + F --> B + G[PR closed: merged or abandoned] --> H[Cleanup job runs] + H --> I[Scribbler app token removes previews/pr-N from gh-pages] + I --> J[Workflow deletes preview deployments + environment] + J --> K[Workflow updates PR comment: preview removed] +``` + +## Required repository configuration + +1. Ensure `gh-pages` branch exists. +2. Configure GitHub Pages to publish from `gh-pages`. +3. Protect `gh-pages` and restrict push access so **only Scribbler bot app** can push. +4. In the `gh-pages` branch protection/ruleset, add **Scribbler bot app** as the only actor allowed to bypass required pull requests and any required status checks for that branch. + +## Scribbler GitHub App permissions + +The app needs the following repository permissions: + +| Permission | Access | Why | +| --- | --- | --- | +| Metadata | Read | Required baseline for API access | +| Contents | Read & write | Push docs and preview content directly to `gh-pages` | +| Issues | Read & write | Post and update preview comments on PR threads | +| Deployments | Read & write | Deactivate and delete preview deployments | +| Administration | Read & write | Delete per-PR environments during cleanup | diff --git a/.github/scripts/docs/Cleanup-PreviewDocs.ps1 b/.github/scripts/docs/Cleanup-PreviewDocs.ps1 new file mode 100644 index 0000000..9612d4a --- /dev/null +++ b/.github/scripts/docs/Cleanup-PreviewDocs.ps1 @@ -0,0 +1,70 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$Token, + [Parameter(Mandatory = $true)] + [int]$PullRequestNumber, + [Parameter(Mandatory = $true)] + [string]$PreviewUrl, + [Parameter(Mandatory = $true)] + [string]$EnvironmentName, + [string]$PagesDirectory = '_pages', + [string]$BaseBranch = 'gh-pages' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/Shared.ps1" + +$clonedPages = $true +Invoke-Git -Arguments @( + 'clone', + '--no-tags', + '--depth', '1', + '--branch', $BaseBranch, + "https://x-access-token:$Token@github.com/$Repository.git", + $PagesDirectory +) -AllowFailure | Out-Null + +if (-not (Test-Path -LiteralPath $PagesDirectory -PathType Container)) { + $clonedPages = $false +} + +if ($clonedPages) { + $previewDirectory = Join-Path $PagesDirectory "previews/pr-$PullRequestNumber" + if (Test-Path -LiteralPath $previewDirectory) { + Remove-Item -LiteralPath $previewDirectory -Recurse -Force + } + + Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.name', 'scribbler-bot[bot]') + Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.email', 'scribe@psmodule.io') + Invoke-Git -Arguments @('-C', $PagesDirectory, 'add', '-A') + + $status = (& git -C $PagesDirectory status --porcelain) + if (-not [string]::IsNullOrWhiteSpace($status)) { + Invoke-Git -Arguments @('-C', $PagesDirectory, 'commit', '-m', "Remove docs preview for PR #$PullRequestNumber") + Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") + } +} + +$env:GH_TOKEN = $Token +$deploymentsJson = Invoke-Gh -Arguments @('api', "repos/$Repository/deployments?environment=$EnvironmentName&per_page=100") +$deployments = @($deploymentsJson | ConvertFrom-Json) + +foreach ($deployment in $deployments) { + Invoke-Gh -Arguments @('api', '--method', 'POST', "repos/$Repository/deployments/$($deployment.id)/statuses", '-f', 'state=inactive') | Out-Null + Invoke-Gh -Arguments @('api', '--method', 'DELETE', "repos/$Repository/deployments/$($deployment.id)") | Out-Null +} + +$deleteEnvironmentExit = Invoke-Gh -Arguments @('api', '--method', 'DELETE', "repos/$Repository/environments/$EnvironmentName") -AllowFailure +if ($LASTEXITCODE -ne 0) { + $environmentCheckExit = Invoke-Gh -Arguments @('api', "repos/$Repository/environments/$EnvironmentName") -AllowFailure + if ($LASTEXITCODE -eq 0) { + throw "Failed to delete environment '$EnvironmentName'." + } +} + +$commentBody = "`n🧹 Preview removed: $PreviewUrl" +Upsert-IssueComment -Repository $Repository -IssueNumber $PullRequestNumber -Marker '' -Body $commentBody diff --git a/.github/scripts/docs/Publish-LiveDocs.ps1 b/.github/scripts/docs/Publish-LiveDocs.ps1 new file mode 100644 index 0000000..ae4d524 --- /dev/null +++ b/.github/scripts/docs/Publish-LiveDocs.ps1 @@ -0,0 +1,60 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$Token, + [Parameter(Mandatory = $true)] + [string]$BuildDirectory, + [Parameter(Mandatory = $true)] + [string]$CommitSha, + [string]$PagesDirectory = '_pages', + [string]$BaseBranch = 'gh-pages' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/Shared.ps1" + +if (-not (Test-Path -LiteralPath $BuildDirectory -PathType Container)) { + throw "Build directory '$BuildDirectory' does not exist." +} + +Invoke-Git -Arguments @( + 'clone', + '--no-tags', + '--depth', '1', + '--branch', $BaseBranch, + "https://x-access-token:$Token@github.com/$Repository.git", + $PagesDirectory +) -AllowFailure | Out-Null + +if (-not (Test-Path -LiteralPath $PagesDirectory -PathType Container)) { + throw "$BaseBranch branch is required for branch-based deployment." +} + +Get-ChildItem -LiteralPath $PagesDirectory -Force | + Where-Object { $_.Name -notin @('.git', 'previews') } | + Remove-Item -Recurse -Force + +Get-ChildItem -LiteralPath $BuildDirectory -Force | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $PagesDirectory -Recurse -Force + } + +New-Item -Path (Join-Path $PagesDirectory '.nojekyll') -ItemType File -Force | Out-Null + +Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.name', 'scribbler-bot[bot]') +Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.email', 'scribe@psmodule.io') +Invoke-Git -Arguments @('-C', $PagesDirectory, 'add', '-A') + +$status = (& git -C $PagesDirectory status --porcelain) +if ([string]::IsNullOrWhiteSpace($status)) { + Set-WorkflowOutput -Name 'has_changes' -Value 'false' + exit 0 +} + +Invoke-Git -Arguments @('-C', $PagesDirectory, 'commit', '-m', "Deploy docs from $CommitSha") +Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") + +Set-WorkflowOutput -Name 'has_changes' -Value 'true' diff --git a/.github/scripts/docs/Publish-PreviewDocs.ps1 b/.github/scripts/docs/Publish-PreviewDocs.ps1 new file mode 100644 index 0000000..fb94672 --- /dev/null +++ b/.github/scripts/docs/Publish-PreviewDocs.ps1 @@ -0,0 +1,61 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$Token, + [Parameter(Mandatory = $true)] + [int]$PullRequestNumber, + [Parameter(Mandatory = $true)] + [string]$PreviewUrl, + [Parameter(Mandatory = $true)] + [string]$BuildDirectory, + [string]$PagesDirectory = '_pages', + [string]$BaseBranch = 'gh-pages' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot/Shared.ps1" + +if (-not (Test-Path -LiteralPath $BuildDirectory -PathType Container)) { + throw "Build directory '$BuildDirectory' does not exist." +} + +Invoke-Git -Arguments @( + 'clone', + '--no-tags', + '--depth', '1', + '--branch', $BaseBranch, + "https://x-access-token:$Token@github.com/$Repository.git", + $PagesDirectory +) + +$previewDirectory = Join-Path $PagesDirectory "previews/pr-$PullRequestNumber" +if (Test-Path -LiteralPath $previewDirectory) { + Remove-Item -LiteralPath $previewDirectory -Recurse -Force +} + +New-Item -Path $previewDirectory -ItemType Directory -Force | Out-Null +Get-ChildItem -LiteralPath $BuildDirectory -Force | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $previewDirectory -Recurse -Force + } + +New-Item -Path (Join-Path $PagesDirectory '.nojekyll') -ItemType File -Force | Out-Null + +Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.name', 'scribbler-bot[bot]') +Invoke-Git -Arguments @('-C', $PagesDirectory, 'config', 'user.email', 'scribe@psmodule.io') +Invoke-Git -Arguments @('-C', $PagesDirectory, 'add', '-A') + +$status = (& git -C $PagesDirectory status --porcelain) +if (-not [string]::IsNullOrWhiteSpace($status)) { + Invoke-Git -Arguments @('-C', $PagesDirectory, 'commit', '-m', "Update docs preview for PR #$PullRequestNumber") + Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") +} + +$commentBody = "`n✅ Preview is ready: $PreviewUrl" +$env:GH_TOKEN = $Token +Upsert-IssueComment -Repository $Repository -IssueNumber $PullRequestNumber -Marker '' -Body $commentBody + +Set-WorkflowOutput -Name 'url' -Value $PreviewUrl diff --git a/.github/scripts/docs/Set-ZensicalSiteUrl.ps1 b/.github/scripts/docs/Set-ZensicalSiteUrl.ps1 new file mode 100644 index 0000000..899472a --- /dev/null +++ b/.github/scripts/docs/Set-ZensicalSiteUrl.ps1 @@ -0,0 +1,13 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ConfigPath, + [Parameter(Mandatory = $true)] + [string]$SiteUrl +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$content = Get-Content -LiteralPath $ConfigPath -Raw +$updated = $content -replace '(?m)^site_url = ".*"$', "site_url = `"$SiteUrl`"" +Set-Content -LiteralPath $ConfigPath -Value $updated diff --git a/.github/scripts/docs/Shared.ps1 b/.github/scripts/docs/Shared.ps1 new file mode 100644 index 0000000..67a258e --- /dev/null +++ b/.github/scripts/docs/Shared.ps1 @@ -0,0 +1,73 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-Git { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [switch]$AllowFailure + ) + + & git @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "git $($Arguments -join ' ') failed with exit code $exitCode." + } + + return $exitCode +} + +function Set-WorkflowOutput { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$Value + ) + + if (-not $env:GITHUB_OUTPUT) { + throw 'GITHUB_OUTPUT is not defined.' + } + + "$Name=$Value" >> $env:GITHUB_OUTPUT +} + +function Invoke-Gh { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [switch]$AllowFailure + ) + + $output = & gh @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -and -not $AllowFailure) { + throw "gh $($Arguments -join ' ') failed with exit code $exitCode." + } + + return $output +} + +function Upsert-IssueComment { + param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [int]$IssueNumber, + [Parameter(Mandatory = $true)] + [string]$Marker, + [Parameter(Mandatory = $true)] + [string]$Body + ) + + $commentsJson = Invoke-Gh -Arguments @('api', "repos/$Repository/issues/$IssueNumber/comments?per_page=100") + $comments = @($commentsJson | ConvertFrom-Json) + $existing = $comments | Where-Object { $_.body -like "*$Marker*" } | Select-Object -First 1 + + if ($null -ne $existing) { + Invoke-Gh -Arguments @('api', '--method', 'PATCH', "repos/$Repository/issues/comments/$($existing.id)", '-f', "body=$Body") | Out-Null + return + } + + Invoke-Gh -Arguments @('api', '--method', 'POST', "repos/$Repository/issues/$IssueNumber/comments", '-f', "body=$Body") | Out-Null +} diff --git a/.github/workflows/Docs.yml b/.github/workflows/Docs.yml index 1cd4c08..7c97e4e 100644 --- a/.github/workflows/Docs.yml +++ b/.github/workflows/Docs.yml @@ -9,16 +9,21 @@ on: - main paths: - src/** + - .github/scripts/** - .github/actions/update-index/** - .github/workflows/Docs.yml - - .github/workflows/Update-Index.yml pull_request: + types: + - opened + - reopened + - synchronize + - closed branches: - main paths: - src/** + - .github/scripts/** - .github/workflows/Docs.yml - - .github/workflows/Update-Index.yml concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +35,7 @@ permissions: jobs: lint: name: Lint - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: ubuntu-24.04 permissions: contents: read @@ -59,17 +64,28 @@ jobs: VALIDATE_YAML_PRETTIER: false VALIDATE_HTML_PRETTIER: false - build: - name: Build + publish: + name: Publish + if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 + environment: + name: github-pages + url: https://psmodule.io/docs/ permissions: - contents: read + contents: write steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.SCRIBBLER_BOT_CLIENT_ID }} + private-key: ${{ secrets.SCRIBBLER_BOT_PRIVATE_KEY }} + - name: Update index uses: ./.github/actions/update-index with: @@ -88,27 +104,88 @@ jobs: run: zensical build --clean working-directory: src - - name: Upload pages artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: src/site + - name: Publish live docs + id: publish-live + shell: pwsh + run: ./.github/scripts/docs/Publish-LiveDocs.ps1 -Repository "${{ github.repository }}" -Token "${{ steps.app-token.outputs.token }}" -BuildDirectory "$PWD/src/site" -CommitSha "${{ github.sha }}" - publish: - name: Publish - needs: [build, lint] - if: github.event_name != 'pull_request' + preview: + name: Preview + if: github.event_name == 'pull_request' && github.event.action != 'closed' + needs: [lint] runs-on: ubuntu-24.04 environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + name: pr-preview-${{ github.event.number }} + url: ${{ steps.preview-url.outputs.url }} permissions: - contents: read - pages: write # deploy to GitHub Pages - id-token: write # OIDC token for actions/deploy-pages + contents: write + deployments: write + issues: write + env: + PREVIEW_URL: https://psmodule.io/docs/previews/pr-${{ github.event.number }}/ steps: - - name: Configure pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.SCRIBBLER_BOT_CLIENT_ID }} + private-key: ${{ secrets.SCRIBBLER_BOT_PRIVATE_KEY }} + + - name: Update index + uses: ./.github/actions/update-index + with: + ClientID: ${{ secrets.SCRIBBLER_BOT_CLIENT_ID }} # zizmor: ignore[secrets-outside-env] + PrivateKey: ${{ secrets.SCRIBBLER_BOT_PRIVATE_KEY }} # zizmor: ignore[secrets-outside-env] + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: 3.x + + - name: Install Zensical + run: pip install zensical + + - name: Build preview site + shell: pwsh + run: | + ./.github/scripts/docs/Set-ZensicalSiteUrl.ps1 -ConfigPath "$PWD/zensical.toml" -SiteUrl "${{ env.PREVIEW_URL }}" + zensical build --clean + working-directory: src + + - name: Publish preview docs + id: preview-url + shell: pwsh + run: ./.github/scripts/docs/Publish-PreviewDocs.ps1 -Repository "${{ github.repository }}" -Token "${{ steps.app-token.outputs.token }}" -PullRequestNumber ${{ github.event.number }} -PreviewUrl "${{ env.PREVIEW_URL }}" -BuildDirectory "$PWD/src/site" + + preview-cleanup: + name: Preview cleanup + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-24.04 + permissions: + contents: write + deployments: write + issues: write + env: + PREVIEW_URL: https://psmodule.io/docs/previews/pr-${{ github.event.number }}/ + PREVIEW_ENVIRONMENT: pr-preview-${{ github.event.number }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.SCRIBBLER_BOT_CLIENT_ID }} + private-key: ${{ secrets.SCRIBBLER_BOT_PRIVATE_KEY }} - - name: Deploy to GitHub Pages - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 - id: deployment + - name: Cleanup preview resources + shell: pwsh + run: ./.github/scripts/docs/Cleanup-PreviewDocs.ps1 -Repository "${{ github.repository }}" -Token "${{ steps.app-token.outputs.token }}" -PullRequestNumber ${{ github.event.number }} -PreviewUrl "${{ env.PREVIEW_URL }}" -EnvironmentName "${{ env.PREVIEW_ENVIRONMENT }}" diff --git a/README.md b/README.md index 3557d2f..0b44564 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ This repository contains the documentation for the [PSModule](https://psmodule.io) development framework. Visit [psmodule.io/docs](https://psmodule.io/docs) to read the documentation created in this repository. + +PRs automatically publish a docs preview at `https://psmodule.io/docs/previews/pr-/` and update the same preview URL as new commits are pushed. The preview is removed when the PR closes (merged or abandoned). See [.github/PR-PREVIEW.md](.github/PR-PREVIEW.md) for setup and required bot permissions.