Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
name: PR Preview

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read
issues: write
pull-requests: write

concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
build-preview:
runs-on: ubuntu-latest
outputs:
artifact_name: ${{ steps.meta.outputs.artifact_name }}
screenshot_name: ${{ steps.meta.outputs.screenshot_name }}
post_path: ${{ steps.changed_post.outputs.post_path }}
post_url: ${{ steps.changed_post.outputs.post_url }}
steps:
- name: Set artifact name
id: meta
run: |
echo "artifact_name=site-preview-pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT"
echo "screenshot_name=site-screenshots-pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT"

- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Build site
run: bundle exec jekyll build

- name: Detect changed post
id: changed_post
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: |
mapfile -t posts < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- '_posts/*.md')
if [[ ${#posts[@]} -eq 0 ]]; then
echo "post_path=" >> "$GITHUB_OUTPUT"
echo "post_url=/" >> "$GITHUB_OUTPUT"
exit 0
fi

post="${posts[0]}"
slug="$(basename "$post" .md)"
slug="${slug#????-??-??-}"
echo "post_path=$post" >> "$GITHUB_OUTPUT"
echo "post_url=/$slug/" >> "$GITHUB_OUTPUT"

- name: Upload site artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.artifact_name }}
path: _site
retention-days: 7

- name: Install Playwright Chromium
run: npx -y playwright@1.53.0 install --with-deps chromium

- name: Capture preview screenshot
env:
POST_URL: ${{ steps.changed_post.outputs.post_url }}
run: |
python3 -m http.server --directory _site 4173 > /tmp/pr-preview-server.log 2>&1 &
server_pid=$!
trap 'kill "$server_pid"' EXIT

for i in {1..20}; do
if curl -fsS "http://127.0.0.1:4173${POST_URL}" >/dev/null; then
break
fi
sleep 0.5
done

npx -y playwright@1.53.0 screenshot --browser=chromium --full-page "http://127.0.0.1:4173${POST_URL}" preview-long.png

- name: Upload screenshot artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.screenshot_name }}
path: preview-long.png
retention-days: 7

