From 4b5287d43b31ac0d662d4134b8576936d112d4af Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 20 Jul 2026 01:04:54 +0200 Subject: [PATCH 1/5] Add PR preview lifecycle workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Docs.yml | 455 +++++++++++++++++++++++++++++++++++-- 1 file changed, 433 insertions(+), 22 deletions(-) diff --git a/.github/workflows/Docs.yml b/.github/workflows/Docs.yml index 1cd4c08..3776c5c 100644 --- a/.github/workflows/Docs.yml +++ b/.github/workflows/Docs.yml @@ -9,16 +9,21 @@ on: - main paths: - src/** + - 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/** + - 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,29 @@ 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 + pull-requests: 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 +105,421 @@ 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: Clone gh-pages branch + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages || { + echo "gh-pages branch is required for branch-based deployment. Create and protect gh-pages before enabling this workflow." + exit 1 + } - publish: - name: Publish - needs: [build, lint] - if: github.event_name != 'pull_request' + - name: Sync live docs content + id: sync-live + shell: pwsh + run: | + Get-ChildItem -LiteralPath "$PWD\_pages" -Force | + Where-Object { $_.Name -notin @('.git', 'previews') } | + Remove-Item -Recurse -Force + + Get-ChildItem -LiteralPath "$PWD\src\site" -Force | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination "$PWD\_pages" -Recurse -Force + } + + New-Item -Path "$PWD\_pages\.nojekyll" -ItemType File -Force | Out-Null + + git -C _pages config user.name "scribbler-bot[bot]" + git -C _pages config user.email "scribe@psmodule.io" + git -C _pages add -A + + $status = git -C _pages status --porcelain + if ([string]::IsNullOrWhiteSpace($status)) { + "has_changes=false" >> $env:GITHUB_OUTPUT + exit 0 + } + + git -C _pages commit -m "Deploy docs from ${{ github.sha }}" + "has_changes=true" >> $env:GITHUB_OUTPUT + + - name: Push publish branch + if: steps.sync-live.outputs.has_changes == 'true' + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git -C _pages push --force-with-lease origin "HEAD:refs/heads/scribbler/publish/main" + + - name: Create or update publish PR + if: steps.sync-live.outputs.has_changes == 'true' + id: publish-pr + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + $repo = "${{ github.repository }}" + $title = "docs(publish): update live docs" + $body = @" + Automated docs publish from `${{ github.sha }}`. + Live URL: https://psmodule.io/docs/ + "@ + $existing = gh pr list --repo $repo --base gh-pages --head "scribbler/publish/main" --state open --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existing)) { + $number = gh pr create --repo $repo --base gh-pages --head "scribbler/publish/main" --title $title --body $body --json number --jq '.number' + } else { + gh pr edit $existing --repo $repo --title $title --body $body | Out-Null + $number = $existing + } + "number=$number" >> $env:GITHUB_OUTPUT + + - name: Enable auto-merge for publish PR + if: steps.sync-live.outputs.has_changes == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr merge ${{ steps.publish-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch + + 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 + deployments: write + issues: write + pull-requests: write + env: + PREVIEW_PAGES_BRANCH: scribbler/preview/pr-${{ github.event.number }} + 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: Deploy to GitHub Pages - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 - id: deployment + - name: Set preview URL output + id: preview-url + run: echo "url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Build preview site + shell: pwsh + run: | + $configPath = Join-Path $PWD 'zensical.toml' + $content = Get-Content -LiteralPath $configPath -Raw + $updated = $content -replace '(?m)^site_url = ".*"$', "site_url = `"$env:PREVIEW_URL`"" + Set-Content -LiteralPath $configPath -Value $updated + zensical build --clean + working-directory: src + + - name: Clone gh-pages branch + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages || { + echo "gh-pages branch is required for previews. Create and protect gh-pages before enabling this workflow." + exit 1 + } + + - name: Sync preview content + id: sync-preview + shell: pwsh + run: | + $previewDir = Join-Path $PWD "_pages\previews\pr-${{ github.event.number }}" + + if (Test-Path -LiteralPath $previewDir) { + Remove-Item -LiteralPath $previewDir -Recurse -Force + } + + New-Item -Path $previewDir -ItemType Directory -Force | Out-Null + Get-ChildItem -LiteralPath "$PWD\src\site" -Force | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $previewDir -Recurse -Force + } + + New-Item -Path "$PWD\_pages\.nojekyll" -ItemType File -Force | Out-Null + + git -C _pages config user.name "scribbler-bot[bot]" + git -C _pages config user.email "scribe@psmodule.io" + git -C _pages add -A + + $status = git -C _pages status --porcelain + if ([string]::IsNullOrWhiteSpace($status)) { + "has_changes=false" >> $env:GITHUB_OUTPUT + exit 0 + } + + git -C _pages commit -m "Update docs preview for PR #${{ github.event.number }}" + "has_changes=true" >> $env:GITHUB_OUTPUT + + - name: Push preview branch + if: steps.sync-preview.outputs.has_changes == 'true' + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git -C _pages push --force-with-lease origin "HEAD:refs/heads/${PREVIEW_PAGES_BRANCH}" + + - name: Create or update gh-pages PR + if: steps.sync-preview.outputs.has_changes == 'true' + id: preview-pr + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + $repo = "${{ github.repository }}" + $title = "docs(preview): PR #${{ github.event.number }}" + $body = @" + Automated preview content update for #${{ github.event.number }}. + Preview URL: $env:PREVIEW_URL + "@ + $existing = gh pr list --repo $repo --base gh-pages --head "${env:PREVIEW_PAGES_BRANCH}" --state open --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existing)) { + $number = gh pr create --repo $repo --base gh-pages --head "${env:PREVIEW_PAGES_BRANCH}" --title $title --body $body --json number --jq '.number' + } else { + gh pr edit $existing --repo $repo --title $title --body $body | Out-Null + $number = $existing + } + "number=$number" >> $env:GITHUB_OUTPUT + + - name: Enable auto-merge for gh-pages PR + if: steps.sync-preview.outputs.has_changes == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr merge ${{ steps.preview-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch + + - name: Comment preview URL + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const marker = ''; + const body = `${marker} + āœ… Preview is ready: ${process.env.PREVIEW_URL}`; + const issueNumber = context.payload.pull_request.number; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + const existing = comments.find((c) => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body + }); + } + + preview-cleanup: + name: Preview cleanup + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-24.04 + permissions: + contents: read + deployments: write + issues: write + pull-requests: write + env: + PREVIEW_CLEANUP_BRANCH: scribbler/preview-cleanup/pr-${{ github.event.number }} + PREVIEW_URL: https://psmodule.io/docs/previews/pr-${{ github.event.number }}/ + PREVIEW_ENVIRONMENT: pr-preview-${{ github.event.number }} + steps: + - 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: Clone gh-pages branch + id: clone-pages + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + if ! git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages; then + echo "missing=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "missing=false" >> "$GITHUB_OUTPUT" + + - name: Remove preview content + if: steps.clone-pages.outputs.missing == 'false' + id: remove-preview + shell: pwsh + run: | + $previewDir = Join-Path $PWD "_pages\previews\pr-${{ github.event.number }}" + if (Test-Path -LiteralPath $previewDir) { + Remove-Item -LiteralPath $previewDir -Recurse -Force + } + + git -C _pages config user.name "scribbler-bot[bot]" + git -C _pages config user.email "scribe@psmodule.io" + git -C _pages add -A + + $status = git -C _pages status --porcelain + if ([string]::IsNullOrWhiteSpace($status)) { + "has_changes=false" >> $env:GITHUB_OUTPUT + exit 0 + } + + git -C _pages commit -m "Remove docs preview for PR #${{ github.event.number }}" + "has_changes=true" >> $env:GITHUB_OUTPUT + + - name: Push cleanup branch + if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' + shell: bash + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git -C _pages push --force-with-lease origin "HEAD:refs/heads/${PREVIEW_CLEANUP_BRANCH}" + + - name: Create or update cleanup PR + if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' + id: cleanup-pr + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + $repo = "${{ github.repository }}" + $title = "docs(preview): cleanup PR #${{ github.event.number }}" + $body = "Automated cleanup for preview URL: $env:PREVIEW_URL" + $existing = gh pr list --repo $repo --base gh-pages --head "${env:PREVIEW_CLEANUP_BRANCH}" --state open --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existing)) { + $number = gh pr create --repo $repo --base gh-pages --head "${env:PREVIEW_CLEANUP_BRANCH}" --title $title --body $body --json number --jq '.number' + } else { + gh pr edit $existing --repo $repo --title $title --body $body | Out-Null + $number = $existing + } + "number=$number" >> $env:GITHUB_OUTPUT + + - name: Enable auto-merge for cleanup PR + if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' + shell: pwsh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr merge ${{ steps.cleanup-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch + + - name: Delete preview deployments and environment + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const environment = process.env.PREVIEW_ENVIRONMENT; + + const deployments = await github.paginate(github.rest.repos.listDeployments, { + owner, + repo, + environment, + per_page: 100 + }); + + for (const deployment of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner, + repo, + deployment_id: deployment.id, + state: 'inactive' + }); + await github.request('DELETE /repos/{owner}/{repo}/deployments/{deployment_id}', { + owner, + repo, + deployment_id: deployment.id + }); + } + + try { + await github.request('DELETE /repos/{owner}/{repo}/environments/{environment_name}', { + owner, + repo, + environment_name: environment + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + - name: Comment preview removal + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const marker = ''; + const issueNumber = context.payload.pull_request.number; + const body = `${marker} + 🧹 Preview removed: ${process.env.PREVIEW_URL}`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + const existing = comments.find((c) => c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body + }); + } From cd5b0ac93fa7d30559859bd60d5cbf2a741f9ca1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 20 Jul 2026 01:04:58 +0200 Subject: [PATCH 2/5] Document preview setup and permissions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/PR-PREVIEW.md | 38 ++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 40 insertions(+) create mode 100644 .github/PR-PREVIEW.md diff --git a/.github/PR-PREVIEW.md b/.github/PR-PREVIEW.md new file mode 100644 index 0000000..126859c --- /dev/null +++ b/.github/PR-PREVIEW.md @@ -0,0 +1,38 @@ +# 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 via a PR into `gh-pages`, + - enables auto-merge for that `gh-pages` PR, + - 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-/` via a PR into `gh-pages`, + - enables auto-merge for that cleanup PR, + - deletes all preview deployments and the preview environment. + +## Required repository configuration + +1. Ensure `gh-pages` branch exists. +2. Configure GitHub Pages to publish from `gh-pages`. +3. Protect `gh-pages` so direct commits are blocked and updates happen through PRs only. +4. Allow auto-merge for PRs in this repository. + +## 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 preview/cleanup branches and update content | +| Pull requests | Read & write | Create/update/merge PRs into `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/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. From 1643c9e9776f6101441ce9424d2732a8e29c137b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 20 Jul 2026 01:13:57 +0200 Subject: [PATCH 3/5] Extract docs workflow logic into scripts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/docs/Cleanup-PreviewDocs.ps1 | 81 +++++ .github/scripts/docs/Publish-LiveDocs.ps1 | 72 ++++ .github/scripts/docs/Publish-PreviewDocs.ps1 | 72 ++++ .github/scripts/docs/Set-ZensicalSiteUrl.ps1 | 13 + .github/scripts/docs/Shared.ps1 | 107 ++++++ .github/workflows/Docs.yml | 361 +------------------ 6 files changed, 361 insertions(+), 345 deletions(-) create mode 100644 .github/scripts/docs/Cleanup-PreviewDocs.ps1 create mode 100644 .github/scripts/docs/Publish-LiveDocs.ps1 create mode 100644 .github/scripts/docs/Publish-PreviewDocs.ps1 create mode 100644 .github/scripts/docs/Set-ZensicalSiteUrl.ps1 create mode 100644 .github/scripts/docs/Shared.ps1 diff --git a/.github/scripts/docs/Cleanup-PreviewDocs.ps1 b/.github/scripts/docs/Cleanup-PreviewDocs.ps1 new file mode 100644 index 0000000..d10e09d --- /dev/null +++ b/.github/scripts/docs/Cleanup-PreviewDocs.ps1 @@ -0,0 +1,81 @@ +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, + [Parameter(Mandatory = $true)] + [string]$HeadBranch, + [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', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") + + $env:GH_TOKEN = $Token + $prNumber = Upsert-PullRequest ` + -Repository $Repository ` + -HeadBranch $HeadBranch ` + -BaseBranch $BaseBranch ` + -Title "docs(preview): cleanup PR #$PullRequestNumber" ` + -Body "Automated cleanup for preview URL: $PreviewUrl" + Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber + } +} + +$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..35c5426 --- /dev/null +++ b/.github/scripts/docs/Publish-LiveDocs.ps1 @@ -0,0 +1,72 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$Token, + [Parameter(Mandatory = $true)] + [string]$BuildDirectory, + [Parameter(Mandatory = $true)] + [string]$HeadBranch, + [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', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") + +$env:GH_TOKEN = $Token +$prNumber = Upsert-PullRequest ` + -Repository $Repository ` + -HeadBranch $HeadBranch ` + -BaseBranch $BaseBranch ` + -Title 'docs(publish): update live docs' ` + -Body "Automated docs publish from `$CommitSha`nLive URL: https://psmodule.io/docs/" +Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber + +Set-WorkflowOutput -Name 'has_changes' -Value 'true' +Set-WorkflowOutput -Name 'pr_number' -Value $prNumber diff --git a/.github/scripts/docs/Publish-PreviewDocs.ps1 b/.github/scripts/docs/Publish-PreviewDocs.ps1 new file mode 100644 index 0000000..306071f --- /dev/null +++ b/.github/scripts/docs/Publish-PreviewDocs.ps1 @@ -0,0 +1,72 @@ +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, + [Parameter(Mandatory = $true)] + [string]$HeadBranch, + [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', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") + + $env:GH_TOKEN = $Token + $prNumber = Upsert-PullRequest ` + -Repository $Repository ` + -HeadBranch $HeadBranch ` + -BaseBranch $BaseBranch ` + -Title "docs(preview): PR #$PullRequestNumber" ` + -Body "Automated preview content update for #$PullRequestNumber`nPreview URL: $PreviewUrl" + Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber +} + +$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..0b56464 --- /dev/null +++ b/.github/scripts/docs/Shared.ps1 @@ -0,0 +1,107 @@ +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-PullRequest { + param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$HeadBranch, + [Parameter(Mandatory = $true)] + [string]$BaseBranch, + [Parameter(Mandatory = $true)] + [string]$Title, + [Parameter(Mandatory = $true)] + [string]$Body + ) + + $existing = Invoke-Gh -Arguments @('pr', 'list', '--repo', $Repository, '--base', $BaseBranch, '--head', $HeadBranch, '--state', 'open', '--json', 'number', '--jq', '.[0].number') + if ([string]::IsNullOrWhiteSpace($existing)) { + return (Invoke-Gh -Arguments @('pr', 'create', '--repo', $Repository, '--base', $BaseBranch, '--head', $HeadBranch, '--title', $Title, '--body', $Body, '--json', 'number', '--jq', '.number')) + } + + Invoke-Gh -Arguments @('pr', 'edit', $existing, '--repo', $Repository, '--title', $Title, '--body', $Body) | Out-Null + return $existing +} + +function Enable-PullRequestAutoMerge { + param( + [Parameter(Mandatory = $true)] + [string]$Repository, + [Parameter(Mandatory = $true)] + [string]$PullRequestNumber + ) + + Invoke-Gh -Arguments @('pr', 'merge', $PullRequestNumber, '--repo', $Repository, '--squash', '--auto', '--delete-branch') | Out-Null +} + +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 3776c5c..be6d36f 100644 --- a/.github/workflows/Docs.yml +++ b/.github/workflows/Docs.yml @@ -9,7 +9,7 @@ on: - main paths: - src/** - - scripts/** + - .github/scripts/** - .github/actions/update-index/** - .github/workflows/Docs.yml pull_request: @@ -22,7 +22,7 @@ on: - main paths: - src/** - - scripts/** + - .github/scripts/** - .github/workflows/Docs.yml concurrency: @@ -105,84 +105,10 @@ jobs: run: zensical build --clean working-directory: src - - name: Clone gh-pages branch - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages || { - echo "gh-pages branch is required for branch-based deployment. Create and protect gh-pages before enabling this workflow." - exit 1 - } - - - name: Sync live docs content - id: sync-live + - name: Publish live docs + id: publish-live shell: pwsh - run: | - Get-ChildItem -LiteralPath "$PWD\_pages" -Force | - Where-Object { $_.Name -notin @('.git', 'previews') } | - Remove-Item -Recurse -Force - - Get-ChildItem -LiteralPath "$PWD\src\site" -Force | - ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination "$PWD\_pages" -Recurse -Force - } - - New-Item -Path "$PWD\_pages\.nojekyll" -ItemType File -Force | Out-Null - - git -C _pages config user.name "scribbler-bot[bot]" - git -C _pages config user.email "scribe@psmodule.io" - git -C _pages add -A - - $status = git -C _pages status --porcelain - if ([string]::IsNullOrWhiteSpace($status)) { - "has_changes=false" >> $env:GITHUB_OUTPUT - exit 0 - } - - git -C _pages commit -m "Deploy docs from ${{ github.sha }}" - "has_changes=true" >> $env:GITHUB_OUTPUT - - - name: Push publish branch - if: steps.sync-live.outputs.has_changes == 'true' - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git -C _pages push --force-with-lease origin "HEAD:refs/heads/scribbler/publish/main" - - - name: Create or update publish PR - if: steps.sync-live.outputs.has_changes == 'true' - id: publish-pr - shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - $repo = "${{ github.repository }}" - $title = "docs(publish): update live docs" - $body = @" - Automated docs publish from `${{ github.sha }}`. - Live URL: https://psmodule.io/docs/ - "@ - $existing = gh pr list --repo $repo --base gh-pages --head "scribbler/publish/main" --state open --json number --jq '.[0].number' - if ([string]::IsNullOrWhiteSpace($existing)) { - $number = gh pr create --repo $repo --base gh-pages --head "scribbler/publish/main" --title $title --body $body --json number --jq '.number' - } else { - gh pr edit $existing --repo $repo --title $title --body $body | Out-Null - $number = $existing - } - "number=$number" >> $env:GITHUB_OUTPUT - - - name: Enable auto-merge for publish PR - if: steps.sync-live.outputs.has_changes == 'true' - shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh pr merge ${{ steps.publish-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch + run: ./.github/scripts/docs/Publish-LiveDocs.ps1 -Repository "${{ github.repository }}" -Token "${{ steps.app-token.outputs.token }}" -BuildDirectory "$PWD/src/site" -HeadBranch "scribbler/publish/main" -CommitSha "${{ github.sha }}" preview: name: Preview @@ -227,134 +153,17 @@ jobs: - name: Install Zensical run: pip install zensical - - name: Set preview URL output - id: preview-url - run: echo "url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT" - shell: bash - - name: Build preview site shell: pwsh run: | - $configPath = Join-Path $PWD 'zensical.toml' - $content = Get-Content -LiteralPath $configPath -Raw - $updated = $content -replace '(?m)^site_url = ".*"$', "site_url = `"$env:PREVIEW_URL`"" - Set-Content -LiteralPath $configPath -Value $updated + ./.github/scripts/docs/Set-ZensicalSiteUrl.ps1 -ConfigPath "$PWD/zensical.toml" -SiteUrl "${{ env.PREVIEW_URL }}" zensical build --clean working-directory: src - - name: Clone gh-pages branch - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages || { - echo "gh-pages branch is required for previews. Create and protect gh-pages before enabling this workflow." - exit 1 - } - - - name: Sync preview content - id: sync-preview - shell: pwsh - run: | - $previewDir = Join-Path $PWD "_pages\previews\pr-${{ github.event.number }}" - - if (Test-Path -LiteralPath $previewDir) { - Remove-Item -LiteralPath $previewDir -Recurse -Force - } - - New-Item -Path $previewDir -ItemType Directory -Force | Out-Null - Get-ChildItem -LiteralPath "$PWD\src\site" -Force | - ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination $previewDir -Recurse -Force - } - - New-Item -Path "$PWD\_pages\.nojekyll" -ItemType File -Force | Out-Null - - git -C _pages config user.name "scribbler-bot[bot]" - git -C _pages config user.email "scribe@psmodule.io" - git -C _pages add -A - - $status = git -C _pages status --porcelain - if ([string]::IsNullOrWhiteSpace($status)) { - "has_changes=false" >> $env:GITHUB_OUTPUT - exit 0 - } - - git -C _pages commit -m "Update docs preview for PR #${{ github.event.number }}" - "has_changes=true" >> $env:GITHUB_OUTPUT - - - name: Push preview branch - if: steps.sync-preview.outputs.has_changes == 'true' - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git -C _pages push --force-with-lease origin "HEAD:refs/heads/${PREVIEW_PAGES_BRANCH}" - - - name: Create or update gh-pages PR - if: steps.sync-preview.outputs.has_changes == 'true' - id: preview-pr - shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - $repo = "${{ github.repository }}" - $title = "docs(preview): PR #${{ github.event.number }}" - $body = @" - Automated preview content update for #${{ github.event.number }}. - Preview URL: $env:PREVIEW_URL - "@ - $existing = gh pr list --repo $repo --base gh-pages --head "${env:PREVIEW_PAGES_BRANCH}" --state open --json number --jq '.[0].number' - if ([string]::IsNullOrWhiteSpace($existing)) { - $number = gh pr create --repo $repo --base gh-pages --head "${env:PREVIEW_PAGES_BRANCH}" --title $title --body $body --json number --jq '.number' - } else { - gh pr edit $existing --repo $repo --title $title --body $body | Out-Null - $number = $existing - } - "number=$number" >> $env:GITHUB_OUTPUT - - - name: Enable auto-merge for gh-pages PR - if: steps.sync-preview.outputs.has_changes == 'true' + - name: Publish preview docs + id: preview-url shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh pr merge ${{ steps.preview-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch - - - name: Comment preview URL - uses: actions/github-script@v8 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const marker = ''; - const body = `${marker} - āœ… Preview is ready: ${process.env.PREVIEW_URL}`; - const issueNumber = context.payload.pull_request.number; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100 - }); - const existing = comments.find((c) => c.body?.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body - }); - } + 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" -HeadBranch "${{ env.PREVIEW_PAGES_BRANCH }}" preview-cleanup: name: Preview cleanup @@ -370,6 +179,11 @@ jobs: 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 @@ -377,149 +191,6 @@ jobs: app-id: ${{ secrets.SCRIBBLER_BOT_CLIENT_ID }} private-key: ${{ secrets.SCRIBBLER_BOT_PRIVATE_KEY }} - - name: Clone gh-pages branch - id: clone-pages - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - if ! git clone --no-tags --depth 1 --branch gh-pages "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" _pages; then - echo "missing=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "missing=false" >> "$GITHUB_OUTPUT" - - - name: Remove preview content - if: steps.clone-pages.outputs.missing == 'false' - id: remove-preview - shell: pwsh - run: | - $previewDir = Join-Path $PWD "_pages\previews\pr-${{ github.event.number }}" - if (Test-Path -LiteralPath $previewDir) { - Remove-Item -LiteralPath $previewDir -Recurse -Force - } - - git -C _pages config user.name "scribbler-bot[bot]" - git -C _pages config user.email "scribe@psmodule.io" - git -C _pages add -A - - $status = git -C _pages status --porcelain - if ([string]::IsNullOrWhiteSpace($status)) { - "has_changes=false" >> $env:GITHUB_OUTPUT - exit 0 - } - - git -C _pages commit -m "Remove docs preview for PR #${{ github.event.number }}" - "has_changes=true" >> $env:GITHUB_OUTPUT - - - name: Push cleanup branch - if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' - shell: bash - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git -C _pages remote set-url origin "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git -C _pages push --force-with-lease origin "HEAD:refs/heads/${PREVIEW_CLEANUP_BRANCH}" - - - name: Create or update cleanup PR - if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' - id: cleanup-pr - shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - $repo = "${{ github.repository }}" - $title = "docs(preview): cleanup PR #${{ github.event.number }}" - $body = "Automated cleanup for preview URL: $env:PREVIEW_URL" - $existing = gh pr list --repo $repo --base gh-pages --head "${env:PREVIEW_CLEANUP_BRANCH}" --state open --json number --jq '.[0].number' - if ([string]::IsNullOrWhiteSpace($existing)) { - $number = gh pr create --repo $repo --base gh-pages --head "${env:PREVIEW_CLEANUP_BRANCH}" --title $title --body $body --json number --jq '.number' - } else { - gh pr edit $existing --repo $repo --title $title --body $body | Out-Null - $number = $existing - } - "number=$number" >> $env:GITHUB_OUTPUT - - - name: Enable auto-merge for cleanup PR - if: steps.clone-pages.outputs.missing == 'false' && steps.remove-preview.outputs.has_changes == 'true' + - name: Cleanup preview resources shell: pwsh - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - gh pr merge ${{ steps.cleanup-pr.outputs.number }} --repo "${{ github.repository }}" --squash --auto --delete-branch - - - name: Delete preview deployments and environment - uses: actions/github-script@v8 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const owner = context.repo.owner; - const repo = context.repo.repo; - const environment = process.env.PREVIEW_ENVIRONMENT; - - const deployments = await github.paginate(github.rest.repos.listDeployments, { - owner, - repo, - environment, - per_page: 100 - }); - - for (const deployment of deployments) { - await github.rest.repos.createDeploymentStatus({ - owner, - repo, - deployment_id: deployment.id, - state: 'inactive' - }); - await github.request('DELETE /repos/{owner}/{repo}/deployments/{deployment_id}', { - owner, - repo, - deployment_id: deployment.id - }); - } - - try { - await github.request('DELETE /repos/{owner}/{repo}/environments/{environment_name}', { - owner, - repo, - environment_name: environment - }); - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - - name: Comment preview removal - uses: actions/github-script@v8 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const marker = ''; - const issueNumber = context.payload.pull_request.number; - const body = `${marker} - 🧹 Preview removed: ${process.env.PREVIEW_URL}`; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100 - }); - const existing = comments.find((c) => c.body?.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body - }); - } + 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 }}" -HeadBranch "${{ env.PREVIEW_CLEANUP_BRANCH }}" From 56746969aa52db0d3209adfb4401976bac87d7ca Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 20 Jul 2026 04:26:29 +0200 Subject: [PATCH 4/5] Switch docs deploy to app-only gh-pages pushes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/PR-PREVIEW.md | 13 +++----- .github/scripts/docs/Cleanup-PreviewDocs.ps1 | 13 +------- .github/scripts/docs/Publish-LiveDocs.ps1 | 14 +------- .github/scripts/docs/Publish-PreviewDocs.ps1 | 13 +------- .github/scripts/docs/Shared.ps1 | 34 -------------------- .github/workflows/Docs.yml | 17 ++++------ 6 files changed, 14 insertions(+), 90 deletions(-) diff --git a/.github/PR-PREVIEW.md b/.github/PR-PREVIEW.md index 126859c..f7ae84f 100644 --- a/.github/PR-PREVIEW.md +++ b/.github/PR-PREVIEW.md @@ -8,21 +8,19 @@ This repository publishes docs previews for pull requests under: 1. On PR open/reopen/synchronize: - builds docs with preview-specific `site_url`, - - updates `previews/pr-/` content via a PR into `gh-pages`, - - enables auto-merge for that `gh-pages` PR, + - 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-/` via a PR into `gh-pages`, - - enables auto-merge for that cleanup PR, + - removes `previews/pr-/` by pushing directly to `gh-pages` as the Scribbler app, - deletes all preview deployments and the preview environment. ## Required repository configuration 1. Ensure `gh-pages` branch exists. 2. Configure GitHub Pages to publish from `gh-pages`. -3. Protect `gh-pages` so direct commits are blocked and updates happen through PRs only. -4. Allow auto-merge for PRs in this repository. +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 @@ -31,8 +29,7 @@ The app needs the following repository permissions: | Permission | Access | Why | | --- | --- | --- | | Metadata | Read | Required baseline for API access | -| Contents | Read & write | Push preview/cleanup branches and update content | -| Pull requests | Read & write | Create/update/merge PRs into `gh-pages` | +| 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 index d10e09d..9612d4a 100644 --- a/.github/scripts/docs/Cleanup-PreviewDocs.ps1 +++ b/.github/scripts/docs/Cleanup-PreviewDocs.ps1 @@ -9,8 +9,6 @@ param( [string]$PreviewUrl, [Parameter(Mandatory = $true)] [string]$EnvironmentName, - [Parameter(Mandatory = $true)] - [string]$HeadBranch, [string]$PagesDirectory = '_pages', [string]$BaseBranch = 'gh-pages' ) @@ -47,16 +45,7 @@ if ($clonedPages) { $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', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") - - $env:GH_TOKEN = $Token - $prNumber = Upsert-PullRequest ` - -Repository $Repository ` - -HeadBranch $HeadBranch ` - -BaseBranch $BaseBranch ` - -Title "docs(preview): cleanup PR #$PullRequestNumber" ` - -Body "Automated cleanup for preview URL: $PreviewUrl" - Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber + Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") } } diff --git a/.github/scripts/docs/Publish-LiveDocs.ps1 b/.github/scripts/docs/Publish-LiveDocs.ps1 index 35c5426..ae4d524 100644 --- a/.github/scripts/docs/Publish-LiveDocs.ps1 +++ b/.github/scripts/docs/Publish-LiveDocs.ps1 @@ -6,8 +6,6 @@ param( [Parameter(Mandatory = $true)] [string]$BuildDirectory, [Parameter(Mandatory = $true)] - [string]$HeadBranch, - [Parameter(Mandatory = $true)] [string]$CommitSha, [string]$PagesDirectory = '_pages', [string]$BaseBranch = 'gh-pages' @@ -57,16 +55,6 @@ if ([string]::IsNullOrWhiteSpace($status)) { } Invoke-Git -Arguments @('-C', $PagesDirectory, 'commit', '-m', "Deploy docs from $CommitSha") -Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") - -$env:GH_TOKEN = $Token -$prNumber = Upsert-PullRequest ` - -Repository $Repository ` - -HeadBranch $HeadBranch ` - -BaseBranch $BaseBranch ` - -Title 'docs(publish): update live docs' ` - -Body "Automated docs publish from `$CommitSha`nLive URL: https://psmodule.io/docs/" -Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber +Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") Set-WorkflowOutput -Name 'has_changes' -Value 'true' -Set-WorkflowOutput -Name 'pr_number' -Value $prNumber diff --git a/.github/scripts/docs/Publish-PreviewDocs.ps1 b/.github/scripts/docs/Publish-PreviewDocs.ps1 index 306071f..fb94672 100644 --- a/.github/scripts/docs/Publish-PreviewDocs.ps1 +++ b/.github/scripts/docs/Publish-PreviewDocs.ps1 @@ -9,8 +9,6 @@ param( [string]$PreviewUrl, [Parameter(Mandatory = $true)] [string]$BuildDirectory, - [Parameter(Mandatory = $true)] - [string]$HeadBranch, [string]$PagesDirectory = '_pages', [string]$BaseBranch = 'gh-pages' ) @@ -53,16 +51,7 @@ 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', '--force-with-lease', 'origin', "HEAD:refs/heads/$HeadBranch") - - $env:GH_TOKEN = $Token - $prNumber = Upsert-PullRequest ` - -Repository $Repository ` - -HeadBranch $HeadBranch ` - -BaseBranch $BaseBranch ` - -Title "docs(preview): PR #$PullRequestNumber" ` - -Body "Automated preview content update for #$PullRequestNumber`nPreview URL: $PreviewUrl" - Enable-PullRequestAutoMerge -Repository $Repository -PullRequestNumber $prNumber + Invoke-Git -Arguments @('-C', $PagesDirectory, 'push', 'origin', "HEAD:refs/heads/$BaseBranch") } $commentBody = "`nāœ… Preview is ready: $PreviewUrl" diff --git a/.github/scripts/docs/Shared.ps1 b/.github/scripts/docs/Shared.ps1 index 0b56464..67a258e 100644 --- a/.github/scripts/docs/Shared.ps1 +++ b/.github/scripts/docs/Shared.ps1 @@ -48,40 +48,6 @@ function Invoke-Gh { return $output } -function Upsert-PullRequest { - param( - [Parameter(Mandatory = $true)] - [string]$Repository, - [Parameter(Mandatory = $true)] - [string]$HeadBranch, - [Parameter(Mandatory = $true)] - [string]$BaseBranch, - [Parameter(Mandatory = $true)] - [string]$Title, - [Parameter(Mandatory = $true)] - [string]$Body - ) - - $existing = Invoke-Gh -Arguments @('pr', 'list', '--repo', $Repository, '--base', $BaseBranch, '--head', $HeadBranch, '--state', 'open', '--json', 'number', '--jq', '.[0].number') - if ([string]::IsNullOrWhiteSpace($existing)) { - return (Invoke-Gh -Arguments @('pr', 'create', '--repo', $Repository, '--base', $BaseBranch, '--head', $HeadBranch, '--title', $Title, '--body', $Body, '--json', 'number', '--jq', '.number')) - } - - Invoke-Gh -Arguments @('pr', 'edit', $existing, '--repo', $Repository, '--title', $Title, '--body', $Body) | Out-Null - return $existing -} - -function Enable-PullRequestAutoMerge { - param( - [Parameter(Mandatory = $true)] - [string]$Repository, - [Parameter(Mandatory = $true)] - [string]$PullRequestNumber - ) - - Invoke-Gh -Arguments @('pr', 'merge', $PullRequestNumber, '--repo', $Repository, '--squash', '--auto', '--delete-branch') | Out-Null -} - function Upsert-IssueComment { param( [Parameter(Mandatory = $true)] diff --git a/.github/workflows/Docs.yml b/.github/workflows/Docs.yml index be6d36f..7c97e4e 100644 --- a/.github/workflows/Docs.yml +++ b/.github/workflows/Docs.yml @@ -72,8 +72,7 @@ jobs: name: github-pages url: https://psmodule.io/docs/ permissions: - contents: read - pull-requests: write + contents: write steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -108,7 +107,7 @@ jobs: - 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" -HeadBranch "scribbler/publish/main" -CommitSha "${{ github.sha }}" + run: ./.github/scripts/docs/Publish-LiveDocs.ps1 -Repository "${{ github.repository }}" -Token "${{ steps.app-token.outputs.token }}" -BuildDirectory "$PWD/src/site" -CommitSha "${{ github.sha }}" preview: name: Preview @@ -119,12 +118,10 @@ jobs: name: pr-preview-${{ github.event.number }} url: ${{ steps.preview-url.outputs.url }} permissions: - contents: read + contents: write deployments: write issues: write - pull-requests: write env: - PREVIEW_PAGES_BRANCH: scribbler/preview/pr-${{ github.event.number }} PREVIEW_URL: https://psmodule.io/docs/previews/pr-${{ github.event.number }}/ steps: - name: Checkout repository @@ -163,19 +160,17 @@ jobs: - 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" -HeadBranch "${{ env.PREVIEW_PAGES_BRANCH }}" + 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: read + contents: write deployments: write issues: write - pull-requests: write env: - PREVIEW_CLEANUP_BRANCH: scribbler/preview-cleanup/pr-${{ github.event.number }} PREVIEW_URL: https://psmodule.io/docs/previews/pr-${{ github.event.number }}/ PREVIEW_ENVIRONMENT: pr-preview-${{ github.event.number }} steps: @@ -193,4 +188,4 @@ jobs: - 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 }}" -HeadBranch "${{ env.PREVIEW_CLEANUP_BRANCH }}" + 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 }}" From bfd59cf135990a2fc97530e35e4b513806a54246 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 20 Jul 2026 04:30:36 +0200 Subject: [PATCH 5/5] Add preview lifecycle flow diagram Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/PR-PREVIEW.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/PR-PREVIEW.md b/.github/PR-PREVIEW.md index f7ae84f..b7d64d8 100644 --- a/.github/PR-PREVIEW.md +++ b/.github/PR-PREVIEW.md @@ -15,6 +15,22 @@ This repository publishes docs previews for pull requests under: - 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.