Skip to content

Add PR docs preview lifecycle via gh-pages #318

Add PR docs preview lifecycle via gh-pages

Add PR docs preview lifecycle via gh-pages #318

Workflow file for this run

name: Docs
on:
workflow_dispatch:
schedule:
- cron: '*/5 * * * *'
push:
branches:
- main
paths:
- src/**
- scripts/**
- .github/actions/update-index/**
- .github/workflows/Docs.yml
pull_request:
types:
- opened
- reopened
- synchronize
- closed
branches:
- main
paths:
- src/**
- scripts/**
- .github/workflows/Docs.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint
if: github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read # super-linter: read packages
statuses: write # super-linter: report status checks
pull-requests: write # super-linter: post PR summary comment
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Lint code base
uses: super-linter/super-linter@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
env:
GITHUB_TOKEN: ${{ github.token }}
ENABLE_GITHUB_ACTIONS_STEP_SUMMARY: true
ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: true
SAVE_SUPER_LINTER_SUMMARY: true
VALIDATE_BIOME_LINT: false
VALIDATE_BIOME_FORMAT: false
VALIDATE_JSCPD: false
VALIDATE_JSON_PRETTIER: false
VALIDATE_MARKDOWN_PRETTIER: false
VALIDATE_YAML_PRETTIER: false
VALIDATE_HTML_PRETTIER: false
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:
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 Zensical project
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
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: pr-preview-${{ github.event.number }}
url: ${{ steps.preview-url.outputs.url }}
permissions:
contents: read
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
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: 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 = '<!-- docs-pr-preview -->';
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 = '<!-- docs-pr-preview -->';
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
});
}