deploy-cloudflare-preview:
runs-on: ubuntu-latest
needs: build-preview
if: ${{ !github.event.pull_request.head.repo.fork && vars.CLOUDFLARE_PAGES_PROJECT != '' && vars.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_API_TOKEN != '' }}
permissions:
contents: read
deployments: write
outputs:
preview_url: ${{ steps.cf.outputs.alias }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Build site
run: bundle exec jekyll build

- name: Publish to Cloudflare Pages
id: cf
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
directory: _site
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
branch: pr-${{ github.event.number }}
wranglerVersion: '3'

comment-preview:
runs-on: ubuntu-latest
needs: [build-preview, deploy-cloudflare-preview]
if: ${{ always() && !github.event.pull_request.head.repo.fork && needs.build-preview.result == 'success' && (needs.deploy-cloudflare-preview.result == 'success' || needs.deploy-cloudflare-preview.result == 'skipped') }}
steps:
- name: Add or update PR preview comment
uses: actions/github-script@v7
env:
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
ARTIFACT_NAME: ${{ needs.build-preview.outputs.artifact_name }}
SCREENSHOT_NAME: ${{ needs.build-preview.outputs.screenshot_name }}
POST_PATH: ${{ needs.build-preview.outputs.post_path }}
POST_URL: ${{ needs.build-preview.outputs.post_url }}
CLOUDFLARE_URL: ${{ needs.deploy-cloudflare-preview.outputs.preview_url }}
CLOUDFLARE_ENABLED: ${{ vars.CLOUDFLARE_PAGES_PROJECT != '' && vars.CLOUDFLARE_ACCOUNT_ID != '' && secrets.CLOUDFLARE_API_TOKEN != '' }}
CLOUDFLARE_RESULT: ${{ needs.deploy-cloudflare-preview.result }}
with:
script: |
const marker = "<!-- pr-preview-comment -->";
const postLine = process.env.POST_PATH
? `- Changed post detected: \`${process.env.POST_PATH}\`\n- Suggested file to open after extracting: \`_site${process.env.POST_URL}index.html\``
: "- No post markdown file changed in this PR; preview starts at `_site/index.html`.";

const cloudflareLine = process.env.CLOUDFLARE_ENABLED === "true"
? (process.env.CLOUDFLARE_RESULT === "success"
? (process.env.CLOUDFLARE_URL
? `- Hosted preview (Cloudflare Pages): [Open preview](${process.env.CLOUDFLARE_URL})`
: "- Hosted preview (Cloudflare Pages): deployed, but URL output was empty. Check deployment details.")
: "- Hosted preview (Cloudflare Pages): deployment did not succeed; check workflow logs.")
: "- Hosted preview (Cloudflare Pages): not configured (set repository vars/secrets; see README).";

const body = `${marker}
## PR Preview Ready

A static preview was built for this PR.

- Download artifact: **${process.env.ARTIFACT_NAME}** from [this workflow run](${process.env.RUN_URL})
- Download long screenshot artifact: **${process.env.SCREENSHOT_NAME}** from [this workflow run](${process.env.RUN_URL})
${postLine}
${cloudflareLine}
- Quick local preview:
- Extract artifact zip
- Run: \`python3 -m http.server --directory _site 4173\`
- Open: \`http://localhost:4173${process.env.POST_URL}\`
`;

const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.rest.issues.listComments({
owner,
repo,
issue_number,
per_page: 100,
});

const existing = comments.data.find((comment) => comment.body && comment.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"bundle": true
}
}
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,59 @@ The site is generated by Jekyll, based on the [Minimal Mistakes](https://mmistak

To submit a blog entry, create a `md` file in the `_posts` directory. Look for examples of posts there for formatting help.

Author bios in the sidebar are enabled for posts by default. For a single author, use:

```yaml
author: Oscar Levin
```

For multiple authors, use:

```yaml
authors:
- Oscar Levin
- Steven Clontz
```

Each name must match an entry in `_data/authors.yml`.

## Building the site

When the site is pushed to github, it will automatically rebuild. To preview locally, run `bundle exec jekyll serve`. Note, some changes to `_config.yml` require stopping and starting this, while other changes to the site should be automatically incorporated.

See the [Minimal Mistakes Documentation](https://mmistakes.github.io/minimal-mistakes/docs/quick-start-guide/) for more.

## PR previews

This repo now includes a PR preview workflow at `.github/workflows/pr-preview.yml`.

For each pull request update, it:

1. Builds the site with Jekyll.
2. Uploads the full `_site` output as a workflow artifact.
3. Captures a full-page screenshot artifact for quick visual review.
4. Optionally deploys to Cloudflare Pages for a hosted preview URL.
5. Posts or updates a sticky PR comment with links and instructions.

This gives reviewers a consistent way to preview rendered pages without running Jekyll locally.

### Cloudflare Pages setup (optional)

If you want hosted preview links directly in PRs, set up Cloudflare Pages once and add repo settings.

1. In Cloudflare, create a Pages project (or use an existing one).
- Project name should match `CLOUDFLARE_PAGES_PROJECT` exactly.
2. In GitHub repo settings, add **Repository variables**:
- `CLOUDFLARE_ACCOUNT_ID`: your Cloudflare account ID
- `CLOUDFLARE_PAGES_PROJECT`: the Cloudflare Pages project name
3. In GitHub repo settings, add **Repository secret**:
- `CLOUDFLARE_API_TOKEN`: Cloudflare API token with `Account -> Cloudflare Pages -> Edit`
4. In Cloudflare, create the API token at **My Profile -> API Tokens -> Create Token -> Custom token** and scope it to the specific account.
5. Open or update a PR. The workflow will deploy `_site` and include preview status in the PR comment.
6. Open the PR **Deployments** panel to click through to the hosted Cloudflare preview URL.

Notes:

- Cloudflare deploy is skipped automatically if these vars/secrets are missing.
- Cloudflare deploy is also skipped for forked PRs for security.
- Artifact and screenshot previews still run even when Cloudflare is skipped.
4 changes: 2 additions & 2 deletions _includes/author-profile.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% assign author = page.author | default: page.authors[0] | default: site.author %}
{% assign author = site.data.authors[author] | default: author %}
{% assign author_key = include.author_key | default: page.author | default: page.authors[0] %}
{% assign author = site.data.authors[author_key] | default: author_key | default: site.author %}

<div itemscope itemtype="https://schema.org/Person" class="h-card">

Expand Down
12 changes: 10 additions & 2 deletions _includes/sidebar.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
{% if page.author_profile or layout.author_profile or page.sidebar %}
<div class="sidebar sticky">
{% if page.author_profile or layout.author_profile %}{% include author-profile.html %}{% endif %}
<div class="sidebar">
{% if page.author_profile or layout.author_profile %}
{% if page.authors %}
{% for author_key in page.authors %}
{% include author-profile.html author_key=author_key %}
{% endfor %}
{% else %}
{% include author-profile.html %}
{% endif %}
{% endif %}
{% if page.sidebar %}
{% for s in page.sidebar %}
{% if s.image %}
Expand Down